From 0f9e425656d3d54223a7b030c32582386f0cb29b Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Sun, 4 Oct 2026 22:58:26 +0800 Subject: [PATCH 1/6] refactor(core): one source, file and reasoning type per layer aimux-core defined a source, a generated file and a reasoning segment twice each: inline in `GenerateContent`/`StreamPart` and again as the lossy `SourcePart`/`FilePart`/`ReasoningPart` of the text results, which dropped `provider_metadata` (so the reasoning signature never reached the top level). `StreamPart::ResponseMetadata` repeated the fields of `types::ResponseMetadata`. Mirror the AI SDK: one type, reused in content, stream parts and the result projections. - `result::Source`, `result::GeneratedFile`, `result::ReasoningOutput` are the payloads of `GenerateContent::{Source,File,Reasoning}` and `StreamPart::{Source,File}`, and the elements of `sources`, `files` and `reasoning` on `GenerateTextResult` and `StreamTextResultAggregated`. - `StreamPart::ResponseMetadata` wraps `types::ResponseMetadata`. - Deleted `SourcePart`, `FilePart`, `ReasoningPart`. - `ResponseMessageBuilder::reasoning` takes the `ReasoningOutput`. Variant JSON is unchanged; the top-level `sources`/`files`/`reasoning` arrays now carry the full element (additive `provider_metadata`, and `url`/`title` serialize as null when absent). aimux-providers, aimux-ffi, bindings and tools are intentionally not updated here. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/generate.rs | 78 +++--------------- aimux-core/src/moa.rs | 8 +- aimux-core/src/openai_output.rs | 25 +++--- aimux-core/src/replay.rs | 8 +- aimux-core/src/response_messages.rs | 25 +++--- aimux-core/src/result.rs | 79 ++++++++----------- aimux-core/src/stream_part.rs | 35 ++------ aimux-core/tests/m11_m12_m6_test.rs | 6 +- aimux-core/tests/m7_aggregation_test.rs | 20 ++--- .../tests/response_messages_tool_test.rs | 6 +- 10 files changed, 102 insertions(+), 188 deletions(-) diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index eaf2eeefd..dd083c501 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -23,7 +23,7 @@ use crate::message::{ModelMessage, ModelPrompt}; use crate::options::{CallOptions, ResponseFormat, ToolChoice}; use crate::parse_tool_call::{ToolCallRepair, parse_tool_call}; use crate::result::{ - FilePart, GenerateContent, GenerateResult, ReasoningPart, SourcePart, StreamResult, + GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, StreamResult, StreamTextResultAggregated, }; use crate::stream_part::{StreamPart, TextStreamPart}; @@ -38,7 +38,7 @@ fn is_output_chunk(part: &StreamPart) -> bool { StreamPart::TextDelta { delta, .. } | StreamPart::ReasoningDelta { delta, .. } | StreamPart::ToolInputDelta { delta, .. } => !delta.is_empty(), - StreamPart::ToolCall(_) | StreamPart::File { .. } => true, + StreamPart::ToolCall(_) | StreamPart::File(_) => true, _ => false, } } @@ -158,16 +158,16 @@ pub struct GenerateTextResult { // ── M7: top-level aggregation (extracted from `raw.content`) ── /// Reasoning / thinking segments from the model. #[serde(default)] - pub reasoning: Vec, + pub reasoning: Vec, /// Concatenated reasoning text (convenience for `reasoning.iter().map(text).join("")`). #[serde(default)] pub reasoning_text: String, /// Sources / citations (search-preview models). #[serde(default)] - pub sources: Vec, + pub sources: Vec, /// Files generated by the model. #[serde(default)] - pub files: Vec, + pub files: Vec, /// Assistant messages ready to append to the prompt for the next turn /// (solves the multi-turn "manually build assistant message" footgun). #[serde(default)] @@ -312,8 +312,8 @@ impl StreamTextResult { pub async fn consume(self) -> Result { let mut text = String::new(); let mut tool_calls: Vec = Vec::new(); - let mut sources: Vec = Vec::new(); - let mut files: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); + let mut files: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); let mut finish_reason = FinishReason { unified: crate::types::FinishReasonUnified::Stop, @@ -378,25 +378,8 @@ impl StreamTextResult { tool_calls.push(call); } StreamPart::ToolResult(result) => rm.tool_result(result), - StreamPart::Source { - id, - source_type, - url, - title, - .. - } => { - sources.push(SourcePart { - id, - source_type, - url, - title, - }); - } - StreamPart::File { - data, media_type, .. - } => { - files.push(FilePart { data, media_type }); - } + StreamPart::Source(source) => sources.push(source), + StreamPart::File(file) => files.push(file), StreamPart::StreamStart { warnings: w, .. } => { warnings = w; } @@ -418,17 +401,7 @@ impl StreamTextResult { StreamPart::Error { error } if provider_error.is_none() => { provider_error = Some(error); } - StreamPart::ResponseMetadata { - id, - timestamp, - model_id, - } => { - response = Some(crate::types::ResponseMetadata { - id, - timestamp, - model_id, - }); - } + StreamPart::ResponseMetadata(meta) => response = Some(meta), _ => {} } } @@ -630,34 +603,9 @@ pub async fn generate_text( rm.tool_call(&parsed); tool_calls.push(parsed); } - GenerateContent::Reasoning { - text: rtext, - provider_metadata, - } => { - rm.reasoning(rtext, provider_metadata.as_ref()); - } - GenerateContent::Source { - id, - source_type, - url, - title, - .. - } => { - sources.push(SourcePart { - id: id.clone(), - source_type: source_type.clone(), - url: url.clone(), - title: title.clone(), - }); - } - GenerateContent::File { - data, media_type, .. - } => { - files.push(FilePart { - data: data.clone(), - media_type: media_type.clone(), - }); - } + GenerateContent::Reasoning(reasoning) => rm.reasoning(reasoning), + GenerateContent::Source(source) => sources.push(source.clone()), + GenerateContent::File(file) => files.push(file.clone()), // Provider-executed results stay in the assistant message so the // provider can replay its own server-tool transcript next turn. GenerateContent::ToolResult(result) => rm.tool_result(result.clone()), diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index b864b1378..24e728a99 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -313,7 +313,7 @@ mod tests { use crate::content::ContentPart; use crate::language_model_message::LanguageModelPromptMessage; use crate::message::Role; - use crate::result::GenerateContent; + use crate::result::{GenerateContent, Source}; use crate::stream_part::StreamPart; use crate::tool::RawToolCall; use crate::types::{FinishReason, FinishReasonUnified, TokenUsage}; @@ -922,13 +922,13 @@ mod tests { thought_signature: None, provider_metadata: None, })), - Ok(StreamPart::Source { + Ok(StreamPart::Source(Source { id: "s1".into(), source_type: "url".into(), url: Some("https://example.com".into()), title: Some("Ex".into()), provider_metadata: None, - }), + })), Ok(StreamPart::Finish { finish_reason: FinishReason { unified: FinishReasonUnified::Stop, @@ -963,7 +963,7 @@ mod tests { saw_tool_call = true; assert_eq!(tool_name, "search"); } - StreamPart::Source { id, .. } => { + StreamPart::Source(Source { id, .. }) => { saw_source = true; assert_eq!(id, "s1"); } diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index 22844ffa3..dc86d6cb0 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -35,12 +35,11 @@ use ts_rs::TS; use crate::error::AiMuxError; use crate::parse_tool_call::raw_tool_call_text; -use crate::result::GenerateContent; -use crate::result::GenerateResult; +use crate::result::{GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source}; use crate::shared::FileData; use crate::stream_part::{StreamPart, TextStreamPart}; use crate::tool::{ToolCall, ToolResult}; -use crate::types::{FinishReason, FinishReasonUnified, Usage}; +use crate::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; // ───────────────────────────────────────────────────────────────────────────── // Non-streaming response types @@ -238,7 +237,7 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio GenerateContent::Text { text, .. } => { content_text.push_str(text); } - GenerateContent::Reasoning { text, .. } => { + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { reasoning_text.push_str(text); } GenerateContent::ToolCall(call) => { @@ -259,7 +258,7 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio }, }); } - GenerateContent::Source { url, title, .. } => { + GenerateContent::Source(Source { url, title, .. }) => { // Map to OpenAI url_citation annotation. let mut ann = serde_json::json!({ "type": "url_citation", @@ -279,9 +278,9 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio content_text.push_str(u); } } - GenerateContent::File { + GenerateContent::File(GeneratedFile { data, media_type, .. - } => { + }) => { if !content_text.is_empty() { content_text.push('\n'); } @@ -551,7 +550,7 @@ impl StreamState { } } - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { if let Some(id) = id { self.id = id.clone(); } @@ -808,9 +807,9 @@ impl StreamState { chunks.push(chunk); } - StreamPart::File { + StreamPart::File(GeneratedFile { data, media_type, .. - } => { + }) => { if let Some(c) = self.ensure_started() { chunks.push(c); } @@ -828,7 +827,7 @@ impl StreamState { chunks.push(chunk); } - StreamPart::Source { url, .. } => { + StreamPart::Source(Source { url, .. }) => { if let Some(c) = self.ensure_started() { chunks.push(c); } @@ -1320,10 +1319,10 @@ mod tests { #[test] fn test_reasoning_non_streaming() { let result = make_result(vec![ - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text: "Thinking...".to_string(), provider_metadata: None, - }, + }), GenerateContent::Text { text: "Answer".to_string(), provider_metadata: None, diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index b2158ee17..fe1116842 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -682,11 +682,11 @@ fn rebuild_stream_result(rec: &Recording) -> Result { || v.get("model").and_then(|x| x.as_str()).is_some()) { response_meta_emitted = true; - parts.push(Ok(StreamPart::ResponseMetadata { + parts.push(Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: v["id"].as_str().map(std::string::ToString::to_string), timestamp: None, model_id: v["model"].as_str().map(std::string::ToString::to_string), - })); + }))); } // usage(含 usage-only 末帧 choices:[]+usage)→ 累积到 Finish.usage。 @@ -1686,7 +1686,7 @@ mod tests { // 首帧 id/model → ResponseMetadata。 assert!(parts.iter().any(|p| matches!( p, - StreamPart::ResponseMetadata { id, model_id, .. } + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) if id.as_deref() == Some("chatcmpl-1") && model_id.as_deref() == Some("gpt-4o") ))); // ToolInputStart。 @@ -1765,7 +1765,7 @@ mod tests { }); assert!(parts.iter().any(|p| matches!( p, - StreamPart::ResponseMetadata { id, model_id, .. } + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) if id.as_deref() == Some("chatcmpl-2") && model_id.as_deref() == Some("gpt-4o") ))); let deltas: Vec = parts diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index 3429851e4..cc54128c6 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -11,7 +11,7 @@ use serde_json::Value; use crate::content::ContentPart; use crate::message::{MessageContent, ModelMessage, Role}; -use crate::result::ReasoningPart; +use crate::result::ReasoningOutput; /// Match the AI SDK's response-message safety rule for invalid tool calls: /// malformed primitive input must not be replayed as a prompt tool-call input. @@ -48,14 +48,14 @@ pub(crate) struct ResponseMessageBuilder { text_provider_options: Option, reasoning_buf: String, reasoning_provider_options: Option, - reasoning: Vec, + reasoning: Vec, } /// What the builder produced: the replayable assistant message plus the /// reasoning aggregate surfaced on the result. pub(crate) struct ResponseMessages { pub messages: Vec, - pub reasoning: Vec, + pub reasoning: Vec, } impl ResponseMessageBuilder { @@ -121,17 +121,15 @@ impl ResponseMessageBuilder { } } - pub fn reasoning(&mut self, text: &str, provider_metadata: Option<&Value>) { + pub fn reasoning(&mut self, reasoning: &ReasoningOutput) { // Pushed unconditionally: redacted thinking has empty text but its // provider metadata must still be replayed. - self.reasoning.push(ReasoningPart { - text: text.to_owned(), - }); - let signature = extract_reasoning_signature(provider_metadata); + self.reasoning.push(reasoning.clone()); + let signature = extract_reasoning_signature(reasoning.provider_metadata.as_ref()); self.parts.push(ContentPart::Reasoning { - text: text.to_owned(), + text: reasoning.text.clone(), signature, - provider_options: provider_metadata.cloned(), + provider_options: reasoning.provider_metadata.clone(), }); } @@ -205,10 +203,13 @@ impl ResponseMessageBuilder { return; } let text = std::mem::take(&mut self.reasoning_buf); + let provider_options = self.reasoning_provider_options.take(); if !text.is_empty() { - self.reasoning.push(ReasoningPart { text: text.clone() }); + self.reasoning.push(ReasoningOutput { + text: text.clone(), + provider_metadata: provider_options.clone(), + }); } - let provider_options = self.reasoning_provider_options.take(); let signature = extract_reasoning_signature(provider_options.as_ref()); self.parts.push(ContentPart::Reasoning { text, diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index c3c9811df..f6a933ed1 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -27,71 +27,54 @@ pub enum GenerateContent { /// argument text; `generate_text` owns parsing, validation, and repair. ToolCall(RawToolCall), /// A source / citation (e.g. URL citation from search-preview models). - Source { - id: String, - source_type: String, - url: Option, - title: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + Source(Source), /// A reasoning / thinking segment produced by the model. - /// - /// Mirrors the Vercel AI SDK `reasoning` content type. `provider_metadata` - /// carries provider-specific data such as the Anthropic thinking-block - /// signature (`providerMetadata.anthropic.signature`). - Reasoning { - text: String, - provider_metadata: Option, - }, + Reasoning(ReasoningOutput), /// A file generated by the model (e.g. an image or document). - /// - /// Mirrors the Vercel AI SDK `file` content type. `data` is a tagged union - /// covering base64 data, URL, or provider reference. - File { - data: FileData, - media_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + File(GeneratedFile), /// A tool result from a provider-executed tool (e.g. xAI file_search, /// web_search). Emitted alongside the preceding `ToolCall` when the /// provider executes the tool server-side. ToolResult(ToolResult), } -// ── M7: top-level aggregation types ───────────────────────────────────────── -// -// Simplified views extracted from `GenerateContent` for the top-level fields of -// `GenerateTextResult`. They omit provider-internal metadata so the common case -// (reading reasoning/sources/files) doesn't require digging through `raw.content`. - -/// A reasoning / thinking segment, extracted to the top level (M7). +/// A source / citation (e.g. URL citation from search-preview models). The +/// payload of both `GenerateContent::Source` and `StreamPart::Source`, and the +/// element of the text results' `sources`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] -pub struct ReasoningPart { - /// The reasoning text. - pub text: String, -} - -/// A source / citation, extracted to the top level (M7). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct SourcePart { +pub struct Source { pub id: String, pub source_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, } -/// A file generated by the model, extracted to the top level (M7). +/// A file generated by the model (e.g. an image or document). Mirrors the Vercel +/// AI SDK `file` content type: `data` is a tagged union covering base64 data, +/// URL, or provider reference. The payload of both `GenerateContent::File` and +/// `StreamPart::File`, and the element of the text results' `files`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] -pub struct FilePart { +pub struct GeneratedFile { pub data: FileData, pub media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// A reasoning / thinking segment produced by the model. Mirrors the Vercel AI +/// SDK `reasoning` content type; `provider_metadata` carries provider-specific +/// data such as the Anthropic thinking-block signature +/// (`providerMetadata.anthropic.signature`). The payload of +/// `GenerateContent::Reasoning` and the element of the text results' `reasoning`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ReasoningOutput { + pub text: String, + pub provider_metadata: Option, } /// Aggregated result of `stream_text().consume()` (M11). Mirrors @@ -104,7 +87,7 @@ pub struct StreamTextResultAggregated { pub text: String, /// Reasoning / thinking segments. #[serde(default)] - pub reasoning: Vec, + pub reasoning: Vec, /// Concatenated reasoning text. #[serde(default)] pub reasoning_text: String, @@ -113,10 +96,10 @@ pub struct StreamTextResultAggregated { pub tool_calls: Vec, /// Sources / citations. #[serde(default)] - pub sources: Vec, + pub sources: Vec, /// Files generated by the model. #[serde(default)] - pub files: Vec, + pub files: Vec, /// Why generation stopped. pub finish_reason: crate::types::FinishReason, /// Raw provider-specific finish reason string. diff --git a/aimux-core/src/stream_part.rs b/aimux-core/src/stream_part.rs index 5e8888d6e..b5499a1be 100644 --- a/aimux-core/src/stream_part.rs +++ b/aimux-core/src/stream_part.rs @@ -8,9 +8,9 @@ use serde_json::Value; use ts_rs::TS; use crate::error::AiMuxError; -use crate::shared::FileData; +use crate::result::{GeneratedFile, Source}; use crate::tool::{RawToolCall, ToolCall, ToolResult}; -use crate::types::{FinishReason, ProviderMetadata, Usage, Warning}; +use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warning}; /// A single chunk in the stream returned by `do_stream`. /// @@ -93,15 +93,7 @@ pub enum StreamPart { // ── P2: file ── /// A file generated by the model (e.g. an image or document). - /// - /// Mirrors the Vercel AI SDK `file` stream part. `data` is a tagged union - /// covering base64 data, URL, or provider reference. - File { - data: FileData, - media_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + File(GeneratedFile), // ── P2: reasoning ── ReasoningStart { @@ -126,21 +118,10 @@ pub enum StreamPart { // ── P2: metadata ── /// Response metadata (id, timestamp, model_id). - ResponseMetadata { - id: Option, - timestamp: Option, - model_id: Option, - }, + ResponseMetadata(ResponseMetadata), /// A source / citation (e.g. URL citation from search-preview models). - Source { - id: String, - source_type: String, - url: Option, - title: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + Source(Source), /// A raw chunk from the provider (for debugging, when `include_raw_chunks` is set). Raw { raw_value: Value }, @@ -158,6 +139,9 @@ impl StreamPart { match self { StreamPart::ToolCall(call) => StreamPart::ToolCall(f(call)), StreamPart::ToolResult(result) => StreamPart::ToolResult(result), + StreamPart::File(file) => StreamPart::File(file), + StreamPart::ResponseMetadata(meta) => StreamPart::ResponseMetadata(meta), + StreamPart::Source(source) => StreamPart::Source(source), $(StreamPart::$variant { $($field),* } => StreamPart::$variant { $($field),* },)* } }; @@ -172,12 +156,9 @@ impl StreamPart { ToolInputStart { id, tool_name, provider_executed, dynamic, title, provider_metadata } ToolInputDelta { id, delta, provider_metadata } ToolInputEnd { id, provider_metadata } - File { data, media_type, provider_metadata } ReasoningStart { id, provider_metadata } ReasoningDelta { id, delta, provider_metadata } ReasoningEnd { id, provider_metadata } - ResponseMetadata { id, timestamp, model_id } - Source { id, source_type, url, title, provider_metadata } Raw { raw_value } } } diff --git a/aimux-core/tests/m11_m12_m6_test.rs b/aimux-core/tests/m11_m12_m6_test.rs index 74a7510a7..e705aec64 100644 --- a/aimux-core/tests/m11_m12_m6_test.rs +++ b/aimux-core/tests/m11_m12_m6_test.rs @@ -5,7 +5,7 @@ use aimux_core::generate::{GenerateTextOptions, generate_object}; use aimux_core::language_model::LanguageModel; use aimux_core::message::MessageContent; use aimux_core::options::{CallOptions, ResponseFormat}; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; @@ -122,13 +122,13 @@ impl LanguageModel for StreamModel { delta: "world!".into(), provider_metadata: None, }), - Ok(StreamPart::Source { + Ok(StreamPart::Source(Source { id: "s1".into(), source_type: "url".into(), url: Some("https://example.com".into()), title: Some("Example".into()), provider_metadata: None, - }), + })), Ok(StreamPart::Finish { finish_reason: FinishReason { unified: FinishReasonUnified::Stop, diff --git a/aimux-core/tests/m7_aggregation_test.rs b/aimux-core/tests/m7_aggregation_test.rs index f3f757d3e..cdd2dead0 100644 --- a/aimux-core/tests/m7_aggregation_test.rs +++ b/aimux-core/tests/m7_aggregation_test.rs @@ -8,7 +8,9 @@ use aimux_core::generate::{GenerateTextOptions, generate_text}; use aimux_core::language_model::LanguageModel; use aimux_core::message::{MessageContent, ModelPrompt, Role}; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{ + GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, StreamResult, +}; use aimux_core::shared::{FileBytes, FileData}; use aimux_core::tool::RawToolCall; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; @@ -29,12 +31,12 @@ impl LanguageModel for RichModel { async fn do_generate(&self, _options: &CallOptions) -> Result { Ok(GenerateResult { content: vec![ - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text: "Let me think...".into(), provider_metadata: Some(serde_json::json!({ "anthropic": { "signature": "sig-abc123" } })), - }, + }), GenerateContent::Text { text: "Hello!".into(), provider_metadata: None, @@ -48,20 +50,20 @@ impl LanguageModel for RichModel { thought_signature: None, provider_metadata: None, }), - GenerateContent::Source { + GenerateContent::Source(Source { id: "src-1".into(), source_type: "url".into(), url: Some("https://example.com".into()), title: Some("Example".into()), provider_metadata: None, - }, - GenerateContent::File { + }), + GenerateContent::File(GeneratedFile { data: FileData::Data { data: FileBytes::Base64("iVBOR".into()), }, media_type: "image/png".into(), provider_metadata: None, - }, + }), ], finish_reason: FinishReason { unified: FinishReasonUnified::Stop, @@ -279,7 +281,7 @@ impl LanguageModel for BedrockModel { text: "Hello from Bedrock!".into(), provider_metadata: None, }, - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text: "Thinking...".into(), // Bedrock stores signature under BOTH "bedrock" and // "amazonBedrock" keys (see bedrock/model.rs:535-540). @@ -287,7 +289,7 @@ impl LanguageModel for BedrockModel { "amazonBedrock": { "signature": "bedrock-sig-xyz" }, "bedrock": { "signature": "bedrock-sig-xyz" } })), - }, + }), ], finish_reason: FinishReason { unified: FinishReasonUnified::Stop, diff --git a/aimux-core/tests/response_messages_tool_test.rs b/aimux-core/tests/response_messages_tool_test.rs index 0b1086504..0ab5134a5 100644 --- a/aimux-core/tests/response_messages_tool_test.rs +++ b/aimux-core/tests/response_messages_tool_test.rs @@ -3,7 +3,7 @@ use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; use aimux_core::language_model::LanguageModel; use aimux_core::message::{MessageContent, Role}; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{RawToolCall, ToolResult}; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; @@ -344,12 +344,12 @@ impl LanguageModel for ContentMetadataModel { "google": { "thoughtSignature": "text-generate" } })), }, - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text: "think".to_string(), provider_metadata: Some(json!({ "anthropic": { "signature": "reason-generate" } })), - }, + }), ], finish_reason: finish_reason(), usage: Usage::default(), From 404603bb29b1b19dcf31d50214fcaa9416c43681 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Sun, 4 Oct 2026 23:05:54 +0800 Subject: [PATCH 2/6] refactor: providers, tests, bindings and docs follow the unified content types Follow-up to the aimux-core change: providers and their tests build `Source`, `GeneratedFile`, `ReasoningOutput` and `ResponseMetadata` payloads in the newtype variants instead of repeating the field lists. TypeScript types are regenerated (`SourcePart`/`FilePart`/`ReasoningPart` become `Source`, `GeneratedFile`, `ReasoningOutput`; `StreamPart` and `GenerateContent` reference them), the Node API doc names the new types, and the changelog records the change as breaking. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- CHANGELOG.md | 12 ++++++ aimux-providers/src/anthropic/stream.rs | 40 ++++++++++--------- aimux-providers/src/bedrock/model.rs | 14 +++---- aimux-providers/src/cohere/model.rs | 14 +++---- aimux-providers/src/google/convert.rs | 26 ++++++------ aimux-providers/src/google/model.rs | 40 ++++++++++--------- aimux-providers/src/huggingface/responses.rs | 14 +++---- aimux-providers/src/mistral/model.rs | 10 ++--- aimux-providers/src/open_responses.rs | 6 +-- aimux-providers/src/openai/model.rs | 18 ++++----- .../src/openai/responses/responses_convert.rs | 22 +++++----- aimux-providers/src/vertex/anthropic_model.rs | 4 +- aimux-providers/src/vertex/model.rs | 14 +++---- aimux-providers/src/xai/model.rs | 18 ++++----- aimux-providers/src/xai/responses/mod.rs | 24 +++++------ aimux-providers/tests/anthropic_model_test.rs | 8 ++-- aimux-providers/tests/azure_responses_test.rs | 4 +- .../tests/bedrock_reasoning_test.rs | 6 +-- aimux-providers/tests/cohere_model_test.rs | 6 +-- .../tests/cohere_remaining_test.rs | 4 +- aimux-providers/tests/conformance_test.rs | 2 +- .../tests/data_loss_regression_test.rs | 33 ++++++++------- .../tests/deepseek_reasoning_test.rs | 6 +-- aimux-providers/tests/google_model_test.rs | 8 ++-- .../tests/google_provider_tools_test.rs | 10 ++--- aimux-providers/tests/groq_test.rs | 4 +- .../tests/huggingface_responses_test.rs | 22 +++++----- aimux-providers/tests/mistral_model_test.rs | 10 +++-- aimux-providers/tests/open_responses_test.rs | 6 +-- .../tests/openai_model_extended_test.rs | 6 +-- aimux-providers/tests/openai_model_test.rs | 8 ++-- .../tests/openai_responses_test.rs | 10 +++-- aimux-providers/tests/openrouter_test.rs | 2 +- aimux-providers/tests/vertex_model_test.rs | 6 +-- aimux-providers/tests/xai_responses_test.rs | 26 ++++++------ aimux-providers/tests/xai_test.rs | 12 +++--- bindings/node/src/types.ts | 6 +-- bindings/node/src/types/FilePart.ts | 7 ---- bindings/node/src/types/GenerateContent.ts | 6 ++- bindings/node/src/types/GenerateTextResult.ts | 12 +++--- bindings/node/src/types/GeneratedFile.ts | 11 +++++ bindings/node/src/types/ReasoningOutput.ts | 11 +++++ bindings/node/src/types/ReasoningPart.ts | 10 ----- bindings/node/src/types/Source.ts | 9 +++++ bindings/node/src/types/SourcePart.ts | 6 --- bindings/node/src/types/StreamPart.ts | 8 ++-- .../src/types/StreamTextResultAggregated.ts | 12 +++--- docs/api/node.md | 6 +-- 48 files changed, 304 insertions(+), 275 deletions(-) delete mode 100644 bindings/node/src/types/FilePart.ts create mode 100644 bindings/node/src/types/GeneratedFile.ts create mode 100644 bindings/node/src/types/ReasoningOutput.ts delete mode 100644 bindings/node/src/types/ReasoningPart.ts create mode 100644 bindings/node/src/types/Source.ts delete mode 100644 bindings/node/src/types/SourcePart.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c61a60f..0d0653d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `GenerateContent::ToolCall` with a non-string `input` no longer deserializes; the standalone `tool::ToolResult` gains a required `tool_name` and optional `dynamic` / `provider_metadata`. +- Source, generated-file and reasoning types are unified, one per concept + (`aimux-core`). `result::Source`, `result::GeneratedFile` and + `result::ReasoningOutput` are the payloads of `GenerateContent::{Source, + File, Reasoning}` and `StreamPart::{Source, File}` (newtype variants now) and + the elements of `sources`, `files` and `reasoning` on `GenerateTextResult` + and `StreamTextResultAggregated`; `StreamPart::ResponseMetadata` wraps + `types::ResponseMetadata`. `SourcePart`, `FilePart` and `ReasoningPart` are + removed (TypeScript: `Source`, `GeneratedFile`, `ReasoningOutput`). Wire + format: enum variants keep their JSON shape; the top-level `sources` / + `files` / `reasoning` arrays gain `provider_metadata` (so the reasoning + signature is no longer dropped), and a source's `url` / `title` serialize as + `null` when absent instead of being omitted. ## [Unreleased] diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 9217bc61f..e637015af 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -24,7 +24,7 @@ use futures::StreamExt; use aimux_core::AbortSignal; use aimux_core::error::AiMuxError; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::Warning; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, TokenUsage, Usage}; @@ -442,16 +442,18 @@ pub(crate) fn stream_parts_for_result_block( ), tool_use_id, )]; - parts.extend(results.iter().map(|result| StreamPart::Source { - id: generate_source_id(), - source_type: "url".to_string(), - url: str_field(result, "url"), - title: str_field(result, "title"), - provider_metadata: Some(json!({ - "anthropic": { - "pageAge": result.get("page_age").cloned().unwrap_or(Value::Null), - } - })), + parts.extend(results.iter().map(|result| { + StreamPart::Source(Source { + id: generate_source_id(), + source_type: "url".to_string(), + url: str_field(result, "url"), + title: str_field(result, "title"), + provider_metadata: Some(json!({ + "anthropic": { + "pageAge": result.get("page_age").cloned().unwrap_or(Value::Null), + } + })), + }) })); parts } @@ -591,12 +593,12 @@ pub(crate) fn parse_anthropic_content( thinking, signature, } => { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: thinking.clone(), provider_metadata: Some(json!({ "anthropic": { "signature": signature } })), - }); + })); } // Provider-executed (server-side) tool calls are surfaced as tool // calls so they round-trip on follow-up turns. @@ -635,12 +637,12 @@ pub(crate) fn parse_anthropic_content( } // Redacted thinking — upstream emits as reasoning with redactedData ContentBlock::RedactedThinking { data } => { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: String::new(), provider_metadata: Some(json!({ "anthropic": { "redactedData": data } })), - }); + })); } // ── Server-tool result blocks → GenerateContent::ToolResult ── // Payload shapes mirror the TS `doGenerate` switch one-for-one @@ -669,7 +671,7 @@ pub(crate) fn parse_anthropic_content( // Each result also becomes a `Source` — that is how the // URLs and titles reach `result.sources`. for result in results { - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source { id: generate_source_id(), source_type: "url".to_string(), url: str_field(result, "url"), @@ -680,7 +682,7 @@ pub(crate) fn parse_anthropic_content( .unwrap_or(Value::Null), } })), - }); + })); } } None => content.push(GenerateContent::ToolResult(ToolResult { @@ -991,11 +993,11 @@ pub(crate) async fn anthropic_stream_core( final_usage = super::usage::usage_from_anthropic(usage); } if !response_meta_emitted { - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: Some(message.id.clone()), timestamp: None, model_id: Some(message.model.clone()), - }); + })); response_meta_emitted = true; } } diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index 517aab464..be387cc87 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -15,7 +15,7 @@ use futures::{StreamExt, stream::BoxStream}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -310,11 +310,11 @@ impl LanguageModel for BedrockModel { let stream = async_stream::stream! { yield Ok(StreamPart::StreamStart { warnings: vec![] }); - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: request_id, timestamp: response_timestamp, model_id: Some(model_id.clone()), - }); + })); let messages = super::event_stream::decode_messages(&response_bytes); @@ -671,10 +671,10 @@ fn extract_content(block: &BedrockContentBlock, content: &mut Vec { match parsed.event_type.as_str() { "message-start" => { - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: parsed.id.clone(), timestamp: None, model_id: None, - }); + })); } "content-start" => { diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 128565583..c87514155 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -22,7 +22,7 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, Source}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, Warning}; use base64::Engine; @@ -1211,7 +1211,7 @@ pub fn extract_sources( for chunk in chunks { if let Some(web) = chunk.get("web") { - sources.push(GenerateContent::Source { + sources.push(GenerateContent::Source(Source { id: next_id(id_counter), source_type: "url".to_string(), url: web @@ -1223,9 +1223,9 @@ pub fn extract_sources( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } else if let Some(image) = chunk.get("image") { - sources.push(GenerateContent::Source { + sources.push(GenerateContent::Source(Source { id: next_id(id_counter), source_type: "url".to_string(), url: image @@ -1237,45 +1237,45 @@ pub fn extract_sources( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } else if let Some(rc) = chunk.get("retrievedContext") { let uri = rc.get("uri").and_then(|v| v.as_str()); let file_search_store = rc.get("fileSearchStore").and_then(|v| v.as_str()); let title = rc.get("title").and_then(|v| v.as_str()); if let Some(uri) = uri { if uri.starts_with("http://") || uri.starts_with("https://") { - sources.push(GenerateContent::Source { + sources.push(GenerateContent::Source(Source { id: next_id(id_counter), source_type: "url".to_string(), url: Some(uri.to_string()), title: title.map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } else { // Document with a file path (gs://, etc.). - sources.push(GenerateContent::Source { + sources.push(GenerateContent::Source(Source { id: next_id(id_counter), source_type: "document".to_string(), url: None, title: Some(title.unwrap_or("Unknown Document").to_string()), provider_metadata: None, - }); + })); } } else if file_search_store.is_some() { // New File Search format (no uri, has fileSearchStore). - sources.push(GenerateContent::Source { + sources.push(GenerateContent::Source(Source { id: next_id(id_counter), source_type: "document".to_string(), url: None, title: Some(title.unwrap_or("Unknown Document").to_string()), provider_metadata: None, - }); + })); } // else: no uri and no fileSearchStore → no source. } else if let Some(maps) = chunk.get("maps") && let Some(uri) = maps.get("uri").and_then(|v| v.as_str()) { - sources.push(GenerateContent::Source { + sources.push(GenerateContent::Source(Source { id: next_id(id_counter), source_type: "url".to_string(), url: Some(uri.to_string()), @@ -1284,7 +1284,7 @@ pub fn extract_sources( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } } diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index ea6aad934..0c295d0f5 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -11,7 +11,9 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{ + GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, StreamResult, +}; use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{ @@ -267,11 +269,11 @@ impl LanguageModel for GoogleModel { if !response_metadata_emitted && let Some(id) = &chunk.response_id { response_metadata_emitted = true; - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: Some(id.clone()), timestamp: None, model_id: chunk.model_version.clone(), - }); + })); } if let Some(usage) = &chunk.usage_metadata { @@ -303,21 +305,21 @@ impl LanguageModel for GoogleModel { let chunk_sources = extract_sources(candidate.grounding_metadata.as_ref(), &mut source_id); for src in chunk_sources { - if let GenerateContent::Source { + if let GenerateContent::Source(Source { url: Some(url), source_type, id, title, provider_metadata: None, - } = src + }) = src && emitted_source_urls.insert(url.clone()) { - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source { id, source_type, url: Some(url), title, provider_metadata: None, - }); + })); } } @@ -573,11 +575,11 @@ impl LanguageModel for GoogleModel { ) { // part.thought === true → upstream emits 'reasoning-file'; // emit plain File (reasoning-file is a separate PR). - yield Ok(StreamPart::File { + yield Ok(StreamPart::File(GeneratedFile { data: FileData::Data { data: FileBytes::Base64(data.to_string()) }, media_type: mime.to_string(), provider_metadata: thought_sig_meta.clone(), - }); + })); } } } @@ -777,10 +779,10 @@ fn extract_content_from_candidate( .and_then(serde_json::Value::as_bool) .unwrap_or(false); if is_thought { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: text.to_string(), provider_metadata: thought_sig_meta.clone(), - }); + })); } else { content.push(GenerateContent::Text { text: text.to_string(), @@ -822,13 +824,13 @@ fn extract_content_from_candidate( ) { // part.thought === true → upstream emits 'reasoning-file'; // emit plain File (reasoning-file is a separate PR). - content.push(GenerateContent::File { + content.push(GenerateContent::File(GeneratedFile { data: FileData::Data { data: FileBytes::Base64(data.to_string()), }, media_type: mime.to_string(), provider_metadata: thought_sig_meta.clone(), - }); + })); } } else if let Some(tc) = part.get("toolCall") { // Server-side tool call (provider-executed, Gemini 3). @@ -906,18 +908,18 @@ fn set_provider_metadata(item: &mut GenerateContent, meta: ProviderMetadata) { GenerateContent::Text { provider_metadata, .. } - | GenerateContent::Reasoning { + | GenerateContent::Reasoning(ReasoningOutput { provider_metadata, .. - } + }) | GenerateContent::ToolCall(RawToolCall { provider_metadata, .. }) - | GenerateContent::File { + | GenerateContent::File(GeneratedFile { provider_metadata, .. - } - | GenerateContent::Source { + }) + | GenerateContent::Source(Source { provider_metadata, .. - } + }) | GenerateContent::ToolResult(ToolResult { provider_metadata, .. }) => { diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index a96c24652..e0ad79fbe 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -30,7 +30,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::Tool; use aimux_core::types::{ @@ -313,11 +313,11 @@ impl LanguageModel for HuggingFaceResponsesModel { .get("model") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string); - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: response_id.clone(), timestamp: format_timestamp(created_at), model_id: model, - }); + })); } } @@ -1102,13 +1102,13 @@ fn build_generate_content(response: &Value) -> Result, AiMu for ann in annotations { let url = ann.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = ann.get("title").and_then(|v| v.as_str()); - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source { id: format!("id-{source_id_counter}"), source_type: "url".to_string(), url: Some(url.to_string()), title: title.map(std::string::ToString::to_string), provider_metadata: None, - }); + })); source_id_counter += 1; } } @@ -1123,12 +1123,12 @@ fn build_generate_content(response: &Value) -> Result, AiMu for cp in content_parts { let text = cp.get("text").and_then(|v| v.as_str()).unwrap_or(""); let item_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: text.to_string(), provider_metadata: Some(json!({ "huggingface": { "itemId": item_id } })), - }); + })); } } diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index bbc07c188..2d7f2d080 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -20,7 +20,7 @@ use serde_json::Value; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -256,10 +256,10 @@ impl LanguageModel for MistralModel { if let Some(r) = extract_reasoning_content(&choice.message.content) && !r.is_empty() { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: r, provider_metadata: None, - }); + })); } // Content can be a string (legacy) or an array of typed parts. @@ -409,7 +409,7 @@ impl LanguageModel for MistralModel { && (chunk.id.is_some() || chunk.model.is_some()) { response_metadata_emitted = true; - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: chunk.id.clone(), timestamp: chunk .created @@ -418,7 +418,7 @@ impl LanguageModel for MistralModel { }) .map(|dt| dt.to_rfc3339()), model_id: chunk.model.clone(), - }); + })); } // Update usage. diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 97366bcac..3e8701d81 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -22,7 +22,7 @@ use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ToolChoice}; use aimux_core::provider::Provider; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::Tool; use aimux_core::types::{ @@ -359,10 +359,10 @@ impl LanguageModel for OpenResponsesModel { { for cp in content_parts { if let Some(text) = cp.get("text").and_then(|t| t.as_str()) { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: text.to_string(), provider_metadata: None, - }); + })); } } } diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 1db9c6a29..208fec778 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -17,7 +17,7 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -312,10 +312,10 @@ pub async fn execute_generate( if let Some(reasoning) = reasoning_text && !reasoning.is_empty() { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: reasoning, provider_metadata: None, - }); + })); } if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { @@ -337,7 +337,7 @@ pub async fn execute_generate( if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") && let Some(uc) = ann.get("url_citation") { - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source { id: format!("annotation-{i}"), source_type: "url".to_string(), url: uc @@ -349,7 +349,7 @@ pub async fn execute_generate( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } } } @@ -555,14 +555,14 @@ pub async fn execute_stream( && (chunk.id.is_some() || chunk.model.is_some()) { response_metadata_emitted = true; - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: chunk.id.clone(), timestamp: chunk .created .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) .map(|dt| dt.to_rfc3339()), model_id: chunk.model.clone(), - }); + })); } // Update usage based on profile.stream_usage_key. @@ -671,7 +671,7 @@ pub async fn execute_stream( == Some("url_citation") && let Some(uc) = ann.get("url_citation") { - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source { id: format!("annotation-{i}"), source_type: "url".to_string(), url: uc @@ -683,7 +683,7 @@ pub async fn execute_stream( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } } } diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index 445a03f81..0f3b29b03 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -22,7 +22,7 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::error::ApiCallError; -use aimux_core::result::{GenerateContent, GenerateResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage, Warning}; @@ -154,7 +154,7 @@ pub fn build_responses_generate_result( for (i, ann) in annotations.iter().enumerate() { if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source { id: format!("annotation-{i}"), source_type: "url".to_string(), url: ann @@ -166,7 +166,7 @@ pub fn build_responses_generate_result( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } } } @@ -244,10 +244,10 @@ pub fn build_responses_generate_result( } })); if parts.is_empty() { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: String::new(), provider_metadata: reasoning_metadata.clone(), - }); + })); } else { for sp in parts { let text = sp @@ -255,10 +255,10 @@ pub fn build_responses_generate_result( .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text, provider_metadata: reasoning_metadata.clone(), - }); + })); } } } @@ -498,11 +498,11 @@ where chrono::DateTime::from_timestamp(secs as i64, 0) }) .map(|dt| dt.to_rfc3339()); - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: response_id.clone(), timestamp, model_id, - }); + })); } } @@ -885,7 +885,7 @@ where && ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source { id: generate_source_id(), source_type: "url".to_string(), url: ann @@ -897,7 +897,7 @@ where .and_then(|v| v.as_str()) .map(std::string::ToString::to_string), provider_metadata: None, - }); + })); } } diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index 91e331b4a..1b2475148 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -295,11 +295,11 @@ impl LanguageModel for VertexAnthropicModel { crate::anthropic::usage::usage_from_anthropic(usage); } if !response_meta_emitted { - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: Some(message.id.clone()), timestamp: None, model_id: Some(message.model.clone()), - }); + })); response_meta_emitted = true; } } diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index fb06072b8..0af5cac85 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -15,7 +15,7 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -311,11 +311,11 @@ impl LanguageModel for VertexModel { if !response_metadata_emitted && let Some(id) = &chunk.response_id { response_metadata_emitted = true; - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: Some(id.clone()), timestamp: response_timestamp.clone(), model_id: chunk.model_version.clone(), - }); + })); } if let Some(usage) = &chunk.usage_metadata { @@ -346,21 +346,21 @@ impl LanguageModel for VertexModel { let chunk_sources = extract_sources(candidate.grounding_metadata.as_ref(), &mut source_id); for src in chunk_sources { - if let GenerateContent::Source { + if let GenerateContent::Source(Source { url: Some(url), source_type, id, title, provider_metadata: None, - } = src + }) = src && emitted_source_urls.insert(url.clone()) { - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source { id, source_type, url: Some(url), title, provider_metadata: None, - }); + })); } } diff --git a/aimux-providers/src/xai/model.rs b/aimux-providers/src/xai/model.rs index fd2c28789..807acaa3f 100644 --- a/aimux-providers/src/xai/model.rs +++ b/aimux-providers/src/xai/model.rs @@ -17,7 +17,7 @@ use serde_json::Value; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -162,10 +162,10 @@ impl LanguageModel for XaiModel { if let Some(reasoning) = choice.message.reasoning_content && !reasoning.is_empty() { - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: reasoning, provider_metadata: None, - }); + })); } // Extract tool calls @@ -187,13 +187,13 @@ impl LanguageModel for XaiModel { // Extract citations if let Some(citations) = &data.citations { for url in citations { - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source { id: generate_source_id(), source_type: "url".to_string(), url: Some(url.clone()), title: None, provider_metadata: None, - }); + })); } } @@ -340,23 +340,23 @@ impl LanguageModel for XaiModel { chrono::DateTime::from_timestamp(c as i64, 0) .map(|dt| dt.to_rfc3339()) }); - yield Ok(StreamPart::ResponseMetadata { + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id: chunk.id.clone(), timestamp, model_id: chunk.model.clone(), - }); + })); } // Emit citations as sources. if let Some(citations) = &chunk.citations { for url in citations { - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source { id: generate_source_id(), source_type: "url".to_string(), url: Some(url.clone()), title: None, provider_metadata: None, - }); + })); } } diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index 8ddc5bdf2..62a88beac 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -27,7 +27,7 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -230,13 +230,13 @@ impl LanguageModel for XaiResponsesModel { .and_then(|v| v.as_str()) .unwrap_or(url) .to_string(); - content.push(GenerateContent::Source { + content.push(GenerateContent::Source(Source { id: generate_source_id(), source_type: "url".to_string(), url: Some(url.to_string()), title: Some(title), provider_metadata: None, - }); + })); } } } @@ -303,10 +303,10 @@ impl LanguageModel for XaiResponsesModel { if let Some(id) = item_id { meta["itemId"] = json!(id); } - content.push(GenerateContent::Reasoning { + content.push(GenerateContent::Reasoning(ReasoningOutput { text: reasoning_text, provider_metadata: Some(json!({ "xai": meta })), - }); + })); } } _ => {} @@ -433,7 +433,7 @@ impl LanguageModel for XaiResponsesModel { let timestamp = created_at.and_then(|c| { chrono::DateTime::from_timestamp(c as i64, 0).map(|dt| dt.to_rfc3339()) }); - yield Ok(StreamPart::ResponseMetadata { id, timestamp, model_id: model }); + yield Ok(StreamPart::ResponseMetadata(ResponseMetadata { id, timestamp, model_id: model })); } continue; } @@ -519,13 +519,13 @@ impl LanguageModel for XaiResponsesModel { if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { let url = ann.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = ann.get("title").and_then(|v| v.as_str()).unwrap_or(url).to_string(); - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source { id: generate_source_id(), source_type: "url".to_string(), url: Some(url.to_string()), title: Some(title), provider_metadata: None, - }); + })); } } } @@ -538,13 +538,13 @@ impl LanguageModel for XaiResponsesModel { if annotation.get("type").and_then(|v| v.as_str()) == Some("url_citation") { let url = annotation.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = annotation.get("title").and_then(|v| v.as_str()).unwrap_or(url).to_string(); - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source { id: generate_source_id(), source_type: "url".to_string(), url: Some(url.to_string()), title: Some(title), provider_metadata: None, - }); + })); } continue; } @@ -834,13 +834,13 @@ impl LanguageModel for XaiResponsesModel { if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { let url = ann.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = ann.get("title").and_then(|v| v.as_str()).unwrap_or(url).to_string(); - yield Ok(StreamPart::Source { + yield Ok(StreamPart::Source(Source { id: generate_source_id(), source_type: "url".to_string(), url: Some(url.to_string()), title: Some(title), provider_metadata: None, - }); + })); } } } diff --git a/aimux-providers/tests/anthropic_model_test.rs b/aimux-providers/tests/anthropic_model_test.rs index 8d57f0169..62f92b92e 100644 --- a/aimux-providers/tests/anthropic_model_test.rs +++ b/aimux-providers/tests/anthropic_model_test.rs @@ -35,7 +35,7 @@ use aimux_core::options::{CallOptions, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{FunctionTool, ProviderTool}; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::anthropic::AnthropicConfig; use aimux_providers::anthropic::model::AnthropicModel; @@ -899,7 +899,7 @@ mod do_stream { StreamPart::StreamStart { warnings } if warnings.is_empty() )); match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { assert_eq!(id.as_deref(), Some("msg_1")); assert_eq!(model_id.as_deref(), Some("claude-3-haiku-20240307")); } @@ -952,13 +952,13 @@ mod do_stream { let meta_count = parts .iter() - .filter(|p| matches!(p, StreamPart::ResponseMetadata { .. })) + .filter(|p| matches!(p, StreamPart::ResponseMetadata(_))) .count(); assert_eq!(meta_count, 1); // The metadata part must come right after StreamStart. assert!(matches!(parts[0], StreamPart::StreamStart { .. })); - assert!(matches!(parts[1], StreamPart::ResponseMetadata { .. })); + assert!(matches!(parts[1], StreamPart::ResponseMetadata(_))); } /// Usage: input_tokens from message_start, output_tokens from message_delta. diff --git a/aimux-providers/tests/azure_responses_test.rs b/aimux-providers/tests/azure_responses_test.rs index cb570c599..928042998 100644 --- a/aimux-providers/tests/azure_responses_test.rs +++ b/aimux-providers/tests/azure_responses_test.rs @@ -41,7 +41,7 @@ use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::{AzureConfig, AzureProvider, TokenProvider}; @@ -866,7 +866,7 @@ async fn should_stream_text_content() { // ResponseMetadata assert!(matches!( &parts[1], - StreamPart::ResponseMetadata { id, .. } if id.as_deref() == Some("resp_123") + StreamPart::ResponseMetadata(ResponseMetadata { id, .. }) if id.as_deref() == Some("resp_123") )); // TextStart diff --git a/aimux-providers/tests/bedrock_reasoning_test.rs b/aimux-providers/tests/bedrock_reasoning_test.rs index f5716eb2e..232fc4271 100644 --- a/aimux-providers/tests/bedrock_reasoning_test.rs +++ b/aimux-providers/tests/bedrock_reasoning_test.rs @@ -37,7 +37,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, StreamResult}; +use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::FinishReasonUnified; @@ -153,10 +153,10 @@ fn as_text(item: &GenerateContent) -> &str { fn as_reasoning(item: &GenerateContent) -> (&str, &Option) { match item { - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text, provider_metadata, - } => (text.as_str(), provider_metadata), + }) => (text.as_str(), provider_metadata), _ => panic!("expected Reasoning content, got {item:?}"), } } diff --git a/aimux-providers/tests/cohere_model_test.rs b/aimux-providers/tests/cohere_model_test.rs index 855820b5b..873f4b16e 100644 --- a/aimux-providers/tests/cohere_model_test.rs +++ b/aimux-providers/tests/cohere_model_test.rs @@ -18,7 +18,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; +use aimux_core::result::{GenerateContent, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::FinishReasonUnified; @@ -1141,13 +1141,13 @@ async fn should_extract_citations_from_response() { } for (i, c) in result.content[1..].iter().enumerate() { match c { - GenerateContent::Source { + GenerateContent::Source(Source { id, source_type, url, title, .. - } => { + }) => { assert_eq!(id, &format!("citation-{i}")); assert_eq!(source_type, "document"); assert_eq!(url, &None); diff --git a/aimux-providers/tests/cohere_remaining_test.rs b/aimux-providers/tests/cohere_remaining_test.rs index 51a93b103..5443980d2 100644 --- a/aimux-providers/tests/cohere_remaining_test.rs +++ b/aimux-providers/tests/cohere_remaining_test.rs @@ -18,7 +18,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, ReasoningOutput}; use aimux_core::tool::{FunctionTool, ProviderTool}; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -588,7 +588,7 @@ async fn should_extract_reasoning_from_response() { // reasoning + text. assert_eq!(result.content.len(), 2); match &result.content[0] { - GenerateContent::Reasoning { text, .. } => { + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { assert_eq!( text, "Okay, so I need to figure out what 2 + 2 is. Let me start by recalling what addition means." diff --git a/aimux-providers/tests/conformance_test.rs b/aimux-providers/tests/conformance_test.rs index 87388af58..42f571ffe 100644 --- a/aimux-providers/tests/conformance_test.rs +++ b/aimux-providers/tests/conformance_test.rs @@ -73,7 +73,7 @@ fn has_tool_call(content: &[GenerateContent]) -> bool { fn has_reasoning(content: &[GenerateContent]) -> bool { content .iter() - .any(|c| matches!(c, GenerateContent::Reasoning { .. })) + .any(|c| matches!(c, GenerateContent::Reasoning(_))) } #[allow(dead_code)] diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index 25dd0a113..bc41d9188 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -37,7 +37,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, StreamResult}; +use aimux_core::result::{GenerateContent, GeneratedFile, ReasoningOutput, Source, StreamResult}; use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{ProviderTool, Tool}; @@ -157,12 +157,12 @@ async fn collect(result: StreamResult) -> Vec { /// The base64 payload of a `GenerateContent::File`, or panic. fn file_base64(c: &GenerateContent) -> &str { match c { - GenerateContent::File { + GenerateContent::File(GeneratedFile { data: FileData::Data { data: FileBytes::Base64(s), }, .. - } => s, + }) => s, other => panic!("expected File with base64 data, got {other:?}"), } } @@ -170,7 +170,7 @@ fn file_base64(c: &GenerateContent) -> &str { fn files(content: &[GenerateContent]) -> Vec<&GenerateContent> { content .iter() - .filter(|c| matches!(c, GenerateContent::File { .. })) + .filter(|c| matches!(c, GenerateContent::File(_))) .collect() } @@ -188,10 +188,10 @@ fn reasonings(content: &[GenerateContent]) -> Vec<(&str, Option<&Value>)> { content .iter() .filter_map(|c| match c { - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text, provider_metadata, - } => Some((text.as_str(), provider_metadata.as_ref())), + }) => Some((text.as_str(), provider_metadata.as_ref())), _ => None, }) .collect() @@ -289,13 +289,13 @@ fn sources(content: &[GenerateContent]) -> Vec> { content .iter() .filter_map(|c| match c { - GenerateContent::Source { + GenerateContent::Source(Source { id, source_type, url, title, provider_metadata, - } => Some(( + }) => Some(( id.as_str(), source_type.as_str(), url.as_deref(), @@ -341,7 +341,7 @@ async fn finding_1_gemini_inline_data_surfaces_as_file() { let f = files(&result.content); assert_eq!(f.len(), 1, "exactly one inlineData part → one File"); match f[0] { - GenerateContent::File { media_type, .. } => { + GenerateContent::File(GeneratedFile { media_type, .. }) => { assert_eq!(media_type, "image/png"); } other => panic!("expected File, got {other:?}"), @@ -379,7 +379,7 @@ async fn finding_1_gemini_image_and_text_output_both_survive() { "text part comes first" ); assert!( - matches!(result.content[1], GenerateContent::File { .. }), + matches!(result.content[1], GenerateContent::File(_)), "inlineData part comes second" ); @@ -394,7 +394,9 @@ async fn finding_1_gemini_image_and_text_output_both_survive() { let f = files(&result.content); assert_eq!(f.len(), 1); match f[0] { - GenerateContent::File { media_type, .. } => assert_eq!(media_type, "image/png"), + GenerateContent::File(GeneratedFile { media_type, .. }) => { + assert_eq!(media_type, "image/png") + } other => panic!("expected File, got {other:?}"), } let b64 = file_base64(f[0]); @@ -439,14 +441,14 @@ async fn finding_1_gemini_inline_data_streams_as_file_part() { let file_parts: Vec<_> = parts .iter() .filter_map(|p| match p { - StreamPart::File { + StreamPart::File(GeneratedFile { data: FileData::Data { data: FileBytes::Base64(b64), }, media_type, .. - } => Some((b64.as_str(), media_type.as_str())), + }) => Some((b64.as_str(), media_type.as_str())), _ => None, }) .collect(); @@ -1190,10 +1192,7 @@ async fn finding_13_mistral_thinking_parts_become_reasoning_in_generate() { assert_eq!(texts(&result.content), vec!["4"]); // Ordering: reasoning precedes text (upstream contract). - assert!(matches!( - result.content[0], - GenerateContent::Reasoning { .. } - )); + assert!(matches!(result.content[0], GenerateContent::Reasoning(_))); assert!(matches!(result.content[1], GenerateContent::Text { .. })); } diff --git a/aimux-providers/tests/deepseek_reasoning_test.rs b/aimux-providers/tests/deepseek_reasoning_test.rs index c51b19be9..237c00e02 100644 --- a/aimux-providers/tests/deepseek_reasoning_test.rs +++ b/aimux-providers/tests/deepseek_reasoning_test.rs @@ -57,7 +57,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::CallOptions; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, ReasoningOutput}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{ReasoningEffort, Warning}; @@ -252,7 +252,7 @@ async fn should_prefer_reasoning_content_over_reasoning_field_when_both_provided other => panic!("expected Text, got {other:?}"), } match &result.content[1] { - GenerateContent::Reasoning { text, .. } => { + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { assert_eq!(text, "This is from reasoning_content") } other => panic!("expected Reasoning from reasoning_content, got {other:?}"), @@ -375,7 +375,7 @@ async fn should_extract_reasoning_content_and_text_from_deepseek_reasoning_fixtu other => panic!("expected Text, got {other:?}"), } match &result.content[1] { - GenerateContent::Reasoning { text, .. } => assert!( + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => assert!( text.contains("How many"), "expected the fixture reasoning_content, got: {text}" ), diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs index 4947e8b3d..e8654f43b 100644 --- a/aimux-providers/tests/google_model_test.rs +++ b/aimux-providers/tests/google_model_test.rs @@ -23,7 +23,7 @@ use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::google::convert::{ build_request_body, convert_to_google_messages, prepare_tools, @@ -1103,7 +1103,7 @@ mod do_stream { // response-metadata match &parts[1] { - StreamPart::ResponseMetadata { id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, .. }) => { assert_eq!(id.as_deref(), Some("resp-1")); } other => panic!("expected ResponseMetadata, got {other:?}"), @@ -1446,9 +1446,9 @@ mod do_stream { let rm = parts .iter() - .find(|p| matches!(p, StreamPart::ResponseMetadata { .. })); + .find(|p| matches!(p, StreamPart::ResponseMetadata(_))); match rm { - Some(StreamPart::ResponseMetadata { id, model_id, .. }) => { + Some(StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. })) => { assert_eq!(id.as_deref(), Some("resp-xyz")); assert_eq!(model_id.as_deref(), Some("gemini-2.0-flash-001")); } diff --git a/aimux-providers/tests/google_provider_tools_test.rs b/aimux-providers/tests/google_provider_tools_test.rs index bf2656c9f..f24929afd 100644 --- a/aimux-providers/tests/google_provider_tools_test.rs +++ b/aimux-providers/tests/google_provider_tools_test.rs @@ -40,7 +40,7 @@ use aimux_core::language_model_message::{ }; use aimux_core::message::{ModelMessage, Role}; use aimux_core::options::{CallOptions, ProviderTool, Tool}; -use aimux_core::result::{GenerateContent, StreamResult}; +use aimux_core::result::{GenerateContent, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::Warning; @@ -185,13 +185,13 @@ fn stream_sources(parts: &[StreamPart]) -> Vec<(String, String, Option, parts .iter() .filter_map(|p| match p { - StreamPart::Source { + StreamPart::Source(Source { id, source_type, url, title, .. - } => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), + }) => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), _ => None, }) .collect() @@ -260,13 +260,13 @@ fn gen_sources( content .iter() .filter_map(|c| match c { - GenerateContent::Source { + GenerateContent::Source(Source { id, source_type, url, title, .. - } => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), + }) => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), _ => None, }) .collect() diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 4196e45ab..500de49cb 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -29,7 +29,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, ReasoningOutput}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -1109,7 +1109,7 @@ mod do_generate { .unwrap(); let reasoning = result.content.iter().find_map(|c| match c { - GenerateContent::Reasoning { text, .. } => Some(text.clone()), + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => Some(text.clone()), _ => None, }); assert!(reasoning.is_some()); diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index c231ebd55..a00387a8f 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -31,10 +31,10 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::huggingface::responses::convert_to_huggingface_responses_messages; use aimux_providers::{HuggingFaceConfig, HuggingFaceProvider}; @@ -453,7 +453,7 @@ async fn should_generate_text_and_sources_from_annotations() { other => panic!("expected Text at [0], got {other:?}"), } match &result.content[1] { - GenerateContent::Source { id, url, title, .. } => { + GenerateContent::Source(Source { id, url, title, .. }) => { assert_eq!(id, "id-0"); assert_eq!(url.as_deref(), Some("https://example.com/article1")); assert_eq!(title.as_deref(), Some("AI Developments Article")); @@ -461,7 +461,7 @@ async fn should_generate_text_and_sources_from_annotations() { other => panic!("expected Source at [1], got {other:?}"), } match &result.content[2] { - GenerateContent::Source { id, url, title, .. } => { + GenerateContent::Source(Source { id, url, title, .. }) => { assert_eq!(id, "id-1"); assert_eq!(url.as_deref(), Some("https://test.com/article2")); assert_eq!(title.as_deref(), Some("Industry Trends Report")); @@ -565,11 +565,11 @@ async fn should_handle_mcp_tools_with_annotations() { other => panic!("expected Text at [2], got {other:?}"), } match &result.content[3] { - GenerateContent::Source { id, .. } => assert_eq!(id, "id-0"), + GenerateContent::Source(Source { id, .. }) => assert_eq!(id, "id-0"), other => panic!("expected Source at [3], got {other:?}"), } match &result.content[4] { - GenerateContent::Source { id, .. } => assert_eq!(id, "id-1"), + GenerateContent::Source(Source { id, .. }) => assert_eq!(id, "id-1"), other => panic!("expected Source at [4], got {other:?}"), } @@ -634,7 +634,7 @@ async fn should_stream_text_deltas() { assert!(matches!(&parts[0], StreamPart::StreamStart { warnings } if warnings.is_empty())); match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { assert_eq!(id.as_deref(), Some("resp_test")); assert_eq!(model_id.as_deref(), Some("deepseek-ai/DeepSeek-V3-0324")); } @@ -1171,7 +1171,7 @@ async fn should_stream_tool_calls() { assert!(matches!(&parts[0], StreamPart::StreamStart { .. })); match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { assert_eq!(id.as_deref(), Some("resp_tool_stream")); assert_eq!(model_id.as_deref(), Some("deepseek-ai/DeepSeek-V3-0324")); } @@ -1420,10 +1420,10 @@ async fn should_handle_reasoning_content_in_responses() { assert_eq!(result.content.len(), 2); match &result.content[0] { - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text, provider_metadata, - } => { + }) => { assert_eq!(text, "Let me think about this problem step by step..."); assert_eq!( provider_metadata.as_ref(), @@ -1497,7 +1497,7 @@ async fn should_stream_reasoning_content() { assert!(matches!(&parts[0], StreamPart::StreamStart { .. })); match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { assert_eq!(id.as_deref(), Some("resp_reasoning_stream")); assert_eq!(model_id.as_deref(), Some("deepseek-ai/DeepSeek-R1")); } diff --git a/aimux-providers/tests/mistral_model_test.rs b/aimux-providers/tests/mistral_model_test.rs index 675a9793b..49d45222b 100644 --- a/aimux-providers/tests/mistral_model_test.rs +++ b/aimux-providers/tests/mistral_model_test.rs @@ -16,10 +16,10 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; +use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::{MistralConfig, MistralProvider}; @@ -1040,7 +1040,7 @@ async fn should_extract_text_from_mixed_thinking_and_text() { // Reasoning first, then the joined text. assert_eq!(result.content.len(), 2); match &result.content[0] { - GenerateContent::Reasoning { text, .. } => { + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { assert_eq!( text, "First thought.Second thought.", "both thinking parts must survive, in order" @@ -1522,7 +1522,9 @@ async fn should_stream_response_metadata() { let parts = collect_stream(result).await; let meta = parts.iter().find_map(|p| match p { - StreamPart::ResponseMetadata { id, model_id, .. } => Some((id.clone(), model_id.clone())), + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { + Some((id.clone(), model_id.clone())) + } _ => None, }); let (id, model_id) = meta.expect("should have ResponseMetadata"); diff --git a/aimux-providers/tests/open_responses_test.rs b/aimux-providers/tests/open_responses_test.rs index 5f817fd48..037959263 100644 --- a/aimux-providers/tests/open_responses_test.rs +++ b/aimux-providers/tests/open_responses_test.rs @@ -22,7 +22,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, ReasoningOutput}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -1078,10 +1078,10 @@ mod do_generate_tests { assert_eq!(result.content.len(), 2); assert_eq!( result.content[0], - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text: "reasoning content".to_string(), provider_metadata: None, - } + }) ); assert_eq!( result.content[1], diff --git a/aimux-providers/tests/openai_model_extended_test.rs b/aimux-providers/tests/openai_model_extended_test.rs index 560f93ccc..ad84a04a9 100644 --- a/aimux-providers/tests/openai_model_extended_test.rs +++ b/aimux-providers/tests/openai_model_extended_test.rs @@ -13,7 +13,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, Source}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -167,12 +167,12 @@ mod do_generate_extended { other => panic!("expected Text, got {other:?}"), } match &result.content[1] { - GenerateContent::Source { + GenerateContent::Source(Source { source_type, url, title, .. - } => { + }) => { assert_eq!(source_type, "url"); assert_eq!(url.as_deref(), Some("https://example.com/doc1.pdf")); assert_eq!(title.as_deref(), Some("Document 1")); diff --git a/aimux-providers/tests/openai_model_test.rs b/aimux-providers/tests/openai_model_test.rs index 538f4e91e..73ef0fdda 100644 --- a/aimux-providers/tests/openai_model_test.rs +++ b/aimux-providers/tests/openai_model_test.rs @@ -36,7 +36,7 @@ use aimux_core::options::{CallOptions, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::{OpenAIConfig, OpenAIProvider}; @@ -842,7 +842,7 @@ mod do_stream { // response-metadata with id and modelId match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { assert_eq!( id.as_deref(), Some("chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP") @@ -952,7 +952,7 @@ mod do_stream { assert!(matches!(parts[0], StreamPart::StreamStart { .. })); // Verify response-metadata - assert!(matches!(parts[1], StreamPart::ResponseMetadata { .. })); + assert!(matches!(parts[1], StreamPart::ResponseMetadata(_))); // Verify tool-input-start let tool_start = parts.iter().find(|p| { @@ -1753,7 +1753,7 @@ mod do_stream { // Verify response-metadata with modelId "o4-mini" match &parts[1] { - StreamPart::ResponseMetadata { model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { model_id, .. }) => { assert_eq!(model_id.as_deref(), Some("o4-mini")); } other => panic!("expected ResponseMetadata, got {other:?}"), diff --git a/aimux-providers/tests/openai_responses_test.rs b/aimux-providers/tests/openai_responses_test.rs index a52c31ad0..db1e30bcb 100644 --- a/aimux-providers/tests/openai_responses_test.rs +++ b/aimux-providers/tests/openai_responses_test.rs @@ -29,10 +29,10 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; +use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::{OpenAIConfig, OpenAIProvider}; @@ -931,7 +931,9 @@ mod do_generate_response { assert_eq!(result.content.len(), 2); match &result.content[0] { - GenerateContent::Reasoning { text, .. } => assert_eq!(text, "thinking..."), + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { + assert_eq!(text, "thinking...") + } other => panic!("expected Reasoning, got {other:?}"), } match &result.content[1] { @@ -1003,7 +1005,7 @@ mod do_stream { )); match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { + StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { assert_eq!(id.as_deref(), Some("resp_1")); assert_eq!(model_id.as_deref(), Some("gpt-4o-2024-07-18")); } diff --git a/aimux-providers/tests/openrouter_test.rs b/aimux-providers/tests/openrouter_test.rs index 85fc67c73..f9944fa1f 100644 --- a/aimux-providers/tests/openrouter_test.rs +++ b/aimux-providers/tests/openrouter_test.rs @@ -652,7 +652,7 @@ mod conformance { fn has_reasoning(content: &[GenerateContent]) -> bool { content .iter() - .any(|c| matches!(c, GenerateContent::Reasoning { .. })) + .any(|c| matches!(c, GenerateContent::Reasoning(_))) } fn has_finish(parts: &[StreamPart]) -> bool { diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs index 41d1ec1f5..8b66ed7c8 100644 --- a/aimux-providers/tests/vertex_model_test.rs +++ b/aimux-providers/tests/vertex_model_test.rs @@ -23,7 +23,7 @@ use aimux_core::language_model_message::{ }; use aimux_core::message::{ModelMessage, Role}; use aimux_core::options::{CallOptions, ProviderTool, Tool}; -use aimux_core::result::{GenerateContent, StreamResult}; +use aimux_core::result::{GenerateContent, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::FinishReasonUnified; @@ -897,13 +897,13 @@ fn stream_sources(parts: &[StreamPart]) -> Vec<(String, String, Option, parts .iter() .filter_map(|p| match p { - StreamPart::Source { + StreamPart::Source(Source { id, source_type, url, title, .. - } => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), + }) => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), _ => None, }) .collect() diff --git a/aimux-providers/tests/xai_responses_test.rs b/aimux-providers/tests/xai_responses_test.rs index 4b956e7ed..88008b4b0 100644 --- a/aimux-providers/tests/xai_responses_test.rs +++ b/aimux-providers/tests/xai_responses_test.rs @@ -24,7 +24,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; use aimux_core::types::ReasoningEffort; @@ -356,10 +356,10 @@ mod reasoning { assert_eq!(result.content.len(), 2); match &result.content[0] { - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text, provider_metadata, - } => { + }) => { assert_eq!(text, "First, analyze the question carefully."); assert_eq!( provider_metadata, @@ -415,10 +415,10 @@ mod reasoning { .unwrap(); match &result.content[0] { - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text, provider_metadata, - } => { + }) => { assert_eq!(text, "Thinking through the problem."); assert_eq!( provider_metadata, @@ -469,7 +469,7 @@ mod reasoning { .unwrap(); match &result.content[0] { - GenerateContent::Reasoning { text, .. } => { + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { assert_eq!(text, "Let me think step by step."); } other => panic!("expected Reasoning, got {other:?}"), @@ -516,10 +516,10 @@ mod reasoning { .unwrap(); match &result.content[0] { - GenerateContent::Reasoning { + GenerateContent::Reasoning(ReasoningOutput { text, provider_metadata, - } => { + }) => { assert_eq!(text, ""); assert_eq!( provider_metadata, @@ -1711,7 +1711,7 @@ mod citations { } // Source 1 match &result.content[1] { - GenerateContent::Source { url, title, .. } => { + GenerateContent::Source(Source { url, title, .. }) => { assert_eq!(url.as_deref(), Some("https://example.com")); assert_eq!(title.as_deref(), Some("example title")); } @@ -1719,7 +1719,7 @@ mod citations { } // Source 2 (title falls back to url) match &result.content[2] { - GenerateContent::Source { url, title, .. } => { + GenerateContent::Source(Source { url, title, .. }) => { assert_eq!(url.as_deref(), Some("https://test.com")); assert_eq!(title.as_deref(), Some("https://test.com")); } @@ -2211,11 +2211,9 @@ mod do_stream { .unwrap(); let parts = collect_stream(result).await; - let source = parts - .iter() - .find(|p| matches!(p, StreamPart::Source { .. })); + let source = parts.iter().find(|p| matches!(p, StreamPart::Source(_))); assert!(source.is_some()); - if let Some(StreamPart::Source { url, title, .. }) = source { + if let Some(StreamPart::Source(Source { url, title, .. })) = source { assert_eq!(url.as_deref(), Some("https://example.com")); assert_eq!(title.as_deref(), Some("example")); } diff --git a/aimux-providers/tests/xai_test.rs b/aimux-providers/tests/xai_test.rs index 66898970e..37c6d0c4d 100644 --- a/aimux-providers/tests/xai_test.rs +++ b/aimux-providers/tests/xai_test.rs @@ -27,7 +27,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::result::GenerateContent; +use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -1826,9 +1826,9 @@ mod do_generate { .content .iter() .filter_map(|c| match c { - GenerateContent::Source { + GenerateContent::Source(Source { url, source_type, .. - } => Some((url.clone(), source_type.clone())), + }) => Some((url.clone(), source_type.clone())), _ => None, }) .collect(); @@ -1880,7 +1880,7 @@ mod do_generate { let has_reasoning = result .content .iter() - .any(|c| matches!(c, GenerateContent::Reasoning { .. })); + .any(|c| matches!(c, GenerateContent::Reasoning(_))); assert!(has_reasoning, "should have reasoning content"); } @@ -2131,7 +2131,7 @@ mod do_stream { let sources: Vec<_> = parts .iter() .filter_map(|p| match p { - StreamPart::Source { url, .. } => url.clone(), + StreamPart::Source(Source { url, .. }) => url.clone(), _ => None, }) .collect(); @@ -2381,7 +2381,7 @@ mod reasoning { assert!(has_text, "should have text 'Hello'"); let reasoning = result.content.iter().find_map(|c| match c { - GenerateContent::Reasoning { text, .. } => Some(text.clone()), + GenerateContent::Reasoning(ReasoningOutput { text, .. }) => Some(text.clone()), _ => None, }); assert!(reasoning.is_some(), "should have reasoning content"); diff --git a/bindings/node/src/types.ts b/bindings/node/src/types.ts index 6c4c779c2..a6211810e 100644 --- a/bindings/node/src/types.ts +++ b/bindings/node/src/types.ts @@ -30,7 +30,6 @@ export type * from './types/EmbeddingResult' export type * from './types/EmbeddingUsage' export type * from './types/FileBytes' export type * from './types/FileData' -export type * from './types/FilePart' export type * from './types/Fingerprint' export type * from './types/FinishReason' export type * from './types/FinishReasonUnified' @@ -40,6 +39,7 @@ export type * from './types/GenerateObjectResult' export type * from './types/GenerateResult' export type * from './types/GenerateTextOptions' export type * from './types/GenerateTextResult' +export type * from './types/GeneratedFile' export type * from './types/HttpExchange' export type * from './types/HttpRecord' export type * from './types/ImageCallOptions' @@ -74,7 +74,7 @@ export type * from './types/ProviderTool' export type * from './types/RawToolCall' export type * from './types/ReasoningEffort' export type * from './types/ReasoningMode' -export type * from './types/ReasoningPart' +export type * from './types/ReasoningOutput' export type * from './types/ReasoningSpec' export type * from './types/ReasoningVisibility' export type * from './types/Recording' @@ -103,7 +103,7 @@ export type * from './types/SessionSource' export type * from './types/SessionStepStat' export type * from './types/SessionView' export type * from './types/Size' -export type * from './types/SourcePart' +export type * from './types/Source' export type * from './types/SpeechCallOptions' export type * from './types/SpeechRequest' export type * from './types/SpeechResponse' diff --git a/bindings/node/src/types/FilePart.ts b/bindings/node/src/types/FilePart.ts deleted file mode 100644 index b4676404e..000000000 --- a/bindings/node/src/types/FilePart.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 { FileData } from "./FileData"; - -/** - * A file generated by the model, extracted to the top level (M7). - */ -export type FilePart = { data: FileData, media_type: string, }; diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index aa72530dc..78bc710ea 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -1,10 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileData } from "./FileData"; +import type { GeneratedFile } from "./GeneratedFile"; import type { RawToolCall } from "./RawToolCall"; +import type { ReasoningOutput } from "./ReasoningOutput"; +import type { Source } from "./Source"; import type { ToolResult } from "./ToolResult"; import type { JsonValue } from "./serde_json/JsonValue"; /** * A content item in the generation result. */ -export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": RawToolCall } | { "Source": { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: JsonValue | null, } } | { "Reasoning": { text: string, provider_metadata: JsonValue | null, } } | { "File": { data: FileData, media_type: string, provider_metadata?: JsonValue | null, } } | { "ToolResult": ToolResult }; +export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": RawToolCall } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ToolResult": ToolResult }; diff --git a/bindings/node/src/types/GenerateTextResult.ts b/bindings/node/src/types/GenerateTextResult.ts index a677b7004..c7be4bfd3 100644 --- a/bindings/node/src/types/GenerateTextResult.ts +++ b/bindings/node/src/types/GenerateTextResult.ts @@ -1,11 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FilePart } from "./FilePart"; import type { FinishReason } from "./FinishReason"; import type { GenerateResult } from "./GenerateResult"; +import type { GeneratedFile } from "./GeneratedFile"; import type { ModelMessage } from "./ModelMessage"; -import type { ReasoningPart } from "./ReasoningPart"; +import type { ReasoningOutput } from "./ReasoningOutput"; import type { ResponseMetadata } from "./ResponseMetadata"; -import type { SourcePart } from "./SourcePart"; +import type { Source } from "./Source"; import type { ToolCall } from "./ToolCall"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; @@ -42,7 +42,7 @@ raw: GenerateResult, /** * Reasoning / thinking segments from the model. */ -reasoning: Array, +reasoning: Array, /** * Concatenated reasoning text (convenience for `reasoning.iter().map(text).join("")`). */ @@ -50,11 +50,11 @@ 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). diff --git a/bindings/node/src/types/GeneratedFile.ts b/bindings/node/src/types/GeneratedFile.ts new file mode 100644 index 000000000..bd81f2a5e --- /dev/null +++ b/bindings/node/src/types/GeneratedFile.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileData } from "./FileData"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A file generated by the model (e.g. an image or document). Mirrors the Vercel + * AI SDK `file` content type: `data` is a tagged union covering base64 data, + * URL, or provider reference. The payload of both `GenerateContent::File` and + * `StreamPart::File`, and the element of the text results' `files`. + */ +export type GeneratedFile = { data: FileData, media_type: string, provider_metadata?: JsonValue | null, }; diff --git a/bindings/node/src/types/ReasoningOutput.ts b/bindings/node/src/types/ReasoningOutput.ts new file mode 100644 index 000000000..8ed0e3607 --- /dev/null +++ b/bindings/node/src/types/ReasoningOutput.ts @@ -0,0 +1,11 @@ +// 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"; + +/** + * A reasoning / thinking segment produced by the model. Mirrors the Vercel AI + * SDK `reasoning` content type; `provider_metadata` carries provider-specific + * data such as the Anthropic thinking-block signature + * (`providerMetadata.anthropic.signature`). The payload of + * `GenerateContent::Reasoning` and the element of the text results' `reasoning`. + */ +export type ReasoningOutput = { text: string, provider_metadata: JsonValue | null, }; diff --git a/bindings/node/src/types/ReasoningPart.ts b/bindings/node/src/types/ReasoningPart.ts deleted file mode 100644 index 83feed56b..000000000 --- a/bindings/node/src/types/ReasoningPart.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A reasoning / thinking segment, extracted to the top level (M7). - */ -export type ReasoningPart = { -/** - * The reasoning text. - */ -text: string, }; diff --git a/bindings/node/src/types/Source.ts b/bindings/node/src/types/Source.ts new file mode 100644 index 000000000..261797b42 --- /dev/null +++ b/bindings/node/src/types/Source.ts @@ -0,0 +1,9 @@ +// 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"; + +/** + * A source / citation (e.g. URL citation from search-preview models). The + * payload of both `GenerateContent::Source` and `StreamPart::Source`, and the + * element of the text results' `sources`. + */ +export type Source = { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: JsonValue | null, }; diff --git a/bindings/node/src/types/SourcePart.ts b/bindings/node/src/types/SourcePart.ts deleted file mode 100644 index 050ce8115..000000000 --- a/bindings/node/src/types/SourcePart.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. - -/** - * A source / citation, extracted to the top level (M7). - */ -export type SourcePart = { id: string, source_type: string, url?: string | null, title?: string | null, }; diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index 153fddc19..1d6722570 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -1,7 +1,9 @@ // 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 { FileData } from "./FileData"; import type { FinishReason } from "./FinishReason"; +import type { GeneratedFile } from "./GeneratedFile"; +import type { ResponseMetadata } from "./ResponseMetadata"; +import type { Source } from "./Source"; import type { ToolCall } from "./ToolCall"; import type { ToolResult } from "./ToolResult"; import type { Usage } from "./Usage"; @@ -27,7 +29,7 @@ dynamic?: boolean | null, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": { data: FileData, media_type: string, provider_metadata?: JsonValue | null, } } | { "ReasoningStart": { id: string, +title?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningStart": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ @@ -39,4 +41,4 @@ provider_metadata: JsonValue | null, } } | { "ReasoningEnd": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). */ -provider_metadata: JsonValue | null, } } | { "ResponseMetadata": { id: string | null, timestamp: string | null, model_id: string | null, } } | { "Source": { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: JsonValue | null, } } | { "Raw": { raw_value: JsonValue, } }; +provider_metadata: JsonValue | null, } } | { "ResponseMetadata": ResponseMetadata } | { "Source": Source } | { "Raw": { raw_value: JsonValue, } }; diff --git a/bindings/node/src/types/StreamTextResultAggregated.ts b/bindings/node/src/types/StreamTextResultAggregated.ts index ae61b42bc..7bf5f51bf 100644 --- a/bindings/node/src/types/StreamTextResultAggregated.ts +++ b/bindings/node/src/types/StreamTextResultAggregated.ts @@ -1,10 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FilePart } from "./FilePart"; import type { FinishReason } from "./FinishReason"; +import type { GeneratedFile } from "./GeneratedFile"; import type { ModelMessage } from "./ModelMessage"; -import type { ReasoningPart } from "./ReasoningPart"; +import type { ReasoningOutput } from "./ReasoningOutput"; import type { ResponseMetadata } from "./ResponseMetadata"; -import type { SourcePart } from "./SourcePart"; +import type { Source } from "./Source"; import type { ToolCall } from "./ToolCall"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; @@ -23,7 +23,7 @@ text: string, /** * Reasoning / thinking segments. */ -reasoning: Array, +reasoning: Array, /** * Concatenated reasoning text. */ @@ -35,11 +35,11 @@ tool_calls: Array, /** * Sources / citations. */ -sources: Array, +sources: Array, /** * Files generated by the model. */ -files: Array, +files: Array, /** * Why generation stopped. */ diff --git a/docs/api/node.md b/docs/api/node.md index 6895ccdea..974751062 100644 --- a/docs/api/node.md +++ b/docs/api/node.md @@ -540,10 +540,10 @@ export type GenerateTextResult = { usage: Usage // token usage warnings: Array // warnings raw: GenerateResult // raw provider result (includes full content) - reasoning: Array // reasoning / thinking segments + reasoning: Array // reasoning / thinking segments reasoning_text: string // the reasoning segments concatenated - sources: Array // sources / citations (search-preview models) - files: Array // files generated by the model + sources: Array // sources / citations (search-preview models) + files: Array // files generated by the model response_messages: Array // assistant messages ready for the next turn raw_finish_reason: string | null // provider's own finish-reason string provider_metadata: JsonValue | null // mirrored from raw.provider_metadata From 6ad20be9e5259191c5b899b7638fede78657f8f3 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Mon, 5 Oct 2026 21:24:21 +0800 Subject: [PATCH 3/6] refactor(core)!: add the missing upstream content variants and usage fields What: provider content and stream parts gain custom, reasoning-file and tool-approval-request. A generated file holds data or a URL only. Input and output token details are separate types with upstream's fields, and raw usage is a JSON object. Request and response information carry body and headers, and a URL file keeps its original URL. Hand-written tests that pinned the old shapes are deleted rather than rewritten. Why: an independent field-by-field comparison with the pinned upstream content, stream-part, usage and result types found these differences. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/Cargo.toml | 10 +- aimux-core/src/composite.rs | 28 +- aimux-core/src/content.rs | 29 + aimux-core/src/download.rs | 130 ++ .../src/download_guard.rs | 13 +- aimux-core/src/error.rs | 6 + aimux-core/src/generate.rs | 664 ++------ aimux-core/src/language_model_message.rs | 6 +- aimux-core/src/lib.rs | 16 +- aimux-core/src/moa.rs | 703 +------- aimux-core/src/openai_output.rs | 946 +---------- aimux-core/src/recording.rs | 7 +- aimux-core/src/replay.rs | 160 +- aimux-core/src/response_messages.rs | 325 ++-- aimux-core/src/result.rs | 221 ++- aimux-core/src/router.rs | 528 ------- aimux-core/src/shared.rs | 20 +- aimux-core/src/stream_part.rs | 68 +- aimux-core/src/trace/layer.rs | 33 +- aimux-core/src/types.rs | 18 +- aimux-core/tests/cache_probe_test.rs | 608 +------ aimux-core/tests/error_value_golden_test.rs | 110 -- aimux-core/tests/m11_m12_m6_test.rs | 366 ----- aimux-core/tests/m7_aggregation_test.rs | 336 ---- aimux-core/tests/recording_e2e_test.rs | 236 --- .../tests/response_messages_tool_test.rs | 499 ------ aimux-core/tests/session_test.rs | 182 --- .../tests/stream_timeout_matrix_test.rs | 179 --- aimux-core/tests/tool_input_test.rs | 420 +---- aimux-ffi/src/lib.rs | 377 +---- aimux-provider-utils/src/lib.rs | 3 +- aimux-providers/src/anthropic/convert.rs | 3 + aimux-providers/src/anthropic/stream.rs | 29 +- aimux-providers/src/anthropic/usage.rs | 10 +- aimux-providers/src/azure/responses.rs | 6 +- aimux-providers/src/bedrock/convert.rs | 11 +- aimux-providers/src/bedrock/model.rs | 24 +- aimux-providers/src/codex.rs | 6 +- aimux-providers/src/cohere/model.rs | 33 +- aimux-providers/src/google/convert.rs | 34 +- aimux-providers/src/google/model.rs | 102 +- aimux-providers/src/huggingface/responses.rs | 36 +- aimux-providers/src/mistral/convert.rs | 5 +- aimux-providers/src/mistral/model.rs | 39 +- aimux-providers/src/open_responses.rs | 27 +- aimux-providers/src/openai/convert.rs | 5 +- aimux-providers/src/openai/model.rs | 37 +- .../src/openai/responses/convert.rs | 29 +- aimux-providers/src/openai/responses/mod.rs | 6 +- .../src/openai/responses/responses_convert.rs | 83 +- aimux-providers/src/vertex/anthropic_model.rs | 24 +- aimux-providers/src/vertex/model.rs | 66 +- aimux-providers/src/xai/convert.rs | 10 +- aimux-providers/src/xai/model.rs | 37 +- aimux-providers/src/xai/responses/convert.rs | 10 +- aimux-providers/src/xai/responses/mod.rs | 31 +- aimux-providers/tests/alibaba_test.rs | 52 +- .../tests/anthropic_aws_model_test.rs | 125 +- aimux-providers/tests/anthropic_model_test.rs | 31 - aimux-providers/tests/azure_model_test.rs | 169 +- aimux-providers/tests/azure_responses_test.rs | 71 - aimux-providers/tests/bedrock_model_test.rs | 179 +-- .../tests/bedrock_reasoning_test.rs | 105 +- .../tests/bedrock_remaining_test.rs | 429 +---- aimux-providers/tests/cohere_model_test.rs | 567 +------ .../tests/cohere_remaining_test.rs | 110 +- .../tests/data_loss_regression_test.rs | 165 +- aimux-providers/tests/deepseek_chat_test.rs | 224 --- .../tests/deepseek_reasoning_test.rs | 334 ---- aimux-providers/tests/google_model_test.rs | 94 -- .../tests/google_provider_tools_test.rs | 141 -- aimux-providers/tests/groq_test.rs | 18 - .../tests/huggingface_responses_test.rs | 462 +----- aimux-providers/tests/mistral_model_test.rs | 571 +------ .../tests/mistral_remaining_test.rs | 44 +- .../tests/openai_compatible_test.rs | 68 +- .../tests/openai_model_extended_test.rs | 531 +------ aimux-providers/tests/openai_model_test.rs | 343 +--- .../tests/openai_responses_test.rs | 33 - aimux-providers/tests/openrouter_test.rs | 29 - aimux-providers/tests/perplexity_test.rs | 28 - .../tests/vertex_anthropic_test.rs | 59 - aimux-providers/tests/vertex_model_test.rs | 386 +---- aimux-providers/tests/xai_responses_test.rs | 566 +------ aimux-providers/tests/xai_test.rs | 1407 +---------------- bindings/flutter/lib/types.dart | 205 ++- bindings/flutter/lib/types.g.dart | 37 +- bindings/flutter/test/typed_model_test.dart | 51 - .../flutter/test/typed_round_trip_test.dart | 71 +- bindings/go/batch1_e2e_test.go | 42 - bindings/go/roundtrip_test.go | 56 - bindings/go/types.go | 43 +- .../main/java/ai/arcships/aimux/Types.java | 618 ++++++-- .../java/ai/arcships/aimux/ContractTest.java | 6 +- .../main/kotlin/ai/arcships/aimux/Types.kt | 170 +- .../kotlin/ai/arcships/aimux/ContractTest.kt | 4 +- .../ai/arcships/aimux/TypedModelTest.kt | 14 +- bindings/node/src/error.rs | 4 +- bindings/node/src/index.ts | 16 + bindings/node/src/types.ts | 11 +- bindings/node/src/types/AiMuxError.ts | 2 +- bindings/node/src/types/ContentPart.ts | 3 +- bindings/node/src/types/FileData.ts | 2 +- bindings/node/src/types/GenerateContent.ts | 3 +- bindings/node/src/types/GenerateResponse.ts | 7 + bindings/node/src/types/GenerateResult.ts | 38 +- bindings/node/src/types/GenerateTextResult.ts | 11 +- bindings/node/src/types/GeneratedFile.ts | 6 +- bindings/node/src/types/GeneratedFileData.ts | 7 + .../{TokenUsage.ts => InputTokenUsage.ts} | 14 +- bindings/node/src/types/OutputTokenUsage.ts | 14 + .../node/src/types/RawToolApprovalRequest.ts | 7 + .../node/src/types/ReasoningFileOutput.ts | 5 + bindings/node/src/types/ReasoningPart.ts | 8 + bindings/node/src/types/StreamPart.ts | 4 +- bindings/node/src/types/StreamResponse.ts | 6 + .../src/types/StreamTextResultAggregated.ts | 11 +- bindings/node/src/types/TextContent.ts | 13 + .../src/types/ToolApprovalRequestOutput.ts | 7 + bindings/node/src/types/Usage.ts | 8 +- bindings/python/python/aimux/wrapper.py | 145 +- bindings/python/src/error.rs | 4 +- bindings/swift/Sources/Aimux/Types.swift | 220 ++- 123 files changed, 2878 insertions(+), 14306 deletions(-) create mode 100644 aimux-core/src/download.rs rename {aimux-provider-utils => aimux-core}/src/download_guard.rs (97%) delete mode 100644 aimux-core/tests/m11_m12_m6_test.rs delete mode 100644 aimux-core/tests/m7_aggregation_test.rs delete mode 100644 aimux-core/tests/recording_e2e_test.rs delete mode 100644 aimux-core/tests/response_messages_tool_test.rs delete mode 100644 aimux-core/tests/session_test.rs delete mode 100644 aimux-core/tests/stream_timeout_matrix_test.rs create mode 100644 bindings/node/src/types/GenerateResponse.ts create mode 100644 bindings/node/src/types/GeneratedFileData.ts rename bindings/node/src/types/{TokenUsage.ts => InputTokenUsage.ts} (58%) create mode 100644 bindings/node/src/types/OutputTokenUsage.ts create mode 100644 bindings/node/src/types/RawToolApprovalRequest.ts create mode 100644 bindings/node/src/types/ReasoningFileOutput.ts create mode 100644 bindings/node/src/types/ReasoningPart.ts create mode 100644 bindings/node/src/types/StreamResponse.ts create mode 100644 bindings/node/src/types/TextContent.ts create mode 100644 bindings/node/src/types/ToolApprovalRequestOutput.ts diff --git a/aimux-core/Cargo.toml b/aimux-core/Cargo.toml index e41db186f..c48aaee7c 100644 --- a/aimux-core/Cargo.toml +++ b/aimux-core/Cargo.toml @@ -17,12 +17,10 @@ futures = { workspace = true } async-stream = { workspace = true } tracing = { workspace = true } ts-rs = { workspace = true } -# Deliberately narrower than the workspace-wide `full`: core's production -# code uses only timers (`time`) and `select!` (`macros`). Keeping the -# declaration at the real API surface documents what Core actually asks -# of the runtime (RFC-0016 had removed core's tokio dependency; RFC-0031 -# reintroduces it for operation deadlines and retry backoff). -tokio = { version = "1", features = ["time", "macros", "rt", "sync"] } +# Core also resolves generated file URLs before exposing file outputs. +tokio = { version = "1", features = ["time", "macros", "rt", "sync", "net"] } +reqwest = { workspace = true } +url = { workspace = true } tokio-util = "0.7" httpdate = { workspace = true } rand = { workspace = true } diff --git a/aimux-core/src/composite.rs b/aimux-core/src/composite.rs index 215fce58d..022a95340 100644 --- a/aimux-core/src/composite.rs +++ b/aimux-core/src/composite.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use crate::language_model::LanguageModel; use crate::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use crate::result::GenerateContent; -use crate::types::{TokenUsage, Usage}; +use crate::types::{InputTokenUsage, OutputTokenUsage, Usage}; /// Composite-model child handle. `Arc` (not `Box`) so children are `Clone`, /// match the FFI/Node registry shape, and stay alive after a child handle is @@ -31,28 +31,26 @@ pub type ChildModel = Arc; /// Add two `Usage` values field-by-field. `Usage::raw` (provider-opaque) is /// dropped — summing raw maps across providers is meaningless. /// -/// Each `TokenUsage` field is `Option`; `None` is treated as zero so a +/// Each usage detail field is `Option`; `None` is treated as zero so a /// child that doesn't report a breakdown doesn't erase the other child's data. #[must_use] pub fn add_usage(a: Usage, b: &Usage) -> Usage { Usage { - input_tokens: add_token_usage(a.input_tokens, &b.input_tokens), - output_tokens: add_token_usage(a.output_tokens, &b.output_tokens), + input_tokens: InputTokenUsage { + total: opt_add(a.input_tokens.total, b.input_tokens.total), + no_cache: opt_add(a.input_tokens.no_cache, b.input_tokens.no_cache), + cache_read: opt_add(a.input_tokens.cache_read, b.input_tokens.cache_read), + cache_write: opt_add(a.input_tokens.cache_write, b.input_tokens.cache_write), + }, + output_tokens: OutputTokenUsage { + total: opt_add(a.output_tokens.total, b.output_tokens.total), + text: opt_add(a.output_tokens.text, b.output_tokens.text), + reasoning: opt_add(a.output_tokens.reasoning, b.output_tokens.reasoning), + }, raw: None, } } -fn add_token_usage(a: TokenUsage, b: &TokenUsage) -> TokenUsage { - TokenUsage { - total: opt_add(a.total, b.total), - no_cache: opt_add(a.no_cache, b.no_cache), - cache_read: opt_add(a.cache_read, b.cache_read), - cache_write: opt_add(a.cache_write, b.cache_write), - text: opt_add(a.text, b.text), - reasoning: opt_add(a.reasoning, b.reasoning), - } -} - fn opt_add(a: Option, b: Option) -> Option { match (a, b) { (None, None) => None, diff --git a/aimux-core/src/content.rs b/aimux-core/src/content.rs index e6dfe464a..d55cb0413 100644 --- a/aimux-core/src/content.rs +++ b/aimux-core/src/content.rs @@ -19,6 +19,35 @@ pub enum ContentPart { provider_options: Option, }, + /// Provider-specific custom content. + Custom { + kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + + /// A file generated during reasoning. + ReasoningFile { + data: crate::shared::GeneratedFileData, + media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_options: Option, + }, + + /// A request to approve a tool call on a later turn. + ToolApprovalRequest { + approval_id: String, + tool_call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + is_automatic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + input_schema_input: Option, + }, + /// An image (raw bytes + MIME type). Image { image: Vec, diff --git a/aimux-core/src/download.rs b/aimux-core/src/download.rs new file mode 100644 index 000000000..cfa7d0f45 --- /dev/null +++ b/aimux-core/src/download.rs @@ -0,0 +1,130 @@ +//! Resolve generated file URLs before exposing their file data. + +use std::net::SocketAddr; + +use futures::StreamExt; + +use crate::abort_signal::AbortSignal; +use crate::download_guard::{validate_download_target, validate_download_url}; +use crate::error::AiMuxError; +use crate::shared::{FileBytes, GeneratedFileData}; + +const DEFAULT_MAX_DOWNLOAD_SIZE: usize = 2 * 1024 * 1024 * 1024; +const MAX_DOWNLOAD_REDIRECTS: usize = 10; + +pub(crate) async fn resolve_generated_file_data( + data: &GeneratedFileData, + abort_signal: Option<&AbortSignal>, +) -> Result { + match data { + GeneratedFileData::Data { data } => Ok(data.clone()), + GeneratedFileData::Url { url, .. } => { + let (data, _) = download(url, None, abort_signal).await?; + Ok(FileBytes::Binary(data)) + } + } +} + +async fn download( + url: &str, + max_bytes: Option, + abort_signal: Option<&AbortSignal>, +) -> Result<(Vec, Option), AiMuxError> { + let operation = download_inner(url, max_bytes.unwrap_or(DEFAULT_MAX_DOWNLOAD_SIZE)); + if let Some(signal) = abort_signal { + tokio::select! { + biased; + () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)), + result = operation => result, + } + } else { + operation.await + } +} + +async fn download_inner( + url: &str, + max_bytes: usize, +) -> Result<(Vec, Option), AiMuxError> { + let mut current = url.to_owned(); + for redirects in 0..=MAX_DOWNLOAD_REDIRECTS { + let parsed = validate_download_url(¤t)?; + let addresses = validate_download_target(¤t, None).await?; + let host = parsed + .host_str() + .ok_or_else(|| AiMuxError::InvalidArgument("download URL has no host".into()))?; + let port = parsed + .port_or_known_default() + .ok_or_else(|| AiMuxError::InvalidArgument("download URL has no known port".into()))?; + let addresses: Vec<_> = addresses + .into_iter() + .map(|address| SocketAddr::new(address, port)) + .collect(); + // Direct connections use only the addresses validated above. An + // automatic proxy or redirect must not bypass the address policy. + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .resolve_to_addrs(host, &addresses) + .build() + .map_err(download_error)?; + let response = client + .get(parsed.clone()) + .header( + reqwest::header::USER_AGENT, + concat!("aimux/", env!("CARGO_PKG_VERSION")), + ) + .send() + .await + .map_err(download_error)?; + let status = response.status(); + if matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308) + && let Some(location) = response.headers().get(reqwest::header::LOCATION) + { + if redirects == MAX_DOWNLOAD_REDIRECTS { + return Err(AiMuxError::Other("download exceeded 10 redirects".into())); + } + let location = location.to_str().map_err(download_error)?; + current = parsed.join(location).map_err(download_error)?.to_string(); + drop(response); + continue; + } + if !status.is_success() { + return Err(AiMuxError::Other(format!( + "download failed with HTTP status {status}" + ))); + } + if response + .content_length() + .is_some_and(|length| length > max_bytes as u64) + { + return Err(size_error(max_bytes)); + } + let media_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let mut stream = response.bytes_stream(); + let mut data = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(download_error)?; + if chunk.len() > max_bytes.saturating_sub(data.len()) { + return Err(size_error(max_bytes)); + } + data.extend_from_slice(&chunk); + } + return Ok((data, media_type)); + } + unreachable!("download loop returns a response or redirect error") +} + +fn download_error(error: impl std::fmt::Display) -> AiMuxError { + AiMuxError::Other(format!("download failed: {error}")) +} + +fn size_error(max_bytes: usize) -> AiMuxError { + AiMuxError::Other(format!( + "download exceeded maximum size of {max_bytes} bytes" + )) +} diff --git a/aimux-provider-utils/src/download_guard.rs b/aimux-core/src/download_guard.rs similarity index 97% rename from aimux-provider-utils/src/download_guard.rs rename to aimux-core/src/download_guard.rs index c009a608e..984e8de06 100644 --- a/aimux-provider-utils/src/download_guard.rs +++ b/aimux-core/src/download_guard.rs @@ -10,7 +10,7 @@ use std::net::IpAddr; -use aimux_core::error::AiMuxError; +use crate::error::AiMuxError; /// Compare scheme, host, and effective port. Unparseable inputs are never /// same-origin. Public so providers can build credential allowlists on top @@ -28,7 +28,8 @@ pub fn same_origin(url: &str, origin: &str) -> bool { /// Trust flows only within the trusted origin: the exemption applies to a /// redirect target only when the redirecting URL is itself on the trusted /// origin, so a foreign hop cannot launder a request into it. -pub(crate) fn hop_trusted_origin<'a>( +#[must_use] +pub fn hop_trusted_origin<'a>( trusted_origin: Option<&'a str>, current_url: &str, ) -> Option<&'a str> { @@ -55,7 +56,7 @@ fn host_ip_literal(host: &str) -> Option { /// /// Callers that resolve DNS afterwards rely on this having rejected every /// non-public literal, so keep this the single literal-check site. -pub(crate) fn validate_download_url(url: &str) -> Result { +pub fn validate_download_url(url: &str) -> Result { let parsed = url::Url::parse(url) .map_err(|error| AiMuxError::InvalidArgument(format!("invalid download URL: {error}")))?; if !matches!(parsed.scheme(), "http" | "https") { @@ -93,7 +94,7 @@ pub(crate) fn validate_download_url(url: &str) -> Result { /// A URL same-origin with `trusted_origin` (normally the configured /// `base_url`) is exempt and returns no addresses; self-hosted deployments /// legitimately serve assets from private space on their own origin. -pub(crate) async fn validate_download_target( +pub async fn validate_download_target( url: &str, trusted_origin: Option<&str>, ) -> Result, AiMuxError> { @@ -206,7 +207,7 @@ fn is_public_download_address(address: IpAddr) -> bool { /// request; they leak deployment topology or unlock metadata endpoints when /// forwarded to a provider-supplied host. Mirrors AI SDK's download header /// policy (auth headers survive; the redirect loop clears them cross-origin). -pub(crate) fn sanitize_download_headers(headers: &mut Vec<(String, String)>) { +pub fn sanitize_download_headers(headers: &mut Vec<(String, String)>) { const BLOCKED: &[&str] = &[ "connection", "keep-alive", @@ -237,7 +238,7 @@ pub(crate) fn sanitize_download_headers(headers: &mut Vec<(String, String)>) { } /// Drop caller headers except `User-Agent` when a redirect crosses origin. -pub(crate) fn retain_user_agent(headers: &mut Vec<(String, String)>) { +pub fn retain_user_agent(headers: &mut Vec<(String, String)>) { let user_agent = headers .iter() .find(|(name, _)| name.eq_ignore_ascii_case("user-agent")) diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index e5796b5af..59e779b14 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -178,6 +178,12 @@ pub enum AiMuxError { #[error("invalid response data: {0}")] InvalidResponseData(String), + #[error("Tool call \"{tool_call_id}\" not found for approval request \"{approval_id}\".")] + ToolCallNotFoundForApproval { + tool_call_id: String, + approval_id: String, + }, + /// A model requested a tool that was not present in the call's tool set. /// Message templates for the three tool variants match the AI SDK verbatim /// (`NoSuchToolError` / `InvalidToolInputError` / `ToolCallRepairError`): diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index dd083c501..d01e79d2d 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -23,8 +23,8 @@ use crate::message::{ModelMessage, ModelPrompt}; use crate::options::{CallOptions, ResponseFormat, ToolChoice}; use crate::parse_tool_call::{ToolCallRepair, parse_tool_call}; use crate::result::{ - GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, StreamResult, - StreamTextResultAggregated, + GenerateContent, GenerateResult, GeneratedFile, ReasoningFileOutput, ReasoningPart, Source, + StreamResult, StreamTextResultAggregated, TextContent, ToolApprovalRequestOutput, }; use crate::stream_part::{StreamPart, TextStreamPart}; use crate::tool::Tool; @@ -33,16 +33,26 @@ use crate::{AbortSignal, retry, timeout}; /// 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 { +fn is_output_chunk(part: &StreamPart) -> bool { match part { StreamPart::TextDelta { delta, .. } | StreamPart::ReasoningDelta { delta, .. } | StreamPart::ToolInputDelta { delta, .. } => !delta.is_empty(), - StreamPart::ToolCall(_) | StreamPart::File(_) => true, + StreamPart::ToolCall(_) | StreamPart::File(_) | StreamPart::ReasoningFile(_) => true, _ => false, } } +async fn resolve_file( + mut file: GeneratedFile, + abort_signal: Option<&AbortSignal>, +) -> Result { + file.data = crate::shared::GeneratedFileData::Data { + data: crate::download::resolve_generated_file_data(&file.data, abort_signal).await?, + }; + Ok(file) +} + // ───────────────────────────────────────────────────────────────────────────── // User-facing options // ───────────────────────────────────────────────────────────────────────────── @@ -143,6 +153,9 @@ impl GenerateTextOptions { #[derive(Debug, Serialize, Deserialize, TS)] #[ts(export)] pub struct GenerateTextResult { + /// Ordered generated content with parsed tool calls. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, /// The generated text (concatenated from all text content parts). pub text: String, /// Tool calls requested by the model. @@ -158,7 +171,7 @@ pub struct GenerateTextResult { // ── M7: top-level aggregation (extracted from `raw.content`) ── /// Reasoning / thinking segments from the model. #[serde(default)] - pub reasoning: Vec, + pub reasoning: Vec, /// Concatenated reasoning text (convenience for `reasoning.iter().map(text).join("")`). #[serde(default)] pub reasoning_text: String, @@ -349,37 +362,53 @@ impl StreamTextResult { } match part { StreamPart::TextStart { - provider_metadata, .. - } => rm.text_start(provider_metadata), + id, + provider_metadata, + } => rm.text_start(id, provider_metadata), StreamPart::TextDelta { + id, delta, provider_metadata, - .. } => { text.push_str(&delta); - rm.text_delta(&delta, provider_metadata); + rm.text_delta(id, &delta, provider_metadata); } StreamPart::TextEnd { - provider_metadata, .. - } => rm.text_end(provider_metadata), + id, + provider_metadata, + } => rm.text_end(id, provider_metadata), StreamPart::ReasoningStart { - provider_metadata, .. - } => rm.reasoning_start(provider_metadata), + id, + provider_metadata, + } => rm.reasoning_start(id, provider_metadata), StreamPart::ReasoningDelta { + id, delta, provider_metadata, - .. - } => rm.reasoning_delta(&delta, provider_metadata), + } => rm.reasoning_delta(id, &delta, provider_metadata), StreamPart::ReasoningEnd { - provider_metadata, .. - } => rm.reasoning_end(provider_metadata), + id, + provider_metadata, + } => rm.reasoning_end(id, provider_metadata), StreamPart::ToolCall(call) => { rm.tool_call(&call); tool_calls.push(call); } StreamPart::ToolResult(result) => rm.tool_result(result), - StreamPart::Source(source) => sources.push(source), - StreamPart::File(file) => files.push(file), + StreamPart::Source(source) => { + rm.source(source.clone()); + sources.push(source); + } + StreamPart::File(file) => { + rm.file(&file, false); + files.push(file); + } + StreamPart::ReasoningFile(output) => rm.file(&output.file, true), + StreamPart::Custom { + kind, + provider_metadata, + } => rm.custom(kind, provider_metadata), + StreamPart::ToolApprovalRequest(approval) => rm.approval(&approval), StreamPart::StreamStart { warnings: w, .. } => { warnings = w; } @@ -426,16 +455,21 @@ impl StreamTextResult { let crate::response_messages::ResponseMessages { messages: response_messages, + content, reasoning, } = rm.finish(); let reasoning_text = reasoning .iter() - .map(|r| r.text.as_str()) + .filter_map(|r| match r { + ReasoningPart::Text(r) => Some(r.text.as_str()), + ReasoningPart::File(_) => None, + }) .collect::>() .join(""); Ok(StreamTextResultAggregated { + content, text, reasoning, reasoning_text, @@ -582,6 +616,20 @@ pub async fn generate_text( let mut files = Vec::new(); // Build the assistant response message content parts in parallel. let mut rm = crate::response_messages::ResponseMessageBuilder::new(); + for content in &result.content { + if let GenerateContent::ToolCall(call) = content { + tool_calls.push( + parse_tool_call( + call.clone(), + tools.as_deref(), + repair_tool_call.as_ref(), + &messages, + operation_instructions.as_deref(), + ) + .await, + ); + } + } for content in &result.content { match content { GenerateContent::Text { @@ -592,33 +640,71 @@ pub async fn generate_text( rm.text(t, provider_metadata.as_ref()); } GenerateContent::ToolCall(call) => { - let parsed = parse_tool_call( - call.clone(), - tools.as_deref(), - repair_tool_call.as_ref(), - &messages, - operation_instructions.as_deref(), - ) - .await; - rm.tool_call(&parsed); - tool_calls.push(parsed); + let parsed = tool_calls + .iter() + .find(|parsed| parsed.tool_call_id == call.tool_call_id) + .expect("every tool call was parsed above"); + rm.tool_call(parsed); } GenerateContent::Reasoning(reasoning) => rm.reasoning(reasoning), - GenerateContent::Source(source) => sources.push(source.clone()), - GenerateContent::File(file) => files.push(file.clone()), - // Provider-executed results stay in the assistant message so the - // provider can replay its own server-tool transcript next turn. + GenerateContent::Source(source) => { + rm.source(source.clone()); + sources.push(source.clone()); + } + GenerateContent::File(file) => { + let file = timeout::run( + resolve_file(file.clone(), abort_signal.as_ref()), + abort_signal.as_ref(), + operation_timeout, + ) + .await?; + rm.file(&file, false); + files.push(file); + } + GenerateContent::ReasoningFile(file) => { + let file = timeout::run( + resolve_file(file.clone(), abort_signal.as_ref()), + abort_signal.as_ref(), + operation_timeout, + ) + .await?; + rm.file(&file, true); + } + GenerateContent::Custom { + kind, + provider_metadata, + } => rm.custom(kind.clone(), provider_metadata.clone()), + GenerateContent::ToolApprovalRequest(approval) => { + let call = tool_calls + .iter() + .find(|call| call.tool_call_id == approval.tool_call_id) + .ok_or_else(|| AiMuxError::ToolCallNotFoundForApproval { + tool_call_id: approval.tool_call_id.clone(), + approval_id: approval.approval_id.clone(), + })?; + rm.approval(&ToolApprovalRequestOutput { + approval_id: approval.approval_id.clone(), + tool_call: call.clone(), + reason: None, + is_automatic: None, + signature: None, + }); + } GenerateContent::ToolResult(result) => rm.tool_result(result.clone()), } } let crate::response_messages::ResponseMessages { messages: response_messages, + content, reasoning, } = rm.finish(); let reasoning_text = reasoning .iter() - .map(|r| r.text.as_str()) + .filter_map(|r| match r { + ReasoningPart::Text(r) => Some(r.text.as_str()), + ReasoningPart::File(_) => None, + }) .collect::>() .join(""); @@ -635,10 +721,11 @@ pub async fn generate_text( // 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(); + let response = result.response.clone().map(Into::into).unwrap_or_default(); let usage = result.usage.clone(); Ok(GenerateTextResult { + content, text, tool_calls, finish_reason: result.finish_reason.clone(), @@ -718,7 +805,12 @@ 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(); + let response = text_result + .raw + .response + .clone() + .map(Into::into) + .unwrap_or_default(); Ok(GenerateObjectResult { object, finish_reason, @@ -873,9 +965,11 @@ pub async fn stream_text( // 解构避免部分 move(result 各字段去向不同)。 let StreamResult { stream, - request_body, - response_headers, + request, + response, } = result; + let request_body = request.and_then(|request| request.body); + let response_headers = response.and_then(|response| response.headers); // The consumption phase keeps observing the same deadlines: the // first-chunk budget armed at operation start continues until the first @@ -893,6 +987,7 @@ pub async fn stream_text( // the stream's terminal fuse — `Finish` and non-recoverable errors must // end the stream under every configuration, or a provider that keeps the // connection open after `Finish` hangs a consumer reading to end-of-stream. + let file_abort_signal = abort_signal.clone(); let stream: Pin> + Send>> = { let mut stream = stream; let chunk_ms = stream_timeout.chunk_ms; @@ -972,8 +1067,9 @@ pub async fn stream_text( }) }; let mut stream = stream; - let stream: Pin> + Send>> = - Box::pin(async_stream::stream! { + let stream: Pin> + Send>> = Box::pin( + async_stream::stream! { + let mut tool_calls = HashMap::new(); while let Some(item) = stream.next().await { match item { Ok(StreamPart::ToolCall(raw)) => { @@ -984,13 +1080,42 @@ pub async fn stream_text( &messages, operation_instructions.as_deref(), ).await; + tool_calls.insert(parsed.tool_call_id.clone(), parsed.clone()); yield Ok(StreamPart::ToolCall(parsed)); } - Ok(part) => yield Ok(part.map_tool_call(|_| unreachable!("matched above"))), + 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(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 { + provider_metadata: file.provider_metadata.clone(), file, + })), + Err(error) => yield Ok(StreamPart::Error { error }), + } + } + Ok(StreamPart::ToolApprovalRequest(approval)) => { + if let Some(call) = tool_calls.get(&approval.tool_call_id) { + yield Ok(StreamPart::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 { + 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), } } - }); + }, + ); // 录制开启时才包装(终结时写 outcome + 传输封闭);关闭时零成本透传。 let stream = crate::recording::RecordingOutcomeStream::new( stream, @@ -1215,456 +1340,3 @@ pub async fn stream_text_as_openai( stream_options, )) } - -#[cfg(test)] -mod operation_retry_tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use async_trait::async_trait; - - use super::*; - use crate::{ApiCallError, LanguageModel, prelude::StreamPart}; - - enum StreamBehavior { - Normal, - FinishThenPending, - ProviderErrorThenFinish, - ParseErrorThenEvent, - TransportErrorThenPending, - NeverReturns, - RetryableFirstError, - NonRetryableFirstError, - } - - struct StreamModel { - behavior: StreamBehavior, - calls: AtomicUsize, - } - - impl StreamModel { - fn new(behavior: StreamBehavior) -> Self { - Self { - behavior, - calls: AtomicUsize::new(0), - } - } - - fn api_error(status: u16, retryable: bool) -> AiMuxError { - AiMuxError::ApiCall(Box::new(ApiCallError { - status_code: Some(status), - response_headers: Some(HashMap::from([("retry-after-ms".into(), "0".into())])), - is_retryable: retryable, - ..ApiCallError::new( - "stream setup failed", - "https://example.test/stream", - serde_json::json!({}), - ) - })) - } - - fn single_event_stream() -> StreamResult { - StreamResult { - stream: Box::pin(futures::stream::iter([Ok(StreamPart::TextDelta { - id: "text-1".into(), - delta: "hello".into(), - provider_metadata: None, - })])), - request_body: None, - response_headers: None, - } - } - - fn finish_stream() -> StreamResult { - let finish = StreamPart::Finish { - finish_reason: crate::types::FinishReason { - unified: crate::types::FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }; - StreamResult { - stream: Box::pin( - futures::stream::iter([Ok(finish)]).chain(futures::stream::pending()), - ), - request_body: None, - response_headers: None, - } - } - - fn provider_error_then_finish_stream() -> StreamResult { - let finish = StreamPart::Finish { - finish_reason: crate::types::FinishReason { - unified: crate::types::FinishReasonUnified::Error, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }; - StreamResult { - stream: Box::pin( - futures::stream::iter([ - Ok(StreamPart::Error { - error: AiMuxError::Other("provider error".into()), - }), - Ok(finish), - ]) - .chain(futures::stream::pending()), - ), - request_body: None, - response_headers: None, - } - } - - fn parse_error_then_event_stream() -> StreamResult { - StreamResult { - stream: Box::pin(futures::stream::iter([ - Err(AiMuxError::JsonParse("malformed SSE data".into())), - Ok(StreamPart::TextDelta { - id: "text-1".into(), - delta: "after-error".into(), - provider_metadata: None, - }), - ])), - request_body: None, - response_headers: None, - } - } - } - - #[async_trait] - impl LanguageModel for StreamModel { - fn provider(&self) -> &str { - "test" - } - - fn model_id(&self) -> &str { - "stream-model" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Err(AiMuxError::Other("unused".into())) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst); - match self.behavior { - StreamBehavior::Normal => Ok(Self::single_event_stream()), - StreamBehavior::FinishThenPending => Ok(Self::finish_stream()), - StreamBehavior::ProviderErrorThenFinish => { - Ok(Self::provider_error_then_finish_stream()) - } - StreamBehavior::ParseErrorThenEvent => Ok(Self::parse_error_then_event_stream()), - StreamBehavior::TransportErrorThenPending => Ok(StreamResult { - stream: Box::pin( - futures::stream::iter([Err(AiMuxError::ApiCall(Box::new(ApiCallError { - is_retryable: true, - ..ApiCallError::new( - "connection reset", - "https://example.test/stream", - serde_json::json!({}), - ) - })))]) - .chain(futures::stream::pending()), - ), - request_body: None, - response_headers: None, - }), - StreamBehavior::NeverReturns => std::future::pending().await, - StreamBehavior::RetryableFirstError if attempt == 0 => { - Err(Self::api_error(429, true)) - } - StreamBehavior::RetryableFirstError => Ok(Self::single_event_stream()), - StreamBehavior::NonRetryableFirstError => Err(Self::api_error(400, false)), - } - } - } - - fn retry_options() -> GenerateTextOptions { - GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - } - } - - #[tokio::test] - async fn stream_setup_preserves_the_peeked_first_event() { - let model = StreamModel::new(StreamBehavior::Normal); - let mut result = stream_text(&model, "hello", retry_options()).await.unwrap(); - let first = result.stream.next().await.unwrap().unwrap(); - assert!(matches!(first, StreamPart::TextDelta { delta, .. } if delta == "hello")); - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn late_consumer_still_receives_a_first_chunk_delivered_on_time() { - // The provider yields immediately; the first-chunk budget measures - // that arrival, not when the caller gets around to polling. Sleeping - // past the budget before the first poll must not turn delivered - // output into a timeout. - let model = StreamModel::new(StreamBehavior::Normal); - let options = GenerateTextOptions { - max_retries: Some(0), - timeout: Some(crate::options::TimeoutConfiguration { - first_chunk_ms: Some(1), - ..Default::default() - }), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::TextDelta { delta, .. } if delta == "hello" - )); - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn caller_abort_still_cancels_the_returned_stream() { - let model = StreamModel::new(StreamBehavior::Normal); - let abort_signal = AbortSignal::new(); - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(abort_signal.clone()), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - abort_signal.abort(); - - assert!(matches!( - result.stream.next().await.unwrap(), - Err(AiMuxError::Aborted(message)) if message == "request aborted" - )); - } - - #[tokio::test] - async fn terminal_stream_part_stops_stream_consumption() { - let model = StreamModel::new(StreamBehavior::FinishThenPending); - let options = GenerateTextOptions { - max_retries: Some(0), - timeout: Some(crate::options::TimeoutConfiguration { - total_ms: Some(50), - ..Default::default() - }), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::Finish { .. } - )); - tokio::time::sleep(std::time::Duration::from_millis(60)).await; - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn provider_error_part_does_not_hide_the_final_finish() { - let model = StreamModel::new(StreamBehavior::ProviderErrorThenFinish); - let abort_signal = AbortSignal::new(); - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(abort_signal.clone()), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::Error { .. } - )); - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::Finish { finish_reason, .. } - if finish_reason.unified == crate::types::FinishReasonUnified::Error - )); - abort_signal.abort(); - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn timeout_wrapper_continues_after_a_malformed_stream_frame() { - let model = StreamModel::new(StreamBehavior::ParseErrorThenEvent); - let options = GenerateTextOptions { - max_retries: Some(0), - timeout: Some(crate::options::TimeoutConfiguration { - total_ms: Some(1_000), - ..Default::default() - }), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap(), - Err(AiMuxError::JsonParse(_)) - )); - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::TextDelta { delta, .. } if delta == "after-error" - )); - } - - #[tokio::test] - async fn abort_wrapper_continues_after_a_malformed_stream_frame() { - let model = StreamModel::new(StreamBehavior::ParseErrorThenEvent); - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(AbortSignal::new()), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap(), - Err(AiMuxError::JsonParse(_)) - )); - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::TextDelta { delta, .. } if delta == "after-error" - )); - } - - #[tokio::test] - async fn first_chunk_timeout_bounds_the_stream_setup_phase() { - // Providers await their first SSE event inside do_stream; a - // 200-then-silence server must be bounded by first_chunk_ms alone. - let model = StreamModel::new(StreamBehavior::NeverReturns); - let options = GenerateTextOptions { - max_retries: Some(0), - timeout: Some(crate::options::TimeoutConfiguration { - first_chunk_ms: Some(5), - ..Default::default() - }), - ..Default::default() - }; - let error = stream_text(&model, "hello", options).await.unwrap_err(); - assert!( - matches!(error, AiMuxError::Timeout(ref message) if message == "First chunk timeout of 5ms exceeded"), - "got {error:?}" - ); - } - - #[tokio::test] - async fn transport_error_ends_the_wrapped_stream() { - let model = StreamModel::new(StreamBehavior::TransportErrorThenPending); - let abort_signal = AbortSignal::new(); - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(abort_signal.clone()), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - - assert!(matches!( - result.stream.next().await.unwrap(), - Err(AiMuxError::ApiCall(_)) - )); - // Without treating Err as terminal this next() would hang on the - // pending tail of the dead source stream. - assert!(result.stream.next().await.is_none()); - } - - #[tokio::test] - async fn retryable_first_stream_error_retries_before_returning_stream() { - let model = StreamModel::new(StreamBehavior::RetryableFirstError); - let mut result = stream_text(&model, "hello", retry_options()).await.unwrap(); - assert_eq!(model.calls.load(Ordering::SeqCst), 2); - assert!(matches!( - result.stream.next().await.unwrap().unwrap(), - StreamPart::TextDelta { delta, .. } if delta == "hello" - )); - } - - #[tokio::test] - async fn non_retryable_first_stream_error_is_returned_unchanged() { - let model = StreamModel::new(StreamBehavior::NonRetryableFirstError); - let error = stream_text(&model, "hello", retry_options()) - .await - .unwrap_err(); - assert!( - matches!(error, AiMuxError::ApiCall(ref detail) if detail.status_code == Some(400)) - ); - assert_eq!(model.calls.load(Ordering::SeqCst), 1); - } -} - -#[cfg(test)] -mod stream_aggregation_tests { - use super::*; - use crate::types::FinishReasonUnified; - - fn result_from(parts: Vec>) -> StreamTextResult { - StreamTextResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - } - } - - fn delta(text: &str) -> TextStreamPart { - StreamPart::TextDelta { - id: "text-1".into(), - delta: text.into(), - provider_metadata: None, - } - } - - fn finish() -> TextStreamPart { - StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - } - } - - #[tokio::test] - async fn truncated_stream_with_partial_output_does_not_claim_a_normal_stop() { - let result = result_from(vec![Ok(delta("par"))]).consume().await.unwrap(); - assert_eq!(result.text, "par"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - - // text() retains the partial result (AI SDK semantics). - let text = result_from(vec![Ok(delta("par"))]).text().await.unwrap(); - assert_eq!(text, "par"); - } - - #[tokio::test] - async fn empty_incomplete_stream_is_an_error() { - let error = result_from(vec![]).consume().await.unwrap_err(); - assert!( - matches!(&error, AiMuxError::InvalidResponseData(message) if message.contains("without a finish chunk")) - ); - let error = result_from(vec![]).text().await.unwrap_err(); - assert!(matches!(error, AiMuxError::InvalidResponseData(_))); - } - - #[tokio::test] - async fn stream_with_finish_still_reports_the_provider_finish_reason() { - let result = result_from(vec![Ok(delta("hi")), Ok(finish())]) - .consume() - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - } - - #[tokio::test] - async fn provider_error_still_wins_after_draining_a_trailing_finish() { - let parts = vec![ - Ok(delta("partial")), - Ok(StreamPart::Error { - error: AiMuxError::Other("provider error".into()), - }), - Ok(finish()), - ]; - let error = result_from(parts).consume().await.unwrap_err(); - assert!(matches!(error, AiMuxError::Other(message) if message == "provider error")); - } -} diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index 3f9500c6e..89e8ec6b2 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -52,7 +52,11 @@ pub fn convert_to_language_model_prompt( for msg in messages { let parts = match &msg.content { crate::message::MessageContent::Text(text) => vec![ContentPart::text(text)], - crate::message::MessageContent::Parts(parts) => parts.clone(), + crate::message::MessageContent::Parts(parts) => parts + .iter() + .filter(|part| !matches!(part, ContentPart::ToolApprovalRequest { .. })) + .cloned() + .collect(), }; result.push(LanguageModelPromptMessage { role: msg.role, diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index c976579e2..98f59426a 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -20,6 +20,9 @@ mod abort_signal; pub mod composite; pub mod content; +pub(crate) mod download; +#[doc(hidden)] +pub mod download_guard; pub mod embedding_model; pub mod error; pub mod files_model; @@ -95,7 +98,11 @@ pub mod prelude { pub use crate::reranking_model::{ RerankingCallOptions, RerankingModel, RerankingResult, rerank, }; - pub use crate::result::{GenerateResult, StreamResult}; + pub use crate::result::{ + GenerateResponse, GenerateResult, GeneratedFile, RawToolApprovalRequest, ReasoningOutput, + ReasoningPart, Source, StreamResponse, StreamResult, TextContent, + ToolApprovalRequestOutput, + }; pub use crate::router::{ FallbackPolicy, Router, RouterConfig, RouterModel, RuleRouter, WeightedRouter, }; @@ -107,7 +114,7 @@ pub mod prelude { init_session_store, list_sessions, session_calls, }; pub use crate::shared::{ - AspectRatio, FileBytes, FileData, SharedHeaders, SharedProviderMetadata, + AspectRatio, FileBytes, FileData, GeneratedFileData, SharedHeaders, SharedProviderMetadata, SharedProviderOptions, SharedProviderReference, Size, }; pub use crate::speech_model::{SpeechCallOptions, SpeechModel, SpeechResult, generate_speech}; @@ -118,7 +125,10 @@ pub mod prelude { TranscriptionResult, TranscriptionStreamOptions, TranscriptionStreamPart, TranscriptionStreamResult, stream_transcribe, transcribe, }; - pub use crate::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Usage, Warning}; + pub use crate::types::{ + FinishReason, FinishReasonUnified, InputTokenUsage, OutputTokenUsage, ReasoningEffort, + Usage, Warning, + }; pub use crate::video_model::{ VideoCallOptions, VideoModel, VideoOperationStart, VideoOperationStatus, VideoPollConfig, VideoPollOptions, VideoResult, generate_video, diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index 24e728a99..4f64f669e 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -151,8 +151,8 @@ impl MoaModel { usage = add_usage(usage, &res.usage); let mid = res .response - .model_id - .clone() + .as_ref() + .and_then(|response| response.model_id.clone()) .unwrap_or_else(|| format!("ref-{i}")); texts.push((mid, extract_text(&res.content))); } @@ -301,703 +301,8 @@ impl LanguageModel for MoaModel { // cache probing (RFC-0015 fingerprinting). Ok(StreamResult { stream: Box::pin(stream), - request_body: None, - response_headers: None, + request: None, + response: None, }) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::content::ContentPart; - use crate::language_model_message::LanguageModelPromptMessage; - use crate::message::Role; - use crate::result::{GenerateContent, Source}; - use crate::stream_part::StreamPart; - use crate::tool::RawToolCall; - use crate::types::{FinishReason, FinishReasonUnified, TokenUsage}; - use async_trait::async_trait; - use futures::stream; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - /// A mock child that returns fixed text with fixed usage. `fail` forces an - /// error. Used as both reference and aggregator. - struct MockChild { - name: &'static str, - text: String, - fail: bool, - usage: Usage, - retry_failures: usize, - calls: Arc, - } - - #[async_trait] - impl LanguageModel for MockChild { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - self.name - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; - if self.fail { - return Err(AiMuxError::Other(format!("{} failed", self.name))); - } - if attempt <= self.retry_failures { - return Err(retryable_error(self.name)); - } - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: self.text.clone(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: self.usage.clone(), - warnings: vec![], - provider_metadata: None, - response: crate::types::ResponseMetadata { - model_id: Some(self.name.into()), - ..Default::default() - }, - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; - if self.fail { - return Err(AiMuxError::Other(format!("{} failed", self.name))); - } - if attempt <= self.retry_failures { - return Err(retryable_error(self.name)); - } - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: self.text.clone(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: self.usage.clone(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } - } - - fn retryable_error(name: &str) -> AiMuxError { - AiMuxError::ApiCall(Box::new(crate::ApiCallError { - status_code: Some(503), - response_headers: Some(std::collections::HashMap::from([( - "retry-after-ms".into(), - "0".into(), - )])), - is_retryable: true, - ..crate::ApiCallError::new( - format!("{name} retryable failure"), - "https://example.test", - serde_json::json!({}), - ) - })) - } - - fn retry_child( - name: &'static str, - text: &str, - retry_failures: usize, - ) -> (ChildModel, Arc) { - let calls = Arc::new(AtomicUsize::new(0)); - ( - Arc::new(MockChild { - name, - text: text.into(), - fail: false, - usage: Usage::default(), - retry_failures, - calls: calls.clone(), - }), - calls, - ) - } - - fn mk(name: &'static str, text: &str, fail: bool, total: u32) -> ChildModel { - Arc::new(MockChild { - name, - text: text.into(), - fail, - usage: Usage { - input_tokens: TokenUsage { - total: Some(total), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(total), - ..Default::default() - }, - raw: None, - }, - retry_failures: 0, - calls: Arc::new(AtomicUsize::new(0)), - }) - } - - fn opts_with_prompt() -> CallOptions { - CallOptions { - prompt: vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is Rust?")], - provider_options: None, - }], - ..Default::default() - } - } - - #[tokio::test] - async fn generate_fans_out_and_aggregates() { - let refs = vec![ - mk("ref-a", "A says hi", false, 10), - mk("ref-b", "B says hi", false, 20), - ]; - let agg = mk("aggregator", "aggregated answer", false, 5); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - // Aggregator text wins as the content. - assert_eq!(extract_text(&r.content), "aggregated answer"); - // usage = references(10 + 20) + aggregator(5) = 35 on each side. - assert_eq!(r.usage.input_tokens.total, Some(35)); - assert_eq!(r.usage.output_tokens.total, Some(35)); - } - - #[tokio::test] - async fn best_effort_drops_failed_reference_and_warns() { - let refs = vec![mk("ref-a", "A ok", false, 10), mk("ref-b", "B", true, 0)]; - let agg = mk("aggregator", "final", false, 1); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - assert_eq!(extract_text(&r.content), "final"); - // Failed reference still contributed its (zero) usage; only ref-a (10) + agg (1). - assert_eq!(r.usage.input_tokens.total, Some(11)); - assert!(r.warnings.iter().any(|w| matches!( - w, - Warning::Other { message } if message.contains("reference 1 failed") - ))); - } - - #[tokio::test] - async fn fail_fast_propagates_first_reference_error() { - let refs = vec![mk("ref-a", "A", true, 0), mk("ref-b", "B ok", false, 10)]; - let agg = mk("aggregator", "final", false, 1); - let moa = MoaModel::new( - refs, - agg, - MoaConfig { - fail_mode: MoaFailMode::FailFast, - ..Default::default() - }, - ); - let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err(); - assert!(err.to_string().contains("ref-a failed")); - } - - #[tokio::test] - async fn all_references_fail_is_error() { - let refs = vec![mk("ref-a", "A", true, 0), mk("ref-b", "B", true, 0)]; - let agg = mk("aggregator", "final", false, 1); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err(); - assert!(err.to_string().contains("all reference models failed")); - } - - #[tokio::test] - async fn no_references_degrades_to_aggregator() { - // With 0 references, aggregator runs the original prompt directly. - let agg = mk("aggregator", "solo answer", false, 3); - let moa = MoaModel::new(vec![], agg, MoaConfig::default()); - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - assert_eq!(extract_text(&r.content), "solo answer"); - assert_eq!(r.usage.input_tokens.total, Some(3)); - } - - #[tokio::test] - async fn stream_runs_references_then_streams_aggregator() { - let refs = vec![mk("ref-a", "A", false, 7), mk("ref-b", "B", false, 8)]; - let agg = mk("aggregator", "streamed", false, 2); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let mut s = result.stream; - let mut saw_start = false; - let mut text = String::new(); - let mut finish_usage: Option = None; - while let Some(part) = s.next().await { - match part.unwrap() { - StreamPart::StreamStart { .. } => saw_start = true, - StreamPart::TextDelta { delta, .. } => text.push_str(&delta), - StreamPart::Finish { usage, .. } => finish_usage = Some(usage), - _ => {} - } - } - assert!(saw_start); - assert_eq!(text, "streamed"); - // references(7 + 8) + aggregator(2) = 17 on each side. - assert_eq!(finish_usage.unwrap().input_tokens.total, Some(17)); - } - - #[tokio::test] - async fn retries_references_and_aggregator_independently_without_replaying_fanout() { - let (ref_a, ref_a_calls) = retry_child("ref-a", "A", 1); - let (ref_b, ref_b_calls) = retry_child("ref-b", "B", 0); - let (aggregator, aggregator_calls) = retry_child("aggregator", "final", 1); - let moa = MoaModel::new(vec![ref_a, ref_b], aggregator, MoaConfig::default()); - assert_eq!(moa.retry_config().max_retries, 0); - - let result = crate::generate::generate_text( - &moa, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(result.text, "final"); - assert_eq!(ref_a_calls.load(Ordering::SeqCst), 2); - assert_eq!(ref_b_calls.load(Ordering::SeqCst), 1); - assert_eq!(aggregator_calls.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn retries_aggregator_stream_setup_without_replaying_references() { - let (reference, reference_calls) = retry_child("reference", "A", 0); - let (aggregator, aggregator_stream_calls) = retry_child("aggregator", "final", 1); - let moa = MoaModel::new(vec![reference], aggregator, MoaConfig::default()); - - let _result = crate::generate::stream_text( - &moa, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(reference_calls.load(Ordering::SeqCst), 1); - assert_eq!(aggregator_stream_calls.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn stream_best_effort_emits_drop_warning_in_start() { - let refs = vec![mk("ref-a", "A", false, 5), mk("ref-b", "B", true, 0)]; - let agg = mk("aggregator", "out", false, 1); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let mut s = result.stream; - // First item is our StreamStart, which should carry the drop warning. - match s.next().await.unwrap().unwrap() { - StreamPart::StreamStart { warnings } => { - assert!(warnings - .iter() - .any(|w| matches!(w, Warning::Other { message } if message.contains("reference 1 failed")))); - } - other => panic!("expected StreamStart, got {other:?}"), - } - } - - #[tokio::test] - async fn strip_reference_tools_default_clears_tools() { - // Verify the helper clears tools/tool_choice when configured (default). - let agg = mk("agg", "x", false, 0); - let moa = MoaModel::new(vec![], agg, MoaConfig::default()); - let opts = CallOptions { - prompt: vec![], - tools: Some(vec![]), - tool_choice: ToolChoice::Required, - ..Default::default() - }; - let ref_opts = moa.reference_options(&opts); - assert!(ref_opts.tools.is_none()); - assert_eq!(ref_opts.tool_choice, ToolChoice::Auto); - // The original options are untouched (clone, not mutate). - assert_eq!(opts.tool_choice, ToolChoice::Required); - } - - #[tokio::test] - async fn no_references_does_not_inject_empty_heading() { - // S4 regression guard: with 0 references the aggregator prompt is the - // original prompt verbatim — no "# Reference model responses" heading. - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hello")], - provider_options: None, - }]; - let built = build_aggregator_prompt(&prompt, None, &[]); - // Still just the single original message; nothing appended. - assert_eq!(built.len(), 1); - // Heading must NOT appear. - assert!(!built[0].content.iter().any(|p| matches!( - p, - ContentPart::Text { text, .. } if text.contains("Reference model responses") - ))); - } - - /// A mock aggregator whose stream yields TextDelta then a transport Err, - /// then (misbehaving) more parts. Guards that MoA relays the Err and stops. - struct MidStreamErrorAggregator; - - #[async_trait] - impl LanguageModel for MidStreamErrorAggregator { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "agg-err" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - unimplemented!() - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "partial".into(), - provider_metadata: None, - }), - // A transport error mid-stream. - Err(AiMuxError::Other("aggregator transport blew up".into())), - // A misbehaving aggregator that keeps going after the Err. - Ok(StreamPart::TextDelta { - id: "t2".into(), - delta: "should-not-see".into(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } - } - - #[tokio::test] - async fn stream_relays_aggregator_transport_error_and_stops() { - // N3 regression guard: a mid-stream transport Err from the aggregator - // is relayed and the stream terminates — no subsequent parts forwarded. - let refs = vec![mk("ref-a", "A", false, 5)]; - let agg = Arc::new(MidStreamErrorAggregator); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let parts: Vec<_> = result.stream.collect().await; - let mut saw_error = false; - let mut text = String::new(); - for part in parts { - match part { - Ok(StreamPart::StreamStart { .. }) => {} - Ok(StreamPart::TextDelta { delta, .. }) => text.push_str(&delta), - Err(e) => { - saw_error = true; - assert!(e.to_string().contains("blew up"), "got: {e}"); - } - _ => {} - } - } - assert!( - saw_error, - "expected the aggregator transport error to be relayed" - ); - assert!( - !text.contains("should-not-see"), - "parts after the terminal Err must not be forwarded; got: {text}" - ); - } - - struct RecoverableFrameErrorAggregator; - - #[async_trait] - impl LanguageModel for RecoverableFrameErrorAggregator { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "agg-recoverable-error" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - unimplemented!() - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(stream::iter([ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Err(AiMuxError::JsonParse("malformed SSE data".into())), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "after-error".into(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }), - ])), - request_body: None, - response_headers: None, - }) - } - } - - #[tokio::test] - async fn stream_continues_after_recoverable_aggregator_frame_error() { - let refs = vec![mk("ref-a", "A", false, 5)]; - let moa = MoaModel::new( - refs, - Arc::new(RecoverableFrameErrorAggregator), - MoaConfig::default(), - ); - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let outcomes: Vec<_> = result.stream.collect().await; - let error_index = outcomes - .iter() - .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse(_)))) - .expect("parse error should be relayed"); - - assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!( - outcome, - Ok(StreamPart::TextDelta { delta, .. }) if delta == "after-error" - ))); - assert!(outcomes.iter().any(|outcome| matches!( - outcome, - Ok(StreamPart::Finish { finish_reason, .. }) - if finish_reason.unified == FinishReasonUnified::Stop - ))); - } - - /// Like `mk` but with full per-field usage (for G4 accumulation testing). - fn mk_full( - name: &'static str, - text: &str, - total: u32, - cache_read: u32, - cache_write: u32, - text_tokens: u32, - reasoning_tokens: u32, - ) -> ChildModel { - Arc::new(MockChild { - name, - text: text.into(), - fail: false, - usage: Usage { - input_tokens: TokenUsage { - total: Some(total), - cache_read: Some(cache_read), - cache_write: Some(cache_write), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(total), - text: Some(text_tokens), - reasoning: Some(reasoning_tokens), - ..Default::default() - }, - raw: None, - }, - retry_failures: 0, - calls: Arc::new(AtomicUsize::new(0)), - }) - } - - #[tokio::test] - async fn generate_accumulates_per_field_usage() { - // G4: add_usage must sum cache_read/cache_write/text/reasoning, not - // just total. ref-a: cache_read=5,cache_write=2,text=3,reasoning=1; - // ref-b: cache_read=7,cache_write=4,text=6,reasoning=2; agg totals=1. - let refs = vec![ - mk_full("ref-a", "A", 10, 5, 2, 3, 1), - mk_full("ref-b", "B", 20, 7, 4, 6, 2), - ]; - let agg = mk_full("aggregator", "out", 1, 0, 0, 1, 0); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - // input: cache_read 5+7+0=12, cache_write 2+4+0=6 - assert_eq!(r.usage.input_tokens.cache_read, Some(12)); - assert_eq!(r.usage.input_tokens.cache_write, Some(6)); - // output: text 3+6+1=10, reasoning 1+2+0=3 - assert_eq!(r.usage.output_tokens.text, Some(10)); - assert_eq!(r.usage.output_tokens.reasoning, Some(3)); - } - - #[tokio::test] - async fn strip_reference_tools_false_preserves_tools() { - // G5: strip_reference_tools=false keeps tools/tool_choice on references. - let agg = mk("agg", "x", false, 0); - let moa = MoaModel::new( - vec![], - agg, - MoaConfig { - strip_reference_tools: false, - ..Default::default() - }, - ); - let opts = CallOptions { - prompt: vec![], - tools: Some(vec![]), - tool_choice: ToolChoice::Required, - ..Default::default() - }; - let ref_opts = moa.reference_options(&opts); - assert_eq!(ref_opts.tools.as_ref().map(std::vec::Vec::len), Some(0)); - assert_eq!(ref_opts.tool_choice, ToolChoice::Required); - } - - #[tokio::test] - async fn aggregator_failure_on_generate_propagates() { - // G11: if the aggregator's do_generate fails, the error surfaces - // (references already ran successfully). - let refs = vec![mk("ref-a", "A", false, 5)]; - let agg = mk("aggregator", "out", true, 1); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err(); - assert!(err.to_string().contains("aggregator failed")); - } - - /// A mock aggregator whose stream emits non-text parts (ToolCall, Source, - /// Reasoning) so we can verify they're forwarded by MoA's do_stream. - struct RichStreamAggregator; - - #[async_trait] - impl LanguageModel for RichStreamAggregator { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "rich-agg" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - unimplemented!() - } - async fn do_stream(&self, _: &CallOptions) -> Result { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "answer".into(), - provider_metadata: None, - }), - Ok(StreamPart::ToolCall(RawToolCall { - tool_call_id: "tc1".into(), - tool_name: "search".into(), - input: r#"{"q":"rust"}"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - })), - Ok(StreamPart::Source(Source { - id: "s1".into(), - source_type: "url".into(), - url: Some("https://example.com".into()), - title: Some("Ex".into()), - provider_metadata: None, - })), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } - } - - #[tokio::test] - async fn stream_forwards_non_text_parts() { - // G10: ToolCall/Source deltas from the aggregator must pass through - // (not just TextDelta). - let refs = vec![mk("ref-a", "A", false, 5)]; - let agg = Arc::new(RichStreamAggregator); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let parts: Vec<_> = result.stream.collect().await; - let mut saw_tool_call = false; - let mut saw_source = false; - for part in parts { - match part.unwrap() { - StreamPart::ToolCall(RawToolCall { tool_name, .. }) => { - saw_tool_call = true; - assert_eq!(tool_name, "search"); - } - StreamPart::Source(Source { id, .. }) => { - saw_source = true; - assert_eq!(id, "s1"); - } - _ => {} - } - } - assert!(saw_tool_call, "aggregator ToolCall must be forwarded"); - assert!(saw_source, "aggregator Source must be forwarded"); - } - - #[tokio::test] - async fn nested_router_as_moa_reference() { - // G8: composability — a RouterModel can be used as a MoaModel reference - // (both are Arc). RFC-0022 §6.2 explicitly says this - // is supported and "needs testing". The router's fallback should run - // inside the MoA fanout, and its output feeds the aggregator. - let router = crate::router::RouterModel::new( - vec![ - mk("router-primary", "router picked primary", true, 0), - mk("router-backup", "router fell back", false, 4), - ], - Box::new(crate::router::RuleRouter), - crate::router::FallbackPolicy::OnError, - crate::router::RouterConfig::default(), - ); - let router_child: ChildModel = Arc::new(router); - let plain_ref = mk("plain-ref", "plain reference", false, 3); - let agg = mk("aggregator", "aggregated", false, 1); - let moa = MoaModel::new(vec![router_child, plain_ref], agg, MoaConfig::default()); - - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - // Aggregator output wins as content. - assert_eq!(extract_text(&r.content), "aggregated"); - // Usage: router(fell back to backup=4) + plain_ref(3) + aggregator(1) = 8. - assert_eq!(r.usage.input_tokens.total, Some(8)); - } -} diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index dc86d6cb0..f0bb28ed2 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -36,7 +36,7 @@ use ts_rs::TS; use crate::error::AiMuxError; use crate::parse_tool_call::raw_tool_call_text; use crate::result::{GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source}; -use crate::shared::FileData; +use crate::shared::GeneratedFileData; use crate::stream_part::{StreamPart, TextStreamPart}; use crate::tool::{ToolCall, ToolResult}; use crate::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -286,6 +286,9 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio } content_text.push_str(&file_data_to_text(data, media_type)); } + GenerateContent::Custom { .. } + | GenerateContent::ReasoningFile(_) + | GenerateContent::ToolApprovalRequest(_) => {} GenerateContent::ToolResult(ToolResult { tool_name, result, @@ -348,13 +351,13 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio // id / model / created let id = result .response - .id - .clone() + .as_ref() + .and_then(|response| response.id.clone()) .unwrap_or_else(|| format!("chatcmpl-{}", random_id())); let model = result .response - .model_id - .clone() + .as_ref() + .and_then(|response| response.model_id.clone()) .unwrap_or_else(|| model.to_string()); let created = now_unix(); @@ -866,7 +869,10 @@ impl StreamState { } } - StreamPart::Raw { .. } => { /* ignore */ } + StreamPart::Raw { .. } + | StreamPart::Custom { .. } + | StreamPart::ReasoningFile(_) + | StreamPart::ToolApprovalRequest(_) => { /* no OpenAI chat equivalent */ } } chunks @@ -986,11 +992,11 @@ fn usage_to_openai(usage: &Usage) -> ChatCompletionUsage { } } -/// Convert [`FileData`] to a text representation (degraded — placed in content). -fn file_data_to_text(data: &FileData, media_type: &str) -> String { +/// Convert [`GeneratedFileData`] to a text representation (degraded — placed in content). +fn file_data_to_text(data: &GeneratedFileData, media_type: &str) -> String { match data { - FileData::Url { url } => url.clone(), - FileData::Data { data } => match data { + GeneratedFileData::Url { url, .. } => url.clone(), + GeneratedFileData::Data { data } => match data { crate::shared::FileBytes::Base64(b64) => { format!("data:{media_type};base64,{b64}") } @@ -1000,8 +1006,6 @@ fn file_data_to_text(data: &FileData, media_type: &str) -> String { format!("data:{media_type};base64,{b64}") } }, - FileData::Reference { reference } => serde_json::to_string(reference).unwrap_or_default(), - FileData::Text { text } => text.clone(), } } @@ -1060,921 +1064,3 @@ fn random_id() -> String { // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use crate::result::GenerateResult; - use crate::tool::RawToolCall; - use crate::types::TokenUsage; - use crate::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; - use futures::StreamExt; - use serde_json::json; - - fn make_result(content: Vec) -> GenerateResult { - GenerateResult { - content, - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: ResponseMetadata { - id: Some("chatcmpl-test123".to_string()), - timestamp: None, - model_id: Some("gpt-4o".to_string()), - }, - request_body: None, - response_headers: None, - } - } - - #[test] - fn test_text_only_non_streaming() { - let result = make_result(vec![GenerateContent::Text { - text: "Hello world".to_string(), - provider_metadata: None, - }]); - let completion = to_chat_completion(&result, "gpt-4o"); - - assert_eq!(completion.object, "chat.completion"); - assert_eq!(completion.id, "chatcmpl-test123"); - assert_eq!(completion.model, "gpt-4o"); - assert_eq!(completion.choices.len(), 1); - assert_eq!(completion.choices[0].index, 0); - assert_eq!( - completion.choices[0].message.content.as_deref(), - Some("Hello world") - ); - assert_eq!(completion.choices[0].message.role, "assistant"); - assert_eq!(completion.choices[0].finish_reason.as_deref(), Some("stop")); - assert!(completion.choices[0].message.tool_calls.is_none()); - assert!(completion.choices[0].message.reasoning_content.is_none()); - } - - #[test] - fn test_tool_call_non_streaming() { - let result = make_result(vec![ - GenerateContent::Text { - text: "Let me check.".to_string(), - provider_metadata: None, - }, - GenerateContent::ToolCall(RawToolCall { - tool_call_id: "call_abc".to_string(), - tool_name: "get_weather".to_string(), - input: r#"{"city":"Tokyo"}"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }), - ]); - // Set finish_reason to ToolCalls. - let mut result = result; - result.finish_reason = FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: None, - }; - - let completion = to_chat_completion(&result, "gpt-4o"); - - let msg = &completion.choices[0].message; - assert_eq!(msg.content.as_deref(), Some("Let me check.")); - let tool_calls = msg.tool_calls.as_ref().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].id, "call_abc"); - assert_eq!(tool_calls[0].tool_type, "function"); - assert_eq!(tool_calls[0].function.name, "get_weather"); - assert_eq!(tool_calls[0].function.arguments, r#"{"city":"Tokyo"}"#); - assert_eq!( - completion.choices[0].finish_reason.as_deref(), - Some("tool_calls") - ); - } - - #[test] - fn test_raw_tool_call_arguments_are_not_double_encoded() { - let result = make_result(vec![GenerateContent::ToolCall(RawToolCall { - tool_call_id: "call_raw".to_string(), - tool_name: "get_weather".to_string(), - input: r#"{"city":"Tokyo"}"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - })]); - - let completion = to_chat_completion(&result, "gpt-4o"); - assert_eq!( - completion.choices[0].message.tool_calls.as_ref().unwrap()[0] - .function - .arguments, - r#"{"city":"Tokyo"}"# - ); - } - - #[test] - fn blank_invalid_tool_arguments_render_as_an_empty_object() { - // Both OpenAI renderers: blank raw text has no wire representation - // other than `{}`. - let error = AiMuxError::InvalidToolInput { - tool_name: "get_weather".to_string(), - tool_input: String::new(), - cause: "Type validation failed: missing 'city'".to_string(), - }; - let arguments = parsed_tool_call_arguments(&json!({}), Some(true), Some(&error)); - assert_eq!(arguments, "{}"); - - let result = make_result(vec![GenerateContent::ToolCall(RawToolCall { - tool_call_id: "call_blank".to_string(), - tool_name: "get_weather".to_string(), - input: " ".to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - })]); - assert_eq!( - to_chat_completion(&result, "gpt-4o").choices[0] - .message - .tool_calls - .as_ref() - .unwrap()[0] - .function - .arguments, - "{}" - ); - } - - #[test] - fn parsed_tool_call_arguments_null_is_not_rewritten_to_empty_object() { - // A valid call whose parsed input happens to be `null` (no schema - // constraint rejected it) must round-trip as `null`, not `{}`. - let arguments = parsed_tool_call_arguments(&Value::Null, None, None); - assert_eq!(arguments, "null"); - } - - /// Regression coverage for the P1 finding on PR #165: `input: Value` - /// alone cannot distinguish a validly parsed JSON string from malformed - /// text wrapped as a fallback string, so an invalid call's arguments are - /// recovered from its typed error instead. Driven end to end so the - /// error/`input` pairing `parse_tool_call` actually produces is pinned - /// too, not just the renderer. - #[tokio::test] - async fn invalid_tool_calls_stream_their_raw_arguments_verbatim() { - let failing_repair = crate::parse_tool_call::ToolCallRepair::new(|_context| async { - Err(AiMuxError::Other("repair model failed".to_string())) - }); - let cases: [(&str, &str, Option<&crate::parse_tool_call::ToolCallRepair>); 3] = [ - // Valid JSON string rejected by the schema: the quotes must - // survive, or the arguments field stops being JSON at all. - ("get_weather", r#""hello""#, None), - // Unknown tool *and* malformed text: `NoSuchTool` carries no raw - // text of its own, so it comes off the unparsable `input`. - ("unknown_tool", r#"{"a":"#, None), - // A failed repair callback wraps the pre-repair error. - ("get_weather", r#"{"a":"#, Some(&failing_repair)), - ]; - let tools = [crate::tool::Tool::Function(crate::tool::FunctionTool::new( - "get_weather", - json!({"type": "object", "properties": {}, "additionalProperties": false}), - ))]; - - for (tool_name, raw_input, repair) in cases { - let parsed = crate::parse_tool_call::parse_tool_call( - crate::tool::RawToolCall { - tool_call_id: "call_1".to_string(), - tool_name: tool_name.to_string(), - input: raw_input.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }, - Some(&tools), - repair, - &[], - None, - ) - .await; - assert_eq!(parsed.invalid, Some(true), "{raw_input}"); - - let parts: Vec> = - vec![Ok(StreamPart::ToolCall(ToolCall { - tool_call_id: parsed.tool_call_id, - tool_name: parsed.tool_name, - input: parsed.input, - provider_executed: parsed.provider_executed, - dynamic: parsed.dynamic, - thought_signature: parsed.thought_signature, - invalid: parsed.invalid, - error: parsed.error, - provider_metadata: parsed.provider_metadata, - }))]; - let chunks = collect_stream(to_chat_completion_stream( - Box::pin(futures::stream::iter(parts)), - "gpt-4o", - OpenAiStreamOptions::default(), - )) - .await; - - let arguments = chunks - .iter() - .find_map(|c| { - c.choices - .first()? - .delta - .tool_calls - .as_ref()? - .first()? - .function - .arguments - .clone() - }) - .expect("tool call arguments chunk not found"); - assert_eq!(arguments, raw_input, "tool {tool_name}"); - } - } - - #[test] - fn test_tool_call_null_content() { - // Tool call with no text → content should be null. - let result = make_result(vec![GenerateContent::ToolCall(RawToolCall { - tool_call_id: "call_abc".to_string(), - tool_name: "get_weather".to_string(), - input: r#"{"city":"Tokyo"}"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - })]); - let completion = to_chat_completion(&result, "gpt-4o"); - - assert_eq!(completion.choices[0].message.content, None); - assert!(completion.choices[0].message.tool_calls.is_some()); - } - - #[test] - fn test_reasoning_non_streaming() { - let result = make_result(vec![ - GenerateContent::Reasoning(ReasoningOutput { - text: "Thinking...".to_string(), - provider_metadata: None, - }), - GenerateContent::Text { - text: "Answer".to_string(), - provider_metadata: None, - }, - ]); - let completion = to_chat_completion(&result, "gpt-4o"); - - assert_eq!( - completion.choices[0].message.reasoning_content.as_deref(), - Some("Thinking...") - ); - assert_eq!( - completion.choices[0].message.content.as_deref(), - Some("Answer") - ); - } - - #[test] - fn test_usage_round_trip() { - let usage = Usage { - input_tokens: TokenUsage { - total: Some(100), - cache_read: Some(30), - cache_write: Some(10), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(50), - reasoning: Some(20), - ..Default::default() - }, - raw: None, - }; - let mut result = make_result(vec![]); - result.usage = usage; - - let completion = to_chat_completion(&result, "gpt-4o"); - let u = &completion.usage; - - assert_eq!(u.prompt_tokens, 100); - assert_eq!(u.completion_tokens, 50); - assert_eq!(u.total_tokens, 150); - assert_eq!(u.prompt_tokens_details.as_ref().unwrap().cached_tokens, 30); - assert_eq!( - u.prompt_tokens_details.as_ref().unwrap().cache_write_tokens, - Some(10) - ); - assert_eq!( - u.completion_tokens_details - .as_ref() - .unwrap() - .reasoning_tokens, - Some(20) - ); - } - - #[test] - fn test_finish_reason_mapping() { - let cases = [ - (FinishReasonUnified::Stop, "stop"), - (FinishReasonUnified::Length, "length"), - (FinishReasonUnified::ContentFilter, "content_filter"), - (FinishReasonUnified::ToolCalls, "tool_calls"), - (FinishReasonUnified::Error, "stop"), - ]; - for (unified, expected) in cases { - let fr = FinishReason { unified, raw: None }; - assert_eq!(finish_reason_to_openai(&fr).as_deref(), Some(expected)); - } - } - - // ── Streaming tests ── - - async fn collect_stream(stream: ChatCompletionStream) -> Vec { - let mut chunks = Vec::new(); - let mut s = stream.stream; - while let Some(result) = s.next().await { - chunks.push(result.unwrap()); - } - chunks - } - - #[tokio::test] - async fn test_stream_text_only() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextStart { - id: "0".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "Hello ".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "world".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::TextEnd { - id: "0".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // First chunk: role frame. - assert_eq!( - chunks[0].choices[0].delta.role.as_deref(), - Some("assistant") - ); - assert_eq!(chunks[0].choices[0].delta.content.as_deref(), Some("")); - - // Content deltas. - assert_eq!( - chunks[1].choices[0].delta.content.as_deref(), - Some("Hello ") - ); - assert_eq!(chunks[2].choices[0].delta.content.as_deref(), Some("world")); - - // Final chunk: finish_reason. - let last = chunks.last().unwrap(); - assert_eq!(last.choices[0].finish_reason.as_deref(), Some("stop")); - assert!(last.choices[0].delta.content.is_none()); - } - - #[tokio::test] - async fn test_stream_tool_calls() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ToolInputStart { - id: "call_1".to_string(), - tool_name: "get_weather".to_string(), - provider_executed: None, - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::ToolInputDelta { - id: "call_1".to_string(), - delta: "{\"city\":".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolInputDelta { - id: "call_1".to_string(), - delta: "\"Tokyo\"}".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolInputEnd { - id: "call_1".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolCall(ToolCall { - tool_call_id: "call_1".to_string(), - tool_name: "get_weather".to_string(), - input: json!({ "city": "Tokyo" }), - provider_executed: None, - dynamic: None, - thought_signature: None, - invalid: None, - error: None, - provider_metadata: None, - })), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // Find the tool_call opening chunk (with id + name). - let open_chunk = chunks - .iter() - .find(|c| { - c.choices - .first() - .and_then(|ch| ch.delta.tool_calls.as_ref()) - .and_then(|tcs| tcs.first()) - .and_then(|tc| tc.id.as_deref()) - == Some("call_1") - }) - .expect("tool call opening chunk not found"); - - let tc = open_chunk.choices[0] - .delta - .tool_calls - .as_ref() - .unwrap() - .first() - .unwrap(); - assert_eq!(tc.index, 0); - assert_eq!(tc.id.as_deref(), Some("call_1")); - assert_eq!(tc.tool_type.as_deref(), Some("function")); - assert_eq!(tc.function.name.as_deref(), Some("get_weather")); - - // Find argument delta chunks. - let arg_chunks: Vec<_> = chunks - .iter() - .filter(|c| { - c.choices - .first() - .and_then(|ch| ch.delta.tool_calls.as_ref()) - .and_then(|tcs| tcs.first()) - .is_some_and(|tc| tc.id.is_none() && tc.function.arguments.is_some()) - }) - .collect(); - assert_eq!(arg_chunks.len(), 2); - assert_eq!( - arg_chunks[0].choices[0].delta.tool_calls.as_ref().unwrap()[0] - .function - .arguments - .as_deref(), - Some("{\"city\":") - ); - - // Final chunk. - let last = chunks.last().unwrap(); - assert_eq!(last.choices[0].finish_reason.as_deref(), Some("tool_calls")); - } - - #[tokio::test] - async fn test_stream_complete_call_backfills_arguments_absent_from_deltas() { - let parts: Vec> = vec![ - Ok(StreamPart::ToolInputStart { - id: "call_1".to_string(), - tool_name: "fetch".to_string(), - provider_executed: Some(true), - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::ToolInputEnd { - id: "call_1".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolCall(ToolCall { - tool_call_id: "call_1".to_string(), - tool_name: "fetch".to_string(), - input: json!({ "url": "https://example.com" }), - provider_executed: Some(true), - dynamic: None, - thought_signature: None, - invalid: None, - error: None, - provider_metadata: None, - })), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let result = to_chat_completion_stream( - Box::pin(futures::stream::iter(parts)), - "claude", - OpenAiStreamOptions::default(), - ); - let chunks = collect_stream(result).await; - let arguments = chunks - .iter() - .filter_map(|chunk| { - chunk - .choices - .first()? - .delta - .tool_calls - .as_ref()? - .first()? - .function - .arguments - .as_deref() - }) - .collect::(); - - assert_eq!(arguments, r#"{"url":"https://example.com"}"#); - } - - #[tokio::test] - async fn test_stream_multiple_tool_calls_index() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ToolInputStart { - id: "call_a".to_string(), - tool_name: "tool_a".to_string(), - provider_executed: None, - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::ToolInputStart { - id: "call_b".to_string(), - tool_name: "tool_b".to_string(), - provider_executed: None, - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // Find both tool call opening chunks. - let open_chunks: Vec<_> = chunks - .iter() - .filter(|c| { - c.choices - .first() - .and_then(|ch| ch.delta.tool_calls.as_ref()) - .and_then(|tcs| tcs.first()) - .and_then(|tc| tc.id.as_deref()) - .is_some() - }) - .collect(); - assert_eq!(open_chunks.len(), 2); - - let tc0 = &open_chunks[0].choices[0].delta.tool_calls.as_ref().unwrap()[0]; - let tc1 = &open_chunks[1].choices[0].delta.tool_calls.as_ref().unwrap()[0]; - assert_eq!(tc0.index, 0); - assert_eq!(tc0.id.as_deref(), Some("call_a")); - assert_eq!(tc1.index, 1); - assert_eq!(tc1.id.as_deref(), Some("call_b")); - } - - #[tokio::test] - async fn test_stream_reasoning() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ReasoningStart { - id: "reasoning-0".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningDelta { - id: "reasoning-0".to_string(), - delta: "Hmm...".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningEnd { - id: "reasoning-0".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "Answer".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // Find reasoning chunk. - let reasoning_chunk = chunks - .iter() - .find(|c| c.choices[0].delta.reasoning_content.is_some()) - .expect("reasoning chunk not found"); - assert_eq!( - reasoning_chunk.choices[0] - .delta - .reasoning_content - .as_deref(), - Some("Hmm...") - ); - - // Find text chunk. - let text_chunk = chunks - .iter() - .find(|c| { - c.choices[0] - .delta - .content - .as_deref() - .is_some_and(|s| s == "Answer") - }) - .expect("text chunk not found"); - assert_eq!( - text_chunk.choices[0].delta.content.as_deref(), - Some("Answer") - ); - } - - #[tokio::test] - async fn test_stream_usage_in_final_chunk() { - let usage = Usage { - input_tokens: TokenUsage { - total: Some(42), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(10), - ..Default::default() - }, - raw: None, - }; - - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "Hi".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage, - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = to_chat_completion_stream( - input_stream, - "gpt-4o", - OpenAiStreamOptions { - include_usage: true, - include_reasoning: true, - }, - ); - let chunks = collect_stream(result).await; - - let last = chunks.last().unwrap(); - assert_eq!(last.usage.as_ref().unwrap().prompt_tokens, 42); - assert_eq!(last.usage.as_ref().unwrap().completion_tokens, 10); - assert_eq!(last.usage.as_ref().unwrap().total_tokens, 52); - } - - #[tokio::test] - async fn test_stream_no_usage_when_disabled() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage { - input_tokens: TokenUsage { - total: Some(42), - ..Default::default() - }, - output_tokens: TokenUsage::default(), - raw: None, - }, - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = to_chat_completion_stream( - input_stream, - "gpt-4o", - OpenAiStreamOptions { - include_usage: false, - include_reasoning: true, - }, - ); - let chunks = collect_stream(result).await; - - let last = chunks.last().unwrap(); - assert!(last.usage.is_none()); - } - - #[tokio::test] - async fn test_stream_error() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "partial".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Error { - error: AiMuxError::ApiCall(Box::new(crate::error::ApiCallError::new( - "something went wrong", - "https://example.test/v1", - serde_json::json!({}), - ))), - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // Error should appear as a content delta. - let error_chunk = chunks - .iter() - .find(|c| { - c.choices[0] - .delta - .content - .as_deref() - .is_some_and(|s| s.contains("[error]")) - }) - .expect("error chunk not found"); - - assert!( - error_chunk.choices[0] - .delta - .content - .as_deref() - .unwrap() - .contains("something went wrong") - ); - - // Final chunk should still have finish_reason. - let last = chunks.last().unwrap(); - assert_eq!(last.choices[0].finish_reason.as_deref(), Some("stop")); - } - - #[tokio::test] - async fn recoverable_frame_error_does_not_truncate_chat_completion_stream() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Err(AiMuxError::JsonParse("malformed SSE data".into())), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "after-error".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let result = to_chat_completion_stream( - Box::pin(futures::stream::iter(parts)), - "gpt-4o", - OpenAiStreamOptions::default(), - ); - let outcomes: Vec<_> = result.stream.collect().await; - let error_index = outcomes - .iter() - .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse(_)))) - .expect("parse error should be preserved as an error item"); - - assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!( - outcome, - Ok(chunk) - if chunk.choices.first().and_then(|choice| choice.delta.content.as_deref()) - == Some("after-error") - ))); - assert!(outcomes.iter().any(|outcome| matches!( - outcome, - Ok(chunk) - if chunk.choices.first().and_then(|choice| choice.finish_reason.as_deref()) - == Some("stop") - ))); - } - - #[tokio::test] - async fn test_stream_completes_without_finish() { - // Stream ends without Finish — should still emit a final chunk. - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "Hello".to_string(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - let last = chunks.last().unwrap(); - assert_eq!(last.choices[0].finish_reason.as_deref(), Some("stop")); - } - - #[test] - fn test_sse_encoding() { - let chunk = ChatCompletionChunk { - id: "chatcmpl-test".to_string(), - object: "chat.completion.chunk".to_string(), - created: 1234567890, - model: "gpt-4o".to_string(), - choices: vec![ChatCompletionChunkChoice { - index: 0, - delta: ChatCompletionDelta { - content: Some("Hi".to_string()), - ..Default::default() - }, - finish_reason: None, - logprobs: None, - }], - usage: None, - }; - - let sse = encode_chunk_sse(&chunk); - assert!(sse.starts_with("data: {")); - assert!(sse.ends_with("\n\n")); - assert!(sse.contains("\"content\":\"Hi\"")); - } - - #[test] - fn test_done_frame() { - assert_eq!(DONE_FRAME, "data: [DONE]\n\n"); - } -} diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index 516f18f94..6af3f9655 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -1542,11 +1542,12 @@ impl RecordingOutcomeStream { } } -impl futures::Stream for RecordingOutcomeStream +impl futures::Stream for RecordingOutcomeStream where - S: futures::Stream, crate::AiMuxError>> + Unpin, + S: futures::Stream, crate::AiMuxError>> + + Unpin, { - type Item = Result, crate::AiMuxError>; + type Item = Result, crate::AiMuxError>; fn poll_next( mut self: std::pin::Pin<&mut Self>, diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index fe1116842..d1001963c 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -511,7 +511,7 @@ fn parse_usage(v: &serde_json::Value) -> Result { let prompt_details = &u["prompt_tokens_details"]; let completion_details = &u["completion_tokens_details"]; Ok(Usage { - input_tokens: crate::types::TokenUsage { + input_tokens: crate::types::InputTokenUsage { total: u32_from_json(&u["prompt_tokens"], "prompt_tokens")?, no_cache: None, cache_read: u32_from_json( @@ -519,21 +519,16 @@ fn parse_usage(v: &serde_json::Value) -> Result { "prompt_tokens_details.cached_tokens", )?, cache_write: None, - text: None, - reasoning: None, }, - output_tokens: crate::types::TokenUsage { + output_tokens: crate::types::OutputTokenUsage { total: u32_from_json(&u["completion_tokens"], "completion_tokens")?, - no_cache: None, - cache_read: None, - cache_write: None, text: None, reasoning: u32_from_json( &completion_details["reasoning_tokens"], "completion_tokens_details.reasoning_tokens", )?, }, - raw: Some(u.clone()), + raw: u.as_object().cloned(), }) } @@ -616,13 +611,15 @@ fn rebuild_generate_result(rec: &Recording) -> Result Result { Ok(StreamResult { stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, + request: None, + response: None, }) } @@ -1460,7 +1457,13 @@ mod tests { assert_eq!(text, "pong"); assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); assert_eq!(result.usage.input_tokens.total, Some(5)); - assert_eq!(result.response.model_id.as_deref(), Some("gpt-4o")); + assert_eq!( + result + .response + .as_ref() + .and_then(|response| response.model_id.as_deref()), + Some("gpt-4o") + ); } #[test] @@ -1835,7 +1838,13 @@ mod tests { }; assert_eq!(text, "pong"); // 证明取的是第 1 次(id=chatcmpl-ok),而非失败的 exchange[0](id=chatcmpl-mock)。 - assert_eq!(result.response.id.as_deref(), Some("chatcmpl-ok")); + assert_eq!( + result + .response + .as_ref() + .and_then(|response| response.id.as_deref()), + Some("chatcmpl-ok") + ); } #[test] @@ -1869,117 +1878,4 @@ mod tests { assert_eq!(model.recordings().len(), 1); let _ = std::fs::remove_dir_all(&dir); } - - // ── P4 请求回放 ───────────────────────────────────────────────────── - - /// 测试用 echo model:把收到的 prompt 文本原样回显。 - #[derive(Clone)] - struct EchoModel { - provider: &'static str, - model_id: &'static str, - } - - impl EchoModel { - fn new() -> Self { - Self { - provider: "openai", - model_id: "gpt-4o", - } - } - } - - #[async_trait] - impl LanguageModel for EchoModel { - fn provider(&self) -> &str { - self.provider - } - fn model_id(&self) -> &str { - self.model_id - } - async fn do_generate(&self, options: &CallOptions) -> Result { - let text = options - .prompt - .iter() - .filter_map(|m| match m.content.first() { - Some(crate::content::ContentPart::Text { text, .. }) => Some(text.clone()), - _ => None, - }) - .collect::>() - .join(" "); - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text, - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: ResponseMetadata::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unreachable!("not used in tests") - } - } - - /// 构造带 options 的录制(temperature/max_output_tokens 有值)。 - /// options 用真实 CallOptions 序列化(与录制路径一致,全字段 round-trip)。 - fn optioned_recording(prompt_text: &str) -> Recording { - let mut rec = openai_recording("t1", prompt_text, "pong", "stop"); - let mut call = sample_options(prompt_text, Some(0.7)); - call.max_output_tokens = Some(128); - rec.input.options = serde_json::to_value(&call).unwrap(); - rec - } - - #[test] - fn replay_with_model_rebuilds_input_and_resends() { - let rec = optioned_recording("hello"); - let model = EchoModel::new(); - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { replay_with_model(&rec, &model, None).await.unwrap() }); - assert_eq!(result.text, "hello"); - } - - #[test] - fn replay_with_model_applies_overrides() { - let rec = optioned_recording("hello"); - let model = EchoModel::new(); - let overrides = ReplayOverrides { - prompt: Some(vec![ - crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: "overridden".into(), - provider_options: None, - }], - provider_options: None, - }, - ]), - ..Default::default() - }; - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - replay_with_model(&rec, &model, Some(&overrides)) - .await - .unwrap() - }); - assert_eq!(result.text, "overridden"); - } - - #[test] - fn replay_with_model_bad_input_options_errors() { - let mut rec = openai_recording("t1", "hello", "pong", "stop"); - rec.input.options = serde_json::json!({ "not": "call-options" }); - let model = EchoModel::new(); - let rt = tokio::runtime::Runtime::new().unwrap(); - let err = rt.block_on(async { replay_with_model(&rec, &model, None).await.unwrap_err() }); - assert!(matches!(err, AiMuxError::JsonParse(_)), "{err}"); - } } diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index cc54128c6..20b95fbab 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -1,17 +1,21 @@ //! Assemble the assistant message replayed to the model on the next turn. //! -//! Rust port of the AI SDK's `toResponseMessages` -//! (`packages/ai/src/generate-text/to-response-messages.ts`): text and -//! reasoning arrive as deltas and must be flushed as positioned segments the -//! moment a part of a different kind lands, so the replayed transcript keeps -//! provider order. Tool calls/results and reasoning signatures are replayed -//! verbatim — Anthropic/Bedrock thinking signatures must round-trip exactly. +//! Rust port of the AI SDK's `toResponseMessages` and streamed content +//! assembly. Text and reasoning segments retain their first position while +//! deltas update them by ID. Provider metadata is replayed on the next turn. +use std::collections::HashMap; + +use base64::Engine; use serde_json::Value; use crate::content::ContentPart; use crate::message::{MessageContent, ModelMessage, Role}; -use crate::result::ReasoningOutput; +use crate::result::{ + GenerateContent, GeneratedFile, ReasoningFileOutput, ReasoningOutput, ReasoningPart, Source, + TextContent, ToolApprovalRequestOutput, +}; +use crate::shared::{FileBytes, GeneratedFileData}; /// Match the AI SDK's response-message safety rule for invalid tool calls: /// malformed primitive input must not be replayed as a prompt tool-call input. @@ -36,26 +40,18 @@ pub(crate) fn extract_reasoning_signature(provider_metadata: Option<&Value>) -> .map(str::to_owned) } -/// Accumulates response-message content parts in provider order. -/// -/// Streaming feeds it per-event (`text_start`/`text_delta`/…); the -/// non-streaming path feeds whole segments (`text`/`reasoning`). Both paths -/// share the flush discipline and the tool-call/result placement rules. +/// Accumulates generated content in provider order, updating streamed segments by ID. #[derive(Default)] pub(crate) struct ResponseMessageBuilder { - parts: Vec, - text_buf: String, - text_provider_options: Option, - reasoning_buf: String, - reasoning_provider_options: Option, - reasoning: Vec, + content: Vec, + text_indexes: HashMap, + reasoning_indexes: HashMap, } -/// What the builder produced: the replayable assistant message plus the -/// reasoning aggregate surfaced on the result. pub(crate) struct ResponseMessages { pub messages: Vec, - pub reasoning: Vec, + pub content: Vec, + pub reasoning: Vec, } impl ResponseMessageBuilder { @@ -63,158 +59,219 @@ impl ResponseMessageBuilder { Self::default() } - // ── Streaming events ──────────────────────────────────────────────── - - pub fn text_start(&mut self, provider_metadata: Option) { - self.flush_reasoning(); - // A new text segment establishes its position immediately; flush a - // preceding implicit segment before starting it. - self.flush_text(); - self.text_provider_options = provider_metadata; + pub fn text_start(&mut self, id: String, metadata: Option) { + self.text_delta(id, "", metadata); } - pub fn text_delta(&mut self, delta: &str, provider_metadata: Option) { - self.flush_reasoning(); - self.text_buf.push_str(delta); - if provider_metadata.is_some() { - self.text_provider_options = provider_metadata; + pub fn text_delta(&mut self, id: String, delta: &str, metadata: Option) { + let index = *self.text_indexes.entry(id).or_insert_with(|| { + let index = self.content.len(); + self.content.push(GenerateContent::Text { + text: String::new(), + provider_metadata: None, + }); + index + }); + if let GenerateContent::Text { + text, + provider_metadata, + } = &mut self.content[index] + { + text.push_str(delta); + if metadata.is_some() { + *provider_metadata = metadata; + } } } - pub fn text_end(&mut self, provider_metadata: Option) { - if provider_metadata.is_some() { - self.text_provider_options = provider_metadata; - } - self.flush_text(); + pub fn text_end(&mut self, id: String, metadata: Option) { + self.text_delta(id.clone(), "", metadata); + self.text_indexes.remove(&id); } - pub fn reasoning_start(&mut self, provider_metadata: Option) { - self.flush_text(); - self.flush_reasoning(); - self.reasoning_provider_options = provider_metadata; + pub fn reasoning_start(&mut self, id: String, metadata: Option) { + self.reasoning_delta(id, "", metadata); } - pub fn reasoning_delta(&mut self, delta: &str, provider_metadata: Option) { - self.flush_text(); - self.reasoning_buf.push_str(delta); - if provider_metadata.is_some() { - self.reasoning_provider_options = provider_metadata; + pub fn reasoning_delta(&mut self, id: String, delta: &str, metadata: Option) { + let index = *self.reasoning_indexes.entry(id).or_insert_with(|| { + let index = self.content.len(); + self.content + .push(GenerateContent::Reasoning(ReasoningOutput { + text: String::new(), + provider_metadata: None, + })); + index + }); + if let GenerateContent::Reasoning(reasoning) = &mut self.content[index] { + reasoning.text.push_str(delta); + if metadata.is_some() { + reasoning.provider_metadata = metadata; + } } } - pub fn reasoning_end(&mut self, provider_metadata: Option) { - self.flush_text(); - if provider_metadata.is_some() { - self.reasoning_provider_options = provider_metadata; - } - self.flush_reasoning(); + pub fn reasoning_end(&mut self, id: String, metadata: Option) { + self.reasoning_delta(id.clone(), "", metadata); + self.reasoning_indexes.remove(&id); } - // ── Whole segments (non-streaming) ────────────────────────────────── - - pub fn text(&mut self, text: &str, provider_metadata: Option<&Value>) { - if !text.is_empty() { - self.parts.push(ContentPart::Text { - text: text.to_owned(), - provider_options: provider_metadata.cloned(), - }); - } + pub fn text(&mut self, text: &str, metadata: Option<&Value>) { + self.content.push(GenerateContent::Text { + text: text.to_owned(), + provider_metadata: metadata.cloned(), + }); } pub fn reasoning(&mut self, reasoning: &ReasoningOutput) { - // Pushed unconditionally: redacted thinking has empty text but its - // provider metadata must still be replayed. - self.reasoning.push(reasoning.clone()); - let signature = extract_reasoning_signature(reasoning.provider_metadata.as_ref()); - self.parts.push(ContentPart::Reasoning { - text: reasoning.text.clone(), - signature, - provider_options: reasoning.provider_metadata.clone(), + self.content + .push(GenerateContent::Reasoning(reasoning.clone())); + } + + pub fn custom(&mut self, kind: String, provider_metadata: Option) { + self.content.push(GenerateContent::Custom { + kind, + provider_metadata, }); } - // ── Tool parts (both paths) ───────────────────────────────────────── + pub fn file(&mut self, file: &GeneratedFile, reasoning: bool) { + self.content.push(if reasoning { + GenerateContent::ReasoningFile(ReasoningFileOutput { + file: file.clone(), + provider_metadata: file.provider_metadata.clone(), + }) + } else { + GenerateContent::File(file.clone()) + }); + } + + pub fn approval(&mut self, approval: &ToolApprovalRequestOutput) { + self.content + .push(GenerateContent::ToolApprovalRequest(approval.clone())); + } pub fn tool_call(&mut self, call: &crate::tool::ToolCall) { - self.flush_text(); - self.flush_reasoning(); - self.parts.push(ContentPart::ToolCall { - tool_call_id: call.tool_call_id.clone(), - tool_name: call.tool_name.clone(), - input: response_tool_call_input(&call.input, call.invalid), - provider_executed: call.provider_executed, - thought_signature: call.thought_signature.clone(), - provider_options: call.provider_metadata.clone(), - }); + self.content.push(GenerateContent::ToolCall(call.clone())); } pub fn tool_result(&mut self, result: crate::tool::ToolResult) { - // Preliminary server-tool results are transient stream updates. The - // provider contract requires a later final result, and only that - // final value belongs in the replay transcript for the next turn. - if result.preliminary == Some(true) { - return; + if result.preliminary != Some(true) { + self.content.push(GenerateContent::ToolResult(result)); } - self.flush_text(); - self.flush_reasoning(); - self.parts.push(ContentPart::ToolResult { - tool_call_id: result.tool_call_id, - tool_name: Some(result.tool_name), - result: result.result, - is_error: result.is_error, - preliminary: result.preliminary, - dynamic: result.dynamic, - provider_options: result.provider_metadata, - }); } - // ── Finalization ──────────────────────────────────────────────────── + pub fn source(&mut self, source: Source) { + self.content.push(GenerateContent::Source(source)); + } - pub fn finish(mut self) -> ResponseMessages { - self.flush_text(); - self.flush_reasoning(); - let messages = if self.parts.is_empty() { + pub fn finish(self) -> ResponseMessages { + let mut parts = Vec::new(); + let mut reasoning = Vec::new(); + for content in &self.content { + parts.push(match content { + GenerateContent::Text { + text, + provider_metadata, + } => { + if text.is_empty() { + continue; + } + ContentPart::Text { + text: text.clone(), + provider_options: provider_metadata.clone(), + } + } + GenerateContent::Reasoning(output) => { + reasoning.push(ReasoningPart::Text(output.clone())); + ContentPart::Reasoning { + text: output.text.clone(), + signature: extract_reasoning_signature(output.provider_metadata.as_ref()), + provider_options: output.provider_metadata.clone(), + } + } + GenerateContent::Custom { + kind, + provider_metadata, + } => ContentPart::Custom { + kind: kind.clone(), + provider_options: provider_metadata.clone(), + }, + GenerateContent::File(file) => match &file.data { + GeneratedFileData::Data { data } => ContentPart::FileBase64 { + data: file_base64(data), + media_type: file.media_type.clone(), + filename: None, + provider_options: file.provider_metadata.clone(), + }, + GeneratedFileData::Url { url, .. } => ContentPart::FileUrl { + url: url.clone(), + media_type: file.media_type.clone(), + provider_options: file.provider_metadata.clone(), + }, + }, + GenerateContent::ReasoningFile(output) => { + reasoning.push(ReasoningPart::File(output.clone())); + ContentPart::ReasoningFile { + data: match &output.file.data { + GeneratedFileData::Data { data } => GeneratedFileData::Data { + data: FileBytes::Base64(file_base64(data)), + }, + url => url.clone(), + }, + media_type: output.file.media_type.clone(), + provider_options: output.provider_metadata.clone(), + } + } + GenerateContent::ToolApprovalRequest(approval) => { + ContentPart::ToolApprovalRequest { + approval_id: approval.approval_id.clone(), + tool_call_id: approval.tool_call.tool_call_id.clone(), + reason: approval.reason.clone(), + is_automatic: approval.is_automatic, + signature: approval.signature.clone(), + input_schema_input: None, + } + } + GenerateContent::ToolCall(call) => ContentPart::ToolCall { + tool_call_id: call.tool_call_id.clone(), + tool_name: call.tool_name.clone(), + input: response_tool_call_input(&call.input, call.invalid), + provider_executed: call.provider_executed, + thought_signature: call.thought_signature.clone(), + provider_options: call.provider_metadata.clone(), + }, + GenerateContent::ToolResult(result) => ContentPart::ToolResult { + tool_call_id: result.tool_call_id.clone(), + tool_name: Some(result.tool_name.clone()), + result: result.result.clone(), + is_error: result.is_error, + preliminary: result.preliminary, + dynamic: result.dynamic, + provider_options: result.provider_metadata.clone(), + }, + GenerateContent::Source(_) => continue, + }); + } + let messages = if parts.is_empty() { Vec::new() } else { vec![ModelMessage { role: Role::Assistant, - content: MessageContent::Parts(self.parts), + content: MessageContent::Parts(parts), }] }; ResponseMessages { messages, - reasoning: self.reasoning, - } - } - - fn flush_text(&mut self) { - if !self.text_buf.is_empty() { - self.parts.push(ContentPart::Text { - text: std::mem::take(&mut self.text_buf), - provider_options: self.text_provider_options.take(), - }); - } else { - self.text_provider_options = None; + content: self.content, + reasoning, } } +} - fn flush_reasoning(&mut self) { - if self.reasoning_buf.is_empty() && self.reasoning_provider_options.is_none() { - return; - } - let text = std::mem::take(&mut self.reasoning_buf); - let provider_options = self.reasoning_provider_options.take(); - if !text.is_empty() { - self.reasoning.push(ReasoningOutput { - text: text.clone(), - provider_metadata: provider_options.clone(), - }); - } - let signature = extract_reasoning_signature(provider_options.as_ref()); - self.parts.push(ContentPart::Reasoning { - text, - signature, - provider_options, - }); +fn file_base64(data: &FileBytes) -> String { + match data { + FileBytes::Base64(data) => data.clone(), + FileBytes::Binary(data) => base64::engine::general_purpose::STANDARD.encode(data), } } diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index f6a933ed1..33118f599 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -1,4 +1,4 @@ -//! Result types for `do_generate` and `do_stream`. +//! Result types for `do_generate` and `do_stream`. use std::collections::HashMap; @@ -8,7 +8,8 @@ use std::pin::Pin; use ts_rs::TS; use crate::error::AiMuxError; -use crate::shared::FileData; +pub use crate::shared::GeneratedFileData; +use crate::shared::RequestInfo; use crate::stream_part::StreamPart; use crate::tool::{RawToolCall, ToolResult}; use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warning}; @@ -16,7 +17,7 @@ use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warn /// A content item in the generation result. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] -pub enum GenerateContent { +pub enum GenerateContent { /// Generated text. Text { text: String, @@ -25,19 +26,63 @@ pub enum GenerateContent { }, /// A tool call requested by the model. `input` is the provider's raw /// argument text; `generate_text` owns parsing, validation, and repair. - ToolCall(RawToolCall), + ToolCall(C), /// A source / citation (e.g. URL citation from search-preview models). Source(Source), /// A reasoning / thinking segment produced by the model. Reasoning(ReasoningOutput), /// A file generated by the model (e.g. an image or document). File(GeneratedFile), + /// A file generated as part of reasoning. + ReasoningFile(R), + /// Provider-specific content. + Custom { + kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + /// Approval required for a provider-executed tool call. + ToolApprovalRequest(A), /// A tool result from a provider-executed tool (e.g. xAI file_search, /// web_search). Emitted alongside the preceding `ToolCall` when the /// provider executes the tool server-side. ToolResult(ToolResult), } +/// Content exposed by text generation after tool-call parsing. +pub type TextContent = + GenerateContent; + +// Export the user-facing alias without changing its Rust representation. +#[cfg(test)] +#[derive(TS)] +#[ts(export, rename = "TextContent", as = "TextContent")] +struct TextContentBinding; + +/// Provider approval request for a provider-executed call. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct RawToolApprovalRequest { + pub approval_id: String, + pub tool_call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + +/// Approval request exposed by text generation. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ToolApprovalRequestOutput { + pub approval_id: String, + pub tool_call: crate::tool::ToolCall, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_automatic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + /// A source / citation (e.g. URL citation from search-preview models). The /// payload of both `GenerateContent::Source` and `StreamPart::Source`, and the /// element of the text results' `sources`. @@ -54,12 +99,12 @@ pub struct Source { /// A file generated by the model (e.g. an image or document). Mirrors the Vercel /// AI SDK `file` content type: `data` is a tagged union covering base64 data, -/// URL, or provider reference. The payload of both `GenerateContent::File` and +/// or URL. The payload of both `GenerateContent::File` and /// `StreamPart::File`, and the element of the text results' `files`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] pub struct GeneratedFile { - pub data: FileData, + pub data: GeneratedFileData, pub media_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, @@ -77,17 +122,37 @@ pub struct ReasoningOutput { pub provider_metadata: Option, } +/// Text or a generated file produced during reasoning. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(untagged)] +#[ts(export)] +pub enum ReasoningPart { + Text(ReasoningOutput), + File(ReasoningFileOutput), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ReasoningFileOutput { + pub file: GeneratedFile, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + /// Aggregated result of `stream_text().consume()` (M11). Mirrors /// `GenerateTextResult`'s user-facing fields (without `raw`, since streaming /// has no `GenerateResult` equivalent). #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] pub struct StreamTextResultAggregated { + /// Ordered content from the last generation step. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, /// The generated text (concatenated `TextDelta`). pub text: String, /// Reasoning / thinking segments. #[serde(default)] - pub reasoning: Vec, + pub reasoning: Vec, /// Concatenated reasoning text. #[serde(default)] pub reasoning_text: String, @@ -127,8 +192,9 @@ pub struct StreamTextResultAggregated { } /// Result of `LanguageModel::do_generate` (non-streaming). -#[derive(Debug, Serialize, Deserialize, TS)] -#[ts(export)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(from = "GenerateResultWire", into = "GenerateResultWire")] +#[ts(export, as = "GenerateResultWire")] pub struct GenerateResult { /// Generated content items (text, tool calls, etc.). pub content: Vec, @@ -140,30 +206,143 @@ pub struct GenerateResult { pub warnings: Vec, /// Provider-specific metadata. pub provider_metadata: Option, - /// Response metadata (id, timestamp, model_id). - pub response: ResponseMetadata, - /// The request body that was sent (for debugging). - pub request_body: Option, - /// Response headers. - pub response_headers: Option>, + /// Optional response information. + pub response: Option, + /// Optional request information. + pub request: Option, } /// Result of `LanguageModel::do_stream` (streaming). pub struct StreamResult { /// The stream of `StreamPart` items. pub stream: Pin> + Send>>, - /// The request body that was sent (for debugging). - pub request_body: Option, - /// Response headers. - pub response_headers: Option>, + pub request: Option, + pub response: Option, } impl std::fmt::Debug for StreamResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("StreamResult") - .field("request_body", &self.request_body) - .field("response_headers", &self.response_headers) + .field("request", &self.request) + .field("response", &self.response) .field("stream", &"") .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/router.rs b/aimux-core/src/router.rs index 3a0745d74..41b5858db 100644 --- a/aimux-core/src/router.rs +++ b/aimux-core/src/router.rs @@ -290,531 +290,3 @@ impl LanguageModel for RouterModel { .await } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::recording::{ - HttpExchange, OutcomeRecord, ProviderRecord, Recorder, RecordingContext, ResponseRecord, - }; - use crate::result::GenerateResult; - use crate::stream_part::StreamPart; - use crate::types::{FinishReason, FinishReasonUnified, Usage}; - use async_trait::async_trait; - use futures::stream; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - /// A mock child that either succeeds with `text` or always fails. - struct MockChild { - name: &'static str, - text: String, - fail: bool, - retry_failures: usize, - calls: Arc, - } - - #[async_trait] - impl LanguageModel for MockChild { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - self.name - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; - if self.fail { - return Err(AiMuxError::Other(format!("{} always fails", self.name))); - } - if attempt <= self.retry_failures { - return Err(retryable_error(self.name)); - } - Ok(GenerateResult { - content: vec![crate::result::GenerateContent::Text { - text: self.text.clone(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; - if self.fail { - return Err(AiMuxError::Other(format!("{} always fails", self.name))); - } - if attempt <= self.retry_failures { - return Err(retryable_error(self.name)); - } - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: self.text.clone(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } - } - - fn retryable_error(name: &str) -> AiMuxError { - AiMuxError::ApiCall(Box::new(crate::ApiCallError { - status_code: Some(503), - response_headers: Some(std::collections::HashMap::from([( - "retry-after-ms".into(), - "0".into(), - )])), - is_retryable: true, - ..crate::ApiCallError::new( - format!("{name} retryable failure"), - "https://example.test", - serde_json::json!({}), - ) - })) - } - - fn retry_child( - name: &'static str, - text: &str, - retry_failures: usize, - ) -> (ChildModel, Arc) { - let calls = Arc::new(AtomicUsize::new(0)); - ( - Arc::new(MockChild { - name, - text: text.into(), - fail: false, - retry_failures, - calls: calls.clone(), - }), - calls, - ) - } - - struct CountingRuleRouter(Arc); - - impl Router for CountingRuleRouter { - fn route( - &self, - prompt: &LanguageModelPrompt, - models: &[ChildModel], - ) -> Result { - self.0.fetch_add(1, Ordering::SeqCst); - RuleRouter.route(prompt, models) - } - } - - fn child(name: &'static str, text: &str, fail: bool) -> ChildModel { - Arc::new(MockChild { - name, - text: text.into(), - fail, - retry_failures: 0, - calls: Arc::new(AtomicUsize::new(0)), - }) - } - - fn opts() -> CallOptions { - CallOptions { - prompt: vec![], - ..Default::default() - } - } - - #[tokio::test] - async fn routes_to_first_with_rule_router() { - let router = RouterModel::new( - vec![child("a", "from-a", false), child("b", "from-b", false)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - let text = crate::composite::extract_text(&r.content); - assert_eq!(text, "from-a"); - } - - #[tokio::test] - async fn weighted_router_picks_max() { - let router = RouterModel::new( - vec![child("a", "from-a", false), child("b", "from-b", false)], - Box::new(WeightedRouter::new(vec![1.0, 5.0])), - FallbackPolicy::None, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - assert_eq!(crate::composite::extract_text(&r.content), "from-b"); - } - - #[tokio::test] - async fn fallback_on_error_walks_remaining() { - // Primary fails, second fails, third succeeds. - let router = RouterModel::new( - vec![ - child("a", "from-a", true), - child("b", "from-b", true), - child("c", "from-c", false), - ], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - assert_eq!(crate::composite::extract_text(&r.content), "from-c"); - } - - #[tokio::test] - async fn fallback_none_propagates_error() { - let router = RouterModel::new( - vec![child("a", "from-a", true), child("b", "from-b", false)], - Box::new(RuleRouter), - FallbackPolicy::None, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - assert!(err.to_string().contains("always fails")); - } - - #[tokio::test] - async fn all_fail_returns_last_error() { - let router = RouterModel::new( - vec![child("a", "from-a", true), child("b", "from-b", true)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - assert!(err.to_string().contains("always fails")); - } - - #[tokio::test] - async fn stream_routes_without_fallback() { - // Primary is selected by RuleRouter; even though it would fail, stream - // does not fall back. Use a healthy primary to confirm the happy path. - let router = RouterModel::new( - vec![child("a", "from-a", false), child("b", "from-b", false)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let result = router.do_stream(&opts()).await.unwrap(); - use futures::StreamExt; - let mut s = result.stream; - let mut text = String::new(); - while let Some(part) = s.next().await { - if let Ok(StreamPart::TextDelta { delta, .. }) = part { - text.push_str(&delta); - } - } - assert_eq!(text, "from-a"); - } - - #[tokio::test] - async fn single_child_failure_preserves_real_error() { - // B1 regression guard: a single failing child must surface its real - // error, not a generic "all models failed". - let router = RouterModel::new( - vec![child("solo", "solo-out", true)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - assert!( - err.to_string().contains("solo always fails"), - "expected the real child error, got: {err}" - ); - } - - #[tokio::test] - async fn single_child_success_works() { - let router = RouterModel::new( - vec![child("solo", "solo-out", false)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - assert_eq!(crate::composite::extract_text(&r.content), "solo-out"); - } - - #[tokio::test] - async fn out_of_bounds_router_index_is_invalid_argument() { - // A buggy/hostile Router returning an OOB index must not panic; it - // surfaces as InvalidArgument. - struct OobRouter; - impl Router for OobRouter { - fn route( - &self, - _prompt: &LanguageModelPrompt, - _models: &[ChildModel], - ) -> Result { - Ok(99) - } - } - let router = RouterModel::new( - vec![child("a", "from-a", false)], - Box::new(OobRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - match err { - AiMuxError::InvalidArgument(msg) => { - assert!(msg.contains("out-of-bounds"), "got: {msg}"); - } - other => panic!("expected InvalidArgument, got {other:?}"), - } - } - - #[tokio::test] - async fn weighted_router_ties_pick_earliest() { - // S2 regression guard: equal weights resolve to the lowest index. - let router = RouterModel::new( - vec![child("a", "from-a", false), child("b", "from-b", false)], - Box::new(WeightedRouter::new(vec![1.0, 1.0])), - FallbackPolicy::None, - RouterConfig::default(), - ); - let r = router.do_generate(&opts()).await.unwrap(); - assert_eq!(crate::composite::extract_text(&r.content), "from-a"); - } - - #[tokio::test] - async fn empty_models_is_error() { - let router = RouterModel::new( - vec![], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let err = router.do_generate(&opts()).await.unwrap_err(); - assert!(err.to_string().contains("no models")); - } - - #[tokio::test] - async fn stream_does_not_fallback_when_routed_child_fails() { - // G3: the key streaming invariant — even with FallbackPolicy::OnError, - // a failing primary is NOT retried via the backup during do_stream. - // (StreamStart is already emitted by the time a retry would matter.) - let router = RouterModel::new( - vec![ - child("primary", "primary-out", true), - child("backup", "backup-out", false), - ], - Box::new(RuleRouter), - FallbackPolicy::OnError, // would fall back in do_generate, must NOT here - RouterConfig::default(), - ); - let err = router.do_stream(&opts()).await.unwrap_err(); - assert!( - err.to_string().contains("primary always fails"), - "do_stream must surface the primary error, not fall back; got: {err}" - ); - } - - #[tokio::test] - async fn usable_as_dyn_language_model_via_generate_text() { - // G7: the RFCs' headline claim — composite models drop into the - // generate_text free function unchanged via &dyn LanguageModel. - let router = RouterModel::new( - vec![child("a", "works through trait object", false)], - Box::new(RuleRouter), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - let r = crate::generate::generate_text( - &router, - "hello", - crate::generate::GenerateTextOptions::default(), - ) - .await - .unwrap(); - assert_eq!(r.text, "works through trait object"); - } - - #[tokio::test] - async fn retries_each_child_before_falling_back_without_rerouting() { - let (primary, primary_calls) = retry_child("primary", "primary", usize::MAX); - let (backup, backup_calls) = retry_child("backup", "backup", 1); - let route_calls = Arc::new(AtomicUsize::new(0)); - let router = RouterModel::new( - vec![primary, backup], - Box::new(CountingRuleRouter(route_calls.clone())), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - assert_eq!(router.retry_config().max_retries, 0); - - let result = crate::generate::generate_text( - &router, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(result.text, "backup"); - assert_eq!(route_calls.load(Ordering::SeqCst), 1); - assert_eq!(primary_calls.load(Ordering::SeqCst), 3); - assert_eq!(backup_calls.load(Ordering::SeqCst), 2); - } - - /// Children must receive a step-labeled child recording context so their - /// exchanges and retry attempts are recorded as nested steps. - #[tokio::test] - async fn children_receive_step_labeled_recording_contexts() { - struct StepProbe { - seen: std::sync::Mutex>>, - } - - #[async_trait] - impl LanguageModel for StepProbe { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "probe" - } - async fn do_generate( - &self, - options: &CallOptions, - ) -> Result { - self.seen.lock().unwrap().push( - options - .recording_context - .as_ref() - .and_then(RecordingContext::step), - ); - Err(AiMuxError::Other("probe fails".into())) - } - async fn do_stream(&self, _: &CallOptions) -> Result { - unreachable!() - } - } - - struct NoopRecorder; - impl Recorder for NoopRecorder { - fn record_input(&self, _: &str, _: &CallOptions, _: &str, _: &str) {} - fn record_provider(&self, _: &str, _: &ProviderRecord) {} - fn record_exchange(&self, _: &str, _: &HttpExchange) {} - fn record_exchange_update( - &self, - _: &str, - _: u32, - _: u32, - _: &ResponseRecord, - _: Option, - ) { - } - fn record_outcome(&self, _: &str, _: &OutcomeRecord) {} - fn flush(&self) {} - } - - let probe = Arc::new(StepProbe { - seen: std::sync::Mutex::new(Vec::new()), - }); - let router = RouterModel::new( - vec![probe.clone()], - Box::new(CountingRuleRouter(Arc::new(AtomicUsize::new(0)))), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - - let options = CallOptions { - recording_context: Some(RecordingContext::new("call-1", Arc::new(NoopRecorder))), - ..CallOptions::default() - }; - let _ = router.do_generate(&options).await; - - let seen = probe.seen.lock().unwrap(); - assert!(!seen.is_empty()); - assert_eq!(seen[0].as_deref(), Some("router[0]:mock/probe")); - } - - /// Even when every child fails and the caller asked for per-call retries, - /// the composite operation must run exactly once: the exhausted child's - /// `Retry` error passes through the outer retry loop instead of replaying - /// routing and fallback. - #[tokio::test] - async fn all_children_failing_does_not_replay_the_composite() { - let (primary, primary_calls) = retry_child("primary", "primary", usize::MAX); - let (backup, backup_calls) = retry_child("backup", "backup", usize::MAX); - let route_calls = Arc::new(AtomicUsize::new(0)); - let router = RouterModel::new( - vec![primary, backup], - Box::new(CountingRuleRouter(route_calls.clone())), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - - let error = crate::generate::generate_text( - &router, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap_err(); - - assert!(matches!(error, crate::AiMuxError::Retry(_)), "{error:?}"); - assert_eq!(route_calls.load(Ordering::SeqCst), 1); - assert_eq!(primary_calls.load(Ordering::SeqCst), 3); - assert_eq!(backup_calls.load(Ordering::SeqCst), 3); - } - - #[tokio::test] - async fn retries_stream_setup_on_selected_child_without_fallback_or_rerouting() { - let (primary, primary_stream_calls) = retry_child("primary", "primary", 1); - let (backup, backup_stream_calls) = retry_child("backup", "backup", 0); - let route_calls = Arc::new(AtomicUsize::new(0)); - let router = RouterModel::new( - vec![primary, backup], - Box::new(CountingRuleRouter(route_calls.clone())), - FallbackPolicy::OnError, - RouterConfig::default(), - ); - - let _result = crate::generate::stream_text( - &router, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(route_calls.load(Ordering::SeqCst), 1); - assert_eq!(primary_stream_calls.load(Ordering::SeqCst), 2); - assert_eq!(backup_stream_calls.load(Ordering::SeqCst), 0); - } -} diff --git a/aimux-core/src/shared.rs b/aimux-core/src/shared.rs index 705f3c945..7a6e71637 100644 --- a/aimux-core/src/shared.rs +++ b/aimux-core/src/shared.rs @@ -73,13 +73,31 @@ pub enum FileData { /// Raw bytes (`Uint8Array`) or a base64-encoded string. Data { data: FileBytes }, /// A URL that points to the file. - Url { url: String }, + Url { + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + original_url: Option, + }, /// A provider reference (`{ [provider]: id }`). Reference { reference: SharedProviderReference }, /// Inline text content (e.g. an inline text document). Text { text: String }, } +/// Data or a URL returned for a generated file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub enum GeneratedFileData { + Data { + data: FileBytes, + }, + Url { + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + original_url: Option, + }, +} + /// Image/video size in `{width}x{height}` format (e.g. `"1024x1024"`). /// /// Newtype that enforces the `WxH` format used by V4 image `size` and video diff --git a/aimux-core/src/stream_part.rs b/aimux-core/src/stream_part.rs index b5499a1be..20ff84d43 100644 --- a/aimux-core/src/stream_part.rs +++ b/aimux-core/src/stream_part.rs @@ -1,4 +1,4 @@ -//! `StreamPart` — the individual chunk types emitted by `do_stream`. +//! `StreamPart` — the individual chunk types emitted by `do_stream`. //! //! Aligned with V4 `LanguageModelV4StreamPart`. v0.1 implements the P0/P1 variants; //! P2 variants (reasoning, file, source) are defined but not yet emitted by providers. @@ -8,7 +8,9 @@ use serde_json::Value; use ts_rs::TS; use crate::error::AiMuxError; -use crate::result::{GeneratedFile, Source}; +use crate::result::{ + GeneratedFile, RawToolApprovalRequest, ReasoningFileOutput, Source, ToolApprovalRequestOutput, +}; use crate::tool::{RawToolCall, ToolCall, ToolResult}; use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warning}; @@ -18,8 +20,8 @@ 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))] -pub enum StreamPart { +#[ts(export, concrete(C = ToolCall, A = ToolApprovalRequestOutput, R = ReasoningFileOutput))] +pub enum StreamPart { // ── P0: text ── /// Start of a text segment. TextStart { @@ -43,7 +45,9 @@ pub enum StreamPart { // ── P0: stream lifecycle ── /// First chunk — carries warnings from the provider. - StreamStart { warnings: Vec }, + StreamStart { + warnings: Vec, + }, /// Final chunk — carries usage, finish reason, and metadata. Finish { finish_reason: FinishReason, @@ -51,7 +55,9 @@ pub enum StreamPart { provider_metadata: Option, }, /// An error occurred mid-stream. - Error { error: AiMuxError }, + Error { + error: AiMuxError, + }, // ── P1: tool calls ── /// Start of a tool call's input streaming. @@ -94,6 +100,13 @@ pub enum StreamPart { // ── P2: file ── /// A file generated by the model (e.g. an image or document). File(GeneratedFile), + ReasoningFile(R), + Custom { + kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + ToolApprovalRequest(A), // ── P2: reasoning ── ReasoningStart { @@ -124,22 +137,54 @@ pub enum StreamPart { Source(Source), /// A raw chunk from the provider (for debugging, when `include_raw_chunks` is set). - Raw { raw_value: Value }, + Raw { + raw_value: Value, + }, } /// The stream `stream_text` and later stages produce: tool calls are parsed. -pub type TextStreamPart = StreamPart; +pub type TextStreamPart = StreamPart; -impl StreamPart { +impl StreamPart { /// Replace the tool-call payload, leaving every other variant untouched. - pub fn map_tool_call(self, f: impl FnOnce(C) -> D) -> StreamPart { + pub fn map_tool_call(self, f: impl FnOnce(C) -> D) -> StreamPart { + self.map_payloads(f, |approval| approval) + } + + /// Replace the approval payload, leaving other variants untouched. + pub fn map_tool_approval_request(self, f: impl FnOnce(A) -> B) -> StreamPart { + self.map_payloads(|call| call, f) + } + + /// Replace tool call and approval payloads. + pub fn map_payloads( + self, + call: impl FnOnce(C) -> D, + approval: impl FnOnce(A) -> B, + ) -> StreamPart { + self.map_all(call, approval, |file| file) + } + + /// Replace a reasoning file with its user-facing output wrapper. + pub fn map_reasoning_file(self, f: impl FnOnce(R) -> F) -> StreamPart { + self.map_all(|call| call, |approval| approval, f) + } + + fn map_all( + self, + call: impl FnOnce(C) -> D, + approval: impl FnOnce(A) -> B, + file: impl FnOnce(R) -> F, + ) -> StreamPart { // The generic parameter forces every variant to be rebuilt. macro_rules! carry { ($($variant:ident { $($field:ident),* })*) => { match self { - StreamPart::ToolCall(call) => StreamPart::ToolCall(f(call)), + StreamPart::ToolCall(value) => StreamPart::ToolCall(call(value)), + StreamPart::ToolApprovalRequest(value) => StreamPart::ToolApprovalRequest(approval(value)), StreamPart::ToolResult(result) => StreamPart::ToolResult(result), StreamPart::File(file) => StreamPart::File(file), + StreamPart::ReasoningFile(value) => StreamPart::ReasoningFile(file(value)), StreamPart::ResponseMetadata(meta) => StreamPart::ResponseMetadata(meta), StreamPart::Source(source) => StreamPart::Source(source), $(StreamPart::$variant { $($field),* } => StreamPart::$variant { $($field),* },)* @@ -159,6 +204,7 @@ impl StreamPart { ReasoningStart { id, provider_metadata } ReasoningDelta { id, delta, provider_metadata } ReasoningEnd { id, provider_metadata } + Custom { kind, provider_metadata } Raw { raw_value } } } diff --git a/aimux-core/src/trace/layer.rs b/aimux-core/src/trace/layer.rs index 2ceec13a4..c5a73b349 100644 --- a/aimux-core/src/trace/layer.rs +++ b/aimux-core/src/trace/layer.rs @@ -226,7 +226,7 @@ impl RecordCtx { output_total: usage.output_tokens.total.map(u64::from), output_text: usage.output_tokens.text.map(u64::from), output_reasoning: usage.output_tokens.reasoning.map(u64::from), - raw: usage.raw.clone(), + raw: usage.raw.clone().map(serde_json::Value::Object), } } @@ -531,10 +531,19 @@ impl LanguageModel for TraceLayer { let ctx = self.make_record_ctx(options, None, None, Arc::new(AtomicU64::new(u64::MAX))); match self.inner.do_generate(options).await { Ok(result) => { - let request_id = result.response.id.clone(); + let request_id = result + .response + .as_ref() + .and_then(|response| response.id.clone()); let mut rec_ctx = ctx; - rec_ctx.request_body = result.request_body.clone(); - rec_ctx.response_headers = result.response_headers.clone(); + rec_ctx.request_body = result + .request + .as_ref() + .and_then(|request| request.body.clone()); + rec_ctx.response_headers = result + .response + .as_ref() + .and_then(|response| response.headers.clone()); rec_ctx.record(&result.usage, None, request_id); Ok(result) } @@ -558,8 +567,14 @@ impl LanguageModel for TraceLayer { }; let rec_ctx = Arc::new(RecordCtx { - request_body: result.request_body.clone(), - response_headers: result.response_headers.clone(), + request_body: result + .request + .as_ref() + .and_then(|request| request.body.clone()), + response_headers: result + .response + .as_ref() + .and_then(|response| response.headers.clone()), ..ctx }); let ttft_obs = ttft.clone(); @@ -574,6 +589,8 @@ impl LanguageModel for TraceLayer { Ok(StreamPart::TextDelta { .. }) | Ok(StreamPart::ReasoningDelta { .. }) | Ok(StreamPart::ToolCall(_)) + | Ok(StreamPart::File(_)) + | Ok(StreamPart::ReasoningFile(_)) ); if is_model_output { ttft_obs.store(started.elapsed().as_millis() as u64, Ordering::Relaxed); @@ -595,8 +612,8 @@ impl LanguageModel for TraceLayer { Ok(StreamResult { stream: Box::pin(guarded), - request_body: result.request_body, - response_headers: result.response_headers, + request: result.request, + response: result.response, }) } } diff --git a/aimux-core/src/types.rs b/aimux-core/src/types.rs index 5ed3ce700..7d6a94583 100644 --- a/aimux-core/src/types.rs +++ b/aimux-core/src/types.rs @@ -31,17 +31,18 @@ pub enum FinishReasonUnified { #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[ts(export)] pub struct Usage { - pub input_tokens: TokenUsage, - pub output_tokens: TokenUsage, + pub input_tokens: InputTokenUsage, + pub output_tokens: OutputTokenUsage, /// Raw usage information from the provider (opaque, provider-specific). #[serde(default, skip_serializing_if = "Option::is_none")] - pub raw: Option, + #[ts(type = "Record | null")] + pub raw: Option>, } -/// Token usage detail (with cache breakdown). +/// Input token usage detail. #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[ts(export)] -pub struct TokenUsage { +pub struct InputTokenUsage { /// Total tokens. pub total: Option, /// Tokens that were not served from cache. @@ -53,6 +54,13 @@ pub struct TokenUsage { /// Tokens written to cache. #[serde(skip_serializing_if = "Option::is_none")] pub cache_write: Option, +} + +/// Output token usage detail. +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct OutputTokenUsage { + pub total: Option, /// Text (non-reasoning) tokens (output side). #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, diff --git a/aimux-core/tests/cache_probe_test.rs b/aimux-core/tests/cache_probe_test.rs index bf7094cd0..8c47ef3fd 100644 --- a/aimux-core/tests/cache_probe_test.rs +++ b/aimux-core/tests/cache_probe_test.rs @@ -1,25 +1,8 @@ -//! RFC-0015 cache-probe tests: prototype scenario migration (§9, 8 scenarios) -//! + TraceLayer end-to-end probes. -//! -//! Layer 1: `judge` unit scenarios — exact control over every input. -//! Layer 2: `TraceLayer` + `RingTraceStore` + `RuleAuditor` end-to-end with a -//! mock model serving synthetic bodies (4 bytes per token, hex-encoded, so -//! the byte proxy `LCPb/4` matches the token counts). +//! Cache audit judgment scenarios. -use std::sync::Arc; - -use futures::executor::block_on; - -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateResult, StreamResult}; -use aimux_core::stream_part::StreamPart; use aimux_core::trace::{ - JudgmentInput, LcpInput, ProviderAuditSpec, RingTraceStore, SessionStats, TraceFilter, - TraceLayer, VerdictKind, judge, matrix as verdict_matrix, + JudgmentInput, LcpInput, ProviderAuditSpec, VerdictKind, judge, matrix as verdict_matrix, }; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, TokenUsage, Usage}; // ───────────────────────────────────────────────────────────────────────────── // Layer 1: judge unit scenarios (prototype §9) @@ -360,590 +343,3 @@ fn missing_usage_is_unknown() { let v = judge(&inp); assert_eq!(v.kind, VerdictKind::Unknown, "{}", v.describe()); } - -// ───────────────────────────────────────────────────────────────────────────── -// Layer 2: TraceLayer end-to-end (mock model, synthetic bodies) -// ───────────────────────────────────────────────────────────────────────────── - -/// 4 bytes per token, hex-encoded — the byte proxy LCPb/4 matches token -/// counts exactly (system segment is shared across sessions). -fn token_body(system_tokens: u64, conv_tokens: u64, conv_start: u64) -> serde_json::Value { - let mut s = String::new(); - for i in 0..system_tokens { - s.push_str(&format!("{:04x}", i & 0xffff)); - } - for i in 0..conv_tokens { - s.push_str(&format!("{:04x}", (conv_start + i) & 0xffff)); - } - serde_json::json!({ - "model": "mock-model", - "messages": [{"role": "user", "content": s}], - }) -} - -struct MockModel { - body: serde_json::Value, - usage: Usage, - provider: &'static str, - model: &'static str, -} - -impl MockModel { - fn new(body: serde_json::Value, claimed: u64, prompt_tokens: u64) -> Self { - Self::with_identity("mock", "mock-1", body, claimed, prompt_tokens) - } - - /// Configurable identity (B1: same model_id, different provider). - fn with_identity( - provider: &'static str, - model: &'static str, - body: serde_json::Value, - claimed: u64, - prompt_tokens: u64, - ) -> Self { - Self { - body, - usage: Usage { - input_tokens: TokenUsage { - total: Some(prompt_tokens as u32), - no_cache: Some((prompt_tokens - claimed) as u32), - cache_read: Some(claimed as u32), - cache_write: Some(0), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(10), - text: Some(10), - ..Default::default() - }, - raw: None, - }, - provider, - model, - } - } -} - -#[async_trait::async_trait] -impl LanguageModel for MockModel { - fn provider(&self) -> &str { - self.provider - } - - fn model_id(&self) -> &str { - self.model - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: self.usage.clone(), - warnings: vec![], - provider_metadata: None, - response: ResponseMetadata { - id: Some("req-1".into()), - timestamp: None, - model_id: Some("mock-1".into()), - }, - request_body: Some(self.body.clone()), - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let usage = self.usage.clone(); - let body = self.body.clone(); - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(StreamPart::StreamStart { warnings: vec![] }); - yield Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "hi".into(), - provider_metadata: None, - }); - yield Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage, - provider_metadata: None, - }); - }), - request_body: Some(body), - response_headers: None, - }) - } -} - -fn model_opts(session_id: Option<&str>) -> CallOptions { - let mut o = CallOptions::new(Default::default()); - o.session_id = session_id.map(std::string::ToString::to_string); - o -} - -/// End-to-end: append-only loop with claimed == shared block prefix → -/// Trusted; overclaim → SuspectOverclaim; aggregation + session chain + -/// JSONL work. Bodies > 4 KiB so block-aligned prefixes match. -#[test] -fn trace_layer_end_to_end() { - let store = Arc::new(RingTraceStore::new()); - let inner: Arc = Arc::new(MockModel::new( - token_body(1024, 512, 0), // 6144 B → block 1 matches later calls - 0, - 1536, - )); - let layer = Arc::new(TraceLayer::new(inner, store.clone()).with_rules_auditor(true)); - - // Call 1: first request — claimed 0 → Trusted. - let r1 = block_on(layer.do_generate(&model_opts(Some("sess-1")))).unwrap(); - assert!(r1.usage.input_tokens.total.is_some()); - - // Call 2: same session, prefix extended; claimed matches the shared - // first block (1024 tokens) → Trusted. - let inner2: Arc = Arc::new(MockModel::new( - token_body(1024, 1536, 0), // 10240 B → block 1 shared with call 1 - 1024, // claimed == shared block tokens - 2560, - )); - let layer2 = Arc::new(TraceLayer::new(inner2, store.clone()).with_rules_auditor(true)); - block_on(layer2.do_generate(&model_opts(Some("sess-1")))).unwrap(); - - // Call 3: overclaim — claimed exceeds the client LCP bound (1024 + τ). - let inner3: Arc = Arc::new(MockModel::new( - token_body(1024, 1536, 0), - 1200, // > 1024 + 128 → SuspectOverclaim - 2560, - )); - let layer3 = Arc::new(TraceLayer::new(inner3, store.clone()).with_rules_auditor(true)); - block_on(layer3.do_generate(&model_opts(Some("sess-1")))).unwrap(); - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs.len(), 1); - let s = &recs[0]; - assert_eq!(s.requests, 3); - // 2 Trusted + 1 SuspectOverclaim. - assert_eq!(s.verdict_counts.get("Trusted"), Some(&2)); - assert_eq!(s.verdict_counts.get("SuspectOverclaim"), Some(&1)); - assert!(s.reported_hit_rate.is_some()); - - // Session chain: 3 records, stable prefix (LCP grows monotonically). - let chain = store.session_chain("sess-1").expect("chain exists"); - assert_eq!(chain.record_ids.len(), 3); - assert!(chain.prefix_stability >= 0.5); - - // JSONL export round-trips. - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - let lines: Vec<&[u8]> = buf - .split(|b| *b == b'\n') - .filter(|l| !l.is_empty()) - .collect(); - assert_eq!(lines.len(), 3, "one TraceRecord per line"); -} - -/// Streaming path records TTFT + usage from the Finish part. -#[test] -fn trace_layer_stream_records_ttft_and_usage() { - let store = Arc::new(RingTraceStore::new()); - let inner: Arc = Arc::new(MockModel::new(token_body(1024, 512, 0), 0, 1536)); - let layer = Arc::new(TraceLayer::new(inner, store.clone()).with_rules_auditor(true)); - - let mut result = block_on(layer.do_stream(&model_opts(Some("sess-1")))).unwrap(); - // Consume the stream fully (records on completion). - while let Some(_part) = block_on(async { futures::StreamExt::next(&mut result.stream).await }) { - } - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs[0].requests, 1); - let rec = store.session_chain("sess-1").unwrap(); - assert_eq!(rec.record_ids.len(), 1); - - // Verify TTFT was captured on the stored record. - let json = { - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - String::from_utf8(buf).unwrap() - }; - assert!( - json.contains("\"ttft_ms\":"), - "streamed record must carry ttft_ms: {json}" - ); -} - -/// StreamPart::Error (a provider-reported mid-stream error) is recorded. -#[test] -fn trace_layer_records_stream_part_error() { - struct MidStreamError; - #[async_trait::async_trait] - impl LanguageModel for MidStreamError { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-stream-err" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - unreachable!() - } - async fn do_stream(&self, _: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(StreamPart::StreamStart { warnings: vec![] }); - // Provider-reported mid-stream error (Ok(Error{..})). - yield Ok(StreamPart::Error { error: AiMuxError::InvalidResponseData("mid-stream failure".into()) }); - }), - request_body: None, - response_headers: None, - }) - } - } - - let store = Arc::new(RingTraceStore::new()); - let layer = Arc::new(TraceLayer::new(Arc::new(MidStreamError), store.clone())); - let mut result = block_on(layer.do_stream(&model_opts(Some("sess-err")))).unwrap(); - while let Some(_p) = block_on(async { futures::StreamExt::next(&mut result.stream).await }) {} - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs[0].requests, 1); - assert_eq!(recs[0].errors, 1, "provider-reported stream error recorded"); - let json = { - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - String::from_utf8(buf).unwrap() - }; - assert!(json.contains("mid-stream failure"), "{json}"); -} - -/// Transport-level `Err` from the stream is also recorded. -#[test] -fn trace_layer_records_stream_transport_error() { - struct TransportErr; - #[async_trait::async_trait] - impl LanguageModel for TransportErr { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-transport-err" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - unreachable!() - } - async fn do_stream(&self, _: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(StreamPart::StreamStart { warnings: vec![] }); - yield Err(AiMuxError::InvalidResponseData("transport failure".into())); - }), - request_body: None, - response_headers: None, - }) - } - } - - let store = Arc::new(RingTraceStore::new()); - let layer = Arc::new(TraceLayer::new(Arc::new(TransportErr), store.clone())); - let mut result = block_on(layer.do_stream(&model_opts(Some("sess-err")))).unwrap(); - while let Some(_p) = block_on(async { futures::StreamExt::next(&mut result.stream).await }) {} - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs[0].errors, 1, "transport error recorded"); -} - -/// Failures (Err from do_generate) are still recorded, without a verdict. -#[test] -fn trace_layer_records_failures() { - struct Failing; - #[async_trait::async_trait] - impl LanguageModel for Failing { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-fail" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - Err(AiMuxError::Other("boom".into())) - } - async fn do_stream(&self, _: &CallOptions) -> Result { - Err(AiMuxError::Other("boom".into())) - } - } - - let store = Arc::new(RingTraceStore::new()); - let layer = Arc::new(TraceLayer::new(Arc::new(Failing), store.clone())); - assert!(block_on(layer.do_generate(&model_opts(Some("sess-err")))).is_err()); - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!(recs[0].requests, 1); - assert_eq!(recs[0].errors, 1); - let json = { - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - String::from_utf8(buf).unwrap() - }; - assert!(json.contains("\"error\":\"boom\""), "{json}"); -} - -/// Aggregation: client_upper_bound_hit_rate sums per-record LCP token upper -/// bounds only — records without LCP evidence contribute 0, never the full -/// request length (RFC-0015 §5.2). -#[test] -fn aggregate_uses_lcp_upper_bound_not_full_length() { - use aimux_core::trace::TraceStats; - - let store = Arc::new(RingTraceStore::new()); - - // Call 1: first request — no history → no LCP upper bound. - let inner1: Arc = - Arc::new(MockModel::new(token_body(1024, 512, 0), 0, 1536)); - let layer1 = Arc::new(TraceLayer::new(inner1, store.clone()).with_rules_auditor(true)); - block_on(layer1.do_generate(&model_opts(Some("sess-1")))).unwrap(); - - // Call 2: same-session prefix continuation → block-1 LCP upper bound - // (4096 B → 1024 tokens). - let inner2: Arc = - Arc::new(MockModel::new(token_body(1024, 1536, 0), 1024, 2560)); - let layer2 = Arc::new(TraceLayer::new(inner2, store.clone()).with_rules_auditor(true)); - block_on(layer2.do_generate(&model_opts(Some("sess-1")))).unwrap(); - - let stats: Vec = store.aggregate(&TraceFilter::default()); - assert_eq!(stats.len(), 1); - let s = &stats[0]; - assert_eq!(s.requests, 2); - let rate = s.client_upper_bound_hit_rate.expect("rate present"); - // LCP upper contributions: call1 = 0 (no match), call2 = 2×4096B/4 = - // 2048 tokens; total input = 1536+2560. - let expected = 2048.0 / (1536.0 + 2560.0); - assert!( - (rate - expected).abs() < 1e-9, - "client upper bound must be LCP-based: {rate} vs {expected}" - ); - assert!( - rate < 0.9, - "full-length estimate would be ~1.0 — LCP evidence must cap it: {rate}" - ); -} - -/// R-2.2 cluster-routing exemption: a stable prefix with zero claimed hits is -/// the normal signature of a load-balanced cluster without sticky routing — -/// without route-affinity evidence it must NOT be flagged as a broken prefix. -#[test] -fn r22_suppressed_without_route_affinity_evidence() { - let spec = spec_for("openai", "gpt-4o"); - let stats = |rounds: u32| SessionStats { - same_session_rounds: rounds, - prefix_stable: true, - lcp_gt_1024: true, - }; - - // 4 rounds, stable prefix, claimed=0 — cluster signature. - let mut inp = base_input(spec); - inp.claimed = 0; - inp.session_stats = Some(stats(4)); - inp = with_lcp(inp, 8192, true); - let v = judge(&inp); - assert_eq!( - v.kind, - VerdictKind::Trusted, - "no route-affinity evidence → not a broken-prefix warning: {}", - v.describe() - ); - assert!(!v.violated.iter().any(|r| r == "R-2.2"), "{}", v.describe()); - assert!( - v.notes - .iter() - .any(|n| n.contains("cluster routing artifact")), - "suppression must be explained: {}", - v.describe() - ); - - // With route affinity KNOWN (single node / sticky / global cache), the - // same signature IS the broken-prefix diagnostic. - let mut aff = base_input(spec); - aff.claimed = 0; - aff.route_affinity_known = true; - aff.session_stats = Some(stats(4)); - aff = with_lcp(aff, 8192, true); - let va = judge(&aff); - assert_eq!(va.kind, VerdictKind::SuspectUnderclaim, "{}", va.describe()); - assert_eq!(va.confidence, aimux_core::trace::VerdictConfidence::Low); -} - -// ───────────────────────────────────────────────────────────────────────────── -// B1 / C4-2 / F1 regression tests -// ───────────────────────────────────────────────────────────────────────────── - -/// B1: `scope_key` isolates by (provider, base_url, model_id). Two TraceLayers -/// over the SAME model_id but DIFFERENT providers sharing one RingTraceStore -/// must not cross-match LCP history. (Before the fix `scope_key` hashed only -/// the model_id, so beta would have matched alpha's prefix.) -#[test] -fn scope_key_isolates_by_provider_same_model_id() { - let store = Arc::new(RingTraceStore::new()); - let body = token_body(1024, 512, 0); // > 4 KiB → block-aligned prefix - - // Provider "alpha", model "shared-m": two identical calls. Call 2 must - // match call 1 (same provider → same scope) → LCP upper bound present. - let a1: Arc = Arc::new(MockModel::with_identity( - "alpha", - "shared-m", - body.clone(), - 0, - 1536, - )); - let layer_a = Arc::new(TraceLayer::new(a1, store.clone()).with_rules_auditor(true)); - block_on(layer_a.do_generate(&model_opts(Some("sa")))).unwrap(); - - let a2: Arc = Arc::new(MockModel::with_identity( - "alpha", - "shared-m", - body.clone(), - 0, - 1536, - )); - let layer_a2 = Arc::new(TraceLayer::new(a2, store.clone()).with_rules_auditor(true)); - block_on(layer_a2.do_generate(&model_opts(Some("sa")))).unwrap(); - - // Provider "beta", SAME model "shared-m", SAME body — must NOT match - // alpha's records (different provider → different scope_key). - let b1: Arc = Arc::new(MockModel::with_identity( - "beta", - "shared-m", - body.clone(), - 0, - 1536, - )); - let layer_b = Arc::new(TraceLayer::new(b1, store.clone()).with_rules_auditor(true)); - block_on(layer_b.do_generate(&model_opts(Some("sb")))).unwrap(); - - let recs = store.aggregate(&TraceFilter::default()); - // Two groups: (alpha, shared-m) and (beta, shared-m). - let alpha = recs - .iter() - .find(|r| r.provider == "alpha") - .expect("alpha group"); - let beta = recs - .iter() - .find(|r| r.provider == "beta") - .expect("beta group"); - assert_eq!(alpha.requests, 2); - assert_eq!(beta.requests, 1); - // alpha call 2 matched call 1 → client upper bound present. - assert!( - alpha.client_upper_bound_hit_rate.is_some(), - "same-provider continuation must produce an LCP upper bound" - ); - // beta is isolated → no LCP evidence (would be Some if scope leaked). - assert!( - beta.client_upper_bound_hit_rate.is_none(), - "different provider must NOT cross-match LCP history (B1)" - ); -} - -/// C4-2 negative case: when an independent invariant (here R-1.6b quantization) -/// ALSO fires, the overclaim does NOT rely solely on the byte proxy, so it is -/// NOT downgraded to Unknown — it stays SuspectOverclaim (capped at Medium by -/// the byte-proxy F2 rule, but still an overclaim). -#[test] -fn c42_byte_proxy_overclaim_with_independent_evidence_is_not_unknown() { - let spec = spec_for("openai", "gpt-4o"); // gran=128 - // u = 512 tokens (LCP upper 2048 B / 4). claimed=700 > 640 (u+τ) → R-1.1, - // and 700 % 128 ≠ 0 → R-1.6b (independent). byte_proxy = true. - let mut inp = base_input(spec); - inp.claimed = 700; - inp = with_lcp(inp, 1536, true); - let v = judge(&inp); - assert_eq!( - v.kind, - VerdictKind::SuspectOverclaim, - "independent evidence (R-1.6b) keeps the overclaim: {}", - v.describe() - ); - assert!(v.violated.iter().any(|r| r == "R-1.6b")); - assert!(v.violated.iter().any(|r| r == "R-1.1")); - // Byte-proxy R-1.1 is capped at Medium (F2); the independent R-1.6b is B. - assert_eq!( - v.confidence, - aimux_core::trace::VerdictConfidence::Medium, - "{}", - v.describe() - ); -} - -/// F1: a stream dropped before completion (take(N) / abort / timeout) must -/// still record its trace — the Drop guard writes it with a non-success marker. -#[test] -fn trace_layer_stream_dropped_early_still_records() { - struct LongStream; - #[async_trait::async_trait] - impl LanguageModel for LongStream { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-long" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - unreachable!() - } - async fn do_stream(&self, _: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(StreamPart::StreamStart { warnings: vec![] }); - // Several deltas but NO Finish — the caller drops mid-stream. - for i in 0..5u32 { - yield Ok(StreamPart::TextDelta { - id: "t".into(), - delta: format!("chunk{i}"), - provider_metadata: None, - }); - } - yield Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }); - }), - request_body: Some(token_body(1024, 512, 0)), - response_headers: None, - }) - } - } - - let store = Arc::new(RingTraceStore::new()); - let layer = Arc::new(TraceLayer::new(Arc::new(LongStream), store.clone())); - let mut result = block_on(layer.do_stream(&model_opts(Some("sess-drop")))).unwrap(); - // Consume only 2 parts, then drop the stream (no Finish seen). - let mut n = 0; - while let Some(_p) = block_on(async { futures::StreamExt::next(&mut result.stream).await }) { - n += 1; - if n >= 2 { - break; - } - } - drop(result); // early drop — must NOT lose the record (F1) - - let recs = store.aggregate(&TraceFilter::default()); - assert_eq!( - recs[0].requests, 1, - "early-dropped stream must still record its trace (F1)" - ); - let json = { - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).unwrap(); - String::from_utf8(buf).unwrap() - }; - assert!( - json.contains("stream dropped before completion"), - "dropped stream record must carry the non-success marker: {json}" - ); -} diff --git a/aimux-core/tests/error_value_golden_test.rs b/aimux-core/tests/error_value_golden_test.rs index 8df2b9aad..7426f4758 100644 --- a/aimux-core/tests/error_value_golden_test.rs +++ b/aimux-core/tests/error_value_golden_test.rs @@ -185,116 +185,6 @@ fn error_value_snapshots_plain_variants() { } } -/// The variant set is a cross-language contract of its own: bindings switch on -/// the wire tag. Adding or removing one is a breaking change (16 variants — -/// the per-status avatars `Auth`/`ModelNotFound`/`RateLimited` are gone, -/// `Http`/`Provider` folded into `ApiCall` (a failed exchange is an `ApiCall` -/// error classified by `status_code`, transport failures included), and the -/// legacy catch-all `Tool` — never constructed anywhere — is replaced by the -/// typed `NoSuchTool`/`InvalidToolInput`/`ToolCallRepair`). -/// Compile-time pin: adding an `AiMuxError` variant breaks this match, so -/// the wire-tag list below and every binding's switch must be updated in the -/// same change (the runtime assertion alone cannot see additions). -#[allow(dead_code)] -fn variant_addition_breaks_this_match(error: &AiMuxError) { - match error { - AiMuxError::ApiCall(_) - | AiMuxError::Retry(_) - | AiMuxError::JsonParse(_) - | AiMuxError::InvalidResponseData(_) - | AiMuxError::NoSuchTool { .. } - | AiMuxError::InvalidToolInput { .. } - | AiMuxError::ToolCallRepair { .. } - | AiMuxError::InvalidArgument(_) - | AiMuxError::InvalidPrompt(_) - | AiMuxError::TokenExpired(_) - | AiMuxError::UnsupportedFunctionality(_) - | AiMuxError::NoSuchModel { .. } - | AiMuxError::NoSuchProvider { .. } - | AiMuxError::Timeout(_) - | AiMuxError::Aborted(_) - | AiMuxError::Other(_) => {} - } -} - -#[test] -fn variant_set_is_exactly_sixteen() { - let all = [ - AiMuxError::ApiCall(Box::new(api_error("x"))), - AiMuxError::Retry(RetryError { - reason: RetryErrorReason::MaxRetriesExceeded, - errors: vec![AiMuxError::ApiCall(Box::new(api_error("x")))], - }), - AiMuxError::JsonParse("x".into()), - AiMuxError::InvalidResponseData("x".into()), - AiMuxError::NoSuchTool { - tool_name: "x".into(), - available_tools: None, - tool_input: None, - }, - AiMuxError::InvalidToolInput { - tool_name: "x".into(), - tool_input: "{}".into(), - cause: "x".into(), - }, - AiMuxError::ToolCallRepair { - original_error: Box::new(AiMuxError::NoSuchTool { - tool_name: "x".into(), - available_tools: None, - tool_input: None, - }), - cause: Box::new(AiMuxError::Other("x".into())), - }, - AiMuxError::InvalidArgument("x".into()), - AiMuxError::InvalidPrompt("x".into()), - AiMuxError::TokenExpired("x".into()), - AiMuxError::UnsupportedFunctionality("x".into()), - AiMuxError::NoSuchModel { - model_id: "x".into(), - model_type: String::new(), - }, - AiMuxError::NoSuchProvider { - provider_id: "x".into(), - }, - AiMuxError::Timeout("x".into()), - AiMuxError::Aborted("request aborted".into()), - AiMuxError::Other("x".into()), - ]; - // The wire tag IS the variant name (externally-tagged serde JSON). - let mut names: Vec = all - .iter() - .map(|e| match serde_json::to_value(e).unwrap() { - serde_json::Value::Object(m) => m.into_iter().next().unwrap().0, - serde_json::Value::String(s) => s, // unit variants (Aborted) - v => panic!("unexpected error_value shape: {v}"), - }) - .collect(); - names.sort_unstable(); - names.dedup(); - assert_eq!( - names, - [ - "Aborted", - "ApiCall", - "InvalidArgument", - "InvalidPrompt", - "InvalidResponseData", - "InvalidToolInput", - "JsonParse", - "NoSuchModel", - "NoSuchProvider", - "NoSuchTool", - "Other", - "Retry", - "Timeout", - "TokenExpired", - "ToolCallRepair", - "UnsupportedFunctionality", - ], - "variant set changed" - ); -} - /// Request context is required. Payloads from the pre-context schema are /// deliberately rejected instead of silently fabricating an empty URL/body. #[test] diff --git a/aimux-core/tests/m11_m12_m6_test.rs b/aimux-core/tests/m11_m12_m6_test.rs deleted file mode 100644 index e705aec64..000000000 --- a/aimux-core/tests/m11_m12_m6_test.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! Tests for M11 (streamText aggregation), M12 (generateObject), and M6 (proxy). - -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_object}; -use aimux_core::language_model::LanguageModel; -use aimux_core::message::MessageContent; -use aimux_core::options::{CallOptions, ResponseFormat}; -use aimux_core::result::{GenerateContent, GenerateResult, Source, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; - -use async_trait::async_trait; -use futures::stream; - -// ── Mock models ───────────────────────────────────────────────────────────── - -/// A mock model whose `do_generate` returns a JSON string (for M12 tests). -struct JsonModel; - -#[async_trait] -impl LanguageModel for JsonModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-json" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: r#"{"name":"John","age":25}"#.into(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -/// A mock model whose `do_generate` returns non-JSON text (for M12 failure test). -struct NonJsonModel; - -#[async_trait] -impl LanguageModel for NonJsonModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-nonjson" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: "This is not JSON at all.".into(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -/// A mock model whose `do_stream` yields a rich set of StreamParts (for M11). -struct StreamModel; - -#[async_trait] -impl LanguageModel for StreamModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-stream" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - unimplemented!() - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ReasoningStart { - id: "r1".into(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningDelta { - id: "r1".into(), - delta: "Thinking...".into(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningEnd { - id: "r1".into(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "Hello ".into(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "world!".into(), - provider_metadata: None, - }), - Ok(StreamPart::Source(Source { - id: "s1".into(), - source_type: "url".into(), - url: Some("https://example.com".into()), - title: Some("Example".into()), - provider_metadata: None, - })), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage { - input_tokens: aimux_core::types::TokenUsage { - total: Some(10), - ..Default::default() - }, - output_tokens: aimux_core::types::TokenUsage { - total: Some(20), - ..Default::default() - }, - ..Default::default() - }, - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -// ── M12: generateObject ────────────────────────────────────────────────────── - -#[tokio::test] -async fn generate_object_parses_json_output() { - let result = generate_object( - &JsonModel, - "Extract user info", - GenerateTextOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(serde_json::json!({"type": "object"})), - name: None, - description: None, - }), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(result.object["name"], "John"); - assert_eq!(result.object["age"], 25); - assert_eq!(result.raw.text, r#"{"name":"John","age":25}"#); -} - -#[tokio::test] -async fn generate_object_non_json_fails() { - let err = generate_object(&NonJsonModel, "whatever", GenerateTextOptions::default()) - .await - .unwrap_err(); - - assert!( - matches!(err, AiMuxError::JsonParse(ref m) if m.contains("not valid JSON")), - "expected JsonParse error, got {err:?}" - ); -} - -// ── M11: streamText aggregation ────────────────────────────────────────────── - -#[tokio::test] -async fn consume_stream_aggregates_text_and_reasoning() { - let result = - aimux_core::generate::stream_text(&StreamModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - assert_eq!(agg.text, "Hello world!"); - assert_eq!(agg.reasoning.len(), 1); - assert_eq!(agg.reasoning[0].text, "Thinking..."); - assert_eq!(agg.reasoning_text, "Thinking..."); -} - -#[tokio::test] -async fn consume_stream_captures_usage_and_finish_reason() { - let result = - aimux_core::generate::stream_text(&StreamModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - assert_eq!(agg.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(agg.raw_finish_reason.as_deref(), Some("stop")); - assert_eq!(agg.usage.input_tokens.total, Some(10)); - assert_eq!(agg.usage.output_tokens.total, Some(20)); -} - -#[tokio::test] -async fn consume_stream_captures_sources() { - let result = - aimux_core::generate::stream_text(&StreamModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - assert_eq!(agg.sources.len(), 1); - assert_eq!(agg.sources[0].id, "s1"); - assert_eq!(agg.sources[0].url.as_deref(), Some("https://example.com")); -} - -#[tokio::test] -async fn consume_stream_builds_response_messages() { - let result = - aimux_core::generate::stream_text(&StreamModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - assert_eq!(agg.response_messages.len(), 1); - assert_eq!( - agg.response_messages[0].role, - aimux_core::message::Role::Assistant - ); - // The text must be in the response message. - match &agg.response_messages[0].content { - MessageContent::Parts(parts) => { - assert!(parts.iter().any(|p| matches!(p, - aimux_core::content::ContentPart::Text { text, .. } if text == "Hello world!" - ))); - } - _ => panic!("expected Parts"), - } -} - -/// A mock model whose stream carries a reasoning block with provider -/// metadata but NO summary text (OpenAI Responses with store=false can -/// return encrypted reasoning without any summary deltas). -struct MetadataOnlyReasoningModel; - -#[async_trait] -impl LanguageModel for MetadataOnlyReasoningModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-meta-reasoning" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - unimplemented!() - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - let meta = serde_json::json!({ - "openai": { - "itemId": "rs_1", - "reasoningEncryptedContent": "enc-blob-123", - } - }); - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ReasoningStart { - id: "rs_1:0".into(), - provider_metadata: Some(meta.clone()), - }), - // No ReasoningDelta at all — no visible summary text. - Ok(StreamPart::ReasoningEnd { - id: "rs_1:0".into(), - provider_metadata: Some(meta), - }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "Done.".into(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -/// Regression: a reasoning part with provider metadata but no summary text -/// used to be dropped from response_messages entirely, losing the -/// encrypted reasoning blob (it must round-trip on the next turn). -#[tokio::test] -async fn consume_keeps_metadata_only_reasoning_in_response_messages() { - let result = aimux_core::generate::stream_text( - &MetadataOnlyReasoningModel, - "hi", - GenerateTextOptions::default(), - ) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - // No visible reasoning text at the top level... - assert!( - agg.reasoning.is_empty(), - "no summary text, no ReasoningPart" - ); - - // ...but the reasoning part must survive in response_messages with its - // provider metadata, or the next turn loses the encrypted blob. - let msg = &agg.response_messages[0]; - match &msg.content { - MessageContent::Parts(parts) => { - let reasoning = parts - .iter() - .find(|p| matches!(p, aimux_core::content::ContentPart::Reasoning { .. })); - match reasoning { - Some(aimux_core::content::ContentPart::Reasoning { - text, - provider_options: Some(po), - .. - }) => { - assert!(text.is_empty()); - assert_eq!(po["openai"]["itemId"], serde_json::json!("rs_1")); - assert_eq!( - po["openai"]["reasoningEncryptedContent"], - serde_json::json!("enc-blob-123") - ); - } - other => panic!( - "response_messages must keep the metadata-carrying reasoning part, got {other:?}" - ), - } - } - _ => panic!("expected Parts"), - } -} diff --git a/aimux-core/tests/m7_aggregation_test.rs b/aimux-core/tests/m7_aggregation_test.rs deleted file mode 100644 index cdd2dead0..000000000 --- a/aimux-core/tests/m7_aggregation_test.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! Tests for M7: top-level result aggregation (reasoning/sources/files/responseMessages). -//! -//! Verifies that `generate_text` extracts reasoning, sources, files, and -//! response_messages from the raw `GenerateResult.content` to the top level. - -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::message::{MessageContent, ModelPrompt, Role}; -use aimux_core::options::CallOptions; -use aimux_core::result::{ - GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, StreamResult, -}; -use aimux_core::shared::{FileBytes, FileData}; -use aimux_core::tool::RawToolCall; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; - -use async_trait::async_trait; - -/// A mock model that returns a fixed `GenerateResult` with all content types. -struct RichModel; - -#[async_trait] -impl LanguageModel for RichModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-1" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![ - GenerateContent::Reasoning(ReasoningOutput { - text: "Let me think...".into(), - provider_metadata: Some(serde_json::json!({ - "anthropic": { "signature": "sig-abc123" } - })), - }), - GenerateContent::Text { - text: "Hello!".into(), - provider_metadata: None, - }, - GenerateContent::ToolCall(RawToolCall { - tool_call_id: "tc-1".into(), - tool_name: "search".into(), - input: r#"{"q":"test"}"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }), - GenerateContent::Source(Source { - id: "src-1".into(), - source_type: "url".into(), - url: Some("https://example.com".into()), - title: Some("Example".into()), - provider_metadata: None, - }), - GenerateContent::File(GeneratedFile { - data: FileData::Data { - data: FileBytes::Base64("iVBOR".into()), - }, - media_type: "image/png".into(), - provider_metadata: None, - }), - ], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!("streaming not needed for M7 tests") - } -} - -/// A mock model that returns only text (no reasoning/sources/files). -struct PlainModel; - -#[async_trait] -impl LanguageModel for PlainModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-2" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: "Just text.".into(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -#[tokio::test] -async fn extracts_reasoning_to_top_level() { - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - assert_eq!(result.reasoning.len(), 1); - assert_eq!(result.reasoning[0].text, "Let me think..."); - assert_eq!(result.reasoning_text, "Let me think..."); -} - -#[tokio::test] -async fn extracts_sources_to_top_level() { - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - assert_eq!(result.sources.len(), 1); - assert_eq!(result.sources[0].id, "src-1"); - assert_eq!(result.sources[0].source_type, "url"); - assert_eq!( - result.sources[0].url.as_deref(), - Some("https://example.com") - ); - assert_eq!(result.sources[0].title.as_deref(), Some("Example")); -} - -#[tokio::test] -async fn extracts_files_to_top_level() { - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - assert_eq!(result.files.len(), 1); - assert_eq!(result.files[0].media_type, "image/png"); - assert!(matches!( - &result.files[0].data, - FileData::Data { data: FileBytes::Base64(b) } if b == "iVBOR" - )); -} - -#[tokio::test] -async fn response_messages_roundtrip_to_next_turn() { - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - // response_messages must contain exactly one assistant message. - assert_eq!(result.response_messages.len(), 1); - let msg = &result.response_messages[0]; - assert_eq!(msg.role, Role::Assistant); - - // Its content must be Parts (not bare Text), containing the text. - match &msg.content { - MessageContent::Parts(parts) => { - assert!( - parts.iter().any(|p| matches!(p, - aimux_core::content::ContentPart::Text { text, .. } if text == "Hello!" - )), - "response message must contain the generated text" - ); - } - other => panic!("expected Parts, got {other:?}"), - } - - // The response message must be appendable to a prompt for the next turn. - let next_prompt: ModelPrompt = vec![ - aimux_core::message::ModelMessage::user("hi"), - result.response_messages[0].clone(), - aimux_core::message::ModelMessage::user("follow-up"), - ] - .into(); - // If this compiles + runs, the round-trip type-checks. - let _ = next_prompt; -} - -#[tokio::test] -async fn plain_result_has_empty_aggregates() { - let result = generate_text(&PlainModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - assert!(result.reasoning.is_empty(), "no reasoning expected"); - assert!(result.reasoning_text.is_empty()); - assert!(result.sources.is_empty()); - assert!(result.files.is_empty()); - // response_messages still has the assistant message (with text). - assert_eq!(result.response_messages.len(), 1); - assert_eq!(result.text, "Just text."); -} - -#[tokio::test] -async fn reasoning_included_in_response_messages_with_signature() { - // Reasoning MUST appear in responseMessages — it carries the Anthropic - // thinking-block signature which must be echoed back on the next turn. - // Consistent with AI SDK's toResponseMessages. - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - let msg = &result.response_messages[0]; - let parts = match &msg.content { - MessageContent::Parts(p) => p, - _ => panic!("expected Parts"), - }; - let reasoning_part = parts - .iter() - .find(|p| matches!(p, aimux_core::content::ContentPart::Reasoning { .. })); - assert!( - reasoning_part.is_some(), - "reasoning must be included in responseMessages" - ); - if let Some(aimux_core::content::ContentPart::Reasoning { signature, .. }) = reasoning_part { - assert_eq!( - signature.as_deref(), - Some("sig-abc123"), - "Anthropic signature must be preserved in responseMessages" - ); - } -} - -#[tokio::test] -async fn tool_calls_included_in_response_messages() { - // Tool calls must appear in responseMessages so the assistant reply can - // be replayed in the next turn (the tool result follows as a separate - // tool message). - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - let msg = &result.response_messages[0]; - let parts = match &msg.content { - MessageContent::Parts(p) => p, - _ => panic!("expected Parts"), - }; - let tool_call_part = parts.iter().find(|p| { - matches!( - p, - aimux_core::content::ContentPart::ToolCall { tool_name, .. } if tool_name == "search" - ) - }); - assert!( - tool_call_part.is_some(), - "tool call must be included in responseMessages" - ); -} - -/// A mock model that returns reasoning with Bedrock-style signature metadata. -struct BedrockModel; - -#[async_trait] -impl LanguageModel for BedrockModel { - fn provider(&self) -> &str { - "bedrock" - } - fn model_id(&self) -> &str { - "bedrock-1" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![ - GenerateContent::Text { - text: "Hello from Bedrock!".into(), - provider_metadata: None, - }, - GenerateContent::Reasoning(ReasoningOutput { - text: "Thinking...".into(), - // Bedrock stores signature under BOTH "bedrock" and - // "amazonBedrock" keys (see bedrock/model.rs:535-540). - provider_metadata: Some(serde_json::json!({ - "amazonBedrock": { "signature": "bedrock-sig-xyz" }, - "bedrock": { "signature": "bedrock-sig-xyz" } - })), - }), - ], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -#[tokio::test] -async fn bedrock_signature_extracted_from_reasoning_metadata() { - // Bedrock stores the thinking signature under "bedrock"/"amazonBedrock" - // keys, not "anthropic". The extraction must find it regardless. - let result = generate_text(&BedrockModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - let msg = &result.response_messages[0]; - let parts = match &msg.content { - MessageContent::Parts(p) => p, - _ => panic!("expected Parts"), - }; - let reasoning_part = parts - .iter() - .find_map(|p| match p { - aimux_core::content::ContentPart::Reasoning { signature, .. } => Some(signature), - _ => None, - }) - .expect("reasoning must be in responseMessages"); - assert_eq!( - reasoning_part.as_deref(), - Some("bedrock-sig-xyz"), - "Bedrock signature must be extracted from bedrock/amazonBedrock metadata key" - ); -} diff --git a/aimux-core/tests/recording_e2e_test.rs b/aimux-core/tests/recording_e2e_test.rs deleted file mode 100644 index 28e46cf85..000000000 --- a/aimux-core/tests/recording_e2e_test.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! RFC-0023 P1 端到端集成测试:层 A 接入(generate_text/stream_text) + -//! 流式终结观测 + JsonlRecorder 落盘。 -//! -//! 全局录制器是进程级单例,所有场景放在同一测试内串行执行(init→验证→替换)。 - -use aimux_core::language_model::LanguageModel; -use aimux_core::options::CallOptions; -use aimux_core::recording::{self, JsonlRecorder, OutcomeStatus, Recording}; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; -use async_trait::async_trait; -use futures::StreamExt; -use std::sync::Arc; - -/// Mock model:非流式固定回话;流式按参数决定行为。 -#[derive(Default)] -struct EchoModel { - /// do_generate 立即失败。 - gen_fails: bool, - /// 流中途发 Error part。 - part_error: bool, - /// do_stream 立即失败。 - stream_fails: bool, -} - -#[async_trait] -impl LanguageModel for EchoModel { - fn provider(&self) -> &str { - "mock-echo" - } - - fn model_id(&self) -> &str { - "echo-1" - } - - async fn do_generate( - &self, - _options: &CallOptions, - ) -> Result { - if self.gen_fails { - return Err(aimux_core::error::AiMuxError::ApiCall(Box::new( - aimux_core::error::ApiCallError::new( - "gen boom", - "https://example.test/v1", - serde_json::json!({}), - ), - ))); - } - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: "pong".into(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: aimux_core::types::ResponseMetadata::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream( - &self, - _options: &CallOptions, - ) -> Result { - if self.stream_fails { - return Err(aimux_core::error::AiMuxError::InvalidResponseData( - "boom".into(), - )); - } - if self.part_error { - let parts = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::Error { - error: aimux_core::error::AiMuxError::InvalidResponseData("mid-stream".into()), - }), - ]; - return Ok(StreamResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }); - } - let parts = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "1".into(), - delta: "hi".into(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -fn run(f: F) -> F::Output { - tokio::runtime::Runtime::new().unwrap().block_on(f) -} - -fn wait_sorted_line(dir: &std::path::Path) -> Recording { - let path = dir.join("recordings.jsonl"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - if let Ok(s) = std::fs::read_to_string(&path) - && let Some(line) = s.lines().next() - && !line.trim().is_empty() - && let Ok(rec) = serde_json::from_str::(line.trim()) - { - return rec; - } - if std::time::Instant::now() > deadline { - panic!("recording not written: {}", path.display()); - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } -} - -#[test] -fn layer_a_records_all_outcome_paths() { - let pid = std::process::id(); - let dir_ok = std::env::temp_dir().join(format!("aimux-e2e-ok-{pid}")); - let dir_err = std::env::temp_dir().join(format!("aimux-e2e-err-{pid}")); - let dir_finish = std::env::temp_dir().join(format!("aimux-e2e-fin-{pid}")); - let dir_part_err = std::env::temp_dir().join(format!("aimux-e2e-pe-{pid}")); - let dir_cancel = std::env::temp_dir().join(format!("aimux-e2e-cancel-{pid}")); - for d in [&dir_ok, &dir_err, &dir_finish, &dir_part_err, &dir_cancel] { - let _ = std::fs::remove_dir_all(d); - } - - // ① generate 成功 → Success + finish_reason。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_ok)))); - run(async { - let r = aimux_core::generate::generate_text( - &EchoModel::default(), - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await; - assert_eq!(r.unwrap().text, "pong"); - }); - let rec = wait_sorted_line(&dir_ok); - assert!(rec.call_id.starts_with("call-")); - assert_eq!(rec.provider.provider, "mock-echo"); - assert_eq!(rec.outcome.status, OutcomeStatus::Success); - assert_eq!(rec.outcome.finish_reason.as_deref(), Some("stop")); - - // ② generate 失败 → Error。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_err)))); - run(async { - let r = aimux_core::generate::generate_text( - &EchoModel { - gen_fails: true, - ..Default::default() - }, - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await; - assert!(r.is_err()); - }); - let rec = wait_sorted_line(&dir_err); - assert_eq!(rec.outcome.status, OutcomeStatus::Error); - assert!(rec.outcome.error.is_some()); - - // ③ 流式正常终结(消费完) → Success。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_finish)))); - run(async { - let result = aimux_core::generate::stream_text( - &EchoModel::default(), - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await - .unwrap(); - let parts: Vec<_> = result.stream.collect::>().await; - assert_eq!(parts.len(), 3); - }); - let rec = wait_sorted_line(&dir_finish); - assert_eq!(rec.outcome.status, OutcomeStatus::Success); - - // ④ 流式 Error part → Error。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_part_err)))); - run(async { - let result = aimux_core::generate::stream_text( - &EchoModel { - part_error: true, - ..Default::default() - }, - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await - .unwrap(); - let parts: Vec<_> = result.stream.collect::>().await; - assert_eq!(parts.len(), 2); - }); - let rec = wait_sorted_line(&dir_part_err); - assert_eq!(rec.outcome.status, OutcomeStatus::Error); - assert!(rec.outcome.error.unwrap_or_default().contains("mid-stream")); - - // ⑤ 流式提前 drop(不消费完)→ Cancelled。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_cancel)))); - run(async { - let result = aimux_core::generate::stream_text( - &EchoModel::default(), - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await - .unwrap(); - drop(result.stream); // 未 poll 完即抛弃 → Drop 终结 Cancelled - }); - let rec = wait_sorted_line(&dir_cancel); - assert_eq!(rec.outcome.status, OutcomeStatus::Cancelled); - - recording::init_recording(None); - let _ = std::fs::remove_dir_all(&dir_ok); -} diff --git a/aimux-core/tests/response_messages_tool_test.rs b/aimux-core/tests/response_messages_tool_test.rs deleted file mode 100644 index 0ab5134a5..000000000 --- a/aimux-core/tests/response_messages_tool_test.rs +++ /dev/null @@ -1,499 +0,0 @@ -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::message::{MessageContent, Role}; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{RawToolCall, ToolResult}; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; -use async_trait::async_trait; -use serde_json::json; - -struct ProviderTranscriptModel; - -fn finish_reason() -> FinishReason { - FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("end_turn".to_string()), - } -} - -#[async_trait] -impl LanguageModel for ProviderTranscriptModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "provider-transcript" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![ - GenerateContent::Text { - text: "before".to_string(), - provider_metadata: None, - }, - GenerateContent::ToolCall(RawToolCall { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - input: r#"{"query":"Rust"}"#.to_string(), - provider_executed: Some(true), - dynamic: Some(true), - thought_signature: None, - provider_metadata: Some(json!({ - "mock": { "serverCallId": "wire-1" } - })), - }), - GenerateContent::ToolResult(ToolResult { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - result: json!({ "answer": "still running" }), - is_error: Some(false), - preliminary: Some(true), - dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-preliminary-1" } - })), - }), - GenerateContent::ToolResult(ToolResult { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - result: json!({ "answer": 42 }), - is_error: Some(false), - preliminary: Some(false), - dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-result-1" } - })), - }), - GenerateContent::Text { - text: "after".to_string(), - provider_metadata: None, - }, - ], - finish_reason: finish_reason(), - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let parts = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "text-1".to_string(), - delta: "before".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolCall(RawToolCall { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - input: r#"{"query":"Rust"}"#.to_string(), - provider_executed: Some(true), - dynamic: Some(true), - thought_signature: None, - provider_metadata: Some(json!({ - "mock": { "serverCallId": "wire-1" } - })), - })), - Ok(StreamPart::ToolResult(ToolResult { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - result: json!({ "answer": "still running" }), - is_error: Some(false), - preliminary: Some(true), - dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-preliminary-1" } - })), - })), - Ok(StreamPart::ToolResult(ToolResult { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - result: json!({ "answer": 42 }), - is_error: Some(false), - preliminary: Some(false), - dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-result-1" } - })), - })), - Ok(StreamPart::TextDelta { - id: "text-2".to_string(), - delta: "after".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: finish_reason(), - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -fn assert_provider_transcript(messages: &[aimux_core::message::ModelMessage]) { - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, Role::Assistant); - let MessageContent::Parts(parts) = &messages[0].content else { - panic!("expected assistant parts"); - }; - assert_eq!( - parts.len(), - 4, - "provider order must be preserved: {parts:?}" - ); - assert!(matches!( - &parts[0], - aimux_core::content::ContentPart::Text { text, .. } if text == "before" - )); - assert!(matches!( - &parts[1], - aimux_core::content::ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - provider_executed: Some(true), - provider_options: Some(options), - .. - } if tool_call_id == "srv-1" - && tool_name == "server_search" - && input == &json!({ "query": "Rust" }) - && options["mock"]["serverCallId"] == "wire-1" - )); - assert!(matches!( - &parts[2], - aimux_core::content::ContentPart::ToolResult { - tool_call_id, - tool_name: Some(tool_name), - result, - is_error: Some(false), - preliminary: Some(false), - dynamic: Some(true), - provider_options: Some(options), - } if tool_call_id == "srv-1" - && tool_name == "server_search" - && result == &json!({ "answer": 42 }) - && options["mock"]["serverResultId"] == "wire-result-1" - )); - assert!(matches!( - &parts[3], - aimux_core::content::ContentPart::Text { text, .. } if text == "after" - )); -} - -#[tokio::test] -async fn generate_response_messages_keep_provider_tool_transcript() { - let result = generate_text( - &ProviderTranscriptModel, - "search", - GenerateTextOptions::default(), - ) - .await - .unwrap(); - - assert_provider_transcript(&result.response_messages); -} - -#[tokio::test] -async fn stream_response_messages_keep_provider_tool_transcript() { - let result = stream_text( - &ProviderTranscriptModel, - "search", - GenerateTextOptions::default(), - ) - .await - .unwrap() - .consume() - .await - .unwrap(); - - assert_provider_transcript(&result.response_messages); -} - -struct InvalidToolInputModel; - -#[async_trait] -impl LanguageModel for InvalidToolInputModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "invalid-tool-input" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::ToolCall(RawToolCall { - tool_call_id: "bad-1".to_string(), - tool_name: "server_search".to_string(), - input: "{".to_string(), - provider_executed: Some(true), - dynamic: Some(true), - thought_signature: None, - provider_metadata: None, - })], - finish_reason: finish_reason(), - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -#[tokio::test] -async fn invalid_primitive_tool_input_is_not_replayed() { - let result = generate_text( - &InvalidToolInputModel, - "search", - GenerateTextOptions::default(), - ) - .await - .unwrap(); - assert_eq!(result.tool_calls[0].input, json!("{")); - assert_eq!(result.tool_calls[0].invalid, Some(true)); - - let MessageContent::Parts(parts) = &result.response_messages[0].content else { - panic!("expected assistant parts"); - }; - assert!(matches!( - &parts[0], - aimux_core::content::ContentPart::ToolCall { input, .. } if input == &json!({}) - )); -} - -struct EmptyModel; - -#[async_trait] -impl LanguageModel for EmptyModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "empty" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: String::new(), - provider_metadata: None, - }], - finish_reason: finish_reason(), - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -#[tokio::test] -async fn empty_generation_has_no_empty_assistant_response_message() { - let result = generate_text(&EmptyModel, "", GenerateTextOptions::default()) - .await - .unwrap(); - assert!(result.response_messages.is_empty()); -} - -struct ContentMetadataModel; - -#[async_trait] -impl LanguageModel for ContentMetadataModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "content-metadata" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![ - GenerateContent::Text { - text: "answer".to_string(), - provider_metadata: Some(json!({ - "google": { "thoughtSignature": "text-generate" } - })), - }, - GenerateContent::Reasoning(ReasoningOutput { - text: "think".to_string(), - provider_metadata: Some(json!({ - "anthropic": { "signature": "reason-generate" } - })), - }), - ], - finish_reason: finish_reason(), - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let parts = vec![ - Ok(StreamPart::TextStart { - id: "text-1".to_string(), - provider_metadata: Some(json!({ "mock": { "phase": "start" } })), - }), - Ok(StreamPart::TextDelta { - id: "text-1".to_string(), - delta: "answer".to_string(), - provider_metadata: Some(json!({ - "google": { "thoughtSignature": "text-delta" } - })), - }), - Ok(StreamPart::TextEnd { - id: "text-1".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningStart { - id: "reason-1".to_string(), - provider_metadata: Some(json!({ - "anthropic": { "signature": "reason-start" } - })), - }), - Ok(StreamPart::ReasoningDelta { - id: "reason-1".to_string(), - delta: "think".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningEnd { - id: "reason-1".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningStart { - id: "reason-2".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningDelta { - id: "reason-2".to_string(), - delta: "again".to_string(), - provider_metadata: Some(json!({ - "anthropic": { "signature": "reason-delta" } - })), - }), - Ok(StreamPart::ReasoningEnd { - id: "reason-2".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: finish_reason(), - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -#[tokio::test] -async fn generate_response_messages_keep_text_and_reasoning_metadata() { - let result = generate_text( - &ContentMetadataModel, - "metadata", - GenerateTextOptions::default(), - ) - .await - .unwrap(); - let MessageContent::Parts(parts) = &result.response_messages[0].content else { - panic!("expected assistant parts"); - }; - assert!(matches!( - &parts[0], - aimux_core::content::ContentPart::Text { - text, - provider_options: Some(options), - } if text == "answer" && options["google"]["thoughtSignature"] == "text-generate" - )); - assert!(matches!( - &parts[1], - aimux_core::content::ContentPart::Reasoning { - text, - signature: Some(signature), - provider_options: Some(options), - } if text == "think" - && signature == "reason-generate" - && options["anthropic"]["signature"] == "reason-generate" - )); -} - -#[tokio::test] -async fn stream_response_messages_keep_latest_segment_metadata() { - let result = stream_text( - &ContentMetadataModel, - "metadata", - GenerateTextOptions::default(), - ) - .await - .unwrap() - .consume() - .await - .unwrap(); - let MessageContent::Parts(parts) = &result.response_messages[0].content else { - panic!("expected assistant parts"); - }; - assert_eq!(parts.len(), 3); - assert!(matches!( - &parts[0], - aimux_core::content::ContentPart::Text { - text, - provider_options: Some(options), - } if text == "answer" && options["google"]["thoughtSignature"] == "text-delta" - )); - assert!(matches!( - &parts[1], - aimux_core::content::ContentPart::Reasoning { - text, - signature: Some(signature), - provider_options: Some(options), - } if text == "think" - && signature == "reason-start" - && options["anthropic"]["signature"] == "reason-start" - )); - assert!(matches!( - &parts[2], - aimux_core::content::ContentPart::Reasoning { - text, - signature: Some(signature), - provider_options: Some(options), - } if text == "again" - && signature == "reason-delta" - && options["anthropic"]["signature"] == "reason-delta" - )); -} diff --git a/aimux-core/tests/session_test.rs b/aimux-core/tests/session_test.rs deleted file mode 100644 index 7b1e23c4f..000000000 --- a/aimux-core/tests/session_test.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Integration tests for RFC-0024 session grouping: the `generate_text` / -//! `stream_text` entry points resolve the session id (explicit first, opt-in -//! inference) and record the call in the registered `SessionStore`. -//! -//! NOTE: all tests touching the global session store/inferer live in ONE test -//! function. `cargo test` runs test functions in parallel, and the global -//! registration is a process-wide singleton — separate parallel tests would -//! race on it. The single function keeps the assertions serial and stable. - -use std::sync::Arc; - -use futures::executor::block_on; - -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::message::{ModelMessage, ModelPrompt}; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateResult, StreamResult}; -use aimux_core::session::{ - SessionSource, SessionStore, init_session_infer, init_session_store, list_sessions, - session_calls, -}; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; - -struct MockModel; - -#[async_trait::async_trait] -impl LanguageModel for MockModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "mock-1" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(futures::stream::empty()), - request_body: None, - response_headers: None, - }) - } -} - -#[test] -fn generate_text_session_integration() { - // Fresh store + inference off (explicit ids only). - init_session_store(Arc::new(SessionStore::new())); - init_session_infer(false); - - // 1. Without an explicit session_id and inference off → no grouping. - block_on(generate_text( - &MockModel, - "hello", - GenerateTextOptions::default(), - )) - .unwrap(); - assert!( - list_sessions().is_empty(), - "no store recording without session id" - ); - - // 2. Explicit session_id groups consecutive calls (steps 0, 1). - block_on(generate_text( - &MockModel, - "first", - GenerateTextOptions { - session_id: Some("sess-1".to_string()), - ..Default::default() - }, - )) - .unwrap(); - block_on(generate_text( - &MockModel, - "second", - GenerateTextOptions { - session_id: Some("sess-1".to_string()), - ..Default::default() - }, - )) - .unwrap(); - - let calls = session_calls("sess-1"); - assert_eq!(calls.len(), 2); - assert_eq!(calls[0].step, 0); - assert_eq!(calls[1].step, 1); - assert_ne!(calls[0].call_id, calls[1].call_id); - - let views = list_sessions(); - assert_eq!(views.len(), 1); - assert_eq!(views[0].session_id, "sess-1"); - assert_eq!(views[0].source, SessionSource::Explicit); - assert_eq!(views[0].calls.len(), 2); - - // 3. Different explicit ids are separate sessions. - block_on(generate_text( - &MockModel, - "other", - GenerateTextOptions { - session_id: Some("sess-2".to_string()), - ..Default::default() - }, - )) - .unwrap(); - assert_eq!(list_sessions().len(), 2); - assert_eq!(session_calls("sess-2").len(), 1); - - // 4. Inference on: prompt-prefix continuation joins an auto session. - init_session_infer(true); - block_on(generate_text( - &MockModel, - "u1", - GenerateTextOptions::default(), - )) - .unwrap(); - let auto_ids: Vec = list_sessions() - .iter() - .filter(|v| v.session_id.starts_with("auto-")) - .map(|v| v.session_id.clone()) - .collect(); - assert_eq!( - auto_ids.len(), - 1, - "first inferred call starts an auto session" - ); - - // Continuation: ["u1", "a1", "u2"] fully contains ["u1"] as prefix. - block_on(generate_text( - &MockModel, - ModelPrompt::Messages(vec![ - ModelMessage::user("u1"), - ModelMessage::assistant("a1"), - ModelMessage::user("u2"), - ]), - GenerateTextOptions::default(), - )) - .unwrap(); - assert_eq!( - list_sessions() - .iter() - .filter(|v| v.session_id.starts_with("auto-")) - .count(), - 1, - "prefix continuation stays in the same auto session" - ); - let auto = list_sessions() - .into_iter() - .find(|v| v.session_id.starts_with("auto-")) - .unwrap(); - assert_eq!(auto.calls.len(), 2); - assert_eq!(auto.source, SessionSource::Inferred); - - // 5. Explicit still wins over inference. - block_on(generate_text( - &MockModel, - "u1", - GenerateTextOptions { - session_id: Some("sess-1".to_string()), - ..Default::default() - }, - )) - .unwrap(); - assert_eq!(session_calls("sess-1").len(), 3); -} diff --git a/aimux-core/tests/stream_timeout_matrix_test.rs b/aimux-core/tests/stream_timeout_matrix_test.rs deleted file mode 100644 index 8121bf7d6..000000000 --- a/aimux-core/tests/stream_timeout_matrix_test.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! RFC-0031 §13 acceptance: chunk-timer semantics (item 17), -//! producer-side timing (consumer delay never counts), and -//! no-lingering-timer-state after drop (item 21b). -//! -//! Paused tokio time makes every assertion exact: auto-advance jumps to the -//! earliest armed deadline, so elapsed times distinguish "reset" from -//! "not reset" deterministically. - -use std::time::Duration; - -use aimux_core::AbortSignal; -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::options::{CallOptions, TimeoutConfiguration}; -use aimux_core::result::{GenerateResult, StreamResult}; -use aimux_core::stream_part::StreamPart; -use async_trait::async_trait; -use futures::StreamExt; - -/// Streams: TextDelta at t=0, then after 600ms one more part (semantic or -/// not), then pends forever. The follow-up part is the probe: only semantic -/// output may reset the chunk timer. -struct ProbeModel { - second_part_is_output: bool, -} - -fn delta(text: &str) -> StreamPart { - StreamPart::TextDelta { - id: "1".into(), - delta: text.into(), - provider_metadata: None, - } -} - -#[async_trait] -impl LanguageModel for ProbeModel { - fn provider(&self) -> &str { - "mock-probe" - } - - fn model_id(&self) -> &str { - "probe-1" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - unimplemented!("streaming-only mock") - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let second_is_output = self.second_part_is_output; - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(delta("first")); - tokio::time::sleep(Duration::from_millis(600)).await; - if second_is_output { - yield Ok(delta("second")); - } else { - // Non-semantic per the AI SDK's isOutputChunk: must NOT - // reset the chunk timer. - yield Ok(StreamPart::TextStart { - id: "1".into(), - provider_metadata: None, - }); - } - futures::future::pending::<()>().await; - }), - request_body: None, - response_headers: None, - }) - } -} - -fn chunk_options() -> GenerateTextOptions { - GenerateTextOptions { - max_retries: Some(0), - timeout: Some(TimeoutConfiguration { - chunk_ms: Some(1_000), - ..Default::default() - }), - ..Default::default() - } -} - -/// Drives the probe stream to the chunk timeout and returns when it fired. -async fn time_to_chunk_timeout(second_part_is_output: bool) -> Duration { - let model = ProbeModel { - second_part_is_output, - }; - let mut result = stream_text(&model, "hello", chunk_options()).await.unwrap(); - let start = tokio::time::Instant::now(); - loop { - match result.stream.next().await.expect("stream must not end") { - Ok(_) => {} - Err(AiMuxError::Timeout(message)) => { - assert_eq!(message, "Chunk timeout of 1000ms exceeded"); - return start.elapsed(); - } - Err(other) => panic!("unexpected error: {other}"), - } - } -} - -#[tokio::test(start_paused = true)] -async fn non_semantic_part_does_not_reset_the_chunk_timer() { - // TextStart at t=600 must not push the deadline: timeout fires 1000ms - // after the only semantic output at t=0, not 1600ms. - assert_eq!( - time_to_chunk_timeout(false).await, - Duration::from_millis(1_000) - ); -} - -#[tokio::test(start_paused = true)] -async fn semantic_output_resets_the_chunk_timer() { - // The second TextDelta at t=600 re-arms the deadline to t=1600. - assert_eq!( - time_to_chunk_timeout(true).await, - Duration::from_millis(1_600) - ); -} - -#[tokio::test(start_paused = true)] -async fn consumer_delay_does_not_count_against_the_chunk_timer() { - // The second delta arrives at t=600, inside the 1000ms chunk budget - // measured from the first delta. A consumer that sleeps until t=2000 - // before polling again must still receive it; only the *next* poll sees - // the timeout, which fired at t=1600 on the producer side. - let model = ProbeModel { - second_part_is_output: true, - }; - let mut result = stream_text(&model, "hello", chunk_options()).await.unwrap(); - assert!(result.stream.next().await.expect("first part").is_ok()); - - tokio::time::sleep(Duration::from_millis(2_000)).await; - - match result.stream.next().await.expect("second part") { - Ok(StreamPart::TextDelta { delta, .. }) => assert_eq!(delta, "second"), - other => panic!("expected the buffered second delta, got {other:?}"), - } - match result.stream.next().await.expect("timeout") { - Err(AiMuxError::Timeout(message)) => { - assert_eq!(message, "Chunk timeout of 1000ms exceeded"); - } - other => panic!("expected chunk timeout, got {other:?}"), - } - assert!(result.stream.next().await.is_none()); -} - -#[tokio::test(start_paused = true)] -async fn dropped_operation_leaves_no_armed_timer_state() { - // §8.0/§13-21b: deadlines live inside the pump task that drives the - // provider stream, and that task is aborted when the returned stream is - // dropped. Dropping the stream mid-flight must leave no timer that could - // later fire and mutate the caller's signal (the rejected abort_after - // design would trip this). - let caller = AbortSignal::new(); - let model = ProbeModel { - second_part_is_output: false, - }; - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(caller.clone()), - timeout: Some(TimeoutConfiguration { - total_ms: Some(5_000), - chunk_ms: Some(1_000), - ..Default::default() - }), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - let first = result.stream.next().await.expect("first part"); - assert!(first.is_ok()); - drop(result); - - // Sail far past every configured deadline. - tokio::time::sleep(Duration::from_secs(60)).await; - assert!(!caller.is_aborted()); -} diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index 9e6988aa1..95959356d 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -2,19 +2,8 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use aimux_core::error::AiMuxError; -use aimux_core::generate::{ - GenerateTextOptions, generate_text, generate_text_as_openai, stream_text, stream_text_as_openai, -}; -use aimux_core::language_model::LanguageModel; -use aimux_core::openai_output::OpenAiStreamOptions; -use aimux_core::options::CallOptions; use aimux_core::parse_tool_call::{ToolCallRepair, parse_tool_call}; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, RawToolCall, Tool, ToolCall}; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; -use async_trait::async_trait; -use futures::StreamExt; +use aimux_core::tool::{FunctionTool, RawToolCall, Tool}; use serde_json::json; fn weather_tool() -> Tool { @@ -304,410 +293,3 @@ fn weather_tool_schema() -> serde_json::Value { "additionalProperties": false }) } - -struct RawToolModel { - tool_name: &'static str, - input: &'static str, - leading_text: bool, - stream_input: bool, -} - -impl RawToolModel { - fn new(input: &'static str) -> Self { - Self { - tool_name: "weather", - input, - leading_text: false, - stream_input: false, - } - } - - fn named(mut self, tool_name: &'static str) -> Self { - self.tool_name = tool_name; - self - } - - fn with_streamed_input(mut self) -> Self { - self.stream_input = true; - self - } -} - -#[async_trait] -impl LanguageModel for RawToolModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "raw-tool-model" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::ToolCall(RawToolCall { - tool_call_id: "call-1".into(), - tool_name: self.tool_name.into(), - input: self.input.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - })], - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: Some("tool_calls".into()), - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let mut parts = vec![Ok(StreamPart::StreamStart { warnings: vec![] })]; - if self.leading_text { - parts.push(Ok(StreamPart::TextDelta { - id: "text-1".into(), - delta: "ready".into(), - provider_metadata: None, - })); - } - if self.stream_input { - parts.extend([ - Ok(StreamPart::ToolInputStart { - id: "call-1".into(), - tool_name: self.tool_name.into(), - provider_executed: None, - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::ToolInputDelta { - id: "call-1".into(), - delta: self.input.into(), - provider_metadata: None, - }), - Ok(StreamPart::ToolInputEnd { - id: "call-1".into(), - provider_metadata: None, - }), - ]); - } - parts.extend([ - Ok(StreamPart::ToolCall(RawToolCall { - tool_call_id: "call-1".into(), - tool_name: self.tool_name.into(), - input: self.input.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - })), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: Some("tool_calls".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]); - Ok(StreamResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -#[tokio::test] -async fn generate_text_parses_provider_raw_input_at_the_core_boundary() { - let model = RawToolModel::new(r#"{"city":"Singapore"}"#); - let result = generate_text( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(result.tool_calls[0].input, json!({ "city": "Singapore" })); - assert_eq!(result.tool_calls[0].invalid, None); - assert!(matches!( - &result.raw.content[0], - GenerateContent::ToolCall(RawToolCall { input, .. }) - if input == r#"{"city":"Singapore"}"# - )); -} - -#[tokio::test] -async fn stream_text_parses_provider_raw_input_at_the_core_boundary() { - let model = RawToolModel::new(r#"{"city":"Singapore"}"#); - let mut result = stream_text( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - ) - .await - .unwrap(); - - while let Some(part) = result.stream.next().await { - if let StreamPart::ToolCall(ToolCall { input, invalid, .. }) = part.unwrap() { - assert_eq!(input, json!({ "city": "Singapore" })); - assert_eq!(invalid, None); - return; - } - } - panic!("expected a parsed tool call"); -} - -#[tokio::test] -async fn openai_outputs_preserve_invalid_raw_tool_arguments() { - let raw_input = r#"{"city":"Singapore"#; - let model = RawToolModel::new(raw_input); - let completion = generate_text_as_openai( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - ) - .await - .unwrap(); - assert_eq!( - completion.choices[0].message.tool_calls.as_ref().unwrap()[0] - .function - .arguments, - raw_input - ); - - let result = stream_text_as_openai( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - OpenAiStreamOptions::default(), - ) - .await - .unwrap(); - let chunks = result - .stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .unwrap(); - - let arguments = chunks - .iter() - .flat_map(|chunk| &chunk.choices) - .filter_map(|choice| choice.delta.tool_calls.as_ref()) - .flatten() - .find_map(|tool_call| tool_call.function.arguments.as_deref()) - .expect("expected complete tool call arguments"); - assert_eq!(arguments, raw_input); -} - -/// RFC-0035: the non-streaming OpenAI output reflects repair; the stream does -/// not — tool-input deltas are the provider's text, forwarded as they arrive, -/// exactly as in the AI SDK. -#[tokio::test] -async fn openai_repair_shows_in_the_completion_but_not_in_the_stream() { - let repair = ToolCallRepair::new(|context| async move { - Ok(Some(RawToolCall { - tool_name: "weather".into(), - input: r#"{"city":"Singapore","days":3}"#.into(), - ..context.tool_call - })) - }); - let model = RawToolModel::new(r#"{"place":"Singapore"}"#) - .named("forecast") - .with_streamed_input(); - let options = GenerateTextOptions { - tools: Some(vec![weather_tool()]), - repair_tool_call: Some(repair.clone()), - ..Default::default() - }; - - let completion = generate_text_as_openai(&model, "weather", options) - .await - .unwrap(); - let non_stream_call = &completion.choices[0].message.tool_calls.as_ref().unwrap()[0]; - - let stream = stream_text_as_openai( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - repair_tool_call: Some(repair), - ..Default::default() - }, - OpenAiStreamOptions::default(), - ) - .await - .unwrap(); - let chunks = stream - .stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .unwrap(); - let stream_calls = chunks - .iter() - .flat_map(|chunk| &chunk.choices) - .filter_map(|choice| choice.delta.tool_calls.as_ref()) - .flatten() - .collect::>(); - assert_eq!(non_stream_call.function.name, "weather"); - assert_eq!( - non_stream_call.function.arguments, - r#"{"city":"Singapore","days":3}"# - ); - - // The stream opened on the provider's name before repair could run, and - // its deltas carry the provider's own text. - assert_eq!(stream_calls[0].function.name.as_deref(), Some("forecast")); - let streamed_arguments = stream_calls - .iter() - .filter_map(|call| call.function.arguments.as_deref()) - .collect::(); - assert_eq!(streamed_arguments, r#"{"place":"Singapore"}"#); -} - -#[tokio::test] -async fn openai_stream_without_repair_preserves_provider_tool_input_deltas() { - let model = RawToolModel::new(r#"{"city":"Singapore"}"#).with_streamed_input(); - let result = stream_text_as_openai( - &model, - "weather", - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - OpenAiStreamOptions::default(), - ) - .await - .unwrap(); - let chunks = result - .stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .unwrap(); - let tool_calls = chunks - .iter() - .flat_map(|chunk| &chunk.choices) - .filter_map(|choice| choice.delta.tool_calls.as_ref()) - .flatten() - .collect::>(); - - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0].id.as_deref(), Some("call-1")); - assert_eq!(tool_calls[0].function.name.as_deref(), Some("weather")); - assert_eq!(tool_calls[0].function.arguments.as_deref(), Some("")); - assert_eq!(tool_calls[1].id, None); - assert_eq!(tool_calls[1].function.name, None); - assert_eq!( - tool_calls[1].function.arguments.as_deref(), - Some(r#"{"city":"Singapore"}"#) - ); -} - -async fn assert_openai_arguments( - model: &RawToolModel, - options: GenerateTextOptions, - expected: &str, -) { - let completion = generate_text_as_openai(model, "weather", options.clone()) - .await - .unwrap(); - let call = &completion.choices[0].message.tool_calls.as_ref().unwrap()[0]; - assert_eq!(call.function.name, model.tool_name); - assert_eq!(call.function.arguments, expected); - - let result = stream_text_as_openai(model, "weather", options, OpenAiStreamOptions::default()) - .await - .unwrap(); - let chunks = result - .stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .unwrap(); - let calls: Vec<_> = chunks - .iter() - .flat_map(|chunk| &chunk.choices) - .filter_map(|choice| choice.delta.tool_calls.as_ref()) - .flatten() - .collect(); - assert_eq!( - calls.iter().find_map(|call| call.function.name.as_deref()), - Some(model.tool_name) - ); - let arguments: String = calls - .iter() - .filter_map(|call| call.function.arguments.as_deref()) - .collect(); - assert_eq!(arguments, expected); -} - -#[tokio::test] -async fn invalid_repair_keeps_original_arguments_with_original_name() { - for name in ["forecast", "weather"] { - let repair = ToolCallRepair::new(|context| async move { - Ok(Some(RawToolCall { - tool_name: "weather".into(), - input: r#"{"city":7}"#.into(), - ..context.tool_call - })) - }); - let model = RawToolModel::new(r#"{"place":"Tokyo"}"#) - .named(name) - .with_streamed_input(); - assert_openai_arguments( - &model, - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - repair_tool_call: Some(repair), - ..Default::default() - }, - r#"{"place":"Tokyo"}"#, - ) - .await; - } -} - -#[tokio::test] -async fn unknown_tool_preserves_raw_arguments_with_and_without_input_deltas() { - for input in [r#""hello""#, "hello", "null", " { \"city\": \"Tokyo\" } "] { - for deltas in [false, true] { - let mut model = RawToolModel::new(input).named("unknown"); - model.stream_input = deltas; - assert_openai_arguments( - &model, - GenerateTextOptions { - tools: Some(vec![weather_tool()]), - ..Default::default() - }, - input, - ) - .await; - } - } -} diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index b60842765..33b50519e 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -468,7 +468,9 @@ 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::InvalidResponseData(_) => AIMUX_E_INVALID_RESPONSE_DATA, + AiMuxError::InvalidResponseData(_) | AiMuxError::ToolCallNotFoundForApproval { .. } => { + AIMUX_E_INVALID_RESPONSE_DATA + } AiMuxError::NoSuchTool { .. } => AIMUX_E_NO_SUCH_TOOL, AiMuxError::InvalidToolInput { .. } => AIMUX_E_INVALID_TOOL_INPUT, AiMuxError::ToolCallRepair { .. } => AIMUX_E_TOOL_CALL_REPAIR, @@ -4057,227 +4059,6 @@ mod tests { } } - /// A tiny mock model for composite-FFI tests (returns fixed text). - struct MockText { - provider: &'static str, - model_id: &'static str, - text: &'static str, - } - #[async_trait::async_trait] - impl aimux_core::LanguageModel for MockText { - fn provider(&self) -> &str { - self.provider - } - fn model_id(&self) -> &str { - self.model_id - } - async fn do_generate( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - Ok(aimux_core::result::GenerateResult { - content: vec![aimux_core::result::GenerateContent::Text { - text: self.text.into(), - provider_metadata: None, - }], - finish_reason: aimux_core::types::FinishReason { - unified: aimux_core::types::FinishReasonUnified::Stop, - raw: None, - }, - usage: aimux_core::types::Usage::default(), - warnings: vec![], - provider_metadata: None, - response: aimux_core::types::ResponseMetadata { - model_id: Some(self.model_id.into()), - ..Default::default() - }, - request_body: None, - response_headers: None, - }) - } - async fn do_stream( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - unimplemented!() - } - } - - fn mock_handle(provider: &'static str, model_id: &'static str, text: &'static str) -> u64 { - intern_model(Arc::new(MockText { - provider, - model_id, - text, - })) - } - - /// Streams: delta, recoverable Err(JsonParse), delta, Finish. - struct RecoverableFrameModel; - #[async_trait::async_trait] - impl aimux_core::LanguageModel for RecoverableFrameModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "recoverable" - } - async fn do_generate( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - unimplemented!() - } - async fn do_stream( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - let delta = |text: &str| aimux_core::stream_part::StreamPart::TextDelta { - id: "1".into(), - delta: text.into(), - provider_metadata: None, - }; - Ok(aimux_core::result::StreamResult { - stream: Box::pin(futures::stream::iter([ - Ok(delta("a")), - Err(AiMuxError::JsonParse("bad frame".into())), - Ok(delta("b")), - Ok(aimux_core::stream_part::StreamPart::Finish { - finish_reason: aimux_core::types::FinishReason { - unified: aimux_core::types::FinishReasonUnified::Stop, - raw: None, - }, - usage: aimux_core::types::Usage::default(), - provider_metadata: None, - }), - ])), - request_body: None, - response_headers: None, - }) - } - } - - /// The pump must deliver a recoverable frame error as a StreamPart::Error - /// data part and keep going — parts after it still arrive and on_done - /// fires (the pre-fix pump returned the error and skipped on_done). - #[test] - fn recoverable_frame_error_does_not_end_the_ffi_stream() { - struct Collected { - parts: Vec, - done: bool, - } - extern "C-unwind" fn on_part(json: *const c_char, ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - let text = unsafe { std::ffi::CStr::from_ptr(json) } - .to_string_lossy() - .into_owned(); - collected.parts.push(text); - } - extern "C-unwind" fn on_done(ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - collected.done = true; - } - - let handle = intern_model(Arc::new(RecoverableFrameModel)); - let mut collected = Collected { - parts: Vec::new(), - done: false, - }; - let prompt = std::ffi::CString::new("\"hi\"").unwrap(); - let e = aimux_stream_text( - handle, - prompt.as_ptr(), - std::ptr::null(), - Some(on_part), - Some(on_done), - &mut collected as *mut Collected as *mut c_void, - ); - assert!(e.is_null(), "{}", msg(e)); - assert!( - collected.done, - "on_done must fire after a recoverable frame" - ); - let error_index = collected - .parts - .iter() - .position(|p| p.contains("\"Error\"")) - .expect("the recoverable frame arrives as a StreamPart::Error part"); - assert!( - collected.parts[error_index + 1..] - .iter() - .any(|p| p.contains("\"b\"")), - "parts after the recoverable frame still arrive: {:?}", - collected.parts - ); - } - - /// On the OpenAI-compatible path every item is a ChatCompletionChunk, so a - /// recoverable frame error is skipped — no error item, no truncation, and - /// on_done still fires. - #[test] - fn recoverable_frame_error_is_skipped_on_the_openai_stream() { - struct Collected { - parts: Vec, - done: bool, - } - extern "C-unwind" fn on_part(json: *const c_char, ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - let text = unsafe { std::ffi::CStr::from_ptr(json) } - .to_string_lossy() - .into_owned(); - collected.parts.push(text); - } - extern "C-unwind" fn on_done(ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - collected.done = true; - } - - let handle = intern_model(Arc::new(RecoverableFrameModel)); - let mut collected = Collected { - parts: Vec::new(), - done: false, - }; - let prompt = std::ffi::CString::new("\"hi\"").unwrap(); - let e = aimux_stream_text_as_openai( - handle, - prompt.as_ptr(), - std::ptr::null(), - Some(on_part), - Some(on_done), - &mut collected as *mut Collected as *mut c_void, - ); - assert!(e.is_null(), "{}", msg(e)); - assert!( - collected.done, - "on_done must fire after a recoverable frame" - ); - assert!( - collected.parts.iter().all(|p| !p.contains("\"error\"")), - "no error item may ride the chunk-typed wire: {:?}", - collected.parts - ); - assert!( - collected.parts.iter().any(|p| p.contains("\"b\"")), - "chunks after the recoverable frame still arrive: {:?}", - collected.parts - ); - } - - #[test] - fn router_new_builds_router_model() { - let handles = [ - mock_handle("mock", "primary", "primary-out"), - mock_handle("mock", "backup", "backup-out"), - ]; - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), std::ptr::null(), &mut h); - assert!(e.is_null(), "{}", msg(e)); - assert!(h != 0, "router handle must be non-zero"); - // Confirm it resolves to a model. - let model = get_model(h).expect("router handle resolves"); - assert_eq!(model.provider(), "router"); - assert_eq!(model.model_id(), "router"); - } - #[test] fn router_new_rejects_empty_handles() { // len == 0 (with or without a NULL array) is "no usable child", not a @@ -4294,112 +4075,6 @@ mod tests { assert_eq!(expect_ffi_error(e), "handles: must not be NULL"); } - #[test] - fn router_new_rejects_bad_json() { - let handles = [mock_handle("mock", "m", "x")]; - let bad = std::ffi::CString::new("{not json").unwrap(); - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), bad.as_ptr(), &mut h); - assert_eq!(h, 0); - assert!(expect_ffi_error(e).starts_with("config_json: invalid JSON:")); - } - - #[test] - fn router_new_treats_empty_config_as_defaults() { - // S1 guard: empty string / "null" config must NOT be a JSON_PARSE error - // (consistent with other config-bearing FFI entry points). - let handles = [mock_handle("mock", "m", "x")]; - for cfg in ["", " ", "null"] { - let c = std::ffi::CString::new(cfg).unwrap(); - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), c.as_ptr(), &mut h); - assert!(e.is_null(), "router with config {cfg:?}: {}", msg(e)); - assert!(h != 0); - } - } - - #[test] - fn moa_new_builds_moa_model() { - let refs = [ - mock_handle("mock", "ref-a", "A"), - mock_handle("mock", "ref-b", "B"), - ]; - let agg = mock_handle("mock", "aggregator", "agg"); - let mut h = zero_err(); - let e = aimux_moa_new(refs.as_ptr(), refs.len(), agg, std::ptr::null(), &mut h); - assert!(e.is_null(), "{}", msg(e)); - assert!(h != 0, "moa handle must be non-zero"); - let model = get_model(h).expect("moa handle resolves"); - assert_eq!(model.provider(), "moa"); - assert_eq!(model.model_id(), "moa"); - } - - #[test] - fn moa_new_rejects_bad_aggregator() { - let refs = [mock_handle("mock", "ref-a", "A")]; - let mut h = zero_err(); - // aggregator handle 999999 does not exist. - let e = aimux_moa_new(refs.as_ptr(), refs.len(), 999_999, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired moa aggregator handle" - ); - } - - #[test] - fn composites_reject_any_dead_member_handle() { - // A dead handle inside the array is a caller bug: reported, never - // silently dropped (the composite would otherwise run with fewer - // members than the caller believes). - let live = mock_handle("mock", "live", "L"); - let dead = mock_handle("mock", "dead", "D"); - aimux_drop_handle(dead); - let handles = [live, dead]; - - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired router child handle" - ); - - let agg = mock_handle("mock", "aggregator", "agg"); - let e = aimux_moa_new( - handles.as_ptr(), - handles.len(), - agg, - std::ptr::null(), - &mut h, - ); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired moa reference handle" - ); - - // NULL with ref_len > 0 is a null pointer, same as router. - let e = aimux_moa_new(std::ptr::null(), 2, agg, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!(expect_ffi_error(e), "reference_handles: must not be NULL"); - } - - #[test] - fn moa_new_allows_zero_references() { - // 0 references is valid (degrades to aggregator-only). - let agg = mock_handle("mock", "aggregator", "agg"); - let mut h = zero_err(); - let e = aimux_moa_new(std::ptr::null(), 0, agg, std::ptr::null(), &mut h); - assert!(e.is_null(), "{}", msg(e)); - assert!(h != 0, "moa with 0 references should succeed"); - } - - // ── Transcription streaming sessions (RFC-0028 Phase 2) ────────────── - - /// A mock transcription model whose `do_stream` echoes: one delta per - /// received audio chunk, then final + finish after the audio ends. - /// Honors the abort signal. struct MockStreamingTranscriber; #[async_trait::async_trait] @@ -4715,50 +4390,4 @@ mod tests { pusher.join().unwrap(); drop_handle(model); } - - #[test] - fn transcription_session_new_rejects_bad_inputs() { - let model = mock_transcriber_handle(); - let mut h = zero_err(); - // Bad JSON. - let bad = std::ffi::CString::new("{not json").unwrap(); - let e = aimux_transcription_session_new(model, 0, bad.as_ptr(), &mut h); - assert_eq!(h, 0); - assert!(expect_ffi_error(e).starts_with("opts_json: invalid JSON:")); - - // Non-transcription model handle. - let lang = mock_handle("mock", "m", "x"); - let e = aimux_transcription_session_new(lang, 0, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired transcription handle" - ); - - // Bad abort handle. - let e = aimux_transcription_session_new(model, 999_999, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!(expect_ffi_error(e), "invalid or expired abort handle"); - - // NULL out-params on next_part. - let mut part: *mut c_char = std::ptr::null_mut(); - assert_eq!( - expect_ffi_error(aimux_transcription_next_part( - 0, - 0, - std::ptr::null_mut(), - std::ptr::null_mut() - )), - "out_part: must not be NULL" - ); - assert_eq!( - expect_ffi_error(aimux_transcription_next_part( - 0, - 0, - &mut part, - std::ptr::null_mut() - )), - "out_state: must not be NULL" - ); - } } diff --git a/aimux-provider-utils/src/lib.rs b/aimux-provider-utils/src/lib.rs index fa33eb5df..08ade7f61 100644 --- a/aimux-provider-utils/src/lib.rs +++ b/aimux-provider-utils/src/lib.rs @@ -8,7 +8,8 @@ //! `@ai-sdk/provider-utils`. Operation retry and timeout live in `aimux-core`. pub mod api_key; -mod download_guard; +#[doc(hidden)] +pub use aimux_core::download_guard; pub mod extract_response_headers; pub mod get_from_api; pub mod handle_fetch_error; diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index c6e98ca52..4620a1ae6 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -669,6 +669,9 @@ fn convert_part_to_anthropic( }; apply_cc(block, cc) } + ContentPart::Custom { .. } + | ContentPart::ReasoningFile { .. } + | ContentPart::ToolApprovalRequest { .. } => return Ok(None), })) } diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index e637015af..55b764799 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -27,7 +27,7 @@ use aimux_core::error::AiMuxError; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::Warning; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, TokenUsage, Usage}; +use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; use aimux_provider_utils::{HttpBody, HttpRequest}; use serde_json::{Value, json}; @@ -848,6 +848,7 @@ pub(crate) async fn anthropic_generate_core( ) .await?; + let response_body = resp.raw_value; let data: AnthropicResponse = resp.value; let content = parse_anthropic_content(&data.content, tool_names); @@ -872,13 +873,16 @@ pub(crate) async fn anthropic_generate_core( usage, warnings, provider_metadata: None, - response: ResponseMetadata { - id: Some(data.id), - timestamp: None, - model_id: Some(data.model), - }, - request_body: Some(body), - response_headers: None, + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: None, + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: Some(data.id), + timestamp: None, + model_id: Some(data.model), + }) + }), }) } @@ -1332,11 +1336,10 @@ pub(crate) async fn anthropic_stream_core( let text_tokens = reasoning_tokens .zip(output_total) .map(|(r, t)| t.saturating_sub(r)); - final_usage.output_tokens = TokenUsage { + final_usage.output_tokens = aimux_core::types::OutputTokenUsage { total: output_total, text: text_tokens, reasoning: reasoning_tokens, - ..Default::default() }; } } @@ -1398,7 +1401,9 @@ pub(crate) async fn anthropic_stream_core( Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } diff --git a/aimux-providers/src/anthropic/usage.rs b/aimux-providers/src/anthropic/usage.rs index 9f991357c..a132f06d0 100644 --- a/aimux-providers/src/anthropic/usage.rs +++ b/aimux-providers/src/anthropic/usage.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use serde_json::Value; -use aimux_core::types::{TokenUsage, Usage}; +use aimux_core::types::Usage; use crate::anthropic::types::AnthropicUsage; @@ -148,20 +148,18 @@ pub fn usage_from_anthropic(usage: &AnthropicUsage) -> Usage { Ok(v) => { let r = convert_anthropic_usage(&v, None); Usage { - input_tokens: TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(r.input_tokens.total as u32), no_cache: Some(r.input_tokens.no_cache as u32), cache_read: Some(r.input_tokens.cache_read as u32), cache_write: Some(r.input_tokens.cache_write as u32), - ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(r.output_tokens.total as u32), text: r.output_tokens.text.map(|t| t as u32), reasoning: r.output_tokens.reasoning.map(|t| t as u32), - ..Default::default() }, - raw: Some(r.raw), + raw: r.raw.as_object().cloned(), } } Err(_) => Usage::default(), diff --git a/aimux-providers/src/azure/responses.rs b/aimux-providers/src/azure/responses.rs index 17926a4ff..31af23892 100644 --- a/aimux-providers/src/azure/responses.rs +++ b/aimux-providers/src/azure/responses.rs @@ -347,8 +347,10 @@ impl LanguageModel for AzureResponsesModel { Ok(StreamResult { stream, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/bedrock/convert.rs b/aimux-providers/src/bedrock/convert.rs index 9b7d8dc5c..010705998 100644 --- a/aimux-providers/src/bedrock/convert.rs +++ b/aimux-providers/src/bedrock/convert.rs @@ -642,7 +642,7 @@ pub fn map_finish_reason(reason: &str) -> FinishReason { /// cases); `outputTokens.text` mirrors the TS `outputTokens.text` field. #[must_use] pub fn convert_usage(usage: Option<&BedrockUsage>) -> aimux_core::types::Usage { - use aimux_core::types::{TokenUsage, Usage}; + use aimux_core::types::Usage; let Some(usage) = usage else { return Usage::default(); @@ -654,20 +654,21 @@ pub fn convert_usage(usage: Option<&BedrockUsage>) -> aimux_core::types::Usage { let cache_write = usage.cache_write_input_tokens.unwrap_or(0); Usage { - input_tokens: TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(input + cache_read + cache_write), no_cache: Some(input), cache_read: Some(cache_read), cache_write: Some(cache_write), - ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(output), text: Some(output), ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index be387cc87..34f062efa 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -214,6 +214,7 @@ impl LanguageModel for BedrockModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: BedrockConverseResponse = resp.value; @@ -250,13 +251,16 @@ impl LanguageModel for BedrockModel { usage, warnings: Vec::new(), provider_metadata: None, - response: ResponseMetadata { - id: request_id, - timestamp: response_headers.get("date").cloned(), - model_id: Some(self.model_id.clone()), - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + 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()), + }) + }), }) } @@ -612,8 +616,10 @@ impl LanguageModel for BedrockModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/codex.rs b/aimux-providers/src/codex.rs index ccf99d3e1..64bb10f7f 100644 --- a/aimux-providers/src/codex.rs +++ b/aimux-providers/src/codex.rs @@ -425,8 +425,10 @@ impl CodexModel { Ok(StreamResult { stream, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index 1f3cad8c3..2163edcb1 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -63,19 +63,20 @@ impl CohereModel { /// - `output.total = output_tokens` fn convert_usage(tokens: &TokenPair) -> Usage { Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(tokens.input_tokens), no_cache: Some(tokens.input_tokens), cache_read: None, cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(tokens.output_tokens), ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(tokens).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(tokens) + .ok() + .and_then(|value| value.as_object().cloned()), } } @@ -138,6 +139,7 @@ impl LanguageModel for CohereModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: ChatResponse = resp.value; @@ -249,13 +251,16 @@ impl LanguageModel for CohereModel { usage, warnings: body_result.warnings, provider_metadata: None, - response: ResponseMetadata { - id: data.generation_id, - timestamp: None, - model_id: None, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.generation_id, + timestamp: None, + model_id: None, + }) + }), }) } @@ -574,8 +579,10 @@ impl LanguageModel for CohereModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index c87514155..7b6b587c6 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -257,6 +257,26 @@ fn convert_assistant_parts( parts.push(p); } } + ContentPart::ReasoningFile { + data: aimux_core::shared::GeneratedFileData::Data { data }, + media_type, + provider_options, + } => { + use base64::Engine; + let data = match data { + aimux_core::shared::FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + aimux_core::shared::FileBytes::Base64(data) => data.clone(), + }; + let mut value = json!({ "inlineData": { "mimeType": media_type, "data": data }, "thought": true }); + if let Some(signature) = read_provider_options(provider_options.as_ref(), namespace) + .and_then(|options| options.get("thoughtSignature")) + { + value["thoughtSignature"] = signature.clone(); + } + parts.push(value); + } ContentPart::ToolCall { tool_call_id, tool_name, @@ -1155,7 +1175,7 @@ pub fn parse_finish_reason(reason: &str, has_tool_calls: bool) -> FinishReason { /// - `output.total = candidatesTokenCount + thoughtsTokenCount` #[must_use] pub fn convert_usage(usage: &super::types::GoogleUsageMetadata) -> aimux_core::types::Usage { - use aimux_core::types::{TokenUsage, Usage}; + use aimux_core::types::Usage; let prompt = usage.prompt_token_count.unwrap_or(0); let candidates = usage.candidates_token_count.unwrap_or(0); @@ -1163,19 +1183,21 @@ pub fn convert_usage(usage: &super::types::GoogleUsageMetadata) -> aimux_core::t let thoughts = usage.thoughts_token_count.unwrap_or(0); Usage { - input_tokens: TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(prompt), no_cache: Some(prompt - cached), cache_read: Some(cached), cache_write: None, - ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(candidates + thoughts), - ..Default::default() + text: Some(candidates), + reasoning: Some(thoughts), }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index 0c295d0f5..b85900d80 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -14,7 +14,7 @@ use aimux_core::options::CallOptions; use aimux_core::result::{ GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, StreamResult, }; -use aimux_core::shared::{FileBytes, FileData}; +use aimux_core::shared::{FileBytes, GeneratedFileData}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{ FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, @@ -123,6 +123,25 @@ impl LanguageModel for GoogleModel { } async fn do_generate(&self, options: &CallOptions) -> Result { + if options + .prompt + .iter() + .filter(|message| message.role == aimux_core::message::Role::Assistant) + .flat_map(|message| &message.content) + .any(|part| { + matches!( + part, + aimux_core::content::ContentPart::ReasoningFile { + data: aimux_core::shared::GeneratedFileData::Url { .. }, + .. + } + ) + }) + { + return Err(AiMuxError::UnsupportedFunctionality( + "File data URLs in assistant messages are not supported".to_string(), + )); + } let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); let (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options); let headers = self.build_headers(options.headers.as_ref()); @@ -138,6 +157,7 @@ impl LanguageModel for GoogleModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: GenerateContentResponse = resp.value; @@ -183,17 +203,39 @@ impl LanguageModel for GoogleModel { usage, warnings: tool_warnings, provider_metadata, - response: ResponseMetadata { - id: data.response_id, - timestamp: None, - model_id: None, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.response_id, + timestamp: None, + model_id: None, + }) + }), }) } async fn do_stream(&self, options: &CallOptions) -> Result { + if options + .prompt + .iter() + .filter(|message| message.role == aimux_core::message::Role::Assistant) + .flat_map(|message| &message.content) + .any(|part| { + matches!( + part, + aimux_core::content::ContentPart::ReasoningFile { + data: aimux_core::shared::GeneratedFileData::Url { .. }, + .. + } + ) + }) + { + return Err(AiMuxError::UnsupportedFunctionality( + "File data URLs in assistant messages are not supported".to_string(), + )); + } let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); let (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options); let headers = self.build_headers(options.headers.as_ref()); @@ -573,13 +615,12 @@ impl LanguageModel for GoogleModel { inline.get("data").and_then(|v| v.as_str()), inline.get("mimeType").and_then(|v| v.as_str()), ) { - // part.thought === true → upstream emits 'reasoning-file'; - // emit plain File (reasoning-file is a separate PR). - yield Ok(StreamPart::File(GeneratedFile { - data: FileData::Data { data: FileBytes::Base64(data.to_string()) }, + let file = GeneratedFile { + data: GeneratedFileData::Data { data: FileBytes::Base64(data.to_string()) }, media_type: mime.to_string(), provider_metadata: thought_sig_meta.clone(), - })); + }; + yield Ok(if part.get("thought").and_then(serde_json::Value::as_bool).unwrap_or(false) { StreamPart::ReasoningFile(file) } else { StreamPart::File(file) }); } } } @@ -665,8 +706,10 @@ impl LanguageModel for GoogleModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } @@ -822,15 +865,24 @@ fn extract_content_from_candidate( inline.get("data").and_then(|v| v.as_str()), inline.get("mimeType").and_then(|v| v.as_str()), ) { - // part.thought === true → upstream emits 'reasoning-file'; - // emit plain File (reasoning-file is a separate PR). - content.push(GenerateContent::File(GeneratedFile { - data: FileData::Data { + let file = GeneratedFile { + data: GeneratedFileData::Data { data: FileBytes::Base64(data.to_string()), }, media_type: mime.to_string(), provider_metadata: thought_sig_meta.clone(), - })); + }; + content.push( + if part + .get("thought") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + GenerateContent::ReasoningFile(file) + } else { + GenerateContent::File(file) + }, + ); } } else if let Some(tc) = part.get("toolCall") { // Server-side tool call (provider-executed, Gemini 3). @@ -920,6 +972,16 @@ fn set_provider_metadata(item: &mut GenerateContent, meta: ProviderMetadata) { | GenerateContent::Source(Source { provider_metadata, .. }) + | GenerateContent::ReasoningFile(GeneratedFile { + provider_metadata, .. + }) + | GenerateContent::Custom { + provider_metadata, .. + } + | GenerateContent::ToolApprovalRequest(aimux_core::result::RawToolApprovalRequest { + provider_metadata, + .. + }) | GenerateContent::ToolResult(ToolResult { provider_metadata, .. }) => { diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index e0ad79fbe..df046b3dd 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -33,9 +33,7 @@ use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::Tool; -use aimux_core::types::{ - FinishReason, FinishReasonUnified, ResponseMetadata, TokenUsage, Usage, Warning, -}; +use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage, Warning}; use aimux_provider_utils::HttpRequest; @@ -196,6 +194,7 @@ impl LanguageModel for HuggingFaceResponsesModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let response: Value = resp.value; @@ -225,13 +224,16 @@ impl LanguageModel for HuggingFaceResponsesModel { provider_metadata: Some(json!({ "huggingface": { "responseId": response_id } })), - response: ResponseMetadata { - id: response_id, - timestamp: format_timestamp(created_at), - model_id: model, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: response_id, + timestamp: format_timestamp(created_at), + model_id: model, + }) + }), }) } @@ -555,8 +557,10 @@ impl LanguageModel for HuggingFaceResponsesModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } @@ -1274,21 +1278,19 @@ fn convert_usage(usage: Option<&Value>) -> Usage { .unwrap_or(0) as u32; Usage { - input_tokens: TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(input_tokens), no_cache: Some(input_tokens - cached_tokens), cache_read: Some(cached_tokens), cache_write: None, - ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(output_tokens), text: Some(output_tokens - reasoning_tokens), reasoning: Some(reasoning_tokens), - ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(u.clone()), + raw: u.as_object().cloned(), } } diff --git a/aimux-providers/src/mistral/convert.rs b/aimux-providers/src/mistral/convert.rs index 88d737c86..c2c954923 100644 --- a/aimux-providers/src/mistral/convert.rs +++ b/aimux-providers/src/mistral/convert.rs @@ -273,7 +273,10 @@ fn convert_part_to_mistral(part: &ContentPart) -> Value { ContentPart::FileBase64 { .. } | ContentPart::FileUrl { .. } | ContentPart::FileReference { .. } - | ContentPart::Reasoning { .. } => Value::Null, + | ContentPart::Reasoning { .. } + | ContentPart::Custom { .. } + | ContentPart::ReasoningFile { .. } + | ContentPart::ToolApprovalRequest { .. } => Value::Null, } } diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 2d7f2d080..df4ed7ad9 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -92,7 +92,7 @@ fn convert_usage(usage: &UsageResponse) -> Usage { let no_cache = prompt_tokens - cache_read; Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(prompt_tokens), no_cache: Some(no_cache), cache_read: if cache_read > 0 { @@ -101,14 +101,15 @@ fn convert_usage(usage: &UsageResponse) -> Usage { None }, cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(completion_tokens), ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } @@ -240,6 +241,7 @@ impl LanguageModel for MistralModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: ChatCompletionResponse = resp.value; @@ -305,16 +307,19 @@ impl LanguageModel for MistralModel { usage, warnings: Vec::new(), provider_metadata: None, - response: 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, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + 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, + }) + }), }) } @@ -605,8 +610,10 @@ impl LanguageModel for MistralModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 3e8701d81..a212f4a52 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -327,6 +327,7 @@ impl LanguageModel for OpenResponsesModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let raw: Value = resp.value; @@ -444,9 +445,12 @@ impl LanguageModel for OpenResponsesModel { usage, warnings, provider_metadata: None, - response, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(response) + }), }) } @@ -822,8 +826,10 @@ impl LanguageModel for OpenResponsesModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } @@ -1351,6 +1357,9 @@ fn part_variant_name(part: &ContentPart) -> &'static str { ContentPart::FileUrl { .. } => "file-url", ContentPart::FileReference { .. } => "file-reference", ContentPart::Reasoning { .. } => "reasoning", + ContentPart::Custom { .. } => "custom", + ContentPart::ReasoningFile { .. } => "reasoning-file", + ContentPart::ToolApprovalRequest { .. } => "tool-approval-request", ContentPart::ToolCall { .. } => "tool-call", ContentPart::ToolResult { .. } => "tool-result", } @@ -1389,20 +1398,18 @@ fn extract_usage_from_value(usage: &Value) -> Usage { .map(|n| n as u32); Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: input_tokens, no_cache: Some(input_tokens.unwrap_or(0) - cached_input_tokens.unwrap_or(0)), cache_read: cached_input_tokens, cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: output_tokens, text: Some(output_tokens.unwrap_or(0) - reasoning_tokens.unwrap_or(0)), reasoning: reasoning_tokens, - ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(usage.clone()), + raw: usage.as_object().cloned(), } } diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index 4c018dba1..f41ec9afc 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -884,7 +884,10 @@ fn convert_part_to_openai(part: &ContentPart, index: usize) -> Result Ok(Value::Null), + ContentPart::ToolResult { .. } + | ContentPart::Custom { .. } + | ContentPart::ReasoningFile { .. } + | ContentPart::ToolApprovalRequest { .. } => Ok(Value::Null), } } diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 208fec778..93283085f 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -159,23 +159,21 @@ fn convert_usage(usage: &UsageResponse, usage_raw: Option<&Value>) -> Usage { let text_tokens = completion_tokens.saturating_sub(reasoning_tokens); Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(prompt_tokens), no_cache: Some(no_cache), cache_read: Some(cached), cache_write, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(completion_tokens), text: Some(text_tokens), reasoning: Some(reasoning_tokens), - ..Default::default() }, // M10 (RFC-0016): keep the provider's original usage JSON verbatim — // vendor-specific fields (e.g. Moonshot `cached_tokens`, DeepSeek // `prompt_cache_hit_tokens`) are otherwise lost for audit/billing. - raw: usage_raw.cloned(), + raw: usage_raw.and_then(|value| value.as_object().cloned()), } } @@ -389,16 +387,19 @@ pub async fn execute_generate( usage, warnings: request_result.warnings, provider_metadata, - response: 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), - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: Some(response_value), + 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), + }) + }), }) } @@ -785,8 +786,10 @@ pub async fn execute_stream( Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } diff --git a/aimux-providers/src/openai/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs index fa7c99f7d..b4e72e4d8 100644 --- a/aimux-providers/src/openai/responses/convert.rs +++ b/aimux-providers/src/openai/responses/convert.rs @@ -75,6 +75,7 @@ pub fn convert_to_responses_input( system_message_mode: SystemMessageMode, store: bool, has_previous_response_id: bool, + has_conversation: bool, ) -> ResponsesInputResult { let mut input: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); @@ -107,6 +108,23 @@ pub fn convert_to_responses_input( Role::Assistant => { for part in &msg.content { match part { + ContentPart::Custom { + kind, + provider_options, + } if kind == "openai.compaction" => { + if let Some(id) = item_id(provider_options) { + if has_conversation { + continue; + } + if store { + input.push(json!({ "type": "item_reference", "id": id })); + } else { + input.push(json!({ "type": "compaction", "id": id, + "encrypted_content": openai_sub_option(provider_options, "encryptedContent") })); + } + } + } + ContentPart::Text { text, provider_options, @@ -864,6 +882,7 @@ pub fn build_responses_request_body( system_message_mode, store_bool, has_previous_response_id, + openai_option(provider_opts, "conversation").is_some(), ); warnings.extend(input_result.warnings); @@ -957,7 +976,7 @@ pub fn build_responses_request_body( pub fn convert_responses_usage(usage: Option<&ResponsesUsage>, raw: Option) -> Usage { let Some(usage) = usage else { return Usage { - raw, + raw: raw.and_then(|value| value.as_object().cloned()), ..Default::default() }; }; @@ -984,20 +1003,18 @@ pub fn convert_responses_usage(usage: Option<&ResponsesUsage>, raw: Option { + content.push(GenerateContent::Custom { + kind: "openai.compaction".to_string(), + provider_metadata: Some(json!({ provider_key.clone(): { + "type": "compaction", "itemId": part.get("id"), + "encryptedContent": part.get("encrypted_content"), + }})), + }); + } + Some("mcp_approval_request") => { + let tool_call_id = generate_source_id(); + content.push(GenerateContent::ToolCall(RawToolCall { + tool_call_id: tool_call_id.clone(), + tool_name: format!( + "mcp.{}", + part.get("name").and_then(Value::as_str).unwrap_or("") + ), + input: part + .get("arguments") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + provider_executed: Some(true), + dynamic: Some(true), + thought_signature: None, + provider_metadata: None, + })); + content.push(GenerateContent::ToolApprovalRequest( + aimux_core::result::RawToolApprovalRequest { + approval_id: part + .get("approval_request_id") + .or_else(|| part.get("id")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + tool_call_id, + provider_metadata: None, + }, + )); + } _ => {} } } @@ -310,13 +350,16 @@ pub fn build_responses_generate_result( usage, warnings: request_warnings, provider_metadata, - response: ResponseMetadata { - id: response_id, - timestamp, - model_id: model, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: Some(data.clone()), + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: response_id, + timestamp, + model_id: model, + }) + }), }) } @@ -518,6 +561,8 @@ where .and_then(|v| v.as_str()) .unwrap_or(""); match item_type { + + "message" => { let id = item .get("id") @@ -764,6 +809,30 @@ where .and_then(|v| v.as_str()) .unwrap_or(""); match item_type { + "mcp_approval_request" => { + let tool_call_id = generate_source_id(); + yield Ok(StreamPart::ToolCall(RawToolCall { + tool_call_id: tool_call_id.clone(), + tool_name: format!("mcp.{}", item.get("name").and_then(Value::as_str).unwrap_or("")), + input: item.get("arguments").and_then(Value::as_str).unwrap_or("").to_string(), + provider_executed: Some(true), dynamic: Some(true), + thought_signature: None, provider_metadata: None, + })); + yield Ok(StreamPart::ToolApprovalRequest(aimux_core::result::RawToolApprovalRequest { + approval_id: item.get("approval_request_id").or_else(|| item.get("id")).and_then(Value::as_str).unwrap_or("").to_string(), + tool_call_id, provider_metadata: None, + })); + } + "compaction" => { + yield Ok(StreamPart::Custom { + kind: "openai.compaction".to_string(), + provider_metadata: Some(json!({ provider_key.clone(): { + "type": "compaction", "itemId": item.get("id"), + "encryptedContent": item.get("encrypted_content"), + }})), + }); + } + "message" => { let id = item .get("id") diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index 1b2475148..19914d352 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -196,6 +196,7 @@ impl LanguageModel for VertexAnthropicModel { ) .await?; + let response_body = resp.raw_value; let data: AnthropicResponse = resp.value; let content = crate::anthropic::stream::parse_anthropic_content( @@ -221,13 +222,16 @@ impl LanguageModel for VertexAnthropicModel { usage, warnings: req.warnings, provider_metadata: None, - response: ResponseMetadata { - id: Some(data.id), - timestamp: None, - model_id: Some(data.model), - }, - request_body: Some(body), - response_headers: None, + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: None, + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: Some(data.id), + timestamp: None, + model_id: Some(data.model), + }) + }), }) } @@ -606,8 +610,10 @@ impl LanguageModel for VertexAnthropicModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 0af5cac85..0709b2c0a 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -155,6 +155,25 @@ impl LanguageModel for VertexModel { } async fn do_generate(&self, options: &CallOptions) -> Result { + if options + .prompt + .iter() + .filter(|message| message.role == aimux_core::message::Role::Assistant) + .flat_map(|message| &message.content) + .any(|part| { + matches!( + part, + aimux_core::content::ContentPart::ReasoningFile { + data: aimux_core::shared::GeneratedFileData::Url { .. }, + .. + } + ) + }) + { + return Err(AiMuxError::UnsupportedFunctionality( + "File data URLs in assistant messages are not supported".to_string(), + )); + } let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); let body = build_vertex_request_body(&self.model_id, options); let headers = self.build_headers(options.headers.as_ref()); @@ -174,6 +193,7 @@ impl LanguageModel for VertexModel { ) .await?; + let response_body = resp.raw_value; let response_headers = resp.response_headers; let data: GenerateContentResponse = resp.value; @@ -215,19 +235,41 @@ impl LanguageModel for VertexModel { usage, warnings: Vec::new(), provider_metadata, - response: 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_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + 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, + }) + }), }) } async fn do_stream(&self, options: &CallOptions) -> Result { + if options + .prompt + .iter() + .filter(|message| message.role == aimux_core::message::Role::Assistant) + .flat_map(|message| &message.content) + .any(|part| { + matches!( + part, + aimux_core::content::ContentPart::ReasoningFile { + data: aimux_core::shared::GeneratedFileData::Url { .. }, + .. + } + ) + }) + { + return Err(AiMuxError::UnsupportedFunctionality( + "File data URLs in assistant messages are not supported".to_string(), + )); + } let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); let body = build_vertex_request_body(&self.model_id, options); let headers = self.build_headers(options.headers.as_ref()); @@ -638,8 +680,10 @@ impl LanguageModel for VertexModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/xai/convert.rs b/aimux-providers/src/xai/convert.rs index 3662539d6..10cf2fed4 100644 --- a/aimux-providers/src/xai/convert.rs +++ b/aimux-providers/src/xai/convert.rs @@ -97,21 +97,21 @@ pub fn convert_xai_usage(usage: &XaiUsageResponse) -> aimux_core::types::Usage { let output_total = completion_tokens + reasoning_tokens; aimux_core::types::Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(input_total), no_cache: Some(input_no_cache), cache_read: Some(cache_read), cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(output_total), text: Some(completion_tokens), reasoning: Some(reasoning_tokens), - ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } diff --git a/aimux-providers/src/xai/model.rs b/aimux-providers/src/xai/model.rs index 807acaa3f..f5a3d4644 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 _raw_value = resp.raw_value.unwrap_or(Value::Null); let data = resp.value; let choice = data @@ -207,18 +207,16 @@ impl LanguageModel for XaiModel { }); let usage = data.usage.as_ref().map(convert_xai_usage).unwrap_or(Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(0), no_cache: Some(0), cache_read: Some(0), cache_write: Some(0), - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(0), text: Some(0), reasoning: Some(0), - ..Default::default() }, raw: None, }); @@ -233,13 +231,16 @@ impl LanguageModel for XaiModel { usage, warnings: request_result.warnings, provider_metadata: None, - response: ResponseMetadata { - id: data.id, - timestamp, - model_id: data.model, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: response_body, + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.id, + timestamp, + model_id: data.model, + }) + }), }) } @@ -576,18 +577,16 @@ impl LanguageModel for XaiModel { raw: None, }), usage: final_usage.unwrap_or(Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(0), no_cache: Some(0), cache_read: Some(0), cache_write: Some(0), - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(0), text: Some(0), reasoning: Some(0), - ..Default::default() }, raw: None, }), @@ -597,8 +596,10 @@ impl LanguageModel for XaiModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/src/xai/responses/convert.rs b/aimux-providers/src/xai/responses/convert.rs index 3226bf6e5..e0f304d07 100644 --- a/aimux-providers/src/xai/responses/convert.rs +++ b/aimux-providers/src/xai/responses/convert.rs @@ -64,21 +64,21 @@ pub fn convert_xai_responses_usage(usage: &XaiResponsesUsage) -> aimux_core::typ }; aimux_core::types::Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(input_total as u32), no_cache: Some(input_no_cache as u32), cache_read: Some(cache_read as u32), cache_write: None, - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(usage.output_tokens as u32), text: Some((usage.output_tokens - reasoning) as u32), reasoning: Some(reasoning as u32), - ..Default::default() }, // RFC-0015 P0-3: keep the raw provider usage payload. - raw: Some(serde_json::to_value(usage).unwrap_or(serde_json::Value::Null)), + raw: serde_json::to_value(usage) + .ok() + .and_then(|value| value.as_object().cloned()), } } diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index 62a88beac..91d2ad77f 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -120,7 +120,7 @@ impl LanguageModel for XaiResponsesModel { let response_headers = resp.response_headers; - let _raw_value = resp.raw_value.unwrap_or(Value::Null); + let raw_value = resp.raw_value.unwrap_or(Value::Null); let data = resp.value; let mut content: Vec = Vec::new(); @@ -348,13 +348,16 @@ impl LanguageModel for XaiResponsesModel { usage, warnings: request_result.warnings, provider_metadata, - response: ResponseMetadata { - id: data.id, - timestamp, - model_id: data.model, - }, - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::GenerateResponse { + body: Some(raw_value), + headers: Some(response_headers), + ..aimux_core::result::GenerateResponse::from(ResponseMetadata { + id: data.id, + timestamp, + model_id: data.model, + }) + }), }) } @@ -912,18 +915,16 @@ impl LanguageModel for XaiResponsesModel { yield Ok(StreamPart::Finish { finish_reason: final_finish_reason, usage: final_usage.unwrap_or(Usage { - input_tokens: aimux_core::types::TokenUsage { + input_tokens: aimux_core::types::InputTokenUsage { total: Some(0), no_cache: Some(0), cache_read: Some(0), cache_write: Some(0), - ..Default::default() }, - output_tokens: aimux_core::types::TokenUsage { + output_tokens: aimux_core::types::OutputTokenUsage { total: Some(0), text: Some(0), reasoning: Some(0), - ..Default::default() }, raw: None, }), @@ -933,8 +934,10 @@ impl LanguageModel for XaiResponsesModel { Ok(StreamResult { stream: Box::pin(stream), - request_body: Some(body), - response_headers: Some(response_headers), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), + response: Some(aimux_core::result::StreamResponse { + headers: Some(response_headers), + }), }) } } diff --git a/aimux-providers/tests/alibaba_test.rs b/aimux-providers/tests/alibaba_test.rs index 7afe919c2..0a789cefe 100644 --- a/aimux-providers/tests/alibaba_test.rs +++ b/aimux-providers/tests/alibaba_test.rs @@ -39,7 +39,7 @@ use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; +use aimux_core::types::FinishReasonUnified; use aimux_providers::{ProviderOptions, provider, provider_from_env}; @@ -437,28 +437,6 @@ async fn extracts_usage_with_reasoning_tokens() { // Reasoning (top-level reasoning �?reasoning_effort, shared OpenAI behaviour) // ════════════════════════════════════════════════════════════════════════════ -/// TS: top-level `reasoning: 'high'` is forwarded as `reasoning_effort`. -/// (Alibaba's TS maps this to `enable_thinking`; the Rust shared converter -/// maps it to `reasoning_effort` instead.) -#[tokio::test] -async fn top_level_reasoning_maps_to_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["reasoning_effort"], json!("high")); -} - // ════════════════════════════════════════════════════════════════════════════ // doGenerate / doStream / errors // ════════════════════════════════════════════════════════════════════════════ @@ -642,31 +620,3 @@ async fn status_429_maps_to_rate_limited() { "expected RateLimited, got {result:?}" ); } - -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn exposes_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} diff --git a/aimux-providers/tests/anthropic_aws_model_test.rs b/aimux-providers/tests/anthropic_aws_model_test.rs index d36943a0d..1c0dc0bfe 100644 --- a/aimux-providers/tests/anthropic_aws_model_test.rs +++ b/aimux-providers/tests/anthropic_aws_model_test.rs @@ -10,7 +10,7 @@ use aimux_core::tool::RawToolCall; use futures::StreamExt; use serde_json::{Value, json}; -use wiremock::matchers::{header, method, path}; +use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; @@ -128,29 +128,6 @@ fn text_response(text: &str) -> Value { // doGenerate tests // ═════════════════════════════════════════════════════════════════════════════ -/// Test: non-streaming text generation extracts text, usage, and finish reason. -#[tokio::test] -async fn anthropic_aws_generate_text_response() { - let server = MockServer::start().await; - mock_messages_json(&server, 200, text_response("Hello from Claude on AWS!")).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Claude on AWS!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(30)); - assert_eq!( - result.response.id.as_deref(), - Some("msg_017TfcQ4AgGxKyBduUpqYPZn") - ); -} - /// Test: non-streaming tool call extraction. #[tokio::test] async fn anthropic_aws_generate_tool_call() { @@ -214,36 +191,6 @@ async fn anthropic_aws_generate_error() { assert!(result.is_err(), "should return error for 429 status"); } -/// Test: request body uses Anthropic format with anthropic-version header. -#[tokio::test] -async fn anthropic_aws_request_body_and_headers() { - let server = MockServer::start().await; - - // Mount a mock that also verifies the x-api-key header. - Mock::given(method("POST")) - .and(path("/messages")) - .and(header("x-api-key", "test-api-key")) - .and(header("anthropic-version", "2023-06-01")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response("OK"))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(as_text(&result.content[0]), "OK"); - - // Verify request body format. - let body = result.request_body.expect("should have request body"); - assert_eq!(body["model"], "claude-sonnet-4-20250514"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); - assert_eq!(body["max_tokens"], 64000); // model default for claude-sonnet-4 -} - // ═════════════════════════════════════════════════════════════════════════════ // doStream tests // ═════════════════════════════════════════════════════════════════════════════ @@ -483,27 +430,6 @@ async fn anthropic_aws_generate_finish_reason_unknown() { assert_eq!(result.finish_reason.raw.as_deref(), Some("pause_turn")); } -/// TS: settings — max_tokens override, temperature, top_p land in the body. -#[tokio::test] -async fn anthropic_aws_generate_settings() { - let server = MockServer::start().await; - mock_messages_json(&server, 200, text_response("ok")).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(1024); - opts.temperature = Some(0.5); - opts.top_p = Some(0.9); - opts.stop_sequences = Some(vec!["END".to_string()]); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["max_tokens"], json!(1024)); - assert!((body["temperature"].as_f64().unwrap() - 0.5).abs() < 1e-6); - assert!((body["top_p"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["stop_sequences"], json!(["END"])); -} - /// TS: a 401 response maps to `AiMuxError::ApiCall` (401 in `status_code`). #[tokio::test] async fn anthropic_aws_generate_auth_error() { @@ -541,52 +467,3 @@ async fn anthropic_aws_generate_rate_limit_error() { "expected RateLimited, got {result:?}" ); } - -/// TS: response headers are exposed on the stream result. -#[tokio::test] -async fn anthropic_aws_stream_response_headers() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { "id": "msg_h", "model": "claude-sonnet-4-20250514", "usage": { "input_tokens": 3 } } - }), - json!({ - "type": "content_block_start", "index": 0, - "content_block": { "type": "text", "text": "" } - }), - json!({ - "type": "content_block_delta", "index": 0, - "delta": { "type": "text_delta", "text": "Hi" } - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 1 } - }), - json!({ "type": "message_stop" }), - ]); - Mock::given(method("POST")) - .and(path("/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse_body), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} diff --git a/aimux-providers/tests/anthropic_model_test.rs b/aimux-providers/tests/anthropic_model_test.rs index 62f92b92e..fb301b036 100644 --- a/aimux-providers/tests/anthropic_model_test.rs +++ b/aimux-providers/tests/anthropic_model_test.rs @@ -229,37 +229,6 @@ mod do_generate { assert_eq!(result.usage.output_tokens.total, Some(5)); } - /// TS: "should send additional response information" - #[tokio::test] - async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "test-id", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "" }], - "model": "test-model", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("test-id")); - assert_eq!(result.response.model_id.as_deref(), Some("test-model")); - assert!(result.response.timestamp.is_none()); - } - /// TS: "should extract tool calls" — text + tool_use, finish_reason tool-calls. #[tokio::test] async fn should_extract_tool_calls() { diff --git a/aimux-providers/tests/azure_model_test.rs b/aimux-providers/tests/azure_model_test.rs index c25609097..daab5af47 100644 --- a/aimux-providers/tests/azure_model_test.rs +++ b/aimux-providers/tests/azure_model_test.rs @@ -31,7 +31,7 @@ use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; +use aimux_core::types::FinishReasonUnified; use aimux_providers::{AzureConfig, AzureProvider, TokenProvider}; @@ -413,39 +413,6 @@ async fn should_merge_provider_and_request_headers() { // Response parsing (reuses OpenAI conversion logic) // ════════════════════════════════════════════════════════════════════════════ -/// `do_generate` extracts the assistant text content. -#[tokio::test] -async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("Hello, World!"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - // Finish reason + response metadata. - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.response.id, Some("chatcmpl-test".to_string())); - assert_eq!(result.response.model_id, Some("gpt-4o".to_string())); -} - /// `do_generate` maps Azure/OpenAI usage tokens. #[tokio::test] async fn should_extract_usage() { @@ -720,140 +687,6 @@ async fn should_stream_tool_call() { assert_eq!(input, r#"{"city":"SF"}"#); } -/// TS: "should send a json_schema response format for structured output" -#[tokio::test] -async fn should_send_json_schema_response_format() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("ok"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let schema = json!({ - "type": "object", - "properties": { "sentiment": { "type": "string" } }, - "required": ["sentiment"], - "additionalProperties": false - }); - let mut options = default_options(test_prompt()); - options.response_format = Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["response_format"]["type"], json!("json_schema")); - assert_eq!(body["response_format"]["json_schema"]["schema"], schema); - assert_eq!( - body["response_format"]["json_schema"]["strict"], - json!(true) - ); -} - -/// TS: "should map top-level reasoning to Azure DeepSeek reasoning effort" — -/// a custom `reasoning` value maps to `reasoning_effort` in the request body. -#[tokio::test] -async fn should_map_reasoning_effort() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("ok"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["reasoning_effort"], json!("high")); -} - -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn should_expose_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/deployments/gpt-4o/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_response("hi")), - ) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: response headers are exposed on the stream result. -#[tokio::test] -async fn should_expose_response_headers_stream() { - let server = MockServer::start().await; - let sse = sse_body(&[ - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n", - ]); - Mock::given(method("POST")) - .and(path("/deployments/gpt-4o/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse), - ) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - /// TS: a `length` finish reason maps to `FinishReasonUnified::Length`. #[tokio::test] async fn should_map_length_finish_reason() { diff --git a/aimux-providers/tests/azure_responses_test.rs b/aimux-providers/tests/azure_responses_test.rs index 928042998..1a20c158b 100644 --- a/aimux-providers/tests/azure_responses_test.rs +++ b/aimux-providers/tests/azure_responses_test.rs @@ -73,23 +73,6 @@ async fn mock_json_response(server: &MockServer, body: Value) { .await; } -/// Mock for a JSON responses-api response with custom headers. -async fn mock_json_response_with_headers( - server: &MockServer, - body: Value, - headers: Vec<(&str, &str)>, -) { - let mut template = ResponseTemplate::new(200).set_body_json(body); - for (k, v) in headers { - template = template.insert_header(k, v); - } - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .respond_with(template) - .mount(server) - .await; -} - /// Standard mock for an SSE streaming response on the deployment path. async fn mock_sse_response(server: &MockServer, sse_body: &str) { Mock::given(method("POST")) @@ -562,31 +545,6 @@ async fn should_extract_usage() { assert_eq!(result.usage.output_tokens.total, Some(538)); } -/// Response metadata (id, model, timestamp) is extracted. -#[tokio::test] -async fn should_extract_response_metadata() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.response.id.as_deref(), - Some("resp_67c97c0203188190a025beb4a75242bc") - ); - assert_eq!(result.response.model_id.as_deref(), Some("gpt-4o")); - assert!(result.response.timestamp.is_some()); -} - /// Provider metadata uses the `azure` namespace key. #[tokio::test] async fn should_use_azure_provider_metadata_namespace() { @@ -654,35 +612,6 @@ async fn should_map_finish_reason_tool_calls() { assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } -/// Response headers are captured. -#[tokio::test] -async fn should_extract_response_headers() { - let server = MockServer::start().await; - mock_json_response_with_headers( - &server, - text_response_body(), - vec![("test-header", "test-value")], - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result.response_headers.expect("response_headers"); - assert_eq!( - headers.get("test-header").map(std::string::String::as_str), - Some("test-value") - ); -} - // ════════════════════════════════════════════════════════════════════════════ // Azure file ID prefix (assistant-) // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/bedrock_model_test.rs b/aimux-providers/tests/bedrock_model_test.rs index b45c401f4..b60f9c50e 100644 --- a/aimux-providers/tests/bedrock_model_test.rs +++ b/aimux-providers/tests/bedrock_model_test.rs @@ -16,10 +16,9 @@ use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; +use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; use aimux_core::types::FinishReasonUnified; use aimux_providers::bedrock::{BedrockModel, BedrockProviderConfig}; @@ -207,48 +206,6 @@ async fn bedrock_generate_error_status() { ); } -/// Test: request body contains the expected Converse format. -#[tokio::test] -async fn bedrock_generate_request_body() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "OK" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 1, "outputTokens": 1 } - }), - ) - .await; - - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(512); - opts.temperature = Some(0.7); - - let model = make_model(&server); - let result = model - .do_generate(&opts) - .await - .expect("do_generate should succeed"); - - let body = result.request_body.expect("should have request body"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); - assert_eq!(body["inferenceConfig"]["maxTokens"], 512); - // temperature is f32, compare with tolerance - let temp = body["inferenceConfig"]["temperature"].as_f64().unwrap(); - assert!( - (temp - 0.7).abs() < 0.001, - "temperature should be ~0.7, got {temp}" - ); -} - // ═════════════════════════════════════════════════════════════════════════════ // doStream tests (AWS event stream binary format) // ═════════════════════════════════════════════════════════════════════════════ @@ -493,65 +450,6 @@ async fn bedrock_generate_usage() { assert_eq!(result.usage.output_tokens.total, Some(7)); } -/// TS: "should send additional response information" — the x-amzn-requestid -/// response header becomes `response.id`. -#[tokio::test] -async fn bedrock_generate_response_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("x-amzn-requestid", "req-abc-123") - .set_body_json(ok_converse_body()), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("req-abc-123")); - assert_eq!( - result.response.model_id.as_deref(), - Some("anthropic.claude-3-5-sonnet-20240620-v1:0") - ); -} - -/// TS: "should expose the raw response headers" -#[tokio::test] -async fn bedrock_generate_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_converse_body()), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - /// TS: "should extract finish reason" — stop_sequence → Stop #[tokio::test] async fn bedrock_generate_finish_reason_stop_sequence() { @@ -654,81 +552,6 @@ async fn bedrock_generate_finish_reason_unknown() { assert_eq!(result.finish_reason.raw.as_deref(), Some("eos")); } -/// TS: "should pass settings" — topP, topK, stopSequences land in inferenceConfig. -#[tokio::test] -async fn bedrock_generate_settings() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.top_p = Some(0.9); - opts.top_k = Some(40.0); - opts.stop_sequences = Some(vec!["END".to_string()]); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert!((body["inferenceConfig"]["topP"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["inferenceConfig"]["topK"], json!(40.0)); - assert_eq!(body["inferenceConfig"]["stopSequences"], json!(["END"])); -} - -/// TS: "should pass tools and tool choice correctly" — required → {"any":{}} -#[tokio::test] -async fn bedrock_generate_tool_choice_required() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function( - FunctionTool::new( - "get-weather".to_string(), - json!({"type":"object","properties":{"city":{"type":"string"}}}), - ) - .with_description("Get weather".to_string()), - )]), - tool_choice: ToolChoice::Required, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["toolConfig"]["toolChoice"], json!({ "any": {} })); - assert_eq!( - body["toolConfig"]["tools"][0]["toolSpec"]["name"], - "get-weather" - ); -} - -/// TS: "should only send the forced tool when toolChoice specifies a specific tool" -#[tokio::test] -async fn bedrock_generate_tool_choice_specific_tool() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool::new( - "get-weather".to_string(), - json!({"type":"object"}), - ))]), - tool_choice: ToolChoice::Tool { - tool_name: "get-weather".to_string(), - }, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!( - body["toolConfig"]["toolChoice"], - json!({ "tool": { "name": "get-weather" } }) - ); - // Only the forced tool is sent. - assert_eq!(body["toolConfig"]["tools"].as_array().unwrap().len(), 1); -} - /// TS: a 429/throttling response maps to `AiMuxError::ApiCall` (429 in `status_code`). #[tokio::test] async fn bedrock_generate_throttling_error() { diff --git a/aimux-providers/tests/bedrock_reasoning_test.rs b/aimux-providers/tests/bedrock_reasoning_test.rs index 232fc4271..2fd3bf07e 100644 --- a/aimux-providers/tests/bedrock_reasoning_test.rs +++ b/aimux-providers/tests/bedrock_reasoning_test.rs @@ -1,4 +1,4 @@ -//! Bedrock reasoning / thinking tests, translated from the TypeScript suite. +//! Bedrock reasoning / thinking tests, translated from the TypeScript suite. //! //! Translation sources (under `reference/ai/packages/amazon-bedrock/src/`): //! - `amazon-bedrock-chat-language-model.test.ts` @@ -25,8 +25,6 @@ //! is provider-agnostic; the converter cannot distinguish an `anthropic` //! signature (which must be dropped) from a `bedrock` one (which is kept). -use std::collections::HashMap; - use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; @@ -90,13 +88,6 @@ fn make_model(server: &MockServer) -> BedrockModel { ) } -/// Wrap a `bedrock`-keyed provider-options value for `CallOptions.provider_options`. -fn bedrock_provider_options(value: Value) -> Option> { - let mut map = HashMap::new(); - map.insert("bedrock".to_string(), value); - Some(map) -} - /// Mock the non-streaming `/converse` endpoint with a JSON body (HTTP 200). async fn mock_converse(server: &MockServer, body: Value) { let model_path = format!("/model/{MODEL_ID}/converse"); @@ -858,100 +849,6 @@ async fn bedrock_stream_reasoning_text_deltas() { // doStream — request-side reasoningConfig → thinking (TDD red until implemented) // ════════════════════════════════════════════════════════════════════════════ -/// TS: "should transform reasoningConfig to thinking in stream requests". -/// -/// `providerOptions.bedrock.reasoningConfig = { type: 'enabled', budgetTokens: -/// 2000 }` plus `maxOutputTokens: 100` must land in the request body as -/// `additionalModelRequestFields.thinking = { type: 'enabled', budget_tokens: -/// 2000 }` and `inferenceConfig.maxTokens = 2100` (100 + 2000), with no -/// top-level `reasoningConfig`. -#[tokio::test] -async fn bedrock_stream_transform_reasoning_config_to_thinking() { - let server = MockServer::start().await; - mock_stream( - &server, - &[( - "event", - "messageStop", - json!({ "stopReason": "stop_sequence" }), - )], - ) - .await; - - let opts = CallOptions { - max_output_tokens: Some(100), - provider_options: bedrock_provider_options(json!({ - "reasoningConfig": { "type": "enabled", "budgetTokens": 2000 } - })), - ..default_options(test_prompt()) - }; - - let model = make_model(&server); - let result = model - .do_stream(&opts) - .await - .expect("do_stream should succeed"); - let body = result.request_body.expect("request body should be present"); - - // Thinking config derived from reasoningConfig. - assert_eq!( - body["additionalModelRequestFields"]["thinking"], - json!({ "type": "enabled", "budget_tokens": 2000 }) - ); - // maxTokens is bumped by budgetTokens (100 + 2000). - assert_eq!(body["inferenceConfig"]["maxTokens"], 2100); - // reasoningConfig must not leak to the top level. - assert!( - body.get("reasoningConfig").is_none(), - "reasoningConfig should not appear at the top level" - ); -} - -/// TS: "merges user additionalModelRequestFields with derived thinking (stream)". -/// -/// User-supplied `additionalModelRequestFields` (`foo`, `custom`) must be -/// merged with the derived `thinking` field, and `reasoningConfig` must not -/// appear at the top level. -#[tokio::test] -async fn bedrock_stream_merge_additional_model_request_fields_with_thinking() { - let server = MockServer::start().await; - mock_stream( - &server, - &[( - "event", - "messageStop", - json!({ "stopReason": "stop_sequence" }), - )], - ) - .await; - - let opts = CallOptions { - provider_options: bedrock_provider_options(json!({ - "reasoningConfig": { "type": "enabled", "budgetTokens": 500 }, - "additionalModelRequestFields": { "foo": "bar", "custom": 42 } - })), - ..default_options(test_prompt()) - }; - - let model = make_model(&server); - let result = model - .do_stream(&opts) - .await - .expect("do_stream should succeed"); - let body = result.request_body.expect("request body should be present"); - - assert!( - body.get("reasoningConfig").is_none(), - "reasoningConfig should not appear at the top level" - ); - assert_eq!(body["additionalModelRequestFields"]["foo"], "bar"); - assert_eq!(body["additionalModelRequestFields"]["custom"], 42); - assert_eq!( - body["additionalModelRequestFields"]["thinking"], - json!({ "type": "enabled", "budget_tokens": 500 }) - ); -} - // ════════════════════════════════════════════════════════════════════════════ // convert_prompt_to_bedrock — prompt-side reasoning replay // (green for signed/unsigned reasoning; #[ignore] where the data model cannot diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs index 446ce9ba1..648761cc1 100644 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ b/aimux-providers/tests/bedrock_remaining_test.rs @@ -20,7 +20,6 @@ //! documents the expected behaviour so it can be flipped on once the gap lands. use aimux_core::tool::RawToolCall; -use std::collections::HashMap; use futures::StreamExt; use serde_json::{Value, json}; @@ -32,11 +31,10 @@ use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; +use aimux_core::options::CallOptions; use aimux_core::provider::Provider; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; use aimux_core::types::FinishReasonUnified; use aimux_providers::bedrock::{ @@ -78,19 +76,6 @@ async fn mock_converse_json(server: &MockServer, status: u16, body: Value) { .await; } -fn ok_converse_body() -> Value { - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "ok" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 4, "outputTokens": 7, "totalTokens": 11 } - }) -} - fn as_text(item: &GenerateContent) -> &str { match item { GenerateContent::Text { text, .. } => text, @@ -669,144 +654,10 @@ async fn arn_model_id_encoded_stream_route() { // ── additionalModelResponseFieldPaths ──────────────────────────────────────── -/// TS: "should return the request body" — the default request body includes -/// `additionalModelResponseFieldPaths: ["/delta/stop_sequence"]` so that the -/// stop_sequence value is returned in `additionalModelResponseFields`. The Rust -/// `build_request_body` does not emit this field. -#[tokio::test] -#[ignore = "TODO: implementation gap — build_request_body does not emit additionalModelResponseFieldPaths"] -async fn request_body_includes_additional_model_response_field_paths() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!( - body["additionalModelResponseFieldPaths"], - json!(["/delta/stop_sequence"]) - ); -} - // ── temperature clamping ───────────────────────────────────────────────────── -/// TS: "should clamp temperature above 1 to 1 and add warning". -#[tokio::test] -#[ignore = "TODO: implementation gap — temperature is not clamped to [0, 1]"] -async fn temperature_clamped_above_1() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.temperature = Some(1.5); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!( - body["inferenceConfig"]["temperature"].as_f64().unwrap(), - 1.0 - ); - assert!( - result.warnings.iter().any( - |w| matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "temperature") - ), - "expected an unsupported-temperature warning, got {:?}", - result.warnings - ); -} - -/// TS: "should clamp temperature below 0 to 0 and add warning". -#[tokio::test] -#[ignore = "TODO: implementation gap — temperature is not clamped to [0, 1]"] -async fn temperature_clamped_below_0() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.temperature = Some(-0.5); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!( - body["inferenceConfig"]["temperature"].as_f64().unwrap(), - 0.0 - ); - assert!( - result.warnings.iter().any( - |w| matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "temperature") - ), - "expected an unsupported-temperature warning" - ); -} - -/// TS: "should not clamp valid temperature between 0 and 1". -#[tokio::test] -async fn temperature_not_clamped_in_range() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.temperature = Some(0.7); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let body = result.request_body.expect("request body"); - let temp = body["inferenceConfig"]["temperature"].as_f64().unwrap(); - assert!( - (temp - 0.7).abs() < 1e-6, - "temperature should be 0.7, got {temp}" - ); - assert!(result.warnings.is_empty(), "no warnings expected"); -} - // ── guardrails ─────────────────────────────────────────────────────────────── -/// TS: "should support guardrails" — `providerOptions.bedrock.guardrailConfig` -/// is forwarded as a top-level `guardrailConfig` in the request body. -#[tokio::test] -#[ignore = "TODO: implementation gap — build_request_body does not emit guardrailConfig"] -async fn guardrail_config_in_request_body() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - let mut po = HashMap::new(); - po.insert( - "bedrock".to_string(), - json!({ - "guardrailConfig": { - "guardrailIdentifier": "-1", - "guardrailVersion": "1", - "trace": "enabled" - } - }), - ); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert_eq!( - body["guardrailConfig"], - json!({ - "guardrailIdentifier": "-1", - "guardrailVersion": "1", - "trace": "enabled" - }) - ); -} - // ── trace in providerMetadata ──────────────────────────────────────────────── /// TS: "should include trace information in providerMetadata" — the response @@ -952,149 +803,6 @@ async fn stream_tool_call_empty_input() { // ── omit toolConfig ────────────────────────────────────────────────────────── -/// TS: "should omit toolConfig when conversation has tool calls but toolChoice -/// is none" — with `toolChoice: none`, no `toolConfig` is sent even when tools -/// are provided. -#[tokio::test] -async fn omit_tool_config_when_tool_choice_none() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool::new( - "weather".to_string(), - json!({ "type": "object", "properties": { "city": { "type": "string" } } }), - ))]), - tool_choice: ToolChoice::None, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert!( - body.get("toolConfig").is_none(), - "toolConfig should be absent when toolChoice is none, got {}", - body["toolConfig"] - ); -} - -/// TS: "should omit toolConfig and filter tool content when conversation has -/// tool calls but no active tools". -/// -/// The `toolConfig` omission (tools = `[]`) works in Rust. The *content -/// filtering* — dropping the assistant's `toolUse` block from the messages when -/// there are no active tools — is **not** implemented, so only the toolConfig -/// assertion is live; the filtering assertion is `#[ignore]` below. -#[tokio::test] -async fn omit_tool_config_when_no_active_tools() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Toronto?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "tool-call-1".to_string(), - "weather".to_string(), - json!({ "city": "Toronto" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "text", "value": "The weather in Toronto is 20°C." }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Now give me a summary.")], - ..Default::default() - }, - ]; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![]), - ..default_options(prompt) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert!( - body.get("toolConfig").is_none(), - "toolConfig should be absent when tools list is empty" - ); -} - -/// TS: (same test) the assistant's `toolUse` block should be filtered out of -/// the messages when there are no active tools. The Rust converter always -/// emits `toolUse` blocks regardless of active tools. -#[tokio::test] -#[ignore = "TODO: implementation gap — convert_prompt_to_bedrock does not filter toolUse blocks when no active tools"] -async fn filter_tool_content_when_no_active_tools() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Toronto?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "tool-call-1".to_string(), - "weather".to_string(), - json!({ "city": "Toronto" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "text", "value": "The weather in Toronto is 20°C." }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Now give me a summary.")], - ..Default::default() - }, - ]; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![]), - ..default_options(prompt) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - // The assistant message should NOT contain a toolUse block. - let assistant_msg = &body["messages"][1]; - assert_eq!(assistant_msg["role"], "assistant"); - let has_tool_use = assistant_msg["content"] - .as_array() - .map(|arr| arr.iter().any(|b| b.get("toolUse").is_some())) - .unwrap_or(false); - assert!( - !has_tool_use, - "toolUse should be filtered out when no active tools" - ); -} - // ── doGenerate: tool call with empty input (non-streaming) ─────────────────── /// TS variant: a non-streaming `toolUse` with no `input` field should yield @@ -1144,37 +852,6 @@ async fn generate_tool_call_empty_input() { // ── doGenerate: basic text + finish reason (sanity, already covered but // exercised here against the remaining-test helper set) ──────────────────── -/// TS: "should pass the model and the messages" — the request body carries the -/// model id implicitly via the URL path and the messages array. -#[tokio::test] -async fn request_body_messages_shape() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("System Prompt")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - let body = result.request_body.expect("request body"); - assert_eq!(body["system"], json!([{ "text": "System Prompt" }])); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); -} - /// TS: "should extract finish reason" — `guardrail_intervened` → ContentFilter. #[tokio::test] async fn finish_reason_guardrail_intervened() { @@ -1206,70 +883,6 @@ async fn finish_reason_guardrail_intervened() { ); } -/// TS: "should send all tools when toolChoice is auto" — all provided tools -/// appear in the toolConfig, not just a filtered subset. -#[tokio::test] -async fn tool_choice_auto_sends_all_tools() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![ - Tool::Function( - FunctionTool::new("get-weather".to_string(), json!({ "type": "object" })) - .with_description("Get weather".to_string()), - ), - Tool::Function( - FunctionTool::new("get-time".to_string(), json!({ "type": "object" })) - .with_description("Get time".to_string()), - ), - ]), - tool_choice: ToolChoice::Auto, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert_eq!(body["toolConfig"]["toolChoice"], json!({ "auto": {} })); - let tools = body["toolConfig"]["tools"].as_array().unwrap(); - assert_eq!( - tools.len(), - 2, - "both tools should be sent with toolChoice: auto" - ); - assert_eq!(tools[0]["toolSpec"]["name"], "get-weather"); - assert_eq!(tools[1]["toolSpec"]["name"], "get-time"); -} - -/// TS: "should omit empty tool descriptions to avoid Bedrock validation errors". -/// -/// A function tool with an empty-string description must not emit a -/// `description` field in the `toolSpec`. -#[tokio::test] -async fn tool_empty_description_omitted() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function( - FunctionTool::new("get-weather".to_string(), json!({ "type": "object" })) - .with_description("".to_string()), - )]), - tool_choice: ToolChoice::Auto, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - let spec = &body["toolConfig"]["tools"][0]["toolSpec"]; - assert!( - spec.get("description").is_none(), - "empty description should be omitted" - ); -} - // ── doStream: error handling ───────────────────────────────────────────────── /// TS: "should handle throttlingException error" (stream) — a 429 stream @@ -1320,43 +933,3 @@ async fn stream_validation_error() { } // ── doStream: request body parity ──────────────────────────────────────────── - -/// TS: "should return the request body" (stream) — the stream result carries -/// the request body for debugging. -#[tokio::test] -async fn stream_request_body_available() { - let server = MockServer::start().await; - - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"text":"hi"}}"#, - ), - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"end_turn"}"#), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(256); - - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.expect("stream request body"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["inferenceConfig"]["maxTokens"], 256); -} diff --git a/aimux-providers/tests/cohere_model_test.rs b/aimux-providers/tests/cohere_model_test.rs index 873f4b16e..45b92c75b 100644 --- a/aimux-providers/tests/cohere_model_test.rs +++ b/aimux-providers/tests/cohere_model_test.rs @@ -17,7 +17,7 @@ use aimux_core::generate::{GenerateTextOptions, stream_text}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; +use aimux_core::options::{CallOptions, Tool}; use aimux_core::result::{GenerateContent, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; @@ -365,90 +365,6 @@ async fn should_extract_usage() { assert_eq!(result.usage.output_tokens.total, Some(10)); } -/// TS: "should pass model and messages" -#[tokio::test] -async fn should_send_correct_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hi" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "command-r-plus"); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "you are a friendly bot!" }, - { "role": "user", "content": "Hello" } - ]) - ); - // No extra keys when options are unset. - assert!(body.get("max_tokens").is_none()); - assert!(body.get("temperature").is_none()); - assert!(body.get("tools").is_none()); - assert!(body.get("documents").is_none()); -} - -/// TS: "should pass tools" with tool_choice none → "NONE" -#[tokio::test] -async fn should_pass_tools_with_tool_choice_none() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "ok" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::None, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["tool_choice"], json!("NONE")); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - // ════════════════════════════════════════════════════════════════════════════ // doStream // ════════════════════════════════════════════════════════════════════════════ @@ -710,40 +626,6 @@ async fn should_stream_reasoning_deltas() { ); } -/// TS: "should pass the messages and the model" (streaming request body) -#[tokio::test] -async fn should_send_streaming_request_body() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"test","type":"message-start","delta":{"message":{"role":"assistant","content":[]}}}"#, - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"Hi"}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":1,"output_tokens":1},"tokens":{"input_tokens":1,"output_tokens":1}}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "command-r-plus"); - assert_eq!(body["stream"], json!(true)); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "you are a friendly bot!" }, - { "role": "user", "content": "Hello" } - ]) - ); -} - /// TS: "should handle unparsable stream parts" #[tokio::test] async fn should_handle_unparsable_stream_parts() { @@ -872,173 +754,6 @@ async fn should_pass_request_headers() { assert!(result.is_ok(), "request should match the header mock"); } -/// TS: "should expose the raw response headers" -#[tokio::test] -async fn should_expose_raw_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_cohere_body()), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should send additional response information" — generation_id → response.id -#[tokio::test] -async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("gen-123")); -} - -/// TS: "should pass response format" — JSON schema → Cohere response_format. -#[tokio::test] -async fn should_pass_response_format() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let schema = json!({ - "type": "object", - "properties": { "text": { "type": "string" } }, - "required": ["text"] - }); - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!( - body["response_format"], - json!({ "type": "json_object", "json_schema": schema }) - ); -} - -/// TS: "should pass tools" with tool_choice required → "REQUIRED" -#[tokio::test] -async fn should_pass_tools_with_tool_choice_required() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Required, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("REQUIRED")); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should pass tools" with tool_choice tool → "REQUIRED" + filtered tools -#[tokio::test] -async fn should_pass_tools_with_tool_choice_tool() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("REQUIRED")); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should extract text documents and send to API" — non-image File parts -/// become Cohere RAG `documents`. The Rust `ContentPart::File` carries no -/// filename, so `title` is omitted (unlike TS). -#[tokio::test] -async fn should_extract_text_documents_from_file_parts() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What does this say?"), - ContentPart::file( - b"This is a test document.".to_vec(), - "text/plain".to_string(), - ), - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!( - body["documents"], - json!([{ "data": { "text": "This is a test document." } }]) - ); - assert_eq!(body["messages"][0]["role"], json!("user")); - assert_eq!(body["messages"][0]["content"], json!("What does this say?")); -} - // ════════════════════════════════════════════════════════════════════════════ // citations — translated from the TS `describe('citations', ...)` block. // @@ -1163,255 +878,6 @@ async fn should_extract_citations_from_response() { assert_eq!(result.usage.output_tokens.total, Some(62)); } -/// TS: "should extract multiple text documents" — two file parts become two -/// RAG documents, each carrying its filename as `title`. -#[tokio::test] -async fn should_extract_multiple_text_documents() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What do these documents say?"), - ContentPart::File { - data: b"First document content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("doc1.txt".to_string()), - provider_options: None, - }, - ContentPart::File { - data: b"Second document content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("doc2.txt".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([ - { "data": { "text": "First document content", "title": "doc1.txt" } }, - { "data": { "text": "Second document content", "title": "doc2.txt" } } - ]) - ); - assert_eq!(body["messages"][0]["role"], json!("user")); - assert_eq!( - body["messages"][0]["content"], - json!("What do these documents say?") - ); - assert_eq!(body["model"], json!("command-r-plus")); -} - -/// TS: "should support JSON files" — an `application/json` file part becomes a -/// RAG document whose `text` is the raw JSON string and `title` is the filename. -#[tokio::test] -async fn should_support_json_files() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this JSON?"), - ContentPart::File { - data: b"{\"key\": \"value\"}".to_vec(), - media_type: "application/json".to_string(), - filename: Some("data.json".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([{ "data": { "text": "{\"key\": \"value\"}", "title": "data.json" } }]) - ); - assert_eq!( - body["messages"][0]["content"], - json!("What is in this JSON?") - ); - assert_eq!(body["model"], json!("command-r-plus")); -} - -/// TS: "should not include mediaType in the outgoing payload (category D)" — a -/// non-text/non-image file (PDF) still becomes a RAG document, and neither the -/// media type nor the `mediaType` key leaks into the serialized request body. -#[tokio::test] -async fn should_not_include_mediatype_in_outgoing_payload() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is this?"), - ContentPart::File { - data: b"Some file content".to_vec(), - media_type: "application/pdf".to_string(), - filename: Some("document.pdf".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([{ "data": { "text": "Some file content", "title": "document.pdf" } }]) - ); - let serialized = body.to_string(); - assert!( - !serialized.contains("application/pdf"), - "mediaType must not leak into payload: {serialized}" - ); - assert!( - !serialized.contains("mediaType"), - "mediaType key must not leak into payload: {serialized}" - ); -} - -/// TS: "should successfully process supported text media types" — `text/plain` -/// and `text/markdown` files both become RAG documents with their content and -/// filename-derived title. -#[tokio::test] -async fn should_process_supported_text_media_types() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is this?"), - ContentPart::File { - data: b"This is plain text content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("text.txt".to_string()), - provider_options: None, - }, - ContentPart::File { - data: b"# Markdown Header\nContent".to_vec(), - media_type: "text/markdown".to_string(), - filename: Some("doc.md".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([ - { "data": { "text": "This is plain text content", "title": "text.txt" } }, - { "data": { "text": "# Markdown Header\nContent", "title": "doc.md" } } - ]) - ); -} - -/// TS: "should not include documents parameter when no files present" — a plain -/// text prompt produces a request body with no `documents` key. -#[tokio::test] -async fn should_not_include_documents_when_no_files_present() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("documents").is_none(), - "documents must be absent when no files are present: {body}" - ); -} - -/// TS: "should send request body" — verify all standard optional fields. -#[tokio::test] -async fn should_send_full_request_body() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - prompt: test_prompt(), - max_output_tokens: Some(100), - temperature: Some(0.5), - top_p: Some(0.9), - top_k: Some(40.0), - seed: Some(7), - stop_sequences: Some(vec!["END".to_string()]), - frequency_penalty: Some(0.1), - presence_penalty: Some(0.2), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "command-r-plus"); - assert_eq!(body["max_tokens"], json!(100)); - assert!((body["temperature"].as_f64().unwrap() - 0.5).abs() < 1e-6); - assert!((body["p"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["k"], json!(40.0)); - assert_eq!(body["seed"], json!(7)); - assert_eq!(body["stop_sequences"], json!(["END"])); - assert!((body["frequency_penalty"].as_f64().unwrap() - 0.1).abs() < 1e-6); - assert!((body["presence_penalty"].as_f64().unwrap() - 0.2).abs() < 1e-6); - assert!(body.get("tools").is_none()); - assert!(body.get("response_format").is_none()); -} - /// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). #[tokio::test] async fn should_handle_rate_limit_error() { @@ -1488,37 +954,6 @@ async fn should_pass_request_headers_stream() { assert!(result.is_ok(), "request should match the header mock"); } -/// TS: "should expose the raw response headers" (streaming) -#[tokio::test] -async fn should_expose_raw_response_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(cohere_text_sse()), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - /// TS: "should handle empty tool call arguments" (streaming) — empty arguments /// string parses to an empty object. #[tokio::test] diff --git a/aimux-providers/tests/cohere_remaining_test.rs b/aimux-providers/tests/cohere_remaining_test.rs index 5443980d2..1a28009c4 100644 --- a/aimux-providers/tests/cohere_remaining_test.rs +++ b/aimux-providers/tests/cohere_remaining_test.rs @@ -1,4 +1,4 @@ -//! Remaining Cohere tests translated from TS that are NOT already covered by +//! Remaining Cohere tests translated from TS that are NOT already covered by //! `cohere_model_test.rs`. Covers: //! //! - `cohere-prepare-tools.test.ts` — all 7 cases (prepare_tools unit tests) @@ -29,22 +29,6 @@ use aimux_providers::{CohereConfig, CohereProvider}; // ── helpers ───────────────────────────────────────────────────────────────── -fn ok_cohere_body() -> Value { - json!({ - "id": "test-id", - "generation_id": "gen-123", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "ok" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }) -} - async fn mock_json_response(server: &MockServer, body: Value) { Mock::given(method("POST")) .and(path("/chat")) @@ -609,98 +593,6 @@ async fn should_extract_reasoning_from_response() { // cohere-chat-language-model.test.ts — top-level reasoning → thinking // ════════════════════════════════════════════════════════════════════════════ -/// TS: "should map top-level reasoning to thinking enabled with budget" -#[tokio::test] -async fn should_map_reasoning_high_to_thinking_enabled() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert!(body.get("thinking").is_some(), "thinking should be defined"); - assert_eq!(body["thinking"]["type"], json!("enabled")); - let budget = body["thinking"]["token_budget"] - .as_u64() - .expect("token_budget"); - assert!(budget > 0, "token_budget should be > 0, got {budget}"); -} - -/// TS: "should map top-level reasoning none to thinking disabled" -#[tokio::test] -async fn should_map_reasoning_none_to_thinking_disabled() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["thinking"], json!({ "type": "disabled" })); -} - -/// TS: "should prefer providerOptions over top-level reasoning" -#[tokio::test] -async fn should_prefer_provider_options_over_reasoning() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let mut po = std::collections::HashMap::new(); - po.insert( - "cohere".to_string(), - json!({ "thinking": { "type": "enabled" } }), - ); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - provider_options: Some(po), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["thinking"]["type"], json!("enabled")); -} - -/// TS: "should not set thinking when reasoning is not specified" -#[tokio::test] -async fn should_not_set_thinking_when_no_reasoning() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - assert!( - body.get("thinking").is_none(), - "thinking should be undefined: {body}" - ); -} - // ════════════════════════════════════════════════════════════════════════════ // resolve_cohere_thinking unit tests // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index bc41d9188..ebfb2ec53 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -29,7 +29,7 @@ use std::path::PathBuf; use futures::StreamExt; use serde_json::{Value, json}; -use wiremock::matchers::{method, path as path_matcher, query_param}; +use wiremock::matchers::{method, path as path_matcher}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; @@ -37,8 +37,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GeneratedFile, ReasoningOutput, Source, StreamResult}; -use aimux_core::shared::{FileBytes, FileData}; +use aimux_core::result::{GenerateContent, ReasoningOutput, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{ProviderTool, Tool}; @@ -154,26 +153,6 @@ async fn collect(result: StreamResult) -> Vec { parts } -/// The base64 payload of a `GenerateContent::File`, or panic. -fn file_base64(c: &GenerateContent) -> &str { - match c { - GenerateContent::File(GeneratedFile { - data: FileData::Data { - data: FileBytes::Base64(s), - }, - .. - }) => s, - other => panic!("expected File with base64 data, got {other:?}"), - } -} - -fn files(content: &[GenerateContent]) -> Vec<&GenerateContent> { - content - .iter() - .filter(|c| matches!(c, GenerateContent::File(_))) - .collect() -} - fn texts(content: &[GenerateContent]) -> Vec<&str> { content .iter() @@ -314,150 +293,10 @@ fn sources(content: &[GenerateContent]) -> Vec> { // gemini/test_google_image_and_text_output.json // ═══════════════════════════════════════════════════════════════════════════ -/// PNG magic bytes, base64-encoded: every image in the two Gemini image -/// cassettes starts with this. Asserting the prefix (rather than "non-empty") -/// proves the *actual* bytes survived rather than some placeholder. -const PNG_BASE64_PREFIX: &str = "iVBORw0KGgoAAAANSUhEUgAA"; - -/// The exact base64 length recorded in `nano_banana_image_generation_smoke`. -const NANO_BANANA_BASE64_LEN: usize = 258_820; - fn google_at(uri: &str) -> GoogleProvider { GoogleProvider::new(GoogleConfig::new("test-api-key").with_base_url(format!("{uri}/v1beta"))) } -#[tokio::test] -async fn finding_1_gemini_inline_data_surfaces_as_file() { - let c = cassette("gemini/nano_banana_image_generation_smoke.json"); - let server = MockServer::start().await; - mount(&server, &c).await; - - let result = google_at(&server.uri()) - .model("gemini-2.5-flash-image") - .do_generate(&opts()) - .await - .expect("do_generate should succeed"); - - let f = files(&result.content); - assert_eq!(f.len(), 1, "exactly one inlineData part → one File"); - match f[0] { - GenerateContent::File(GeneratedFile { media_type, .. }) => { - assert_eq!(media_type, "image/png"); - } - other => panic!("expected File, got {other:?}"), - } - let b64 = file_base64(f[0]); - assert!( - b64.starts_with(PNG_BASE64_PREFIX), - "image bytes must be the recorded PNG, got prefix {:?}", - &b64[..b64.len().min(32)] - ); - assert_eq!( - b64.len(), - NANO_BANANA_BASE64_LEN, - "the whole base64 payload must survive, not a truncated head" - ); -} - -#[tokio::test] -async fn finding_1_gemini_image_and_text_output_both_survive() { - let c = cassette("gemini/test_google_image_and_text_output.json"); - let server = MockServer::start().await; - mount(&server, &c).await; - - let result = google_at(&server.uri()) - .model("gemini-2.5-flash-image") - .do_generate(&opts()) - .await - .expect("do_generate should succeed"); - - // Text part first, then the inlineData part — order matters, upstream - // walks `parts` in order. - assert_eq!(result.content.len(), 2, "one text + one file"); - assert!( - matches!(result.content[0], GenerateContent::Text { .. }), - "text part comes first" - ); - assert!( - matches!(result.content[1], GenerateContent::File(_)), - "inlineData part comes second" - ); - - let t = texts(&result.content); - assert_eq!(t.len(), 1); - assert!( - t[0].starts_with("Once, in a hidden cenote, lived an axolotl named Pip"), - "recorded story text must survive verbatim, got {:?}", - &t[0][..t[0].len().min(60)] - ); - - let f = files(&result.content); - assert_eq!(f.len(), 1); - match f[0] { - GenerateContent::File(GeneratedFile { media_type, .. }) => { - assert_eq!(media_type, "image/png") - } - other => panic!("expected File, got {other:?}"), - } - let b64 = file_base64(f[0]); - assert!(b64.starts_with(PNG_BASE64_PREFIX)); - assert_eq!(b64.len(), 2_580_504); -} - -/// The streaming path had the same hole. The chunk is built from the cassette's -/// own `parts` array so the bytes are the recorded ones. -#[tokio::test] -async fn finding_1_gemini_inline_data_streams_as_file_part() { - let body = cassette_json("gemini/nano_banana_image_generation_smoke.json"); - let candidate = &body["candidates"][0]; - let sse = format!( - "data: {}\n\n", - json!({ "candidates": [{ "content": candidate["content"].clone(), "finishReason": "STOP" }] }) - ); - - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path_matcher( - "/v1beta/models/gemini-2.5-flash-image:streamGenerateContent", - )) - .and(query_param("alt", "sse")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse), - ) - .mount(&server) - .await; - - let parts = collect( - google_at(&server.uri()) - .model("gemini-2.5-flash-image") - .do_stream(&opts()) - .await - .expect("do_stream should succeed"), - ) - .await; - - let file_parts: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::File(GeneratedFile { - data: - FileData::Data { - data: FileBytes::Base64(b64), - }, - media_type, - .. - }) => Some((b64.as_str(), media_type.as_str())), - _ => None, - }) - .collect(); - assert_eq!(file_parts.len(), 1, "expected one StreamPart::File"); - assert_eq!(file_parts[0].1, "image/png"); - assert!(file_parts[0].0.starts_with(PNG_BASE64_PREFIX)); - assert_eq!(file_parts[0].0.len(), NANO_BANANA_BASE64_LEN); -} - // ═══════════════════════════════════════════════════════════════════════════ // Finding 3 — Gemini `part.thought` never read (thinking merged into the answer) // Finding 5 — Gemini `thoughtSignature` dropped diff --git a/aimux-providers/tests/deepseek_chat_test.rs b/aimux-providers/tests/deepseek_chat_test.rs index 79c399d9c..bffa49285 100644 --- a/aimux-providers/tests/deepseek_chat_test.rs +++ b/aimux-providers/tests/deepseek_chat_test.rs @@ -263,48 +263,6 @@ fn json_completion_body() -> Value { // Translated from `deepseek-chat-language-model.test.ts` doGenerate › text. // ════════════════════════════════════════════════════════════════════════════ -/// TS: doGenerate › text › "should send correct request body" (line ~39). -/// -/// Verifies that system + user messages, temperature, and top_p are correctly -/// serialized in the request body. -#[tokio::test] -async fn should_send_correct_text_request_body() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server, "deepseek-chat"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let mut options = default_options(prompt); - options.temperature = Some(0.5); - options.top_p = Some(0.3); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-chat")); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "You are a helpful assistant." }, - { "role": "user", "content": "Hello" } - ]) - ); - assert_eq!(body["temperature"], json!(0.5)); - assert_eq!(body["top_p"], json!(0.3)); -} - /// TS: doGenerate › text › "should extract text content" (line ~68). /// /// Verifies that the text content is extracted from the response and the @@ -342,54 +300,6 @@ async fn should_extract_text_content() { // Translated from `deepseek-chat-language-model.test.ts` doGenerate › tool call. // ════════════════════════════════════════════════════════════════════════════ -/// TS: doGenerate › tool call › "should send correct request body" (line ~318). -/// -/// Verifies that tools are correctly serialized in the request body. -/// stage2-001(RFC-0017 阶段 2):DeepSeek 特化已退役——`providerOptions.deepseek` -/// 不再翻译为请求体 `thinking` 字段,用户改用 bodyOverrides 注入(新语义透传)。 -#[tokio::test] -async fn should_send_correct_tool_call_request_body() { - let server = MockServer::start().await; - mock_json(&server, tool_call_completion_body()).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![weather_tool().into()]); - // 退役后 providerOptions.deepseek.* 被忽略(不再有 apply_deepseek_override)。 - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-reasoner")); - assert!( - body.get("thinking").is_none(), - "stage2-001: DeepSeek 特化退役,thinking 不再由 providerOptions.deepseek 注入,改用 bodyOverrides" - ); - assert_eq!( - body["tools"], - json!([{ - "type": "function", - "function": { - "name": "weather", - "parameters": { - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - } - }]) - ); - // The messages should contain the single "Hello" user message. - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": "Hello" }]) - ); -} - /// TS: doGenerate › tool call › "should extract tool call content" (line ~668). /// /// Verifies that tool calls are extracted from the response as @@ -436,93 +346,6 @@ async fn should_extract_tool_call_content() { // ("Return JSON." or "Return JSON that conforms to the following schema: ..."). // ════════════════════════════════════════════════════════════════════════════ -/// TS: "should send correct request body without schema" (line ~383). -/// -/// Without a schema, the request body should have -/// `response_format: { type: "json_object" }` and a system message -/// "Return JSON." prepended to the messages. -#[tokio::test] -async fn should_send_json_response_format_without_schema() { - let server = MockServer::start().await; - mock_json(&server, json_completion_body()).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }); - options.tools = Some(vec![weather_tool().into()]); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["response_format"], json!({ "type": "json_object" })); - // DeepSeek does not inject a "Return JSON." system message. - assert_eq!( - body["messages"][0], - json!({ "role": "user", "content": "Hello" }) - ); -} - -/// TS: "should send correct request body with schema" (line ~451). -/// -/// With a schema but without `supportsStructuredOutputs`, the request body -/// should still use `response_format: { type: "json_object" }` (NOT -/// `json_schema`) and inject a system message containing the schema. -#[tokio::test] -async fn should_send_json_response_format_with_schema() { - let server = MockServer::start().await; - mock_json(&server, json_completion_body()).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let schema = json!({ - "type": "object", - "properties": { - "elements": { - "type": "array", - "items": { - "type": "object", - "properties": { - "location": { "type": "string" }, - "temperature": { "type": "number" }, - "condition": { "type": "string" } - }, - "required": ["location", "temperature", "condition"], - "additionalProperties": false - } - } - }, - "required": ["elements"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }); - options.tools = Some(vec![weather_tool().into()]); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - // With a schema, deepseek uses json_schema format (via OpenAI shared converter). - assert_eq!(body["response_format"]["type"], "json_schema"); - // DeepSeek does not inject a system message. - assert_eq!( - body["messages"][0], - json!({ "role": "user", "content": "Hello" }) - ); -} - /// TS: "should extract text content" (json response format, line ~542). /// /// Verifies that JSON content is extracted as a text part. @@ -561,53 +384,6 @@ async fn should_extract_json_response_text_content() { // Translated from `deepseek-chat-language-model.test.ts` doStream › text. // ════════════════════════════════════════════════════════════════════════════ -/// TS: doStream › text › "should send model id, settings, and input" (line ~715). -/// -/// Verifies that the stream request body includes `stream: true`, -/// `stream_options: { include_usage: true }`, and the correct messages. -#[tokio::test] -async fn should_send_correct_stream_request_body() { - let server = MockServer::start().await; - let body = sse_body(&[&sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#, - )]); - mock_sse(&server, body).await; - - let model = make_provider(&server, "deepseek-chat"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let mut options = default_options(prompt); - options.temperature = Some(0.5); - options.top_p = Some(0.3); - - let result = model.do_stream(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-chat")); - assert_eq!(body["stream"], json!(true)); - assert_eq!(body["stream_options"], json!({ "include_usage": true })); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "You are a helpful assistant." }, - { "role": "user", "content": "Hello" } - ]) - ); - assert_eq!(body["temperature"], json!(0.5)); - assert_eq!(body["top_p"], json!(0.3)); -} - /// TS: doStream › text › "should stream text" (line ~748). /// /// Verifies that text deltas are streamed as TextStart / TextDelta / TextEnd diff --git a/aimux-providers/tests/deepseek_reasoning_test.rs b/aimux-providers/tests/deepseek_reasoning_test.rs index 237c00e02..4d5c3f671 100644 --- a/aimux-providers/tests/deepseek_reasoning_test.rs +++ b/aimux-providers/tests/deepseek_reasoning_test.rs @@ -45,8 +45,6 @@ //! resulting request path is `/chat/completions` — matching the convention in //! `openai_compatible_test.rs`. -use std::collections::HashMap; - use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; @@ -59,7 +57,6 @@ use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, ReasoningOutput}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{ReasoningEffort, Warning}; use aimux_providers::{ProviderOptions, provider}; @@ -95,16 +92,6 @@ fn make_provider(server: &MockServer) -> Box { .expect("deepseek provider should build") } -/// Wrap a value as `providerOptions.deepseek.`. -/// -/// The TS tests pass `providerOptions: { deepseek: { ... } }`. The Rust -/// `CallOptions.provider_options` is keyed by provider name. -fn deepseek_opts(value: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("deepseek".to_string(), value); - Some(m) -} - /// Mount a JSON chat-completion response on `/chat/completions`. async fn mock_json(server: &MockServer, body: Value) { Mock::given(method("POST")) @@ -177,16 +164,6 @@ fn reasoning_deltas(parts: &[StreamPart]) -> Vec { .collect() } -/// True if the warnings contain a `Compatibility` warning for `feature`. -fn has_reasoning_compatibility_warning(warnings: &[Warning]) -> bool { - warnings.iter().any(|w| { - matches!( - w, - Warning::Compatibility { feature, .. } if feature == "reasoning" - ) - }) -} - // ════════════════════════════════════════════════════════════════════════════ // doGenerate — reasoning_content response extraction // @@ -402,300 +379,6 @@ async fn should_extract_reasoning_content_and_text_from_deepseek_reasoning_fixtu // 透传语义(原断言特化的测试已按新语义改写)。 // ════════════════════════════════════════════════════════════════════════════ -/// stage2-001: DeepSeek 特化退役——`providerOptions.deepseek.thinking` 不再 -/// 翻译为请求体 `thinking` 字段(thinking 注入改由用户 bodyOverrides 定义)。 -#[tokio::test] -async fn should_not_inject_thinking_from_deepseek_provider_options() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-reasoner")); - assert!( - body.get("thinking").is_none(), - "stage2-001: DeepSeek 特化退役,thinking 不再由 providerOptions.deepseek 注入,改用 bodyOverrides" - ); -} - -/// stage2-001: 顶层 `reasoning: 'high'` 透传为 `reasoning_effort: "high"`, -/// 不再注入 `thinking`。 -#[tokio::test] -async fn should_passthrough_reasoning_high_to_reasoning_effort_without_thinking() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: thinking 注入已退役,开思考改用 bodyOverrides:{{thinking:{{type:'enabled'}}}}" - ); - assert_eq!(body["reasoning_effort"], json!("high")); -} - -/// stage2-001: 顶层 `reasoning: 'none'` 透传为 `reasoning_effort: "none"`, -/// 不含 thinking 注入(任务验收:透传语义)。 -#[tokio::test] -async fn should_passthrough_reasoning_none_to_reasoning_effort_without_thinking() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::None); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: thinking 注入已退役,关思考改用 bodyOverrides:{{thinking:{{type:'disabled'}}}}" - ); - assert_eq!(body["reasoning_effort"], json!("none")); -} - -/// stage2-001: `reasoning: 'xhigh'` 直传为 `reasoning_effort: "xhigh"`, -/// 无归一化(xhigh→max 已退役),无 warning。 -#[tokio::test] -async fn should_passthrough_reasoning_xhigh_without_normalization() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Xhigh); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("xhigh")); - let warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - warning.is_none(), - "stage2-001: 归一化已退役,xhigh 直传不产生归一化 warning" - ); -} - -/// TS: "should map top-level reasoning low to reasoning_effort low without a -/// compatibility warning" (line ~175). -/// -/// PASSES: Rust maps `Low` → `reasoning_effort: "low"` with no compatibility -/// warning. -#[tokio::test] -async fn should_map_top_level_reasoning_low_to_reasoning_effort_low_without_warning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Low); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("low")); - assert!( - !has_reasoning_compatibility_warning(&result.warnings), - "low should not emit a compatibility warning" - ); -} - -/// TS: "should map top-level reasoning medium to reasoning_effort medium" -/// (line ~192). -/// -/// PASSES: Rust maps `Medium` → `reasoning_effort: "medium"`. -#[tokio::test] -async fn should_map_top_level_reasoning_medium_to_reasoning_effort_medium() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Medium); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("medium")); -} - -/// stage2-001: `reasoning: 'minimal'` 直传为 `reasoning_effort: "minimal"`, -/// 无归一化(minimal→low 已退役),无 warning。 -#[tokio::test] -async fn should_passthrough_reasoning_minimal_without_normalization() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Minimal); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("minimal")); - let warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - warning.is_none(), - "stage2-001: 归一化已退役,minimal 直传不产生归一化 warning" - ); -} - -/// stage2-001: `providerOptions.deepseek.reasoningEffort` 不再被翻译 -/// (deepseek 特化退役,共享 builder 只读 `openai` key)→ `reasoning_effort` 缺席。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_reasoning_effort() { - for effort in ["low", "medium", "xhigh"] { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "reasoningEffort": effort })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("reasoning_effort").is_none(), - "stage2-001: deepseek 特化退役,providerOptions.deepseek.reasoningEffort 被忽略,effort={effort} 不出现" - ); - } -} - -/// stage2-001: `providerOptions.deepseek.thinking.type=adaptive` 不再翻译为 -/// 请求体 `thinking` 字段(特化退役)→ thinking 缺席。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_thinking_adaptive() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "adaptive" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: providerOptions.deepseek.thinking 被忽略,改用 bodyOverrides" - ); -} - -/// stage2-001: `providerOptions.deepseek.reasoningEffort = "max"` 被忽略 → -/// `reasoning_effort` 缺席;thinking 亦缺席。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_reasoning_effort_max() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "reasoningEffort": "max" })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("reasoning_effort").is_none(), - "stage2-001: providerOptions.deepseek.reasoningEffort 被忽略" - ); - // 未设置 reasoning/thinking 时,请求体不含 thinking,依赖 API 默认。 - assert!(body.get("thinking").is_none()); -} - -/// stage2-001: `providerOptions.deepseek.thinking` 不再优先于顶层 `reasoning` -/// (特化退役)——thinking 缺席,`reasoning:'none'` 透传为 `reasoning_effort:"none"`。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_thinking_over_top_level_reasoning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::None); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: providerOptions.deepseek.thinking 被忽略,改用 bodyOverrides" - ); - assert_eq!(body["reasoning_effort"], json!("none")); -} - -/// stage2-001: `providerOptions.deepseek.reasoningEffort` 被忽略,顶层 -/// `reasoning:'high'` 透传为 `reasoning_effort:"high"`。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_reasoning_effort_over_top_level_reasoning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - options.provider_options = deepseek_opts(json!({ "reasoningEffort": "max" })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("high")); -} - -/// TS: "should not set thinking when reasoning is not specified" (line ~302). -/// -/// PASSES: `thinking` is never emitted by the shared builder. -#[tokio::test] -async fn should_not_set_thinking_when_reasoning_not_specified() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "thinking should be absent when no reasoning is specified" - ); -} - // ════════════════════════════════════════════════════════════════════════════ // doStream — reasoning_content streaming // @@ -937,20 +620,3 @@ async fn should_stream_reasoning_from_deepseek_reasoning_fixture() { // ───────────────────────────────────────────────────────────────────────────── // Local fixtures // ───────────────────────────────────────────────────────────────────────────── - -/// A minimal non-streaming chat-completion body returning "Hello, World!". -/// Used by the request-body tests (the response content is irrelevant there). -fn text_completion_body() -> Value { - json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello, World!" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }) -} diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs index e8654f43b..be16815db 100644 --- a/aimux-providers/tests/google_model_test.rs +++ b/aimux-providers/tests/google_model_test.rs @@ -495,71 +495,8 @@ mod do_generate { // ── should expose response id ───────────────────────────────────────────── - #[tokio::test] - async fn should_expose_response_id() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }], - "responseId": "TestResponseId123" - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.response.id, Some("TestResponseId123".to_string())); - } - // ── should return the request body for debugging ────────────────────────── - #[tokio::test] - async fn should_return_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let body = result - .request_body - .as_ref() - .expect("request body should be present"); - // contents should be [{ role: "user", parts: [{ text: "Hello" }] }] - assert_eq!(body["contents"][0]["role"], "user"); - assert_eq!(body["contents"][0]["parts"][0]["text"], "Hello"); - } - // ── should expose finishMessage in provider metadata ───────────────────── // TS: "should expose finishMessage in provider metadata" @@ -776,37 +713,6 @@ mod do_generate { // ── should expose response headers ─────────────────────────────────────── // TS: "should expose the raw response headers" - #[tokio::test] - async fn should_expose_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - })), - ) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let headers = result.response_headers.expect("response headers"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); - } - // ── should handle empty content with MALFORMED_FUNCTION_CALL ───────────── // TS: "should handle MALFORMED_FUNCTION_CALL finish reason and empty content object" diff --git a/aimux-providers/tests/google_provider_tools_test.rs b/aimux-providers/tests/google_provider_tools_test.rs index f24929afd..b654f257f 100644 --- a/aimux-providers/tests/google_provider_tools_test.rs +++ b/aimux-providers/tests/google_provider_tools_test.rs @@ -587,113 +587,7 @@ mod prepare_tools { ); } - #[tokio::test] - async fn warn_when_mixing_function_and_provider_tools_pre_gemini_3() { - // TS: "should warn when mixing function and provider-defined tools" - // On pre-Gemini-3 models the function tool is dropped and a warning is - // emitted for the combination. - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.5-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let function_tool = Tool::Function( - aimux_core::tool::FunctionTool::new( - "testFunction".to_string(), - json!({ "type": "object", "properties": {} }), - ) - .with_description("A test function".to_string()), - ); - let model = provider_at(&server.uri()).model("gemini-2.5-flash"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![function_tool, google_search_tool()], - )) - .await - .expect("do_generate should succeed"); - - let has_warning = result.warnings.iter().any(|w| matches!( - w, - Warning::Unsupported { feature, .. } if feature == "combination of function and provider-defined tools" - )); - assert!( - has_warning, - "expected a mixed-tools warning, got {:?}", - result.warnings - ); - // The request body should keep only the provider tool (function dropped). - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["tools"], json!([{ "googleSearch": {} }])); - } - // ── search tool selection (request body via do_generate) ────────────────── - - #[tokio::test] - async fn search_tool_selection_google_search_for_gemini_2_0_pro() { - // TS: "should use googleSearch for gemini-2.0-pro" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![google_search_tool()], - )) - .await - .expect("do_generate should succeed"); - - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["tools"], json!([{ "googleSearch": {} }])); - } - - #[tokio::test] - async fn search_tool_selection_url_context_for_gemini_2_0_pro() { - // TS: "should use urlContextTool for gemini-2.0-pro" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools(test_prompt(), vec![url_context_tool()])) - .await - .expect("do_generate should succeed"); - - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["tools"], json!([{ "urlContext": {} }])); - } } // ════════════════════════════════════════════════════════════════════════════ @@ -1071,41 +965,6 @@ mod do_generate { // Gemini may emit more than one `codeExecutionResult` for a single // `executableCode`; every result must retain the originating call id. - #[tokio::test] - async fn code_execution_request_body_contains_code_execution() { - // TS: "should handle code execution tool calls" (request body portion) - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "executableCode": { "language": "PYTHON", "code": "print(1+1)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } } - ], - "role": "model" - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) - .await - .expect("do_generate should succeed"); - - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["tools"], json!([{ "codeExecution": {} }])); - } - #[tokio::test] async fn code_execution_tool_call_content() { // TS: "should handle code execution tool calls" (content portion) diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 500de49cb..9b772d45d 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -1546,24 +1546,6 @@ mod do_generate { let body = first_request_body(&server).await; assert_eq!(body["response_format"]["json_schema"]["strict"], false); } - - /// TS: "should send request body" (request.body) - #[tokio::test] - async fn request_body_string() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let request_body = result.request_body.expect("should have request body"); - assert_eq!(request_body["model"], "gemma2-9b-it"); - assert_eq!(request_body["messages"][0]["content"], "Hello"); - } } // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index a00387a8f..81a9ac54f 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -17,7 +17,6 @@ //! resulting request path is `/responses`. use aimux_core::tool::{RawToolCall, ToolResult}; -use std::collections::HashMap; use futures::StreamExt; use serde_json::{Value, json}; @@ -30,10 +29,9 @@ use aimux_core::generate::{GenerateTextOptions, generate_text}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; +use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::huggingface::responses::convert_to_huggingface_responses_messages; @@ -306,50 +304,6 @@ async fn should_handle_missing_usage_gracefully() { assert_eq!(result.usage.output_tokens.reasoning, None); } -/// TS: doGenerate › basic text response › "should send model id, settings, and -/// input" -#[tokio::test] -async fn should_send_model_id_settings_and_input() { - let server = MockServer::start().await; - mock_json(&server, basic_response_body()).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let mut options = default_options(prompt); - options.temperature = Some(0.5); - options.top_p = Some(0.3); - options.max_output_tokens = Some(100); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-ai/DeepSeek-V3-0324")); - assert_eq!(body["temperature"], json!(0.5)); - assert_eq!(body["top_p"], json!(0.3)); - assert_eq!(body["max_output_tokens"], json!(100)); - assert_eq!(body["stream"], json!(false)); - assert_eq!( - body["input"], - json!([ - { "role": "system", "content": "You are a helpful assistant." }, - { "role": "user", "content": [{ "type": "input_text", "text": "Hello" }] } - ]) - ); -} - /// TS: doGenerate › basic text response › "should handle unsupported settings /// with warnings" #[tokio::test] @@ -811,33 +765,6 @@ async fn should_handle_streaming_errors() { ))); } -/// TS: doStream › "should send correct streaming request" -#[tokio::test] -async fn should_send_correct_streaming_request() { - let server = MockServer::start().await; - let chunks = sse_body(&[sse_event( - r#"{"type":"response.completed","response":{"id":"resp_test","status":"completed"},"sequence_number":1}"#, - )]); - mock_sse(&server, chunks).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let mut options = default_options(test_prompt()); - options.temperature = Some(0.7); - - let result = model.do_stream(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-ai/DeepSeek-V3-0324")); - assert_eq!(body["temperature"], json!(0.7)); - assert_eq!(body["stream"], json!(true)); - assert_eq!( - body["input"], - json!([{ "role": "user", "content": [{ "type": "input_text", "text": "Hello" }] }]) - ); -} - // ════════════════════════════════════════════════════════════════════════════ // Message conversion // ════════════════════════════════════════════════════════════════════════════ @@ -870,39 +797,6 @@ async fn stub_empty_response(server: &MockServer) { .await; } -/// TS: message conversion › "should convert user messages with images" -#[tokio::test] -async fn should_convert_user_messages_with_images() { - let server = MockServer::start().await; - stub_empty_response(&server).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What do you see?"), - ContentPart::file_base64("AQIDBA==", "image/jpeg"), - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["input"][0]["content"], - json!([ - { "text": "What do you see?", "type": "input_text" }, - { "image_url": "data:image/jpeg;base64,AQIDBA==", "type": "input_image" } - ]) - ); -} - /// TS: message conversion › "should throw for file parts with provider /// references" #[tokio::test] @@ -933,49 +827,6 @@ async fn should_throw_for_file_parts_with_provider_references() { ); } -/// TS: message conversion › "should handle assistant messages" -#[tokio::test] -async fn should_handle_assistant_messages() { - let server = MockServer::start().await; - stub_empty_response(&server).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hi there!")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("How are you?")], - ..Default::default() - }, - ]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["input"], - json!([ - { "content": [{ "text": "Hello", "type": "input_text" }], "role": "user" }, - { "content": [{ "text": "Hi there!", "type": "output_text" }], "role": "assistant" }, - { "content": [{ "text": "How are you?", "type": "input_text" }], "role": "user" } - ]) - ); -} - /// TS: message conversion › "should warn about unsupported assistant content /// types" — tool-call, tool-result, and reasoning in assistant messages /// produce NO warnings (they are silently skipped). @@ -1231,136 +1082,6 @@ async fn should_stream_tool_calls() { // Structured output // ════════════════════════════════════════════════════════════════════════════ -/// TS: structured output › "should send text.format for structured output" -#[tokio::test] -async fn should_send_text_format_for_structured_output() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_structured", - "model": "moonshotai/Kimi-K2-Instruct", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [ - { - "id": "msg_structured", - "type": "message", - "role": "assistant", - "status": "completed", - "content": [ - { "type": "output_text", "text": "{\"name\": \"John Doe\", \"age\": 30}" } - ] - } - ], - "output_text": null - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("moonshotai/Kimi-K2-Instruct"); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" } - }, - "required": ["name", "age"] - })), - name: None, - description: None, - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["text"]["format"], - json!({ - "type": "json_schema", - "strict": false, - "name": "response", - "schema": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" } - }, - "required": ["name", "age"] - } - }) - ); - // description should NOT be present (it was None). - assert!( - body["text"]["format"].get("description").is_none(), - "description should be absent when None" - ); -} - -/// TS: structured output › "should handle structured output with custom name -/// and description" -#[tokio::test] -async fn should_handle_structured_output_with_custom_name_and_description() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_structured", - "model": "moonshotai/Kimi-K2-Instruct", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "{}" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("moonshotai/Kimi-K2-Instruct"); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: Some(json!({ "type": "object", "properties": { "name": { "type": "string" } } })), - name: Some("person_profile".to_string()), - description: Some("A person profile with basic information".to_string()), - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["text"]["format"]["name"], json!("person_profile")); - assert_eq!( - body["text"]["format"]["description"], - json!("A person profile with basic information") - ); -} - // ════════════════════════════════════════════════════════════════════════════ // Reasoning // ════════════════════════════════════════════════════════════════════════════ @@ -1575,191 +1296,10 @@ async fn should_stream_reasoning_content() { // Provider options // ════════════════════════════════════════════════════════════════════════════ -/// TS: provider options › "should send provider-specific options" -#[tokio::test] -async fn should_send_provider_specific_options() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_provider_options", - "model": "deepseek-ai/DeepSeek-V3-0324", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let mut options = default_options(test_prompt()); - let mut po = HashMap::new(); - po.insert( - "huggingface".to_string(), - json!({ - "metadata": { "key": "value" }, - "instructions": "Be concise", - "strictJsonSchema": true - }), - ); - options.provider_options = Some(po); - options.response_format = Some(ResponseFormat::Json { - schema: Some(json!({ "type": "object" })), - name: None, - description: None, - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["metadata"], json!({ "key": "value" })); - assert_eq!(body["instructions"], json!("Be concise")); - assert_eq!(body["text"]["format"]["strict"], json!(true)); -} - // ════════════════════════════════════════════════════════════════════════════ // Tool preparation // ════════════════════════════════════════════════════════════════════════════ -/// TS: tool preparation › "should prepare tools correctly" -#[tokio::test] -async fn should_prepare_tools_correctly() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_tools", - "model": "deepseek-ai/DeepSeek-V3-0324", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![Tool::from( - FunctionTool::new( - "getWeather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"] - }), - ) - .with_description("Get weather information"), - )]); - options.tool_choice = ToolChoice::Tool { - tool_name: "getWeather".to_string(), - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["tools"], - json!([{ - "type": "function", - "name": "getWeather", - "description": "Get weather information", - "parameters": { - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"] - } - }]) - ); - assert_eq!( - body["tool_choice"], - json!({ "type": "function", "function": { "name": "getWeather" } }) - ); -} - -/// TS: tool preparation › "should handle auto and required tool choices" -#[tokio::test] -async fn should_handle_auto_and_required_tool_choices() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_tools", - "model": "deepseek-ai/DeepSeek-V3-0324", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - // Test auto - let mut options = default_options(test_prompt()); - options.tools = Some(vec![Tool::from(FunctionTool::new( - "test", - json!({ "type": "object" }), - ))]); - options.tool_choice = ToolChoice::Auto; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("auto")); - - // Test required - let mut options = default_options(test_prompt()); - options.tools = Some(vec![Tool::from(FunctionTool::new( - "test", - json!({ "type": "object" }), - ))]); - options.tool_choice = ToolChoice::Required; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("required")); -} - // ════════════════════════════════════════════════════════════════════════════ // Top-level-only media type resolution // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/mistral_model_test.rs b/aimux-providers/tests/mistral_model_test.rs index 49d45222b..832a9af6a 100644 --- a/aimux-providers/tests/mistral_model_test.rs +++ b/aimux-providers/tests/mistral_model_test.rs @@ -15,10 +15,9 @@ use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; +use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; use aimux_providers::{MistralConfig, MistralProvider}; @@ -37,23 +36,6 @@ fn default_options(prompt: LanguageModelPrompt) -> CallOptions { CallOptions::new(prompt) } -fn test_tool() -> Tool { - Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - }) -} - async fn mock_json_response(server: &MockServer, body: Value) { Mock::given(method("POST")) .and(path("/chat/completions")) @@ -121,61 +103,6 @@ fn text_deltas(parts: &[StreamPart]) -> Vec { // doGenerate // ════════════════════════════════════════════════════════════════════════════ -/// TS: "should extract text content" -#[tokio::test] -async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "5319bd0299614c679a0068a4f2c8ffd0", - "created": 1769088720, - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 13, - "total_tokens": 447, - "completion_tokens": 434 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "tool_calls": null, - "content": "Hello, World!" - } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); - assert_eq!( - result.response.id.as_deref(), - Some("5319bd0299614c679a0068a4f2c8ffd0") - ); - assert_eq!( - result.response.model_id.as_deref(), - Some("mistral-small-latest") - ); -} - /// TS: "should extract usage" #[tokio::test] async fn should_extract_usage() { @@ -318,167 +245,6 @@ async fn should_extract_tool_call() { assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } -/// TS: "should send correct request body" -#[tokio::test] -async fn should_send_correct_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "Hello" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // Verify the request body. - let request_body = result.request_body.expect("request body should be set"); - assert_eq!(request_body["model"], "mistral-small-latest"); - assert_eq!( - request_body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - // No extra keys should be present. - assert!(request_body.get("max_tokens").is_none()); - assert!(request_body.get("temperature").is_none()); - assert!(request_body.get("tools").is_none()); -} - -/// TS: "should pass tools and toolChoice" — Mistral maps `required` → `"any"`. -#[tokio::test] -async fn should_pass_tools_with_tool_choice_required() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Required, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["tool_choice"], - json!("any"), - "Mistral uses 'any' for required tool choice" - ); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should pass tools and toolChoice" — `tool` choice filters tools. -#[tokio::test] -async fn should_pass_tools_with_tool_choice_tool() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["tool_choice"], json!("any")); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should forward stopSequences as the Mistral stop parameter" -#[tokio::test] -async fn should_forward_stop_sequences() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - stop_sequences: Some(vec!["foo".to_string(), "bar".to_string()]), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["stop"], json!(["foo", "bar"])); -} - /// TS: "should map model_length finish reason to length" #[tokio::test] async fn should_map_model_length_finish_reason() { @@ -630,40 +396,6 @@ async fn should_stream_tool_call() { ); } -/// TS: "should pass the messages" (streaming request body). -#[tokio::test] -async fn should_send_streaming_request_body() { - let server = MockServer::start().await; - mock_sse_response( - &server, - &sse_body(&[ - r#"data: {"id":"test","model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}} - -"#, - ]), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "mistral-small-latest"); - assert_eq!(body["stream"], json!(true)); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - // No stream_options (Mistral doesn't use it). - assert!(body.get("stream_options").is_none()); -} - /// TS: "should stream text with content objects" (array content format) #[tokio::test] async fn should_stream_text_with_array_content() { @@ -740,33 +472,6 @@ fn ok_text_body() -> Value { }) } -/// TS: "should forward presencePenalty and frequencyPenalty without unsupported warnings" -#[tokio::test] -async fn should_forward_presence_and_frequency_penalty() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - presence_penalty: Some(0.1), - frequency_penalty: Some(0.2), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - // f32 round-trip: compare with tolerance rather than exact equality. - let pp = body["presence_penalty"].as_f64().unwrap(); - let fp = body["frequency_penalty"].as_f64().unwrap(); - assert!((pp - 0.1).abs() < 1e-6, "presence_penalty={pp}"); - assert!((fp - 0.2).abs() < 1e-6, "frequency_penalty={fp}"); -} - /// TS: "should pass headers" — request-level custom headers reach the server. #[tokio::test] async fn should_pass_request_headers() { @@ -801,159 +506,6 @@ async fn should_pass_request_headers() { assert!(result.is_ok(), "request should match the header mock"); } -/// TS: "should expose the raw response headers" -#[tokio::test] -async fn should_expose_raw_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_text_body()), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should send additional response information" (id, modelId) -#[tokio::test] -async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("test-id")); - assert_eq!( - result.response.model_id.as_deref(), - Some("mistral-small-latest") - ); -} - -/// TS: "should inject JSON instruction for JSON response format" — Rust sets -/// `response_format: { type: "json_object" }` (no schema). The Rust impl does -/// not inject a system message, so we assert only the response_format field. -#[tokio::test] -async fn should_set_json_object_response_format() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["response_format"], json!({ "type": "json_object" })); -} - -/// TS: "should inject JSON instruction for JSON response format with schema" -#[tokio::test] -async fn should_set_json_schema_response_format() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let schema = json!({ - "type": "object", - "properties": { "name": { "type": "string" } } - }); - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["response_format"]["type"], json!("json_schema")); - assert_eq!(body["response_format"]["json_schema"]["schema"], schema); - assert_eq!( - body["response_format"]["json_schema"]["name"], - json!("response") - ); - assert_eq!( - body["response_format"]["json_schema"]["strict"], - json!(false) - ); -} - -/// TS: "should avoid duplication when trailing assistant message" — continuation -/// mode sets `prefix: true` on the trailing assistant message. -#[tokio::test] -async fn should_mark_trailing_assistant_message_as_prefix() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("prefix ")], - ..Default::default() - }, - ]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - let messages = body["messages"].as_array().expect("messages array"); - let last = messages.last().expect("last message"); - assert_eq!(last["role"], json!("assistant")); - assert_eq!(last["content"], json!("prefix ")); - assert_eq!(last["prefix"], json!(true)); -} - /// TS: "should extract content when message content is a content object" #[tokio::test] async fn should_extract_content_when_message_content_is_object() { @@ -1244,44 +796,6 @@ async fn should_extract_multiple_tool_calls() { assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } -/// TS: "should send request body" — verify all standard optional fields. -#[tokio::test] -async fn should_send_default_request_body_shape() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - max_output_tokens: Some(100), - temperature: Some(0.7), - top_p: Some(0.9), - seed: Some(42), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "mistral-small-latest"); - assert_eq!(body["max_tokens"], json!(100)); - // f32 round-trip: compare with tolerance. - assert!((body["temperature"].as_f64().unwrap() - 0.7).abs() < 1e-6); - assert!((body["top_p"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["random_seed"], json!(42)); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - assert!(body.get("stop").is_none()); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - assert!(body.get("response_format").is_none()); - assert!(body.get("stream").is_none()); -} - /// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). #[tokio::test] async fn should_handle_rate_limit_error() { @@ -1428,39 +942,6 @@ async fn should_stream_interleaved_thinking_and_text() { ); } -/// TS: "should expose the raw response headers" (streaming) -#[tokio::test] -async fn should_expose_raw_response_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse_json_body(&[ - r#"{"id":"test","model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}}"#, - ])), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - /// TS: "should pass headers" (streaming) — request-level custom headers. #[tokio::test] async fn should_pass_request_headers_stream() { @@ -1586,53 +1067,3 @@ async fn should_stream_error_in_chunk() { "expected a 429 stream error, got {parts:?}" ); } - -/// TS: "should avoid duplication when trailing assistant message" (streaming) -/// — the trailing assistant message is sent with `prefix: true`. -#[tokio::test] -async fn should_stream_with_trailing_assistant_prefix() { - let server = MockServer::start().await; - mock_sse_response( - &server, - &sse_json_body(&[ - r#"{"id":"53ff663126294946a6b7a4747b70597e","object":"chat.completion.chunk","created":1750537996,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"logprobs":null}]}"#, - r#"{"id":"53ff663126294946a6b7a4747b70597e","object":"chat.completion.chunk","created":1750537996,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":"prefix"},"finish_reason":null,"logprobs":null}]}"#, - r#"{"id":"53ff663126294946a6b7a4747b70597e","object":"chat.completion.chunk","created":1750537996,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":" and more content"},"finish_reason":"stop","logprobs":null}],"usage":{"prompt_tokens":4,"total_tokens":36,"completion_tokens":32}}"#, - ]), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("prefix ")], - ..Default::default() - }, - ]; - - let result = model - .do_stream(&default_options(prompt)) - .await - .expect("do_stream should succeed"); - - let body = result.request_body.clone().expect("body"); - let messages = body["messages"].as_array().expect("messages array"); - let last = messages.last().expect("last message"); - assert_eq!(last["role"], json!("assistant")); - assert_eq!(last["prefix"], json!(true)); - - let parts = collect_stream(result).await; - assert_eq!( - text_deltas(&parts), - vec!["prefix".to_string(), " and more content".to_string()] - ); -} diff --git a/aimux-providers/tests/mistral_remaining_test.rs b/aimux-providers/tests/mistral_remaining_test.rs index b06ba2332..bbf117500 100644 --- a/aimux-providers/tests/mistral_remaining_test.rs +++ b/aimux-providers/tests/mistral_remaining_test.rs @@ -1,4 +1,4 @@ -//! Remaining Mistral tests translated from TS that are NOT already covered by +//! Remaining Mistral tests translated from TS that are NOT already covered by //! `mistral_model_test.rs`. Covers: //! //! - `mistral-prepare-tools.test.ts` — all 8 cases (prepare_tools unit tests) @@ -64,20 +64,6 @@ async fn mock_json_response(server: &MockServer, body: Value) { .await; } -fn ok_mistral_body() -> Value { - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }) -} - // ════════════════════════════════════════════════════════════════════════════ // mistral-prepare-tools.test.ts // ════════════════════════════════════════════════════════════════════════════ @@ -830,31 +816,3 @@ fn build_request_body_json_object_when_no_schema() { // ════════════════════════════════════════════════════════════════════════════ // doGenerate — request body via the model (verifies build_request_body wiring) // ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should send correct request body" — the model wires build_request_body -/// into the HTTP request and exposes it via `result.request_body`. -#[tokio::test] -async fn should_expose_request_body_from_model() { - let server = MockServer::start().await; - mock_json_response(&server, ok_mistral_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!(body["model"], "mistral-small-latest"); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - // No optional fields set → absent. - assert!(body.get("max_tokens").is_none()); - assert!(body.get("tools").is_none()); - assert!(body.get("stream").is_none()); -} diff --git a/aimux-providers/tests/openai_compatible_test.rs b/aimux-providers/tests/openai_compatible_test.rs index e554f4287..645e1ad29 100644 --- a/aimux-providers/tests/openai_compatible_test.rs +++ b/aimux-providers/tests/openai_compatible_test.rs @@ -29,7 +29,7 @@ use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; +use aimux_core::types::FinishReasonUnified; use aimux_providers::{ ProviderOptions, bedrock_mantle::BedrockMantleConfig, bedrock_mantle::BedrockMantleProvider, @@ -851,35 +851,6 @@ mod default_base_urls { // rate-limit mapping. // ════════════════════════════════════════════════════════════════════════════ -/// TS (deepseek): a top-level `reasoning` value maps to `reasoning_effort`. -#[tokio::test] -async fn deepseek_maps_reasoning_to_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let model = provider( - "deepseek", - Some("test-api-key".to_string()), - "deepseek-reasoner", - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("provider construction"); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["reasoning_effort"], json!("high")); -} - /// TS (groq): usage tokens are extracted from the response. #[tokio::test] async fn groq_extracts_usage() { @@ -951,43 +922,6 @@ async fn deepseek_rate_limit_maps_to_rate_limited() { ); } -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn groq_exposes_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let model = provider( - "groq", - Some("test-api-key".to_string()), - "llama-3.3-70b-versatile", - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("provider construction"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - // P1 thin-wrapper providers (provider-research batch). openai_compatible_tests!(chutes, "chutes", "default"); openai_compatible_tests!(meta, "meta", "meta/muse-spark-1.1"); diff --git a/aimux-providers/tests/openai_model_extended_test.rs b/aimux-providers/tests/openai_model_extended_test.rs index ad84a04a9..58d7fbd5b 100644 --- a/aimux-providers/tests/openai_model_extended_test.rs +++ b/aimux-providers/tests/openai_model_extended_test.rs @@ -15,7 +15,7 @@ use aimux_core::message::Role; use aimux_core::options::{CallOptions, ToolChoice}; use aimux_core::result::{GenerateContent, Source}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; +use aimux_core::types::FinishReasonUnified; use aimux_providers::{OpenAIConfig, OpenAIProvider}; @@ -53,11 +53,7 @@ fn default_opts(p: LanguageModelPrompt) -> CallOptions { recording_context: None, } } -fn po(map: Value) -> Option> { - let mut h = std::collections::HashMap::new(); - h.insert("openai".to_string(), map); - Some(h) -} + fn sse_event(json_str: &str) -> String { format!("data: {json_str}\n\n") } @@ -266,408 +262,6 @@ mod do_generate_extended { assert_eq!(result.usage.output_tokens.reasoning, Some(10)); assert_eq!(result.usage.output_tokens.text, Some(10)); } - - /// TS: "should send request body" — verify the default request body shape - #[tokio::test] - async fn sends_default_request_body() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_opts(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["model"], json!("gpt-3.5-turbo")); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": "Hello" }]) - ); - } - - /// TS: "should pass the model and the messages" - #[tokio::test] - async fn passes_model_and_messages() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_opts(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!( - body.clone(), - json!({ "model": "gpt-3.5-turbo", "messages": [{ "role": "user", "content": "Hello" }] }) - ); - } - - /// TS: "should pass settings" (logitBias, parallelToolCalls, user) - #[tokio::test] - async fn passes_settings() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po( - json!({ "logitBias": { "50256": -100 }, "parallelToolCalls": false, "user": "test-user-id" }), - ), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["logit_bias"], json!({ "50256": -100 })); - assert_eq!(body["parallel_tool_calls"], json!(false)); - assert_eq!(body["user"], json!("test-user-id")); - } - - /// TS: "should not set reasoning_effort when reasoning is 'provider-default'" - #[tokio::test] - async fn no_reasoning_effort_for_provider_default() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - reasoning: Some(ReasoningEffort::ProviderDefault), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert!(body.get("reasoning_effort").is_none()); - } - - /// TS: "should pass top-level reasoning as reasoning_effort" - #[tokio::test] - async fn top_level_reasoning_as_effort() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["reasoning_effort"], json!("medium")); - } - - /// TS: "should pass reasoningEffort setting from provider metadata" - #[tokio::test] - async fn reasoning_effort_from_provider() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "reasoningEffort": "low" })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["reasoning_effort"], json!("low")); - } - - /// TS: "should pass textVerbosity setting from provider options" - #[tokio::test] - async fn text_verbosity() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-4o", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o"); - - let opts = CallOptions { - provider_options: po(json!({ "textVerbosity": "low" })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["verbosity"], json!("low")); - } - - /// TS: reasoning models — "should clear out temperature, top_p, etc." - #[tokio::test] - async fn reasoning_clears_temperature() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - temperature: Some(0.5), - top_p: Some(0.7), - frequency_penalty: Some(0.2), - presence_penalty: Some(0.3), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert!(body.get("temperature").is_none()); - assert!(body.get("top_p").is_none()); - assert!(body.get("frequency_penalty").is_none()); - assert!(body.get("presence_penalty").is_none()); - assert_eq!(result.warnings.len(), 4); - } - - /// TS: "should convert maxOutputTokens to max_completion_tokens" - #[tokio::test] - async fn reasoning_max_completion_tokens() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - max_output_tokens: Some(1000), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["max_completion_tokens"], json!(1000)); - assert!(body.get("max_tokens").is_none()); - } - - /// TS: "should use developer messages for o1" - #[tokio::test] - async fn developer_messages_for_o1() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o1", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o1"); - - let p: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let result = model - .do_generate(&default_opts(p)) - .await - .expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["messages"][0]["role"], json!("developer")); - } - - /// TS: "should send store extension setting" - #[tokio::test] - async fn store_extension() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po(json!({ "store": true })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["store"], json!(true)); - } - - /// TS: "should send metadata extension values" - #[tokio::test] - async fn metadata_extension() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po(json!({ "metadata": { "custom": "value" } })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["metadata"], json!({ "custom": "value" })); - } - - /// TS: "should send prediction extension setting" - #[tokio::test] - async fn prediction_extension() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po( - json!({ "prediction": { "type": "content", "content": "Hello, World!" } }), - ), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!( - body["prediction"], - json!({ "type": "content", "content": "Hello, World!" }) - ); - } - - /// TS: "should send serviceTier flex processing setting" - #[tokio::test] - async fn service_tier_flex() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["service_tier"], json!("flex")); - } - - /// TS: "should send serviceTier priority processing setting" - #[tokio::test] - async fn service_tier_priority() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-4o-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["service_tier"], json!("priority")); - } - - /// TS: "should remove temperature setting for gpt-4o-search-preview" - #[tokio::test] - async fn search_preview_removes_temperature() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-4o-search-preview", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-search-preview"); - - let opts = CallOptions { - temperature: Some(0.7), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert!(body.get("temperature").is_none()); - assert_eq!(result.warnings.len(), 1); - } } // ════════════════════════════════════════════════════════════════════════════ @@ -812,125 +406,4 @@ mod do_stream_extended { other => panic!("expected Finish, got {other:?}"), } } - - /// TS: "should send store extension setting" (streaming) - #[tokio::test] - async fn stream_store_extension() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po(json!({ "store": true })), - ..default_opts(test_prompt()) - }; - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["store"], json!(true)); - assert_eq!(body["stream"], json!(true)); - } - - /// TS: "should send metadata extension values" (streaming) - #[tokio::test] - async fn stream_metadata_extension() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po(json!({ "metadata": { "custom": "value" } })), - ..default_opts(test_prompt()) - }; - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["metadata"], json!({ "custom": "value" })); - } - - /// TS: "should send serviceTier flex processing setting in streaming" - #[tokio::test] - async fn stream_service_tier_flex() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["service_tier"], json!("flex")); - } - - /// TS: "should send serviceTier priority processing setting in streaming" - #[tokio::test] - async fn stream_service_tier_priority() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-4o-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-4o-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-4o-mini","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["service_tier"], json!("priority")); - } } diff --git a/aimux-providers/tests/openai_model_test.rs b/aimux-providers/tests/openai_model_test.rs index 73ef0fdda..852534b4f 100644 --- a/aimux-providers/tests/openai_model_test.rs +++ b/aimux-providers/tests/openai_model_test.rs @@ -18,17 +18,15 @@ //! `do_generate` / `do_stream`, and asserts on the result. use aimux_core::tool::RawToolCall; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, Request, ResponseTemplate}; +use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; +use aimux_core::generate::{GenerateTextOptions, generate_text}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; @@ -165,23 +163,6 @@ async fn mock_json_response_with_delay(server: &MockServer, body: Value, delay: .await; } -/// Standard mock for a JSON chat completion response with custom headers. -async fn mock_json_response_with_headers( - server: &MockServer, - body: Value, - headers: &[(&str, &str)], -) { - let mut template = ResponseTemplate::new(200).set_body_json(body); - for (k, v) in headers { - template = template.insert_header(*k, *v); - } - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(template) - .mount(server) - .await; -} - /// Standard mock for an SSE streaming response. async fn mock_sse_response(server: &MockServer, sse_body: &str) { Mock::given(method("POST")) @@ -195,25 +176,6 @@ async fn mock_sse_response(server: &MockServer, sse_body: &str) { .await; } -/// Mock for an SSE streaming response with custom headers. -async fn mock_sse_response_with_headers( - server: &MockServer, - sse_body: &str, - headers: &[(&str, &str)], -) { - let mut template = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()); - for (k, v) in headers { - template = template.insert_header(*k, *v); - } - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(template) - .mount(server) - .await; -} - /// Build an SSE event string from a JSON value. fn sse_event(json_str: &str) -> String { format!("data: {json_str}\n\n") @@ -303,44 +265,6 @@ mod do_generate { // ── L1: timestamp from `created` (RFC-0016) ─────────────────────────────── - /// The `created` Unix-seconds field must surface as an RFC3339 timestamp - /// in ResponseMetadata (matching AI SDK's `created*1000 → Date`). - #[tokio::test] - async fn should_fill_timestamp_from_created() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-ts", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "hi" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 1, "total_tokens": 2, "completion_tokens": 1 } - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.response.timestamp.as_deref(), - Some("2024-03-22T13:43:57+00:00"), - "created=1711115037 must convert to RFC3339" - ); - } - // ── should extract usage ────────────────────────────────────────────────── /// TS: "should extract usage" @@ -444,41 +368,6 @@ mod do_generate { // ── should send additional response information ─────────────────────────── - /// TS: "should send additional response information" - #[tokio::test] - async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "object": "chat.completion", - "created": 123, - "model": "test-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.response.id, Some("test-id".to_string())); - assert_eq!(result.response.model_id, Some("test-model".to_string())); - } - // ── should support partial usage ────────────────────────────────────────── /// TS: "should support partial usage" — `completion_tokens` absent. @@ -650,45 +539,6 @@ mod do_generate { // ── should expose the raw response headers ──────────────────────────────── - /// TS: "should expose the raw response headers" - #[tokio::test] - async fn should_expose_raw_response_headers() { - let server = MockServer::start().await; - mock_json_response_with_headers( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - &[("test-header", "test-value")], - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); - } - // ── should parse tool results ───────────────────────────────────────────── /// TS: "should parse tool results" — tool_calls in the response message. @@ -1378,162 +1228,6 @@ mod do_stream { } } - /// RFC-0031 §13.19b/c: a retryable error in the first SSE event is an - /// attempt failure, and the first normal event from the retry is put back - /// at the head of the returned stream rather than being swallowed. - #[tokio::test] - async fn first_sse_429_retries_and_preserves_the_retry_first_event() { - let server = MockServer::start().await; - let attempts = Arc::new(AtomicUsize::new(0)); - let responder_attempts = Arc::clone(&attempts); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(move |_request: &Request| { - let body = if responder_attempts.fetch_add(1, Ordering::SeqCst) == 0 { - sse_body(&[&sse_event( - r#"{"error":{"message":"slow down","type":"rate_limit_error","code":429}}"#, - )]) - } else { - sse_body(&[&sse_event( - r#"{"id":"chatcmpl-retry","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":"stop"}]}"#, - )]) - }; - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("retry-after-ms", "0") - .set_body_string(body) - }) - .mount(&server) - .await; - - let provider = - OpenAIProvider::new(OpenAIConfig::new("test-api-key").with_base_url(server.uri())); - let model = provider.model("gpt-3.5-turbo"); - let result = stream_text( - &model, - "Hello", - GenerateTextOptions { - max_retries: Some(1), - ..Default::default() - }, - ) - .await - .expect("the second stream attempt should succeed"); - let parts: Vec<_> = result - .stream - .map(|part| part.expect("Core stream part should succeed")) - .collect() - .await; - - assert_eq!(attempts.load(Ordering::SeqCst), 2); - assert_eq!(text_deltas(&parts), vec!["hello"]); - } - - #[tokio::test] - async fn first_sse_body_transport_error_enters_core_retry() { - use std::time::Duration; - - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - async fn read_request(socket: &mut tokio::net::TcpStream) { - let mut request = Vec::new(); - let mut buffer = [0_u8; 4096]; - let header_end = loop { - let count = socket.read(&mut buffer).await.unwrap(); - assert_ne!(count, 0, "client closed before sending request headers"); - request.extend_from_slice(&buffer[..count]); - if let Some(offset) = request.windows(4).position(|part| part == b"\r\n\r\n") { - break offset + 4; - } - }; - let headers = String::from_utf8_lossy(&request[..header_end]); - let content_length = headers - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().ok()) - .flatten() - }) - .unwrap_or(0); - while request.len() - header_end < content_length { - let count = socket.read(&mut buffer).await.unwrap(); - if count == 0 { - break; - } - request.extend_from_slice(&buffer[..count]); - } - } - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let attempts = Arc::new(AtomicUsize::new(0)); - let observed_attempts = Arc::clone(&attempts); - let server = tokio::spawn(async move { - for attempt in 0..2 { - let (mut socket, _) = listener.accept().await.unwrap(); - observed_attempts.fetch_add(1, Ordering::SeqCst); - read_request(&mut socket).await; - if attempt == 0 { - socket - .write_all( - b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: 10000\r\nconnection: close\r\n\r\ndata: {\"id\":\"partial", - ) - .await - .unwrap(); - } else { - let body = sse_body(&[&sse_event( - r#"{"id":"chatcmpl-retry","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"retried"},"finish_reason":"stop"}]}"#, - )]); - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - socket.write_all(response.as_bytes()).await.unwrap(); - } - socket.shutdown().await.unwrap(); - } - }); - - let provider = OpenAIProvider::new( - OpenAIConfig::new("test-api-key") - .with_base_url(base_url) - .with_retry_config(aimux_core::retry::RetryConfig { - max_retries: 1, - initial_delay: Duration::ZERO, - backoff_factor: 2, - }), - ); - let result = stream_text( - &provider.model("gpt-3.5-turbo"), - "Hello", - GenerateTextOptions { - max_retries: Some(1), - ..Default::default() - }, - ) - .await - .expect("the body transport failure should be retried"); - - let attempt_count = attempts.load(Ordering::SeqCst); - if attempt_count == 2 { - server.await.unwrap(); - } else { - server.abort(); - } - assert_eq!( - attempt_count, 2, - "first body transport error was not retried" - ); - let parts: Vec<_> = result - .stream - .map(|part| part.expect("Core stream part should succeed")) - .collect() - .await; - assert_eq!(text_deltas(&parts), vec!["retried"]); - } - // ── should forward error stream parts after output has started ──────────── /// TS: "should forward error stream parts after output has started" @@ -1637,39 +1331,6 @@ mod do_stream { // ── should expose the raw response headers ──────────────────────────────── - /// TS: "should expose the raw response headers" (streaming) - #[tokio::test] - async fn should_expose_raw_response_headers_stream() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse_response_with_headers(&server, &body, &[("test-header", "test-value")]).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); - } - // ── should return cached tokens in providerMetadata ─────────────────────── /// TS: "should return cached tokens in providerMetadata" diff --git a/aimux-providers/tests/openai_responses_test.rs b/aimux-providers/tests/openai_responses_test.rs index db1e30bcb..86ec7053b 100644 --- a/aimux-providers/tests/openai_responses_test.rs +++ b/aimux-providers/tests/openai_responses_test.rs @@ -740,39 +740,6 @@ mod do_generate_response { // -- should extract response id metadata -- - /// TS: "should extract response id metadata" - #[tokio::test] - async fn should_extract_response_id_metadata() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.response.id.as_deref(), - Some("resp_67c97c0203188190a025beb4a75242bc") - ); - assert_eq!( - result.response.model_id.as_deref(), - Some("gpt-4o-2024-07-18") - ); - - // providerMetadata should contain responseId and reasoningContext. - let pm = result - .provider_metadata - .as_ref() - .and_then(|v| v.get("openai")) - .expect("provider_metadata.openai should exist"); - assert_eq!(pm["responseId"], "resp_67c97c0203188190a025beb4a75242bc"); - assert_eq!(pm["reasoningContext"], "current_turn"); - } - // -- should throw error when no output -- /// TS: "should throw a descriptive error when the response has no output" diff --git a/aimux-providers/tests/openrouter_test.rs b/aimux-providers/tests/openrouter_test.rs index f9944fa1f..0921a379e 100644 --- a/aimux-providers/tests/openrouter_test.rs +++ b/aimux-providers/tests/openrouter_test.rs @@ -586,35 +586,6 @@ async fn status_429_maps_to_rate_limited() { ); } -/// The raw response headers are exposed on the generate result. -#[tokio::test] -async fn exposes_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("openai/gpt-4o-mini"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - // ════════════════════════════════════════════════════════════════════════════ // Conformance — real recorded API responses (cassettes). // diff --git a/aimux-providers/tests/perplexity_test.rs b/aimux-providers/tests/perplexity_test.rs index 4dd6ac16c..16805a692 100644 --- a/aimux-providers/tests/perplexity_test.rs +++ b/aimux-providers/tests/perplexity_test.rs @@ -451,31 +451,3 @@ async fn status_429_maps_to_rate_limited() { "expected RateLimited, got {result:?}" ); } - -/// TS: "should expose the raw response headers". -#[tokio::test] -async fn exposes_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} diff --git a/aimux-providers/tests/vertex_anthropic_test.rs b/aimux-providers/tests/vertex_anthropic_test.rs index a95350841..98d8e79e4 100644 --- a/aimux-providers/tests/vertex_anthropic_test.rs +++ b/aimux-providers/tests/vertex_anthropic_test.rs @@ -133,30 +133,6 @@ fn text_response(text: &str) -> Value { // doGenerate tests // ═════════════════════════════════════════════════════════════════════════════ -/// Test: non-streaming text generation via rawPredict extracts text, usage, -/// and finish reason from the standard Anthropic response body. -#[tokio::test] -async fn vertex_anthropic_generate_text_response() { - let server = MockServer::start().await; - mock_raw_predict_json(&server, 200, text_response("Hello from Claude on Vertex!")).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Claude on Vertex!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(30)); - assert_eq!( - result.response.id.as_deref(), - Some("msg_017TfcQ4AgGxKyBduUpqYPZn") - ); -} - #[tokio::test] async fn vertex_anthropic_generate_keeps_direct_caller_metadata() { let server = MockServer::start().await; @@ -228,41 +204,6 @@ async fn vertex_anthropic_url_and_auth() { ); } -/// Test: the rawPredict request body is wrapped in the `anthropic_version` -/// envelope and drops the `model` field (the model identity lives in the URL). -#[tokio::test] -async fn vertex_anthropic_request_body_envelope() { - let server = MockServer::start().await; - mock_raw_predict_json(&server, 200, text_response("OK")).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // The reported request body is the envelope actually sent. - let reported = result.request_body.expect("should have request body"); - assert_eq!(reported["anthropic_version"], "vertex-2023-10-16"); - assert_eq!(reported["messages"][0]["role"], "user"); - assert_eq!(reported["messages"][0]["content"][0]["text"], "Hello"); - assert!( - reported.get("model").is_none(), - "model must not be in the body" - ); - - // The body sent over the wire matches (anthropic_version present, no model). - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let wire: Value = serde_json::from_slice(&requests[0].body).expect("valid json body"); - assert_eq!(wire["anthropic_version"], "vertex-2023-10-16"); - assert_eq!(wire["messages"][0]["content"][0]["text"], "Hello"); - assert!( - wire.get("model").is_none(), - "model must not be in the wire body" - ); -} - // ═════════════════════════════════════════════════════════════════════════════ // doStream tests // ═════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs index 8b66ed7c8..f33db8950 100644 --- a/aimux-providers/tests/vertex_model_test.rs +++ b/aimux-providers/tests/vertex_model_test.rs @@ -18,11 +18,9 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::content::ContentPart; use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{ - LanguageModelPrompt, LanguageModelPromptMessage, convert_to_language_model_prompt, -}; -use aimux_core::message::{ModelMessage, Role}; -use aimux_core::options::{CallOptions, ProviderTool, Tool}; +use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; +use aimux_core::message::Role; +use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, Source, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::FinishReasonUnified; @@ -211,106 +209,6 @@ async fn vertex_generate_tool_call() { assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } -#[tokio::test] -async fn vertex_renamed_code_execution_passes_core_generate_boundary() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [ - { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } } - ] - }, - "finishReason": "STOP" - }] - }), - ) - .await; - let model = make_model(&server); - let tool = Tool::Provider(ProviderTool { - id: "google.code_execution".to_string(), - name: "runCode".to_string(), - args: json!({}), - }); - - let result = generate_text( - &model, - "Run code", - GenerateTextOptions { - tools: Some(vec![tool.clone()]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("renamed Vertex code execution should pass Core validation"); - let call = result.tool_calls.first().expect("code execution call"); - assert_eq!(call.tool_name, "runCode"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.invalid, None); - let call_metadata = call.provider_metadata.as_ref().expect("call metadata"); - assert_eq!( - call_metadata["googleVertex"], - json!({ - "serverToolCallId": call.tool_call_id, - "serverToolType": "code_execution", - }) - ); - assert_eq!(call_metadata["vertex"], call_metadata["googleVertex"]); - assert!(call_metadata.get("google").is_none()); - assert!(result.raw.content.iter().any(|content| matches!( - content, - GenerateContent::ToolResult(ToolResult { tool_name, .. }) if tool_name == "runCode" - ))); - let result_ids: Vec<&str> = result - .raw - .content - .iter() - .filter_map(|content| match content { - GenerateContent::ToolResult(ToolResult { tool_call_id, .. }) => { - Some(tool_call_id.as_str()) - } - _ => None, - }) - .collect(); - assert_eq!(result_ids.len(), 2); - assert!( - result_ids - .iter() - .all(|id| *id == call.tool_call_id.as_str()) - ); - - let mut messages = vec![ModelMessage::user("Run code")]; - messages.extend(result.response_messages); - messages.push(ModelMessage::user("Continue")); - let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); - next_options.tools = Some(vec![tool]); - let replay = model - .do_generate(&next_options) - .await - .expect("Vertex replay request should succeed") - .request_body - .expect("request body"); - let assistant = replay["contents"] - .as_array() - .unwrap() - .iter() - .find(|content| content["role"] == "model") - .expect("assistant replay content"); - assert_eq!( - assistant["parts"], - json!([ - { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } }, - ]) - ); -} - #[tokio::test] async fn vertex_server_tool_call_and_response_pass_core_generate_boundary() { let server = MockServer::start().await; @@ -596,17 +494,6 @@ async fn vertex_provider_config() { // Additional cases — finish reasons, settings, headers, stream tool calls. // ═════════════════════════════════════════════════════════════════════════════ -/// A minimal Vertex generateContent "ok" body. -fn ok_vertex_body() -> Value { - json!({ - "candidates": [{ - "content": { "role": "model", "parts": [{ "text": "ok" }] }, - "finishReason": "STOP" - }], - "usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 7 } - }) -} - /// TS: MAX_TOKENS → Length #[tokio::test] async fn vertex_generate_finish_reason_max_tokens() { @@ -661,71 +548,6 @@ async fn vertex_generate_finish_reason_safety() { ); } -/// TS: settings land in `generationConfig`. -#[tokio::test] -async fn vertex_generate_settings() { - let server = MockServer::start().await; - mock_generate_content(&server, ok_vertex_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(256); - opts.temperature = Some(0.5); - opts.top_p = Some(0.9); - opts.top_k = Some(40.0); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["generationConfig"]["maxOutputTokens"], json!(256)); - assert!((body["generationConfig"]["temperature"].as_f64().unwrap() - 0.5).abs() < 1e-6); - assert!((body["generationConfig"]["topP"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["generationConfig"]["topK"], json!(40.0)); -} - -/// TS: request body carries the user message as `contents`. -#[tokio::test] -async fn vertex_generate_request_body() { - let server = MockServer::start().await; - mock_generate_content(&server, ok_vertex_body()).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["contents"][0]["role"], json!("user")); - assert_eq!(body["contents"][0]["parts"][0]["text"], json!("Hello")); -} - -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn vertex_generate_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_vertex_body()), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - /// TS: streaming tool calls via SSE. #[tokio::test] async fn vertex_stream_tool_call() { @@ -769,41 +591,6 @@ async fn vertex_stream_tool_call() { assert_eq!(input, r#"{"location":"Tokyo"}"#); } -/// TS: response headers are exposed on the stream result. -#[tokio::test] -async fn vertex_stream_response_headers() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[json!({ - "candidates": [{ - "content": { "parts": [{ "text": "Hi" }] }, - "finishReason": "STOP" - }] - })]); - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:streamGenerateContent")) - .and(query_param("alt", "sse")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse_body), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - /// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). #[tokio::test] async fn vertex_generate_rate_limit() { @@ -909,173 +696,6 @@ fn stream_sources(parts: &[StreamPart]) -> Vec<(String, String, Option, .collect() } -/// TS: "should stream code execution tool calls and results" — the Vertex -/// stream must not silently drop provider-executed code results (#141). -#[tokio::test] -async fn vertex_stream_code_execution_tool_calls_and_results() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [ - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } } - ] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "ok" }] }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = make_model(&server); - let tool = Tool::Provider(ProviderTool { - id: "google.code_execution".to_string(), - name: "runCode".to_string(), - args: json!({}), - }); - let options = CallOptions { - tools: Some(vec![tool.clone()]), - ..default_options(test_prompt()) - }; - let result = model - .do_stream(&options) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let calls = stream_tool_calls(&parts); - let has_call = calls.iter().any(|(_, name, input)| { - name == "runCode" && *input == r#"{"language":"PYTHON","code":"print(\"hello\")"}"# - }); - assert!( - has_call, - "expected a code_execution tool-call, got {calls:?}" - ); - - // The ToolResult must reference the preceding executableCode call id. - let results = stream_tool_results(&parts); - let call_id = calls - .iter() - .find(|(_, name, _)| name == "runCode") - .map(|(id, _, _)| id.clone()) - .expect("code_execution call id"); - let has_result = results.iter().any(|(id, output)| { - *id == call_id && *output == json!({ "outcome": "OUTCOME_OK", "output": "hello\n" }) - }); - assert!( - has_result, - "expected a code_execution tool-result, got {results:?}" - ); - assert_eq!(results.len(), 2); - assert!(results.iter().all(|(id, _)| id == &call_id)); - assert!(parts.iter().any(|part| matches!( - part, - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - provider_metadata: Some(metadata), - .. - }) if tool_name == "runCode" - && metadata["googleVertex"] == json!({ - "serverToolCallId": tool_call_id, - "serverToolType": "code_execution", - }) - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); - assert!(parts.iter().any(|part| matches!( - part, - StreamPart::ToolResult(ToolResult { - tool_call_id, - tool_name, - provider_metadata: Some(metadata), - .. - }) if tool_name == "runCode" - && metadata["googleVertex"] == json!({ - "serverToolCallId": tool_call_id, - "serverToolType": "code_execution", - }) - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); - - // Provider-executed tool → Stop, not ToolCalls. - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert_eq!( - finish.expect("finish part").unified, - FinishReasonUnified::Stop - ); - - let result = stream_text( - &model, - "Run code", - GenerateTextOptions { - tools: Some(vec![tool.clone()]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("stream_text should start") - .consume() - .await - .expect("renamed Vertex code execution should pass Core validation"); - let call = result.tool_calls.first().expect("code execution call"); - assert_eq!(call.tool_name, "runCode"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.invalid, None); - - let mut messages = vec![ModelMessage::user("Run code")]; - messages.extend(result.response_messages); - messages.push(ModelMessage::user("Continue")); - let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); - next_options.tools = Some(vec![tool]); - let replay = model - .do_generate(&next_options) - .await - .expect("Vertex stream replay request should succeed") - .request_body - .expect("request body"); - let assistant = replay["contents"] - .as_array() - .unwrap() - .iter() - .find(|content| content["role"] == "model") - .expect("assistant replay content"); - assert_eq!( - assistant["parts"], - json!([ - { "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } }, - ]) - ); -} - /// TS: "should stream code execution result with missing output field". #[tokio::test] async fn vertex_stream_code_execution_result_missing_output() { diff --git a/aimux-providers/tests/xai_responses_test.rs b/aimux-providers/tests/xai_responses_test.rs index 88008b4b0..09dff50f8 100644 --- a/aimux-providers/tests/xai_responses_test.rs +++ b/aimux-providers/tests/xai_responses_test.rs @@ -11,7 +11,6 @@ //! `do_generate` / `do_stream`, and asserts on the result. use aimux_core::tool::{RawToolCall, ToolResult}; -use std::collections::HashMap; use futures::StreamExt; use serde_json::{Value, json}; @@ -23,11 +22,10 @@ use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::options::{CallOptions, ToolChoice}; use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; -use aimux_core::types::ReasoningEffort; use aimux_providers::{XAIConfig, XAIProvider}; @@ -75,12 +73,6 @@ fn make_provider(server: &MockServer) -> XAIProvider { XAIProvider::new(config) } -fn xai_provider_options(opts: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("xai".to_string(), opts); - Some(m) -} - /// A standard Responses API JSON body returning a text message. fn text_response_body() -> Value { json!({ @@ -540,396 +532,6 @@ mod reasoning { mod settings { use super::*; - /// TS: should send model id and settings - #[tokio::test] - async fn send_model_id_and_settings() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - prompt: vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("you are helpful")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hello")], - ..Default::default() - }, - ], - temperature: Some(0.5), - top_p: Some(0.9), - max_output_tokens: Some(100), - ..CallOptions::new(vec![]) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["model"], "grok-4-fast-non-reasoning"); - assert_eq!(body["temperature"], 0.5); - assert_eq!(body["top_p"], 0.9); - assert_eq!(body["max_output_tokens"], 100); - assert_eq!( - body["input"], - json!([ - { "role": "system", "content": "you are helpful" }, - { "role": "user", "content": [{ "type": "input_text", "text": "hello" }] } - ]) - ); - } - - /// TS: reasoningEffort provider option - #[tokio::test] - async fn reasoning_effort_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "reasoningEffort": "high" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "high"); - } - - /// TS: reasoningSummary provider option - #[tokio::test] - async fn reasoning_summary_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "reasoningSummary": "concise" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["reasoning"]["summary"], - "concise" - ); - } - - /// TS: reasoningEffort and reasoningSummary together - #[tokio::test] - async fn reasoning_effort_and_summary() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options( - json!({ "reasoningEffort": "high", "reasoningSummary": "detailed" }), - ), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["reasoning"], - json!({ "effort": "high", "summary": "detailed" }) - ); - } - - /// TS: store:false - #[tokio::test] - async fn store_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "store": false })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["store"], false); - assert_eq!(body["include"], json!(["reasoning.encrypted_content"])); - } - - /// TS: store:true (should not set store or include) - #[tokio::test] - async fn store_true() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "store": true })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("store").is_none()); - assert!(body.get("include").is_none()); - } - - /// TS: previousResponseId - #[tokio::test] - async fn previous_response_id() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "previousResponseId": "resp_456" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["previous_response_id"], - "resp_456" - ); - } - - /// TS: include with file_search_call.results - #[tokio::test] - async fn include_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options( - json!({ "include": ["file_search_call.results"] }), - ), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["include"], - json!(["file_search_call.results"]) - ); - } - - /// TS: include with file_search_call.results and store:false - #[tokio::test] - async fn include_with_store_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options( - json!({ "include": ["file_search_call.results"], "store": false }), - ), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["store"], false); - assert_eq!( - body["include"], - json!(["file_search_call.results", "reasoning.encrypted_content"]) - ); - } - - /// TS: should map top-level reasoning to reasoning effort - #[tokio::test] - async fn top_level_reasoning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.3", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.3"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "high"); - } - - /// TS: should map top-level reasoning none to "none" - #[tokio::test] - async fn top_level_reasoning_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.3", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.3"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "none"); - } - - /// TS: should prefer providerOptions reasoningEffort over top-level reasoning - #[tokio::test] - async fn prefer_provider_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.3", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.3"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - provider_options: xai_provider_options(json!({ "reasoningEffort": "high" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "high"); - } - - /// TS: should omit reasoning effort and warn for models that do not support it - #[tokio::test] - async fn unsupported_reasoning_model() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.20-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.20-reasoning"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("reasoning").is_none()); - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, details } => { - feature == "reasoning" - && details.as_deref() - == Some("reasoning \"none\" is not supported by this model.") - } - _ => false, - })); - } - /// TS: should warn about unsupported stopSequences #[tokio::test] async fn warn_stop_sequences() { @@ -957,76 +559,6 @@ mod settings { _ => false, })); } - - /// TS: should send response format json schema - #[tokio::test] - async fn response_format_json_schema() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"], - "additionalProperties": false - })), - name: Some("recipe".to_string()), - description: Some("A recipe object".to_string()), - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["text"]["format"]["type"], "json_schema"); - assert_eq!(body["text"]["format"]["strict"], true); - assert_eq!(body["text"]["format"]["name"], "recipe"); - assert_eq!(body["text"]["format"]["description"], "A recipe object"); - } - - /// TS: should send response format json object when no schema provided - #[tokio::test] - async fn response_format_json_object() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["text"]["format"]["type"], - "json_object" - ); - } } // ════════════════════════════════════════════════════════════════════════════ @@ -1117,48 +649,6 @@ mod tools { ); } - /// TS: should send web_search tool with args in request - #[tokio::test] - async fn web_search_tool_request() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({ - "allowedDomains": ["wikipedia.org"], - "enableImageSearch": true, - "enableImageUnderstanding": true - }), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!( - body["tools"], - json!([{ - "type": "web_search", - "allowed_domains": ["wikipedia.org"], - "enable_image_search": true, - "enable_image_understanding": true - }]) - ); - } - /// TS: should include web_search tool call #[tokio::test] async fn web_search_tool_call() { @@ -1605,60 +1095,6 @@ mod tools { other => panic!("expected ToolCall, got {other:?}"), } } - - /// TS: should omit additionalProperties from serialized function tool schemas - #[tokio::test] - async fn omit_additional_properties() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![ - FunctionTool::new( - "saveContact", - json!({ - "type": "object", - "properties": { - "address": { - "type": "object", - "properties": { "city": { "type": "string" } }, - "required": ["city"], - "additionalProperties": false - } - }, - "required": ["address"], - "additionalProperties": false - }), - ) - .into(), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let tools = result.request_body.unwrap()["tools"] - .as_array() - .unwrap() - .clone(); - let params = &tools[0]["parameters"]; - // additionalProperties: false should be removed at all levels. - assert!(params.get("additionalProperties").is_none()); - assert!( - params["properties"]["address"] - .get("additionalProperties") - .is_none() - ); - } } // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/xai_test.rs b/aimux-providers/tests/xai_test.rs index 37c6d0c4d..64455de4e 100644 --- a/aimux-providers/tests/xai_test.rs +++ b/aimux-providers/tests/xai_test.rs @@ -26,11 +26,11 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::options::{CallOptions, ToolChoice}; use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; +use aimux_core::tool::{FunctionTool, Tool}; +use aimux_core::types::FinishReasonUnified; use aimux_providers::xai::convert::supports_reasoning_effort; use aimux_providers::{XAIConfig, XAIProvider}; @@ -505,785 +505,17 @@ mod convert_usage { // (xai-prepare-tools.test.ts) // ════════════════════════════════════════════════════════════════════════════ -mod prepare_tools { - use super::*; - - /// TS: should return undefined tools and toolChoice when tools are undefined - #[tokio::test] - async fn no_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - } - - /// TS: should return undefined tools and toolChoice when tools are empty - #[tokio::test] - async fn empty_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - } - - /// TS: should correctly prepare function tools - #[tokio::test] - async fn function_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let tools = body["tools"].as_array().expect("tools should be array"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["type"], "function"); - assert_eq!(tools[0]["function"]["name"], "testFunction"); - assert_eq!(tools[0]["function"]["description"], "A test function"); - assert_eq!(tools[0]["function"]["parameters"]["type"], "object"); - assert!(tools[0]["function"].get("strict").is_none()); - } - - /// TS: should add warnings for provider-defined tools - #[tokio::test] - async fn provider_defined_tools_warning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.unsupported_tool".to_string(), - name: "unsupported_tool".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - // No tools in body (all were provider-defined) - let tools = body.get("tools"); - assert!( - tools.is_none() - || tools - .unwrap() - .as_array() - .map(std::vec::Vec::is_empty) - .unwrap_or(true) - ); - - // Should have a warning - let has_warning = result.warnings.iter().any(|w| { - matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "provider-defined tool unsupported_tool") - }); - assert!( - has_warning, - "should have unsupported warning for provider-defined tool" - ); - } - - /// TS: should handle multiple tools including provider-defined and function tools - #[tokio::test] - async fn mixed_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![ - Tool::from(FunctionTool { - name: "calculator".to_string(), - description: Some("calculate numbers".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - }), - Tool::Provider(ProviderTool { - id: "xai.some_tool".to_string(), - name: "some_tool".to_string(), - args: json!({}), - }), - Tool::from(FunctionTool { - name: "weather".to_string(), - description: Some("get weather".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - }), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let tools = body["tools"].as_array().expect("tools should be array"); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0]["function"]["name"], "calculator"); - assert_eq!(tools[1]["function"]["name"], "weather"); - - let has_warning = result.warnings.iter().any(|w| { - matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "provider-defined tool some_tool") - }); - assert!(has_warning); - } - - /// TS: should handle tool choice "auto" - #[tokio::test] - async fn tool_choice_auto() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::Auto, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"], "auto"); - } - - /// TS: should handle tool choice "none" - #[tokio::test] - async fn tool_choice_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::None, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"], "none"); - } - - /// TS: should handle tool choice "required" - #[tokio::test] - async fn tool_choice_required() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::Required, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"], "required"); - } - - /// TS: should handle tool choice "tool" - #[tokio::test] - async fn tool_choice_tool() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"]["type"], "function"); - assert_eq!(body["tool_choice"]["function"]["name"], "testFunction"); - } - - /// TS: should pass through strict mode when strict is true - #[tokio::test] - async fn strict_true() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: Some(true), - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tools"][0]["function"]["strict"], true); - } - - /// TS: should pass through strict mode when strict is false - #[tokio::test] - async fn strict_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: Some(false), - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tools"][0]["function"]["strict"], false); - } - - /// TS: should not include strict when strict is undefined - #[tokio::test] - async fn strict_undefined() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body["tools"][0]["function"].get("strict").is_none()); - } - - /// TS: should remove additionalProperties: false from tool schemas - #[tokio::test] - async fn removes_additional_properties_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let params = &body["tools"][0]["function"]["parameters"]; - assert!(params.get("additionalProperties").is_none()); - assert_eq!(params["type"], "object"); - assert_eq!(params["properties"]["value"]["type"], "string"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convertToXaiChatMessages — tested through do_generate request body -// (convert-to-xai-chat-messages.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_messages { - use super::*; - - /// TS: should convert simple text messages - #[tokio::test] - async fn simple_text() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"], "Hello"); - } - - /// TS: should convert system messages - #[tokio::test] - async fn system_message() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["messages"][0]["role"], "system"); - assert_eq!( - body["messages"][0]["content"], - "You are a helpful assistant." - ); - } - - /// TS: should convert assistant messages - #[tokio::test] - async fn assistant_message() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hello there!")], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["messages"][1]["role"], "assistant"); - assert_eq!(body["messages"][1]["content"], "Hello there!"); - } - - /// TS: should convert messages with image parts (data) - #[tokio::test] - async fn image_data_part() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::file(vec![0, 1, 2, 3], "image/png"), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[0]["type"], "text"); - assert_eq!(content[0]["text"], "What is in this image?"); - assert_eq!(content[1]["type"], "image_url"); - assert_eq!( - content[1]["image_url"]["url"], - "data:image/png;base64,AAECAw==" - ); - } - - /// TS: should convert image URLs - #[tokio::test] - async fn image_url_part() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_url( - "https://example.com/image.jpg", - "image/jpeg", - )], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[0]["type"], "image_url"); - assert_eq!( - content[0]["image_url"]["url"], - "https://example.com/image.jpg" - ); - } - - /// TS: should convert image file parts with provider reference (xai key) - #[tokio::test] - async fn image_reference() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"xai": "file-abc123", "openai": "file-xyz789"}), - )], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[0]["type"], "file"); - assert_eq!(content[0]["file"]["file_id"], "file-abc123"); - } - - /// TS: should throw error when provider reference is missing xai key - #[tokio::test] - async fn missing_xai_reference_panics() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"openai": "file-xyz789"}), - )], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await; - assert!( - result.is_err(), - "should error when xai reference is missing" - ); - } - - /// TS: should convert tool calls and tool responses - #[tokio::test] - async fn tool_calls_and_responses() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_123", - "weather", - json!({"location": "Paris"}), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_123", - json!({"temperature": 20}), - )], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - // Assistant message with tool_calls - assert_eq!(body["messages"][0]["role"], "assistant"); - assert_eq!(body["messages"][0]["content"], ""); - let tool_calls = body["messages"][0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls[0]["id"], "call_123"); - assert_eq!(tool_calls[0]["type"], "function"); - assert_eq!(tool_calls[0]["function"]["name"], "weather"); - assert_eq!( - tool_calls[0]["function"]["arguments"], - "{\"location\":\"Paris\"}" - ); - - // Tool message - assert_eq!(body["messages"][1]["role"], "tool"); - assert_eq!(body["messages"][1]["tool_call_id"], "call_123"); - assert_eq!(body["messages"][1]["content"], "{\"temperature\":20}"); - } - - /// TS: should handle multiple tool calls in one message - #[tokio::test] - async fn multiple_tool_calls() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::tool_call("call_123", "weather", json!({"location": "Paris"})), - ContentPart::tool_call("call_456", "time", json!({"timezone": "UTC"})), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let tool_calls = body["messages"][0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0]["function"]["name"], "weather"); - assert_eq!(tool_calls[1]["function"]["name"], "time"); - } - - /// TS: should handle mixed content with text and tool calls - #[tokio::test] - async fn mixed_text_and_tool_calls() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check the weather for you."), - ContentPart::tool_call("call_123", "weather", json!({"location": "Paris"})), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!( - body["messages"][0]["content"], - "Let me check the weather for you." - ); - let tool_calls = body["messages"][0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0]["function"]["name"], "weather"); - } - - /// TS: should pass imageDetail from xai provider options on image parts - #[tokio::test] - async fn image_detail_provider_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: Some(json!({"xai": {"imageDetail": "low"}})), - }, - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); +// ════════════════════════════════════════════════════════════════════════════ +// convertToXaiChatMessages — tested through do_generate request body +// (convert-to-xai-chat-messages.test.ts) +// ════════════════════════════════════════════════════════════════════════════ - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[1]["image_url"]["detail"], "low"); - } +mod convert_messages { + use super::*; - /// TS: should not set detail when imageDetail is not set + /// TS: should throw error when provider reference is missing xai key #[tokio::test] - async fn no_image_detail() { + async fn missing_xai_reference_panics() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/chat/completions")) @@ -1295,17 +527,17 @@ mod convert_messages { let model = provider.model("grok-3"); let prompt = vec![LanguageModelPromptMessage { role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::file(vec![0, 1, 2, 3], "image/png"), - ], + content: vec![ContentPart::file_reference( + "image/png", + json!({"openai": "file-xyz789"}), + )], ..Default::default() }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert!(content[1]["image_url"].get("detail").is_none()); + let result = model.do_generate(&default_options(prompt)).await; + assert!( + result.is_err(), + "should error when xai reference is missing" + ); } } @@ -1403,273 +635,48 @@ mod do_generate { let tool_call = result.content.iter().find_map(|c| match c { GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "call_93562515"); - assert_eq!(name, "weather"); - assert_eq!(input, r#"{"location":"San Francisco"}"#); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - } - - /// TS: should extract usage (from xai-text fixture) - #[tokio::test] - async fn extract_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.cache_read, Some(2)); - assert_eq!(result.usage.input_tokens.no_cache, Some(10)); - assert_eq!(result.usage.input_tokens.total, Some(12)); - assert_eq!(result.usage.output_tokens.reasoning, Some(228)); - assert_eq!(result.usage.output_tokens.text, Some(1)); - assert_eq!(result.usage.output_tokens.total, Some(229)); - } - - /// TS: should send additional response information (id, modelId, timestamp) - #[tokio::test] - async fn response_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!( - result.response.id.as_deref(), - Some("2af5c888-e886-6dcb-7844-95f8fe010b00") - ); - assert_eq!(result.response.model_id.as_deref(), Some("grok-3-mini")); - assert!(result.response.timestamp.is_some()); - } - - /// TS: should expose the raw response headers - #[tokio::test] - async fn response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let headers = result.response_headers.as_ref().expect("headers"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); - } - - /// TS: should pass the model and the messages - #[tokio::test] - async fn pass_model_and_messages() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "grok-3"); - assert_eq!(body["messages"][0]["content"], "Hello"); - assert_eq!(body["messages"][0]["role"], "user"); - } - - /// TS: should pass tools and toolChoice - #[tokio::test] - async fn pass_tools_and_tool_choice() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let _ = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tool_choice"]["function"]["name"], "test-tool"); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); - // additionalProperties should be removed - assert!( - body["tools"][0]["function"]["parameters"] - .get("additionalProperties") - .is_none() - ); - } - - /// TS: should pass parallel_function_calling provider option - #[tokio::test] - async fn pass_parallel_function_calling() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_options("parallel_function_calling", json!(false)), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["parallel_function_calling"], false); - } - - /// TS: should pass logprobs provider options - #[tokio::test] - async fn pass_logprobs() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "logprobs": true, - "topLogprobs": 5 - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["logprobs"], true); - assert_eq!(body["top_logprobs"], 5); - } - - /// TS: should enable logprobs when topLogprobs is set - #[tokio::test] - async fn enable_logprobs_when_top_logprobs_set() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_options("topLogprobs", json!(3)), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["logprobs"], true); - assert_eq!(body["top_logprobs"], 3); + tool_call_id, + tool_name, + input, + .. + }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + _ => None, + }); + let (id, name, input) = tool_call.expect("should have ToolCall"); + assert_eq!(id, "call_93562515"); + assert_eq!(name, "weather"); + assert_eq!(input, r#"{"location":"San Francisco"}"#); + assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } - /// TS: should pass search parameters + /// TS: should extract usage (from xai-text fixture) #[tokio::test] - async fn pass_search_parameters() { + async fn extract_usage() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) + .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) .mount(&server) .await; let provider = make_provider(&server); let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { - "mode": "auto", - "returnCitations": true, - "fromDate": "2024-01-01", - "toDate": "2024-12-31", - "maxSearchResults": 10 - } - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); + let result = model + .do_generate(&default_options(test_prompt())) + .await + .unwrap(); - let body = result.request_body.unwrap(); - assert_eq!(body["search_parameters"]["mode"], "auto"); - assert_eq!(body["search_parameters"]["return_citations"], true); - assert_eq!(body["search_parameters"]["from_date"], "2024-01-01"); - assert_eq!(body["search_parameters"]["to_date"], "2024-12-31"); - assert_eq!(body["search_parameters"]["max_search_results"], 10); + assert_eq!(result.usage.input_tokens.cache_read, Some(2)); + assert_eq!(result.usage.input_tokens.no_cache, Some(10)); + assert_eq!(result.usage.input_tokens.total, Some(12)); + assert_eq!(result.usage.output_tokens.reasoning, Some(228)); + assert_eq!(result.usage.output_tokens.text, Some(1)); + assert_eq!(result.usage.output_tokens.total, Some(229)); } - /// TS: should pass search parameters with sources array + /// TS: should pass the model and the messages #[tokio::test] - async fn pass_search_parameters_with_sources() { + async fn pass_model_and_messages() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/chat/completions")) @@ -1679,45 +686,21 @@ mod do_generate { let provider = make_provider(&server); let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { - "mode": "on", - "sources": [ - {"type": "web", "country": "US", "excludedWebsites": ["example.com"], "safeSearch": false}, - {"type": "x", "includedXHandles": ["grok"], "excludedXHandles": ["openai"], "postFavoriteCount": 5, "postViewCount": 50}, - {"type": "news", "country": "GB"}, - {"type": "rss", "links": ["https://status.x.ai/feed.xml"]} - ] - } - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); + let _ = model + .do_generate(&default_options(test_prompt())) + .await + .unwrap(); - let body = result.request_body.unwrap(); - let sp = &body["search_parameters"]; - assert_eq!(sp["mode"], "on"); - let sources = sp["sources"].as_array().unwrap(); - assert_eq!(sources.len(), 4); - assert_eq!(sources[0]["type"], "web"); - assert_eq!(sources[0]["country"], "US"); - assert_eq!(sources[0]["excluded_websites"][0], "example.com"); - assert_eq!(sources[0]["safe_search"], false); - assert_eq!(sources[1]["type"], "x"); - assert_eq!(sources[1]["included_x_handles"][0], "grok"); - assert_eq!(sources[1]["excluded_x_handles"][0], "openai"); - assert_eq!(sources[1]["post_favorite_count"], 5); - assert_eq!(sources[1]["post_view_count"], 50); - assert_eq!(sources[2]["type"], "news"); - assert_eq!(sources[2]["country"], "GB"); - assert_eq!(sources[3]["type"], "rss"); - assert_eq!(sources[3]["links"][0], "https://status.x.ai/feed.xml"); + let requests = server.received_requests().await.unwrap(); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["model"], "grok-3"); + assert_eq!(body["messages"][0]["content"], "Hello"); + assert_eq!(body["messages"][0]["role"], "user"); } - /// TS: should support json schema response format without warnings + /// TS: should pass tools and toolChoice #[tokio::test] - async fn json_schema_response_format() { + async fn pass_tools_and_tool_choice() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/chat/completions")) @@ -1728,28 +711,37 @@ mod do_generate { let provider = make_provider(&server); let model = provider.model("grok-3"); let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"] - })), - name: Some("person".to_string()), + tools: Some(vec![Tool::from(FunctionTool { + name: "test-tool".to_string(), description: None, - }), + input_schema: json!({ + "type": "object", + "properties": { "value": { "type": "string" } }, + "required": ["value"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }), + strict: None, + provider_options: None, + input_examples: None, + })]), + tool_choice: ToolChoice::Tool { + tool_name: "test-tool".to_string(), + }, ..default_options(test_prompt()) }; - let result = model.do_generate(&options).await.unwrap(); + let _ = model.do_generate(&options).await.unwrap(); - let body = result.request_body.unwrap(); - assert_eq!(body["response_format"]["type"], "json_schema"); - assert_eq!(body["response_format"]["json_schema"]["name"], "person"); - assert_eq!(body["response_format"]["json_schema"]["strict"], true); - assert_eq!( - body["response_format"]["json_schema"]["schema"]["type"], - "object" + let requests = server.received_requests().await.unwrap(); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["tool_choice"]["function"]["name"], "test-tool"); + assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); + // additionalProperties should be removed + assert!( + body["tools"][0]["function"]["parameters"] + .get("additionalProperties") + .is_none() ); - assert!(result.warnings.is_empty()); } /// TS: should handle missing usage in response @@ -1883,31 +875,6 @@ mod do_generate { .any(|c| matches!(c, GenerateContent::Reasoning(_))); assert!(has_reasoning, "should have reasoning content"); } - - /// TS: should send request body with correct fields - #[tokio::test] - async fn request_body_fields() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["model"], "grok-3"); - assert_eq!(body["messages"][0]["content"], "Hello"); - // Should NOT have stream fields - assert!(body.get("stream").is_none()); - assert!(body.get("stream_options").is_none()); - } } // ════════════════════════════════════════════════════════════════════════════ @@ -2149,212 +1116,6 @@ mod do_stream { mod reasoning { use super::*; - /// TS: should pass reasoning_effort parameter - #[tokio::test] - async fn pass_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("high")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should pass reasoning_effort: "none" via providerOptions - #[tokio::test] - async fn pass_reasoning_effort_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("none")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "none"); - } - - /// TS: should map top-level reasoning to reasoning_effort - #[tokio::test] - async fn map_top_level_reasoning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should map top-level reasoning medium to reasoning_effort: "medium" - #[tokio::test] - async fn map_top_level_reasoning_medium() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "medium"); - } - - /// TS: should coerce top-level reasoning xhigh to high - #[tokio::test] - async fn coerce_xhigh_to_high() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Xhigh), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should map top-level reasoning none to reasoning_effort: "none" - #[tokio::test] - async fn map_top_level_reasoning_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "none"); - } - - /// TS: should prefer providerOptions reasoningEffort over top-level reasoning - #[tokio::test] - async fn prefer_provider_options_over_top_level() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - provider_options: xai_options("reasoningEffort", json!("high")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should omit reasoning_effort and warn for models that do not support it - #[tokio::test] - async fn omit_for_unsupported_model() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-4.20-reasoning"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("reasoning_effort").is_none()); - - let has_warning = result.warnings.iter().any(|w| { - matches!(w, aimux_core::types::Warning::Unsupported { feature, details } - if feature == "reasoning" - && details.as_deref() == Some("reasoning \"none\" is not supported by this model.")) - }); - assert!(has_warning, "should have reasoning unsupported warning"); - } - - /// TS: should still pass providerOptions reasoningEffort for models that do not support top-level reasoning - #[tokio::test] - async fn still_pass_provider_options_for_unsupported_model() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-4.20-reasoning"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("none")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "none"); - } - /// TS: should extract reasoning content #[tokio::test] async fn extract_reasoning_content() { diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index 2eb4beb2f..ed8b8aedf 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -15,7 +15,7 @@ // - ContentPart (content.rs) — sealed, internally-tagged (type) // - FileData/FileBytes (shared.rs) — sealed, externally-tagged // - ToolCall (tool.rs:102) -// - Usage / TokenUsage (types.rs:33, types.rs:44) +// - Usage / InputTokenUsage / OutputTokenUsage (types.rs:33, types.rs:44) // - FinishReason (types.rs:10) import 'dart:async'; @@ -76,43 +76,38 @@ enum ReasoningEffort { // Token usage // ───────────────────────────────────────────────────────────────────────────── -/// Token usage detail (with cache breakdown). Mirrors `TokenUsage.ts`. +/// Input token usage detail with cache breakdown. /// /// All fields are nullable: `total` is always serialized by the Rust core /// (null when unknown), the rest are omitted when `None`. @JsonSerializable() -class TokenUsage { +class InputTokenUsage { + final int? total; + @JsonKey(name: 'no_cache') final int? noCache; + @JsonKey(name: 'cache_read') final int? cacheRead; + @JsonKey(name: 'cache_write') final int? cacheWrite; + InputTokenUsage({this.total, this.noCache, this.cacheRead, this.cacheWrite}); + factory InputTokenUsage.fromJson(Map json) => _$InputTokenUsageFromJson(json); + Map toJson() => _$InputTokenUsageToJson(this); +} + +@JsonSerializable() +class OutputTokenUsage { final int? total; - @JsonKey(name: 'no_cache') - final int? noCache; - @JsonKey(name: 'cache_read') - final int? cacheRead; - @JsonKey(name: 'cache_write') - final int? cacheWrite; final int? text; final int? reasoning; - - TokenUsage({ - this.total, - this.noCache, - this.cacheRead, - this.cacheWrite, - this.text, - this.reasoning, - }); - - factory TokenUsage.fromJson(Map json) => - _$TokenUsageFromJson(json); - Map toJson() => _$TokenUsageToJson(this); + OutputTokenUsage({this.total, this.text, this.reasoning}); + factory OutputTokenUsage.fromJson(Map json) => _$OutputTokenUsageFromJson(json); + Map toJson() => _$OutputTokenUsageToJson(this); } /// Token usage statistics. Mirrors `Usage.ts`. @JsonSerializable() class Usage { @JsonKey(name: 'input_tokens') - final TokenUsage inputTokens; + final InputTokenUsage inputTokens; @JsonKey(name: 'output_tokens') - final TokenUsage outputTokens; + final OutputTokenUsage outputTokens; /// Raw usage information from the provider (opaque JSON). final Map? raw; @@ -422,6 +417,16 @@ class ResponseMetadata { Map toJson() => _$ResponseMetadataToJson(this); } +class GenerateResponse { + final String? id; + final String? timestamp; + final String? modelId; + final dynamic body; + GenerateResponse({this.id, this.timestamp, this.modelId, this.body}); + 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']); + Map toJson() => {'id': id, 'timestamp': timestamp, 'model_id': modelId, if (body != null) 'body': body}; +} + /// A content item in a `GenerateResult`. Mirrors `GenerateContent.ts` /// (`aimux-core/src/result.rs`). /// @@ -458,6 +463,9 @@ sealed class GenerateContent { 'ToolCall' => GenerateContentToolCall.fromJson(payload), 'Source' => GenerateContentSource.fromJson(payload), 'Reasoning' => GenerateContentReasoning.fromJson(payload), + 'Custom' => GenerateContentCustom.fromJson(payload), + 'ReasoningFile' => GenerateContentReasoningFile.fromJson(payload), + 'ToolApprovalRequest' => GenerateContentToolApprovalRequest.fromJson(payload), 'File' => GenerateContentFile.fromJson(payload), 'ToolResult' => GenerateContentToolResult.fromJson(payload), _ => GenerateContentUnknown(tag: e.key, data: payload), @@ -610,7 +618,7 @@ final class GenerateContentReasoning extends GenerateContent { /// A file generated by the model (e.g. an image or document). The `data` /// field is a `FileData` tagged union; there is **no** `filename` field. final class GenerateContentFile extends GenerateContent { - final FileData data; + final GeneratedFileData data; final String mediaType; final Map? providerMetadata; @@ -625,7 +633,7 @@ final class GenerateContentFile extends GenerateContent { factory GenerateContentFile.fromJson(Map json) => GenerateContentFile( - data: FileData.fromJson(json['data'] as Map), + data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerMetadata: json['provider_metadata'] as Map?, @@ -696,6 +704,35 @@ final class GenerateContentToolResult extends GenerateContent { /// Newer core versions may emit tags this binding does not model yet; this /// class passes them through verbatim (`{tag: data}`) instead of discarding /// or crashing. +final class GenerateContentCustom extends GenerateContent { + final String kind; + final Map? providerMetadata; + GenerateContentCustom({required this.kind, this.providerMetadata}); + @override String get tag => 'Custom'; + factory GenerateContentCustom.fromJson(Map json) => GenerateContentCustom(kind: json['kind'] as String, providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'Custom': {'kind': kind, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + +final class GenerateContentReasoningFile extends GenerateContent { + final GeneratedFileData data; + final String mediaType; + final Map? providerMetadata; + GenerateContentReasoningFile({required this.data, required this.mediaType, this.providerMetadata}); + @override String get tag => 'ReasoningFile'; + factory GenerateContentReasoningFile.fromJson(Map json) => GenerateContentReasoningFile(data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'ReasoningFile': {'data': data.toJson(), 'media_type': mediaType, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + +final class GenerateContentToolApprovalRequest extends GenerateContent { + final String approvalId; + final String toolCallId; + final Map? providerMetadata; + GenerateContentToolApprovalRequest({required this.approvalId, required this.toolCallId, this.providerMetadata}); + @override String get tag => 'ToolApprovalRequest'; + factory GenerateContentToolApprovalRequest.fromJson(Map json) => GenerateContentToolApprovalRequest(approvalId: json['approval_id'] as String, toolCallId: json['tool_call_id'] as String, providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'ToolApprovalRequest': {'approval_id': approvalId, 'tool_call_id': toolCallId, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + final class GenerateContentUnknown extends GenerateContent { @override final String tag; @@ -713,21 +750,21 @@ class GenerateResult { final Usage usage; final List> warnings; final Map? providerMetadata; - final ResponseMetadata response; + final GenerateResponse? response; final Map? requestBody; final Map? responseHeaders; - GenerateResult({required this.content, required this.finishReason, required this.usage, this.warnings = const [], this.providerMetadata, required this.response, this.requestBody, this.responseHeaders}); + GenerateResult({required this.content, required this.finishReason, required this.usage, this.warnings = const [], this.providerMetadata, this.response, this.requestBody, this.responseHeaders}); 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), usage: Usage.fromJson(json['usage'] as Map), warnings: (json['warnings'] as List? ?? []).map((e) => e as Map).toList(), providerMetadata: json['provider_metadata'] as Map?, - response: ResponseMetadata.fromJson(json['response'] 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?, ); - 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, if (requestBody != null) 'request_body': requestBody, if (responseHeaders != null) 'response_headers': responseHeaders}; } // ───────────────────────────────────────────────────────────────────────────── @@ -738,6 +775,7 @@ class GenerateResult { @JsonSerializable() class GenerateTextResult { final String text; + final List> content; @JsonKey(name: 'tool_calls') final List toolCalls; @JsonKey(name: 'finish_reason') @@ -769,6 +807,7 @@ class GenerateTextResult { GenerateTextResult({ required this.text, + this.content = const [], required this.toolCalls, required this.finishReason, required this.usage, @@ -840,6 +879,7 @@ class GenerateObjectResult { @JsonSerializable() class StreamTextResultAggregated { final String text; + final List> content; // reasoning/sources/files use weak types — same strategy as GenerateTextResult. final List> reasoning; @JsonKey(name: 'reasoning_text') @@ -1133,6 +1173,9 @@ sealed class StreamPart { StreamPartToolInputEnd() => 'ToolInputEnd', StreamPartToolCall() => 'ToolCall', StreamPartToolResult() => 'ToolResult', + StreamPartCustom() => 'Custom', + StreamPartReasoningFile() => 'ReasoningFile', + StreamPartToolApprovalRequest() => 'ToolApprovalRequest', StreamPartFile() => 'File', StreamPartReasoningStart() => 'ReasoningStart', StreamPartReasoningDelta() => 'ReasoningDelta', @@ -1163,6 +1206,9 @@ sealed class StreamPart { 'ToolInputEnd' => StreamPartToolInputEnd.fromJson(payload), 'ToolCall' => StreamPartToolCall.fromJson(payload), 'ToolResult' => StreamPartToolResult.fromJson(payload), + 'Custom' => StreamPartCustom.fromJson(payload), + 'ReasoningFile' => StreamPartReasoningFile.fromJson(payload), + 'ToolApprovalRequest' => StreamPartToolApprovalRequest.fromJson(payload), 'File' => StreamPartFile.fromJson(payload), 'ReasoningStart' => StreamPartReasoningStart.fromJson(payload), 'ReasoningDelta' => StreamPartReasoningDelta.fromJson(payload), @@ -1527,7 +1573,7 @@ final class StreamPartToolResult extends StreamPart { /// A file generated by the model (e.g. an image or document). The `data` /// field is a `FileData` tagged union; there is **no** `filename` field. final class StreamPartFile extends StreamPart { - final FileData data; + final GeneratedFileData data; final String mediaType; final Map? providerMetadata; @@ -1538,7 +1584,7 @@ final class StreamPartFile extends StreamPart { }); factory StreamPartFile.fromJson(Map json) => StreamPartFile( - data: FileData.fromJson(json['data'] as Map), + data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerMetadata: json['provider_metadata'] as Map?, @@ -1718,6 +1764,33 @@ final class StreamPartRaw extends StreamPart { /// Newer core versions may emit tags this binding does not model yet; this /// class passes them through verbatim (`{tag: data}`) instead of discarding /// or crashing. +final class StreamPartCustom extends StreamPart { + final String kind; + final Map? providerMetadata; + StreamPartCustom({required this.kind, this.providerMetadata}); + factory StreamPartCustom.fromJson(Map json) => StreamPartCustom(kind: json['kind'] as String, providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'Custom': {'kind': kind, if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + +final class StreamPartReasoningFile extends StreamPart { + final GenerateContentFile file; + final Map? providerMetadata; + StreamPartReasoningFile({required this.file, this.providerMetadata}); + factory StreamPartReasoningFile.fromJson(Map json) => StreamPartReasoningFile(file: GenerateContentFile.fromJson(json['file'] as Map), providerMetadata: json['provider_metadata'] as Map?); + @override Map toJson() => {'ReasoningFile': {'file': file.toJson(), if (providerMetadata != null) 'provider_metadata': providerMetadata}}; +} + +final class StreamPartToolApprovalRequest extends StreamPart { + final String approvalId; + final ToolCall toolCall; + final String? reason; + final bool? isAutomatic; + final String? signature; + StreamPartToolApprovalRequest({required this.approvalId, required this.toolCall, this.reason, this.isAutomatic, this.signature}); + factory StreamPartToolApprovalRequest.fromJson(Map json) => StreamPartToolApprovalRequest(approvalId: json['approval_id'] as String, toolCall: ToolCall.fromJson(json['tool_call'] as Map), reason: json['reason'] as String?, isAutomatic: json['is_automatic'] as bool?, signature: json['signature'] as String?); + @override Map toJson() => {'ToolApprovalRequest': {'approval_id': approvalId, 'tool_call': toolCall.toJson(), if (reason != null) 'reason': reason, if (isAutomatic != null) 'is_automatic': isAutomatic, if (signature != null) 'signature': signature}}; +} + final class StreamPartUnknown extends StreamPart { final String tag; final Map data; @@ -1770,7 +1843,7 @@ sealed class FileData { return switch (e.key) { 'Data' => FileDataData( data: FileBytes.fromJson((e.value as Map)['data'] as Map)), - 'Url' => FileDataUrl(url: (e.value as Map)['url'] as String), + 'Url' => FileDataUrl(url: (e.value as Map)['url'] as String, originalUrl: (e.value as Map)['original_url'] as String?), 'Reference' => FileDataReference( reference: (e.value as Map)['reference'] as Map), 'Text' => FileDataText(text: (e.value as Map)['text'] as String), @@ -1789,9 +1862,10 @@ final class FileDataData extends FileData { final class FileDataUrl extends FileData { final String url; - const FileDataUrl({required this.url}); + final String? originalUrl; + const FileDataUrl({required this.url, this.originalUrl}); @override - Map toJson() => {'Url': {'url': url}}; + Map toJson() => {'Url': {'url': url, if (originalUrl != null) 'original_url': originalUrl}}; } final class FileDataReference extends FileData { @@ -1808,6 +1882,35 @@ final class FileDataText extends FileData { Map toJson() => {'Text': {'text': text}}; } +sealed class GeneratedFileData { + const GeneratedFileData(); + factory GeneratedFileData.fromJson(Map json) { + final e = json.entries.first; + return switch (e.key) { + 'Data' => GeneratedFileDataData( + data: FileBytes.fromJson((e.value as Map)['data'] as Map)), + 'Url' => GeneratedFileDataUrl(url: (e.value as Map)['url'] as String, originalUrl: (e.value as Map)['original_url'] as String?), + _ => throw FormatException('unknown GeneratedFileData tag: $e'), + }; + } + Map toJson(); +} + +final class GeneratedFileDataData extends GeneratedFileData { + final FileBytes data; + const GeneratedFileDataData({required this.data}); + @override + Map toJson() => {'Data': {'data': data.toJson()}}; +} + +final class GeneratedFileDataUrl extends GeneratedFileData { + final String url; + final String? originalUrl; + const GeneratedFileDataUrl({required this.url, this.originalUrl}); + @override + Map toJson() => {'Url': {'url': url, if (originalUrl != null) 'original_url': originalUrl}}; +} + // ───────────────────────────────────────────────────────────────────────────── // ContentPart (content.rs) — internally-tagged: {"type": "text", "text": "..."} // ───────────────────────────────────────────────────────────────────────────── @@ -1817,6 +1920,9 @@ sealed class ContentPart { factory ContentPart.fromJson(Map json) { final tag = json['type'] as String; return switch (tag) { + 'custom' => ContentPartCustom.fromJson(json), + 'reasoning_file' => ContentPartReasoningFile.fromJson(json), + 'tool_approval_request' => ContentPartToolApprovalRequest.fromJson(json), 'text' => ContentPartText.fromJson(json), 'image' => ContentPartImage.fromJson(json), 'file' => ContentPartFile.fromJson(json), @@ -2045,6 +2151,35 @@ final class ContentPartToolResult extends ContentPart { }; } +final class ContentPartCustom extends ContentPart { + final String kind; + final Map? providerOptions; + ContentPartCustom({required this.kind, this.providerOptions}); + factory ContentPartCustom.fromJson(Map json) => ContentPartCustom(kind: json['kind'] as String, providerOptions: json['provider_options'] as Map?); + @override Map toJson() => {'type': 'custom', 'kind': kind, if (providerOptions != null) 'provider_options': providerOptions}; +} + +final class ContentPartReasoningFile extends ContentPart { + final GeneratedFileData data; + final String mediaType; + final Map? providerOptions; + ContentPartReasoningFile({required this.data, required this.mediaType, this.providerOptions}); + factory ContentPartReasoningFile.fromJson(Map json) => ContentPartReasoningFile(data: GeneratedFileData.fromJson(json['data'] as Map), mediaType: json['media_type'] as String, providerOptions: json['provider_options'] as Map?); + @override Map toJson() => {'type': 'reasoning_file', 'data': data.toJson(), 'media_type': mediaType, if (providerOptions != null) 'provider_options': providerOptions}; +} + +final class ContentPartToolApprovalRequest extends ContentPart { + final String approvalId; + final String toolCallId; + final String? reason; + final bool? isAutomatic; + final String? signature; + final dynamic inputSchemaInput; + ContentPartToolApprovalRequest({required this.approvalId, required this.toolCallId, this.reason, this.isAutomatic, this.signature, this.inputSchemaInput}); + factory ContentPartToolApprovalRequest.fromJson(Map json) => ContentPartToolApprovalRequest(approvalId: json['approval_id'] as String, toolCallId: json['tool_call_id'] as String, reason: json['reason'] as String?, isAutomatic: json['is_automatic'] as bool?, signature: json['signature'] as String?, inputSchemaInput: json['input_schema_input']); + @override Map toJson() => {'type': 'tool_approval_request', 'approval_id': approvalId, 'tool_call_id': toolCallId, if (reason != null) 'reason': reason, if (isAutomatic != null) 'is_automatic': isAutomatic, if (signature != null) 'signature': signature, if (inputSchemaInput != null) 'input_schema_input': inputSchemaInput}; +} + final class ContentPartUnknown extends ContentPart { final String tag; final Map data; diff --git a/bindings/flutter/lib/types.g.dart b/bindings/flutter/lib/types.g.dart index a0a8f56fa..1f8cd010c 100644 --- a/bindings/flutter/lib/types.g.dart +++ b/bindings/flutter/lib/types.g.dart @@ -7,30 +7,17 @@ part of 'types.dart'; // JsonSerializableGenerator // ************************************************************************** -TokenUsage _$TokenUsageFromJson(Map json) => TokenUsage( - total: (json['total'] as num?)?.toInt(), - noCache: (json['no_cache'] as num?)?.toInt(), - cacheRead: (json['cache_read'] as num?)?.toInt(), - cacheWrite: (json['cache_write'] as num?)?.toInt(), - text: (json['text'] as num?)?.toInt(), - reasoning: (json['reasoning'] as num?)?.toInt(), - ); +InputTokenUsage _$InputTokenUsageFromJson(Map json) => InputTokenUsage(total: (json['total'] as num?)?.toInt(), noCache: (json['no_cache'] as num?)?.toInt(), cacheRead: (json['cache_read'] as num?)?.toInt(), cacheWrite: (json['cache_write'] as num?)?.toInt()); +Map _$InputTokenUsageToJson(InputTokenUsage instance) => {'total': instance.total, 'no_cache': instance.noCache, 'cache_read': instance.cacheRead, 'cache_write': instance.cacheWrite}; -Map _$TokenUsageToJson(TokenUsage instance) => - { - 'total': instance.total, - 'no_cache': instance.noCache, - 'cache_read': instance.cacheRead, - 'cache_write': instance.cacheWrite, - 'text': instance.text, - 'reasoning': instance.reasoning, - }; +OutputTokenUsage _$OutputTokenUsageFromJson(Map json) => OutputTokenUsage(total: (json['total'] as num?)?.toInt(), text: (json['text'] as num?)?.toInt(), reasoning: (json['reasoning'] as num?)?.toInt()); +Map _$OutputTokenUsageToJson(OutputTokenUsage instance) => {'total': instance.total, 'text': instance.text, 'reasoning': instance.reasoning}; Usage _$UsageFromJson(Map json) => Usage( inputTokens: - TokenUsage.fromJson(json['input_tokens'] as Map), + InputTokenUsage.fromJson(json['input_tokens'] as Map), outputTokens: - TokenUsage.fromJson(json['output_tokens'] as Map), + OutputTokenUsage.fromJson(json['output_tokens'] as Map), raw: json['raw'] as Map?, ); @@ -113,6 +100,7 @@ Map _$ResponseMetadataToJson(ResponseMetadata instance) => GenerateTextResult _$GenerateTextResultFromJson(Map json) => GenerateTextResult( + content: (json['content'] as List? ?? []).map((item) => item as Map).toList(), text: json['text'] as String, toolCalls: (json['tool_calls'] as List) .map((e) => ToolCall.fromJson(e as Map)) @@ -149,12 +137,13 @@ GenerateTextResult _$GenerateTextResultFromJson(Map json) => : ResponseMetadata(), totalUsage: json['total_usage'] != null ? Usage.fromJson(json['total_usage'] as Map) - : Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), ); Map _$GenerateTextResultToJson(GenerateTextResult instance) => { 'text': instance.text, + 'content': instance.content, 'tool_calls': instance.toolCalls, 'finish_reason': instance.finishReason, 'usage': instance.usage, @@ -180,7 +169,7 @@ GenerateObjectResult _$GenerateObjectResultFromJson( rawFinishReason: json['raw_finish_reason'] as String?, usage: json['usage'] != null ? Usage.fromJson(json['usage'] as Map) - : Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), warnings: (json['warnings'] as List?) ?.map((e) => e as Map) .toList() ?? @@ -210,6 +199,7 @@ Map _$GenerateObjectResultToJson( StreamTextResultAggregated _$StreamTextResultAggregatedFromJson( Map json) => StreamTextResultAggregated( + content: (json['content'] as List? ?? []).map((item) => item as Map).toList(), text: json['text'] as String? ?? '', reasoning: (json['reasoning'] as List?) ?.map((e) => e as Map) @@ -233,10 +223,10 @@ StreamTextResultAggregated _$StreamTextResultAggregatedFromJson( rawFinishReason: json['raw_finish_reason'] as String?, usage: json['usage'] != null ? Usage.fromJson(json['usage'] as Map) - : Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), totalUsage: json['total_usage'] != null ? Usage.fromJson(json['total_usage'] as Map) - : Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + : Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), warnings: (json['warnings'] as List?) ?.map((e) => e as Map) .toList() ?? @@ -255,6 +245,7 @@ Map _$StreamTextResultAggregatedToJson( StreamTextResultAggregated instance) => { 'text': instance.text, + 'content': instance.content, 'reasoning': instance.reasoning, 'reasoning_text': instance.reasoningText, 'tool_calls': instance.toolCalls, diff --git a/bindings/flutter/test/typed_model_test.dart b/bindings/flutter/test/typed_model_test.dart index 5c967f25d..838deb5f3 100644 --- a/bindings/flutter/test/typed_model_test.dart +++ b/bindings/flutter/test/typed_model_test.dart @@ -319,58 +319,7 @@ void main() { const apiKey = 'sk-test-mock-key'; const modelId = 'gpt-4o'; - test('generateText returns a typed GenerateTextResult (text + tool calls)', () async { - final server = await startMockServer([ - {'body': toolCallOpenAIResponse}, - ]); - addTearDown(server.close); - final resultJson = await runTypedGenerateText(_TypedArgs( - baseUrl: server.baseUrl, - apiKey: apiKey, - modelId: modelId, - prompt: 'What is the weather in Tokyo?', - tools: [_weatherTool()], - toolChoice: ToolChoice.required, - maxOutputTokens: 1024, - temperature: 0.7, - )); - final result = GenerateTextResult.fromJson(resultJson); - - // Typed field access on the result. - expect(result, isA()); - expect(result.text, isA()); - expect(result.toolCalls, hasLength(1)); - expect(result.toolCalls[0], isA()); - expect(result.toolCalls[0].toolName, 'get_weather'); - expect(result.toolCalls[0].toolCallId, 'call_abc'); - expect(result.toolCalls[0].input?['location'], 'Tokyo'); - expect(result.raw, isA()); - - // finish_reason + usage are typed, not dynamic maps. - expect(result.finishReason, isA()); - expect(result.finishReason.unified, 'tool-calls'); - expect(result.finishReason.raw, 'tool_calls'); - expect(result.usage, isA()); - expect(result.usage.inputTokens, isA()); - expect(result.usage.inputTokens.total, 20); - expect(result.usage.outputTokens.total, 10); - - // The raw provider result carries a ToolCall content variant. - final content = result.raw.content; - expect(content.any((c) => c.tag == 'ToolCall'), isTrue); - - // tools / tool_choice / max_output_tokens / temperature reached provider. - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - expect(server.recorded.first.path, '/chat/completions'); - final sentBody = server.recorded.first.body as Map; - expect(sentBody, contains('tools')); - expect(sentBody['tool_choice'], 'required'); - // gpt-4o is a non-reasoning model, so max_output_tokens maps to `max_tokens`. - expect(sentBody['max_tokens'], 1024); - expect(sentBody['temperature'], closeTo(0.7, 1e-9)); - }); test('generateText with a plain prompt returns text', () async { final server = await startMockServer([ diff --git a/bindings/flutter/test/typed_round_trip_test.dart b/bindings/flutter/test/typed_round_trip_test.dart index 0bb432616..0690373b6 100644 --- a/bindings/flutter/test/typed_round_trip_test.dart +++ b/bindings/flutter/test/typed_round_trip_test.dart @@ -150,31 +150,7 @@ void main() { expect(r.text, 'thinking...'); }); - test('File variant — no filename field', () { - final original = GenerateContentFile( - data: FileDataUrl(url: 'https://example.com/img.png'), - mediaType: 'image/png', - providerMetadata: null, - ); - final json = original.toJson(); - // Externally-tagged shape: a single top-level key. - expect(json.keys, ['File']); - final payload = json['File'] as Map; - - // Contract: `data` + `media_type`, never `filename`. - expect(payload, contains('data')); - expect(payload, contains('media_type')); - expect(payload, isNot(contains('filename'))); - - final decoded = GenerateContent.fromJson(json); - expect(decoded, isA()); - expect(decoded.tag, 'File'); - final f = decoded as GenerateContentFile; - expect(f.data, isA()); - expect((f.data as FileDataUrl).url, 'https://example.com/img.png'); - expect(f.mediaType, 'image/png'); - }); test('ToolResult variant — uses result, not output', () { final original = GenerateContentToolResult( @@ -280,31 +256,7 @@ void main() { expect(s.warnings.first['code'], 'deprecation'); }); - test('Finish variant', () { - final original = StreamPartFinish( - finishReason: FinishReason(unified: 'stop', raw: 'stop'), - usage: Usage( - inputTokens: TokenUsage(total: 10, text: 10), - outputTokens: TokenUsage(total: 5, text: 5), - ), - providerMetadata: null, - ); - // `Finish` nests `@JsonSerializable` `Usage`/`TokenUsage`, whose - // generated `toJson()` (explicitToJson: false) does not recurse — so we - // deep-flatten across the JSON boundary before re-parsing, exactly as the - // real Rust→Dart pipeline does. See `typed_model_test.dart` for the same - // note. - final decoded = - StreamPart.fromJson(deepFlatten(original.toJson())); - - expect(decoded, isA()); - expect(decoded.type, 'Finish'); - final f = decoded as StreamPartFinish; - expect(f.finishReason.unified, 'stop'); - expect(f.finishReason.raw, 'stop'); - expect(f.usage.inputTokens.total, 10); - expect(f.usage.outputTokens.total, 5); - }); + test('Error variant', () { final original = StreamPartError(error: {'message': 'boom', 'code': 500}); @@ -419,28 +371,7 @@ void main() { }); // ── File ── - test('File variant — no filename field', () { - final original = StreamPartFile( - data: FileDataUrl(url: 'https://example.com/img.png'), - mediaType: 'image/png', - providerMetadata: null, - ); - final json = original.toJson(); - - expect(json.keys, ['File']); - final payload = json['File'] as Map; - expect(payload, contains('data')); - expect(payload, contains('media_type')); - expect(payload, isNot(contains('filename'))); - final decoded = StreamPart.fromJson(json); - expect(decoded, isA()); - expect(decoded.type, 'File'); - final f = decoded as StreamPartFile; - expect(f.data, isA()); - expect((f.data as FileDataUrl).url, 'https://example.com/img.png'); - expect(f.mediaType, 'image/png'); - }); // ── Reasoning ── test('ReasoningStart variant', () { diff --git a/bindings/go/batch1_e2e_test.go b/bindings/go/batch1_e2e_test.go index bd49feca4..8cc575733 100644 --- a/bindings/go/batch1_e2e_test.go +++ b/bindings/go/batch1_e2e_test.go @@ -81,49 +81,7 @@ func TestE2E_StreamTextEmitsRawWhenEnabled(t *testing.T) { // RFC-0016 M10: streaming Finish carries the provider's raw usage object // (buildTextDeltaSSE usage has prompt_tokens=3). -func TestE2E_StreamFinishUsageRawNonNull(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("text/event-stream") - srv.SetResponse(buildTextDeltaSSE()) - - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) - defer m.Close() - - stream := m.StreamText(`"Say hello"`, "") - for part := range stream.Parts() { - sp, err := ParseStreamPart(part) - if err != nil { - t.Fatalf("failed to parse: %v", err) - } - if sp.Tag != "Finish" { - continue - } - var finish struct { - Usage struct { - Raw json.RawMessage `json:"raw"` - } `json:"usage"` - } - if err := json.Unmarshal(sp.Payload, &finish); err != nil { - t.Fatalf("failed to decode Finish payload: %v", err) - } - if len(finish.Usage.Raw) == 0 || string(finish.Usage.Raw) == "null" { - t.Fatal("usage.raw must be populated (RFC-0016 M10)") - } - var raw map[string]any - json.Unmarshal(finish.Usage.Raw, &raw) - if raw["prompt_tokens"] != float64(3) { - t.Errorf("usage.raw.prompt_tokens should be 3, got %#v", raw["prompt_tokens"]) - } - return - } - t.Fatal("no Finish part received") -} -// RFC-0016 M9: body-build warnings (topK unsupported) reach StreamStart. -// OpenAI's full profile supports top_k, so this is exercised on a profile -// that does not — the shared OpenAI path is what groq_test.rs covers in Rust; -// here we assert the Go side parses a non-empty warnings payload verbatim. func TestE2E_StreamStartCarriesNonEmptyWarnings(t *testing.T) { srv := newMockServer() defer srv.Close() diff --git a/bindings/go/roundtrip_test.go b/bindings/go/roundtrip_test.go index 091ea9183..360e830d2 100644 --- a/bindings/go/roundtrip_test.go +++ b/bindings/go/roundtrip_test.go @@ -296,41 +296,6 @@ func TestGenerateTextOptionsIncludeRawChunksRoundTrip(t *testing.T) { // ── GenerateTextResult round-trip ──────────────────────────────────────────── -func TestGenerateTextResultRoundTrip(t *testing.T) { - original := GenerateTextResult{ - Text: "hello", - ToolCalls: []ToolCall{ - {ToolCallID: "call_1", ToolName: "get_weather"}, - }, - Usage: Usage{ - InputTokens: TokenUsage{Total: u32ptr(10)}, - OutputTokens: TokenUsage{Total: u32ptr(5)}, - }, - Raw: GenerateResult{ - Content: []ContentPart{json.RawMessage(`{"Text":{"text":"hello"}}`)}, - FinishReason: FinishReason{Unified: FinishStop}, - }, - FinishReason: FinishReason{Unified: FinishStop}, - } - b, err := json.Marshal(original) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } - var decoded GenerateTextResult - if err := json.Unmarshal(b, &decoded); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if decoded.Text != "hello" { - t.Errorf("text mismatch: got %s", decoded.Text) - } - if len(decoded.ToolCalls) != 1 || decoded.ToolCalls[0].ToolName != "get_weather" { - t.Errorf("tool_calls mismatch: %+v", decoded.ToolCalls) - } - if decoded.Raw.FinishReason.Unified != FinishStop { - t.Errorf("raw.finish_reason mismatch: got %s", decoded.Raw.FinishReason.Unified) - } -} - func TestGenerateTextResultWithWarningsRoundTrip(t *testing.T) { original := GenerateTextResult{ Text: "ok", @@ -349,27 +314,6 @@ func TestGenerateTextResultWithWarningsRoundTrip(t *testing.T) { // ── Usage / TokenUsage round-trip ───────────────────────────────────────────── -func TestUsageRoundTrip(t *testing.T) { - original := Usage{ - InputTokens: TokenUsage{Total: u32ptr(100), CacheRead: u32ptr(50)}, - OutputTokens: TokenUsage{Total: u32ptr(20), Reasoning: u32ptr(5)}, - } - b, _ := json.Marshal(original) - s := string(b) - for _, want := range []string{`"input_tokens"`, `"output_tokens"`, `"total"`, `"cache_read"`, `"reasoning"`} { - if !contains(s, want) { - t.Errorf("expected %s in wire JSON, got %s", want, s) - } - } - var decoded Usage - json.Unmarshal(b, &decoded) - if decoded.InputTokens.Total == nil || *decoded.InputTokens.Total != 100 { - t.Error("input_tokens.total did not round-trip") - } -} - -// ── StreamPart parse round-trip ─────────────────────────────────────────────── - func TestStreamPartParseRoundTrip(t *testing.T) { cases := []struct { name string diff --git a/bindings/go/types.go b/bindings/go/types.go index b63089e6c..f34e09132 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -77,21 +77,26 @@ func ToolChoiceTool(name string) ToolChoice { // ── Core types ─────────────────────────────────────────────────────────────── -// TokenUsage is token usage detail with cache breakdown. -type TokenUsage struct { +// InputTokenUsage is input token usage detail with cache breakdown. +type InputTokenUsage struct { Total *uint32 `json:"total,omitempty"` NoCache *uint32 `json:"no_cache,omitempty"` CacheRead *uint32 `json:"cache_read,omitempty"` CacheWrite *uint32 `json:"cache_write,omitempty"` - Text *uint32 `json:"text,omitempty"` - Reasoning *uint32 `json:"reasoning,omitempty"` +} + +// OutputTokenUsage is output token usage detail. +type OutputTokenUsage struct { + Total *uint32 `json:"total,omitempty"` + Text *uint32 `json:"text,omitempty"` + Reasoning *uint32 `json:"reasoning,omitempty"` } // Usage is token usage statistics. type Usage struct { - InputTokens TokenUsage `json:"input_tokens,omitempty"` - OutputTokens TokenUsage `json:"output_tokens,omitempty"` - Raw json.RawMessage `json:"raw,omitempty"` + InputTokens InputTokenUsage `json:"input_tokens,omitempty"` + OutputTokens OutputTokenUsage `json:"output_tokens,omitempty"` + Raw map[string]json.RawMessage `json:"raw,omitempty"` } // FinishReason is the finish reason. @@ -130,22 +135,29 @@ type ResponseMetadata struct { ModelID *string `json:"model_id,omitempty"` } +// GenerateResponseMetadata is response metadata with an optional HTTP body. +type GenerateResponseMetadata struct { + ResponseMetadata + Body json.RawMessage `json:"body,omitempty"` +} + // GenerateResult is the raw provider result. // Mirrors Kotlin GenerateResult (Types.kt:853-871). type GenerateResult struct { - Content []ContentPart `json:"content,omitempty"` - FinishReason FinishReason `json:"finish_reason,omitempty"` - Usage Usage `json:"usage,omitempty"` - Warnings []json.RawMessage `json:"warnings,omitempty"` - ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` - Response ResponseMetadata `json:"response,omitempty"` - RequestBody json.RawMessage `json:"request_body,omitempty"` - ResponseHeaders map[string]string `json:"response_headers,omitempty"` + Content []ContentPart `json:"content,omitempty"` + FinishReason FinishReason `json:"finish_reason,omitempty"` + Usage Usage `json:"usage,omitempty"` + 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"` } // GenerateTextResult is the typed result of a GenerateText call. // Mirrors Kotlin GenerateTextResult (Types.kt:878-886). type GenerateTextResult struct { + Content []ContentPart `json:"content,omitempty"` Text string `json:"text"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` FinishReason FinishReason `json:"finish_reason,omitempty"` @@ -190,6 +202,7 @@ type GenerateObjectResult struct { // Mirrors Rust StreamTextResultAggregated. reasoning/sources/files use weak // types (json.RawMessage) — same strategy as GenerateTextResult. type StreamTextResultAggregated struct { + Content []ContentPart `json:"content,omitempty"` Text string `json:"text"` Reasoning []json.RawMessage `json:"reasoning,omitempty"` ReasoningText string `json:"reasoning_text,omitempty"` 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 62d272e89..58309b44c 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -78,7 +78,7 @@ private AimuxJson() {} * instead of re-entering the polymorphic serializer — which would * otherwise infinite-recurse ({@code StackOverflowError}). Non-recursing * serializers that concrete variants may transitively contain - * ({@link ToolChoice}, {@link FileBytes}, {@link FileData}) are kept. + * ({@link ToolChoice}, {@link FileBytes}, {@link GeneratedFileData}) are kept. */ public static final ObjectMapper INNER_MAPPER = createInnerMapper(); @@ -107,6 +107,8 @@ private static ObjectMapper configure(ObjectMapper m, boolean polymorphic) { module.addDeserializer(FileBytes.class, new FileBytesDeserializer()); module.addSerializer(FileData.class, new FileDataSerializer()); module.addDeserializer(FileData.class, new FileDataDeserializer()); + module.addSerializer(GeneratedFileData.class, new GeneratedFileDataSerializer()); + module.addDeserializer(GeneratedFileData.class, new GeneratedFileDataDeserializer()); // Externally-tagged polymorphic serializers — only on the public // mapper. They use INNER_MAPPER internally to avoid self-recursion. if (polymorphic) { @@ -158,98 +160,86 @@ public enum ReasoningEffort { // Core nested types. // ───────────────────────────────────────────────────────────────────────────── - /** - * Token usage detail (with cache breakdown). Every field is nullable because - * providers do not always populate all breakdowns. - */ - public static class TokenUsage { + public static class InputTokenUsage { @JsonProperty("total") private Long total; @JsonProperty("no_cache") private Long noCache; @JsonProperty("cache_read") private Long cacheRead; @JsonProperty("cache_write") private Long cacheWrite; - @JsonProperty("text") private Long text; - @JsonProperty("reasoning") private Long reasoning; - - @JsonCreator - TokenUsage() {} - - private TokenUsage(Long total, Long noCache, Long cacheRead, Long cacheWrite, Long text, Long reasoning) { + @JsonCreator InputTokenUsage() {} + public InputTokenUsage(Long total, Long noCache, Long cacheRead, Long cacheWrite) { this.total = total; this.noCache = noCache; this.cacheRead = cacheRead; this.cacheWrite = cacheWrite; - this.text = text; - this.reasoning = reasoning; - } - - /** Convenience: a usage with only the total token count set. */ - public static TokenUsage of(Long total) { - return new TokenUsage(total, null, null, null, null, null); } - public Long getTotal() { return total; } public Long getNoCache() { return noCache; } public Long getCacheRead() { return cacheRead; } public Long getCacheWrite() { return cacheWrite; } - public Long getText() { return text; } - public Long getReasoning() { return reasoning; } - public static Builder builder() { return new Builder(); } - public static class Builder { private Long total; private Long noCache; private Long cacheRead; private Long cacheWrite; + public Builder total(Long value) { this.total = value; return this; } + public Builder noCache(Long value) { this.noCache = value; return this; } + public Builder cacheRead(Long value) { this.cacheRead = value; return this; } + public Builder cacheWrite(Long value) { this.cacheWrite = value; return this; } + public InputTokenUsage build() { return new InputTokenUsage(total, noCache, cacheRead, cacheWrite); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof InputTokenUsage)) return false; + InputTokenUsage other = (InputTokenUsage) value; + return Objects.equals(total, other.total) && Objects.equals(noCache, other.noCache) && Objects.equals(cacheRead, other.cacheRead) && Objects.equals(cacheWrite, other.cacheWrite); + } + @Override public int hashCode() { return Objects.hash(total, noCache, cacheRead, cacheWrite); } + } + public static class OutputTokenUsage { + @JsonProperty("total") private Long total; + @JsonProperty("text") private Long text; + @JsonProperty("reasoning") private Long reasoning; + @JsonCreator OutputTokenUsage() {} + public OutputTokenUsage(Long total, Long text, Long reasoning) { + this.total = total; + this.text = text; + this.reasoning = reasoning; + } + public Long getTotal() { return total; } + public Long getText() { return text; } + public Long getReasoning() { return reasoning; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private Long total; private Long text; private Long reasoning; - - public Builder total(Long v) { this.total = v; return this; } - public Builder noCache(Long v) { this.noCache = v; return this; } - public Builder cacheRead(Long v) { this.cacheRead = v; return this; } - public Builder cacheWrite(Long v) { this.cacheWrite = v; return this; } - public Builder text(Long v) { this.text = v; return this; } - public Builder reasoning(Long v) { this.reasoning = v; return this; } - - public TokenUsage build() { - return new TokenUsage(total, noCache, cacheRead, cacheWrite, text, reasoning); - } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof TokenUsage)) return false; - TokenUsage that = (TokenUsage) o; - return Objects.equals(total, that.total) - && Objects.equals(noCache, that.noCache) - && Objects.equals(cacheRead, that.cacheRead) - && Objects.equals(cacheWrite, that.cacheWrite) - && Objects.equals(text, that.text) - && Objects.equals(reasoning, that.reasoning); + public Builder total(Long value) { this.total = value; return this; } + public Builder text(Long value) { this.text = value; return this; } + public Builder reasoning(Long value) { this.reasoning = value; return this; } + public OutputTokenUsage build() { return new OutputTokenUsage(total, text, reasoning); } } - - @Override - public int hashCode() { - return Objects.hash(total, noCache, cacheRead, cacheWrite, text, reasoning); + @Override public boolean equals(Object value) { + if (!(value instanceof OutputTokenUsage)) return false; + OutputTokenUsage other = (OutputTokenUsage) value; + return Objects.equals(total, other.total) && Objects.equals(text, other.text) && Objects.equals(reasoning, other.reasoning); } + @Override public int hashCode() { return Objects.hash(total, text, reasoning); } } - /** * Token usage statistics. * - * Mirrors `Usage.ts`: `{ input_tokens: TokenUsage, output_tokens: TokenUsage, - * raw?: JsonValue | null }`. + * Mirrors `Usage.ts`: `{ input_tokens: InputTokenUsage, output_tokens: OutputTokenUsage, + * raw?: JSONObject | null }`. */ public static class Usage { - @JsonProperty("input_tokens") private TokenUsage inputTokens = new TokenUsage(); - @JsonProperty("output_tokens") private TokenUsage outputTokens = new TokenUsage(); - @JsonProperty("raw") private JsonNode raw; + @JsonProperty("input_tokens") private InputTokenUsage inputTokens = new InputTokenUsage(); + @JsonProperty("output_tokens") private OutputTokenUsage outputTokens = new OutputTokenUsage(); + @JsonProperty("raw") private ObjectNode raw; @JsonCreator Usage() {} - private Usage(TokenUsage inputTokens, TokenUsage outputTokens, JsonNode raw) { + private Usage(InputTokenUsage inputTokens, OutputTokenUsage outputTokens, ObjectNode raw) { this.inputTokens = inputTokens; this.outputTokens = outputTokens; this.raw = raw; @@ -257,23 +247,23 @@ private Usage(TokenUsage inputTokens, TokenUsage outputTokens, JsonNode raw) { /** Convenience for tests/inspection. */ public static Usage of(Long input, Long output) { - return new Usage(TokenUsage.of(input), TokenUsage.of(output), null); + return new Usage(InputTokenUsage.builder().total(input).build(), OutputTokenUsage.builder().total(output).build(), null); } - public TokenUsage getInputTokens() { return inputTokens; } - public TokenUsage getOutputTokens() { return outputTokens; } - public JsonNode getRaw() { return raw; } + public InputTokenUsage getInputTokens() { return inputTokens; } + public OutputTokenUsage getOutputTokens() { return outputTokens; } + public ObjectNode getRaw() { return raw; } public static Builder builder() { return new Builder(); } public static class Builder { - private TokenUsage inputTokens = new TokenUsage(); - private TokenUsage outputTokens = new TokenUsage(); - private JsonNode raw; + private InputTokenUsage inputTokens = new InputTokenUsage(); + private OutputTokenUsage outputTokens = new OutputTokenUsage(); + private ObjectNode raw; - public Builder inputTokens(TokenUsage v) { this.inputTokens = v; return this; } - public Builder outputTokens(TokenUsage v) { this.outputTokens = v; return this; } - public Builder raw(JsonNode v) { this.raw = v; return this; } + public Builder inputTokens(InputTokenUsage v) { this.inputTokens = v; return this; } + public Builder outputTokens(OutputTokenUsage v) { this.outputTokens = v; return this; } + public Builder raw(ObjectNode v) { this.raw = v; return this; } public Usage build() { return new Usage(inputTokens, outputTokens, raw); } } @@ -1411,6 +1401,106 @@ public int hashCode() { return Objects.hash(toolCallId, result, toolName, isError, preliminary, dynamic, providerOptions); } } + public static class Custom extends ContentPart { + @JsonProperty("kind") private String kind; + @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonCreator Custom() {} + public Custom(String kind, JsonNode providerOptions) { + this.kind = kind; + this.providerOptions = providerOptions; + } + public String getKind() { return kind; } + public JsonNode getProviderOptions() { return providerOptions; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String kind; + private JsonNode providerOptions; + public Builder kind(String value) { this.kind = value; return this; } + public Builder providerOptions(JsonNode value) { this.providerOptions = value; return this; } + public Custom build() { return new Custom(kind, providerOptions); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof Custom)) return false; + Custom other = (Custom) value; + return Objects.equals(kind, other.kind) && Objects.equals(providerOptions, other.providerOptions); + } + @Override public int hashCode() { return Objects.hash(kind, providerOptions); } + } + public static class ReasoningFile extends ContentPart { + @JsonProperty("data") private GeneratedFileData data; + @JsonProperty("media_type") private String mediaType; + @JsonProperty("provider_options") private JsonNode providerOptions; + @JsonCreator ReasoningFile() {} + public ReasoningFile(GeneratedFileData data, String mediaType, JsonNode providerOptions) { + this.data = data; + this.mediaType = mediaType; + this.providerOptions = providerOptions; + } + public GeneratedFileData getData() { return data; } + public String getMediaType() { return mediaType; } + public JsonNode getProviderOptions() { return providerOptions; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private GeneratedFileData data; + private String mediaType; + private JsonNode providerOptions; + public Builder data(GeneratedFileData value) { this.data = value; return this; } + public Builder mediaType(String value) { this.mediaType = value; return this; } + public Builder providerOptions(JsonNode value) { this.providerOptions = value; return this; } + public ReasoningFile build() { return new ReasoningFile(data, mediaType, providerOptions); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ReasoningFile)) return false; + ReasoningFile other = (ReasoningFile) value; + return Objects.equals(data, other.data) && Objects.equals(mediaType, other.mediaType) && Objects.equals(providerOptions, other.providerOptions); + } + @Override public int hashCode() { return Objects.hash(data, mediaType, providerOptions); } + } + public static class ToolApprovalRequest extends ContentPart { + @JsonProperty("approval_id") private String approvalId; + @JsonProperty("tool_call_id") private String toolCallId; + @JsonProperty("reason") private String reason; + @JsonProperty("is_automatic") private Boolean isAutomatic; + @JsonProperty("signature") private String signature; + @JsonProperty("input_schema_input") private JsonNode inputSchemaInput; + @JsonCreator ToolApprovalRequest() {} + public ToolApprovalRequest(String approvalId, String toolCallId, String reason, Boolean isAutomatic, String signature, JsonNode inputSchemaInput) { + this.approvalId = approvalId; + this.toolCallId = toolCallId; + this.reason = reason; + this.isAutomatic = isAutomatic; + this.signature = signature; + this.inputSchemaInput = inputSchemaInput; + } + public String getApprovalId() { return approvalId; } + public String getToolCallId() { return toolCallId; } + public String getReason() { return reason; } + public Boolean getIsAutomatic() { return isAutomatic; } + public String getSignature() { return signature; } + public JsonNode getInputSchemaInput() { return inputSchemaInput; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String approvalId; + private String toolCallId; + private String reason; + private Boolean isAutomatic; + private String signature; + private JsonNode inputSchemaInput; + public Builder approvalId(String value) { this.approvalId = value; return this; } + public Builder toolCallId(String value) { this.toolCallId = value; return this; } + public Builder reason(String value) { this.reason = value; return this; } + public Builder isAutomatic(Boolean value) { this.isAutomatic = value; return this; } + public Builder signature(String value) { this.signature = value; return this; } + public Builder inputSchemaInput(JsonNode value) { this.inputSchemaInput = value; return this; } + public ToolApprovalRequest build() { return new ToolApprovalRequest(approvalId, toolCallId, reason, isAutomatic, signature, inputSchemaInput); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ToolApprovalRequest)) return false; + ToolApprovalRequest other = (ToolApprovalRequest) value; + return Objects.equals(approvalId, other.approvalId) && Objects.equals(toolCallId, other.toolCallId) && Objects.equals(reason, other.reason) && Objects.equals(isAutomatic, other.isAutomatic) && Objects.equals(signature, other.signature) && Objects.equals(inputSchemaInput, other.inputSchemaInput); + } + @Override public int hashCode() { return Objects.hash(approvalId, toolCallId, reason, isAutomatic, signature, inputSchemaInput); } + } } /** Custom (de)serializer for {@link ContentPart} — internally tagged on `"type"`. */ @@ -1435,6 +1525,9 @@ private static String variantTag(ContentPart value) { if (value instanceof ContentPart.Reasoning) return "reasoning"; if (value instanceof ContentPart.ToolCall) return "tool_call"; if (value instanceof ContentPart.ToolResult) return "tool_result"; + if (value instanceof ContentPart.Custom) return "custom"; + if (value instanceof ContentPart.ReasoningFile) return "reasoning_file"; + if (value instanceof ContentPart.ToolApprovalRequest) return "tool_approval_request"; throw new IllegalArgumentException("Unknown ContentPart: " + value); } } @@ -1458,6 +1551,9 @@ public ContentPart deserialize(JsonParser p, DeserializationContext ctxt) throws case "reasoning": return AimuxJson.MAPPER.treeToValue(node, ContentPart.Reasoning.class); case "tool_call": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolCall.class); case "tool_result": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolResult.class); + case "custom": return AimuxJson.MAPPER.treeToValue(node, ContentPart.Custom.class); + case "reasoning_file": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ReasoningFile.class); + case "tool_approval_request": return AimuxJson.MAPPER.treeToValue(node, ContentPart.ToolApprovalRequest.class); default: throw new IOException("Unknown ContentPart type: '" + type + "'"); } @@ -2007,7 +2103,7 @@ public int hashCode() { // ───────────────────────────────────────────────────────────────────────────── // File bytes / file data (shared V4 file types). // - // `FileBytes` and `FileData` are externally-tagged enums + // `FileBytes` and `GeneratedFileData` are externally-tagged enums // (`{"Binary": [...]}`, `{"Data": {"data": ...}}`, ...). Each is modeled with a // custom serializer that dispatches on the single tag key. // ───────────────────────────────────────────────────────────────────────────── @@ -2145,26 +2241,30 @@ public boolean equals(Object o) { } public static class Url extends FileData { - private String url = ""; - - @JsonCreator - Url() {} - - public Url(String url) { this.url = url; } - + @JsonProperty("url") private String url; + @JsonProperty("original_url") private String originalUrl; + @JsonCreator Url() {} + public Url(String url, String originalUrl) { + this.url = url; + this.originalUrl = originalUrl; + } public String getUrl() { return url; } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Url)) return false; - return Objects.equals(url, ((Url) o).url); + public String getOriginalUrl() { return originalUrl; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String url; + private String originalUrl; + public Builder url(String value) { this.url = value; return this; } + public Builder originalUrl(String value) { this.originalUrl = value; return this; } + public Url build() { return new Url(url, originalUrl); } } - - @Override - public int hashCode() { return Objects.hash(url); } + @Override public boolean equals(Object value) { + if (!(value instanceof Url)) return false; + Url other = (Url) value; + return Objects.equals(url, other.url) && Objects.equals(originalUrl, other.originalUrl); + } + @Override public int hashCode() { return Objects.hash(url, originalUrl); } } - public static class Reference extends FileData { private JsonNode reference = emptyObject(); @@ -2219,11 +2319,19 @@ public void serialize(FileData value, JsonGenerator gen, SerializerProvider seri gen.writeTree(AimuxJson.MAPPER.valueToTree(((FileData.Data) value).getData())); gen.writeEndObject(); } else if (value instanceof FileData.Url) { - gen.writeObjectField("Url", ((FileData.Url) value).getUrl()); + gen.writeObjectFieldStart("Url"); + gen.writeStringField("url", ((FileData.Url) value).getUrl()); + if (((FileData.Url) value).getOriginalUrl() != null) + gen.writeStringField("original_url", ((FileData.Url) value).getOriginalUrl()); + gen.writeEndObject(); } else if (value instanceof FileData.Reference) { - gen.writeObjectField("Reference", ((FileData.Reference) value).getReference()); + gen.writeObjectFieldStart("Reference"); + gen.writeObjectField("reference", ((FileData.Reference) value).getReference()); + gen.writeEndObject(); } else if (value instanceof FileData.Text) { - gen.writeObjectField("Text", ((FileData.Text) value).getText()); + gen.writeObjectFieldStart("Text"); + gen.writeStringField("text", ((FileData.Text) value).getText()); + gen.writeEndObject(); } else { throw new IOException("Unknown FileData: " + value); } @@ -2246,17 +2354,120 @@ public FileData deserialize(JsonParser p, DeserializationContext ctxt) throws IO case "Data": return new FileData.Data(AimuxJson.MAPPER.treeToValue(innerObj.get("data"), FileBytes.class)); case "Url": - return new FileData.Url(inner.asText()); + return new FileData.Url(innerObj.path("url").asText(), innerObj.hasNonNull("original_url") ? innerObj.get("original_url").asText() : null); case "Reference": return new FileData.Reference(innerObj.get("reference") == null ? emptyObject() : innerObj.get("reference")); case "Text": - return new FileData.Text(inner.asText()); + return new FileData.Text(innerObj.path("text").asText()); default: throw new IOException("Unknown FileData tag: '" + tag + "'"); } } } + /** + * File data as a tagged discriminated union. + * + * Mirrors `GeneratedFileData.ts`: `{"Data": {"data": FileBytes}} | {"Url": {"url": ...}} + * Generated files contain only data or a URL. + */ + public abstract static class GeneratedFileData { + private GeneratedFileData() {} + + public static class Data extends GeneratedFileData { + private FileBytes data = new FileBytes.Base64(""); + + @JsonCreator + Data() {} + + public Data(FileBytes data) { this.data = data; } + + public FileBytes getData() { return data; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Data)) return false; + return Objects.equals(data, ((Data) o).data); + } + + @Override + public int hashCode() { return Objects.hash(data); } + } + + public static class Url extends GeneratedFileData { + @JsonProperty("url") private String url; + @JsonProperty("original_url") private String originalUrl; + @JsonCreator Url() {} + public Url(String url, String originalUrl) { + this.url = url; + this.originalUrl = originalUrl; + } + public String getUrl() { return url; } + public String getOriginalUrl() { return originalUrl; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String url; + private String originalUrl; + public Builder url(String value) { this.url = value; return this; } + public Builder originalUrl(String value) { this.originalUrl = value; return this; } + public Url build() { return new Url(url, originalUrl); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof Url)) return false; + Url other = (Url) value; + return Objects.equals(url, other.url) && Objects.equals(originalUrl, other.originalUrl); + } + @Override public int hashCode() { return Objects.hash(url, originalUrl); } + } + + } + + /** Custom (de)serializer for {@link GeneratedFileData} — externally tagged. */ + public static class GeneratedFileDataSerializer extends JsonSerializer { + @Override + public void serialize(GeneratedFileData value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + if (value instanceof GeneratedFileData.Data) { + gen.writeObjectFieldStart("Data"); + gen.writeFieldName("data"); + gen.writeTree(AimuxJson.MAPPER.valueToTree(((GeneratedFileData.Data) value).getData())); + gen.writeEndObject(); + } else if (value instanceof GeneratedFileData.Url) { + gen.writeObjectFieldStart("Url"); + gen.writeStringField("url", ((GeneratedFileData.Url) value).getUrl()); + if (((GeneratedFileData.Url) value).getOriginalUrl() != null) + gen.writeStringField("original_url", ((GeneratedFileData.Url) value).getOriginalUrl()); + gen.writeEndObject(); + } else { + throw new IOException("Unknown GeneratedFileData: " + value); + } + gen.writeEndObject(); + } + } + + /** Custom (de)serializer for {@link GeneratedFileData}. */ + public static class GeneratedFileDataDeserializer extends JsonDeserializer { + @Override + public GeneratedFileData deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + if (!node.isObject() || node.size() != 1) { + throw new IOException("GeneratedFileData must be a single-key externally-tagged object, got: " + node); + } + String tag = node.fieldNames().next(); + JsonNode inner = node.get(tag); + JsonNode innerObj = inner.isObject() ? inner : AimuxJson.MAPPER.createObjectNode(); + switch (tag) { + case "Data": + return new GeneratedFileData.Data(AimuxJson.MAPPER.treeToValue(innerObj.get("data"), FileBytes.class)); + case "Url": + return new GeneratedFileData.Url(innerObj.path("url").asText(), innerObj.hasNonNull("original_url") ? innerObj.get("original_url").asText() : null); + default: + throw new IOException("Unknown GeneratedFileData tag: '" + tag + "'"); + } + } + } + // ───────────────────────────────────────────────────────────────────────────── // GenerateContent / GenerateResult (the `raw` field of GenerateTextResult). // @@ -2489,31 +2700,31 @@ public boolean equals(Object o) { } public static class File extends GenerateContent { - @JsonProperty("data") private FileData data = new FileData.Text(""); + @JsonProperty("data") private GeneratedFileData data = new GeneratedFileData.Data(new FileBytes.Base64("")); @JsonProperty("media_type") private String mediaType = ""; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonCreator File() {} - private File(FileData data, String mediaType, JsonNode providerMetadata) { + private File(GeneratedFileData data, String mediaType, JsonNode providerMetadata) { this.data = data; this.mediaType = mediaType; this.providerMetadata = providerMetadata; } - public FileData getData() { return data; } + public GeneratedFileData getData() { return data; } public String getMediaType() { return mediaType; } public JsonNode getProviderMetadata() { return providerMetadata; } public static Builder builder() { return new Builder(); } public static class Builder { - private FileData data = new FileData.Text(""); + private GeneratedFileData data = new GeneratedFileData.Data(new FileBytes.Base64("")); private String mediaType = ""; private JsonNode providerMetadata; - public Builder data(FileData v) { this.data = v; return this; } + public Builder data(GeneratedFileData v) { this.data = v; return this; } public Builder mediaType(String v) { this.mediaType = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } @@ -2609,6 +2820,91 @@ public int hashCode() { } } + public static class Custom extends GenerateContent { + @JsonProperty("kind") private String kind; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator Custom() {} + public Custom(String kind, JsonNode providerMetadata) { + this.kind = kind; + this.providerMetadata = providerMetadata; + } + public String getKind() { return kind; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String kind; + private JsonNode providerMetadata; + public Builder kind(String value) { this.kind = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public Custom build() { return new Custom(kind, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof Custom)) return false; + Custom other = (Custom) value; + return Objects.equals(kind, other.kind) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(kind, providerMetadata); } + } + public static class ReasoningFile extends GenerateContent { + @JsonProperty("data") private GeneratedFileData data; + @JsonProperty("media_type") private String mediaType; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator ReasoningFile() {} + public ReasoningFile(GeneratedFileData data, String mediaType, JsonNode providerMetadata) { + this.data = data; + this.mediaType = mediaType; + this.providerMetadata = providerMetadata; + } + public GeneratedFileData getData() { return data; } + public String getMediaType() { return mediaType; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private GeneratedFileData data; + private String mediaType; + private JsonNode providerMetadata; + public Builder data(GeneratedFileData value) { this.data = value; return this; } + public Builder mediaType(String value) { this.mediaType = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public ReasoningFile build() { return new ReasoningFile(data, mediaType, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ReasoningFile)) return false; + ReasoningFile other = (ReasoningFile) value; + return Objects.equals(data, other.data) && Objects.equals(mediaType, other.mediaType) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(data, mediaType, providerMetadata); } + } + public static class ToolApprovalRequest extends GenerateContent { + @JsonProperty("approval_id") private String approvalId; + @JsonProperty("tool_call_id") private String toolCallId; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator ToolApprovalRequest() {} + public ToolApprovalRequest(String approvalId, String toolCallId, JsonNode providerMetadata) { + this.approvalId = approvalId; + this.toolCallId = toolCallId; + this.providerMetadata = providerMetadata; + } + public String getApprovalId() { return approvalId; } + public String getToolCallId() { return toolCallId; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String approvalId; + private String toolCallId; + private JsonNode providerMetadata; + public Builder approvalId(String value) { this.approvalId = value; return this; } + public Builder toolCallId(String value) { this.toolCallId = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public ToolApprovalRequest build() { return new ToolApprovalRequest(approvalId, toolCallId, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ToolApprovalRequest)) return false; + ToolApprovalRequest other = (ToolApprovalRequest) value; + return Objects.equals(approvalId, other.approvalId) && Objects.equals(toolCallId, other.toolCallId) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(approvalId, toolCallId, providerMetadata); } + } /** Fallback for variants introduced after this wrapper was written. */ public static class Unknown extends GenerateContent { private String tag; @@ -2646,6 +2942,9 @@ public static String variantTag(GenerateContent value) { if (value instanceof GenerateContent.Reasoning) return "Reasoning"; if (value instanceof GenerateContent.File) return "File"; if (value instanceof GenerateContent.ToolResult) return "ToolResult"; + if (value instanceof GenerateContent.Custom) return "Custom"; + if (value instanceof GenerateContent.ReasoningFile) return "ReasoningFile"; + if (value instanceof GenerateContent.ToolApprovalRequest) return "ToolApprovalRequest"; if (value instanceof GenerateContent.Unknown) return ((GenerateContent.Unknown) value).getTag(); throw new IllegalArgumentException("Unknown GenerateContent: " + value); } @@ -2686,6 +2985,9 @@ public GenerateContent deserialize(JsonParser p, DeserializationContext ctxt) th case "Reasoning": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.Reasoning.class); case "File": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.File.class); case "ToolResult": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.ToolResult.class); + case "Custom": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.Custom.class); + case "ReasoningFile": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.ReasoningFile.class); + case "ToolApprovalRequest": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.ToolApprovalRequest.class); default: return new GenerateContent.Unknown(tag, inner); } } @@ -2805,6 +3107,7 @@ public int hashCode() { * Mirrors `GenerateTextResult.ts`. */ public static class GenerateTextResult { + @JsonProperty("content") private List content = new ArrayList<>(); @JsonProperty("text") private String text = ""; @JsonProperty("tool_calls") private List toolCalls = new ArrayList<>(); @JsonProperty("finish_reason") private FinishReason finishReason = new FinishReason(); @@ -2847,6 +3150,7 @@ private GenerateTextResult(String text, List toolCalls, FinishReason f this.totalUsage = totalUsage; } + public List getContent() { return content; } public String getText() { return text; } public List getToolCalls() { return toolCalls; } public FinishReason getFinishReason() { return finishReason; } @@ -3043,6 +3347,7 @@ public int hashCode() { * weak types ({@link JsonNode}) — same strategy as {@link GenerateTextResult}. */ public static class StreamTextResultAggregated { + @JsonProperty("content") private List content = new ArrayList<>(); @JsonProperty("text") private String text = ""; @JsonProperty("reasoning") private List reasoning = new ArrayList<>(); @JsonProperty("reasoning_text") private String reasoningText = ""; @@ -3082,6 +3387,7 @@ private StreamTextResultAggregated(String text, List reasoning, String this.responseMessages = responseMessages; } + public List getContent() { return content; } public String getText() { return text; } public List getReasoning() { return reasoning; } public String getReasoningText() { return reasoningText; } @@ -3706,31 +4012,31 @@ public int hashCode() { /** A file generated by the model (e.g. an image or document). */ public static class File extends StreamPart { - @JsonProperty("data") private JsonNode data = emptyObject(); + @JsonProperty("data") private GeneratedFileData data = new GeneratedFileData.Data(new FileBytes.Base64("")); @JsonProperty("media_type") private String mediaType = ""; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonCreator File() {} - private File(JsonNode data, String mediaType, JsonNode providerMetadata) { + private File(GeneratedFileData data, String mediaType, JsonNode providerMetadata) { this.data = data; this.mediaType = mediaType; this.providerMetadata = providerMetadata; } - public JsonNode getData() { return data; } + public GeneratedFileData getData() { return data; } public String getMediaType() { return mediaType; } public JsonNode getProviderMetadata() { return providerMetadata; } public static Builder builder() { return new Builder(); } public static class Builder { - private JsonNode data = emptyObject(); + private GeneratedFileData data = new GeneratedFileData.Data(new FileBytes.Base64("")); private String mediaType = ""; private JsonNode providerMetadata; - public Builder data(JsonNode v) { this.data = v; return this; } + public Builder data(GeneratedFileData v) { this.data = v; return this; } public Builder mediaType(String v) { this.mediaType = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } @@ -4041,6 +4347,106 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(error); } } + public static class Custom extends StreamPart { + @JsonProperty("kind") private String kind; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator Custom() {} + public Custom(String kind, JsonNode providerMetadata) { + this.kind = kind; + this.providerMetadata = providerMetadata; + } + public String getKind() { return kind; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String kind; + private JsonNode providerMetadata; + public Builder kind(String value) { this.kind = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public Custom build() { return new Custom(kind, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof Custom)) return false; + Custom other = (Custom) value; + return Objects.equals(kind, other.kind) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(kind, providerMetadata); } + } + public static class ReasoningFile extends StreamPart { + @JsonProperty("file") private GenerateContent.File file; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator ReasoningFile() {} + public ReasoningFile(GenerateContent.File file, JsonNode providerMetadata) { + this.file = file; + this.providerMetadata = providerMetadata; + } + public GenerateContent.File getFile() { return file; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private GenerateContent.File file; + private JsonNode providerMetadata; + public Builder file(GenerateContent.File value) { this.file = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public ReasoningFile build() { return new ReasoningFile(file, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ReasoningFile)) return false; + ReasoningFile other = (ReasoningFile) value; + return Objects.equals(file, other.file) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(file, providerMetadata); } + } + public static class ToolApprovalRequest extends StreamPart { + @JsonProperty("approval_id") private String approvalId; + @JsonProperty("tool_call") private Types.ToolCall toolCall; + @JsonProperty("reason") private String reason; + @JsonProperty("is_automatic") private Boolean isAutomatic; + @JsonProperty("signature") private String signature; + @JsonProperty("tool_call_id") private String toolCallId; + @JsonProperty("provider_metadata") private JsonNode providerMetadata; + @JsonCreator ToolApprovalRequest() {} + public ToolApprovalRequest(String approvalId, Types.ToolCall toolCall, String reason, Boolean isAutomatic, String signature, String toolCallId, JsonNode providerMetadata) { + this.approvalId = approvalId; + this.toolCall = toolCall; + this.reason = reason; + this.isAutomatic = isAutomatic; + this.signature = signature; + this.toolCallId = toolCallId; + this.providerMetadata = providerMetadata; + } + public String getApprovalId() { return approvalId; } + public Types.ToolCall getToolCall() { return toolCall; } + public String getReason() { return reason; } + public Boolean getIsAutomatic() { return isAutomatic; } + public String getSignature() { return signature; } + public String getToolCallId() { return toolCallId; } + public JsonNode getProviderMetadata() { return providerMetadata; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private String approvalId; + private Types.ToolCall toolCall; + private String reason; + private Boolean isAutomatic; + private String signature; + private String toolCallId; + private JsonNode providerMetadata; + public Builder approvalId(String value) { this.approvalId = value; return this; } + public Builder toolCall(Types.ToolCall value) { this.toolCall = value; return this; } + public Builder reason(String value) { this.reason = value; return this; } + public Builder isAutomatic(Boolean value) { this.isAutomatic = value; return this; } + public Builder signature(String value) { this.signature = value; return this; } + public Builder toolCallId(String value) { this.toolCallId = value; return this; } + public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } + public ToolApprovalRequest build() { return new ToolApprovalRequest(approvalId, toolCall, reason, isAutomatic, signature, toolCallId, providerMetadata); } + } + @Override public boolean equals(Object value) { + if (!(value instanceof ToolApprovalRequest)) return false; + ToolApprovalRequest other = (ToolApprovalRequest) value; + return Objects.equals(approvalId, other.approvalId) && Objects.equals(toolCall, other.toolCall) && Objects.equals(reason, other.reason) && Objects.equals(isAutomatic, other.isAutomatic) && Objects.equals(signature, other.signature) && Objects.equals(toolCallId, other.toolCallId) && Objects.equals(providerMetadata, other.providerMetadata); + } + @Override public int hashCode() { return Objects.hash(approvalId, toolCall, reason, isAutomatic, signature, toolCallId, providerMetadata); } + } /** Fallback for variants introduced after this wrapper was written. */ public static class Unknown extends StreamPart { private String tag; @@ -4090,6 +4496,9 @@ public static String variantTag(StreamPart value) { if (value instanceof StreamPart.Source) return "Source"; if (value instanceof StreamPart.Raw) return "Raw"; if (value instanceof StreamPart.Error) return "Error"; + if (value instanceof StreamPart.Custom) return "Custom"; + if (value instanceof StreamPart.ReasoningFile) return "ReasoningFile"; + if (value instanceof StreamPart.ToolApprovalRequest) return "ToolApprovalRequest"; if (value instanceof StreamPart.Unknown) return ((StreamPart.Unknown) value).getTag(); throw new IllegalArgumentException("Unknown StreamPart: " + value); } @@ -4142,6 +4551,9 @@ public StreamPart deserialize(JsonParser p, DeserializationContext ctxt) throws case "Source": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Source.class); case "Raw": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Raw.class); case "Error": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Error.class); + case "Custom": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Custom.class); + case "ReasoningFile": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningFile.class); + case "ToolApprovalRequest": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ToolApprovalRequest.class); default: return new StreamPart.Unknown(tag, inner); } } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java index 30f7b1d19..5d376b3a9 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java @@ -223,9 +223,9 @@ void streamPartToolCallThoughtSignatureRoundTrips() throws Exception { @Test void usageRawPreservesVendorFieldsRoundTrip() throws Exception { Types.Usage usage = Types.Usage.builder() - .inputTokens(Types.TokenUsage.builder().total(20L).build()) - .outputTokens(Types.TokenUsage.builder().total(5L).build()) - .raw(Types.AimuxJson.MAPPER.readTree( + .inputTokens(Types.InputTokenUsage.builder().total(20L).build()) + .outputTokens(Types.OutputTokenUsage.builder().total(5L).build()) + .raw((com.fasterxml.jackson.databind.node.ObjectNode) Types.AimuxJson.MAPPER.readTree( "{\"prompt_cache_hit_tokens\":42,\"prompt_tokens\":20}")) .build(); 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 2820ef158..83095e314 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -85,40 +85,37 @@ enum class ReasoningEffort { // Core nested types. // ───────────────────────────────────────────────────────────────────────────── -/** - * Token usage detail (with cache breakdown). Every field is nullable because - * providers do not always populate all breakdowns. - */ @Serializable -data class TokenUsage( +data class InputTokenUsage( val total: Long? = null, @SerialName("no_cache") val noCache: Long? = null, @SerialName("cache_read") val cacheRead: Long? = null, @SerialName("cache_write") val cacheWrite: Long? = null, +) + +@Serializable +data class OutputTokenUsage( + val total: Long? = null, val text: Long? = null, val reasoning: Long? = null, -) { - companion object { - fun of(total: Long?): TokenUsage = TokenUsage(total = total) - } -} +) /** * Token usage statistics. * - * Mirrors `Usage.ts`: `{ input_tokens: TokenUsage, output_tokens: TokenUsage, + * Mirrors `Usage.ts`: `{ input_tokens: InputTokenUsage, output_tokens: OutputTokenUsage, * raw?: JsonValue | null }`. */ @Serializable data class Usage( - @SerialName("input_tokens") val inputTokens: TokenUsage = TokenUsage(), - @SerialName("output_tokens") val outputTokens: TokenUsage = TokenUsage(), - val raw: JsonElement? = null, + @SerialName("input_tokens") val inputTokens: InputTokenUsage = InputTokenUsage(), + @SerialName("output_tokens") val outputTokens: OutputTokenUsage = OutputTokenUsage(), + val raw: JsonObject? = null, ) { companion object { /** Convenience for tests/inspection. */ fun of(input: Long?, output: Long?): Usage = - Usage(inputTokens = TokenUsage.of(input), outputTokens = TokenUsage.of(output)) + Usage(inputTokens = InputTokenUsage(input), outputTokens = OutputTokenUsage(output)) } } @@ -146,6 +143,14 @@ data class ResponseMetadata( @SerialName("model_id") val modelId: String? = null, ) +@Serializable +data class GenerateResponse( + val id: String? = null, + val timestamp: String? = null, + @SerialName("model_id") val modelId: String? = null, + val body: JsonElement? = null, +) + /** * A tool call requested by the model. * @@ -334,6 +339,15 @@ object ToolChoiceSerializer : KSerializer { */ @Serializable(with = ContentPartSerializer::class) sealed interface ContentPart { + @Serializable + data class Custom(val kind: String, @SerialName("provider_options") val providerOptions: JsonElement? = null) : ContentPart + + @Serializable + data class ReasoningFile(val data: GeneratedFileData, @SerialName("media_type") val mediaType: String, @SerialName("provider_options") val providerOptions: JsonElement? = null) : ContentPart + + @Serializable + data class ToolApprovalRequest(@SerialName("approval_id") val approvalId: String, @SerialName("tool_call_id") val toolCallId: String, val reason: String? = null, @SerialName("is_automatic") val isAutomatic: Boolean? = null, val signature: String? = null, @SerialName("input_schema_input") val inputSchemaInput: JsonElement? = null) : ContentPart + @Serializable data class Text( @@ -430,6 +444,9 @@ object ContentPartSerializer : KSerializer { ?: throw SerializationException("ContentPart is missing the 'type' discriminator: $element") val ctx = json.json return when (type) { + "custom" -> ctx.decodeFromJsonElement(ContentPart.Custom.serializer(), obj) + "reasoning_file" -> ctx.decodeFromJsonElement(ContentPart.ReasoningFile.serializer(), obj) + "tool_approval_request" -> ctx.decodeFromJsonElement(ContentPart.ToolApprovalRequest.serializer(), obj) "text" -> ctx.decodeFromJsonElement(ContentPart.Text.serializer(), obj) "image" -> ctx.decodeFromJsonElement(ContentPart.Image.serializer(), obj) "file" -> ctx.decodeFromJsonElement(ContentPart.File.serializer(), obj) @@ -448,6 +465,9 @@ object ContentPartSerializer : KSerializer { ?: throw SerializationException("ContentPart can only be encoded to JSON") val ctx = json.json val (typeTag, inner) = when (value) { + is ContentPart.Custom -> "custom" to ctx.encodeToJsonElement(ContentPart.Custom.serializer(), value) + is ContentPart.ReasoningFile -> "reasoning_file" to ctx.encodeToJsonElement(ContentPart.ReasoningFile.serializer(), value) + is ContentPart.ToolApprovalRequest -> "tool_approval_request" to ctx.encodeToJsonElement(ContentPart.ToolApprovalRequest.serializer(), value) is ContentPart.Text -> "text" to ctx.encodeToJsonElement(ContentPart.Text.serializer(), value) is ContentPart.Image -> "image" to ctx.encodeToJsonElement(ContentPart.Image.serializer(), value) is ContentPart.File -> "file" to ctx.encodeToJsonElement(ContentPart.File.serializer(), value) @@ -721,7 +741,7 @@ sealed interface FileData { data class Data(val data: FileBytes = FileBytes.Base64("")) : FileData @Serializable - data class Url(val url: String = "") : FileData + data class Url(val url: String = "", @SerialName("original_url") val originalUrl: String? = null) : FileData @Serializable data class Reference(val reference: JsonElement = JsonObject(emptyMap())) : FileData @@ -775,6 +795,59 @@ object FileDataSerializer : KSerializer { } } +@Serializable(with = GeneratedFileDataSerializer::class) +sealed interface GeneratedFileData { + + @Serializable + data class Data(val data: FileBytes = FileBytes.Base64("")) : GeneratedFileData + + @Serializable + data class Url(val url: String = "", @SerialName("original_url") val originalUrl: String? = null) : GeneratedFileData + + +} + +/** + * Custom (de)serializer for [GeneratedFileData]. + * + * Externally tagged: each variant is a single-key object whose value is the + * variant's inner object; delegation goes to the matching variant's generated + * serializer. + */ +object GeneratedFileDataSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("aimux.GeneratedFileData") + + override fun deserialize(decoder: Decoder): GeneratedFileData { + val json = decoder as? JsonDecoder + ?: throw SerializationException("GeneratedFileData can only be decoded from JSON") + val element = json.decodeJsonElement() + val obj = element.jsonObject + require(obj.size == 1) { + "GeneratedFileData must be a single-key externally-tagged object, got: $element" + } + val (tag, inner) = obj.entries.single() + val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) + val ctx = json.json + return when (tag) { + "Data" -> ctx.decodeFromJsonElement(GeneratedFileData.Data.serializer(), innerObj) + "Url" -> ctx.decodeFromJsonElement(GeneratedFileData.Url.serializer(), innerObj) + else -> throw SerializationException("Unknown GeneratedFileData tag: '$tag'") + } + } + + override fun serialize(encoder: Encoder, value: GeneratedFileData) { + val json = encoder as? JsonEncoder + ?: throw SerializationException("GeneratedFileData can only be encoded to JSON") + val ctx = json.json + val (tag, inner) = when (value) { + is GeneratedFileData.Data -> "Data" to ctx.encodeToJsonElement(GeneratedFileData.Data.serializer(), value) + is GeneratedFileData.Url -> "Url" to ctx.encodeToJsonElement(GeneratedFileData.Url.serializer(), value) + } + json.encodeJsonElement(JsonObject(mapOf(tag to inner))) + } +} + // ───────────────────────────────────────────────────────────────────────────── // GenerateContent / GenerateResult (the `raw` field of GenerateTextResult). // @@ -793,6 +866,26 @@ object FileDataSerializer : KSerializer { */ @Serializable(with = GenerateContentSerializer::class) sealed interface GenerateContent { + @Serializable + data class Custom( + val kind: String, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : GenerateContent + + @Serializable + data class ReasoningFile( + val data: FileData, + @SerialName("media_type") val mediaType: String, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : GenerateContent + + @Serializable + data class ToolApprovalRequest( + @SerialName("approval_id") val approvalId: String, + @SerialName("tool_call_id") val toolCallId: String, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : GenerateContent + @Serializable data class Text( @@ -828,7 +921,7 @@ sealed interface GenerateContent { @Serializable data class File( - val data: FileData = FileData.Text(""), + val data: GeneratedFileData = GeneratedFileData.Data(), @SerialName("media_type") val mediaType: String = "", @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) : GenerateContent @@ -851,6 +944,9 @@ sealed interface GenerateContent { /** The externally-tagged variant name for this [GenerateContent] (e.g. "Text", "ToolCall"). */ val GenerateContent.variantTag: String get() = when (this) { + is GenerateContent.Custom -> "Custom" + is GenerateContent.ReasoningFile -> "ReasoningFile" + is GenerateContent.ToolApprovalRequest -> "ToolApprovalRequest" is GenerateContent.Text -> "Text" is GenerateContent.ToolCall -> "ToolCall" is GenerateContent.Source -> "Source" @@ -884,6 +980,9 @@ object GenerateContentSerializer : KSerializer { val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) val ctx = json.json return when (tag) { + "Custom" -> ctx.decodeFromJsonElement(GenerateContent.Custom.serializer(), innerObj) + "ReasoningFile" -> ctx.decodeFromJsonElement(GenerateContent.ReasoningFile.serializer(), innerObj) + "ToolApprovalRequest" -> ctx.decodeFromJsonElement(GenerateContent.ToolApprovalRequest.serializer(), innerObj) "Text" -> ctx.decodeFromJsonElement(GenerateContent.Text.serializer(), innerObj) "ToolCall" -> ctx.decodeFromJsonElement(GenerateContent.ToolCall.serializer(), innerObj) "Source" -> ctx.decodeFromJsonElement(GenerateContent.Source.serializer(), innerObj) @@ -899,6 +998,9 @@ object GenerateContentSerializer : KSerializer { ?: throw SerializationException("GenerateContent can only be encoded to JSON") val ctx = json.json val (tag, inner) = when (value) { + is GenerateContent.Custom -> "Custom" to ctx.encodeToJsonElement(GenerateContent.Custom.serializer(), value) + is GenerateContent.ReasoningFile -> "ReasoningFile" to ctx.encodeToJsonElement(GenerateContent.ReasoningFile.serializer(), value) + is GenerateContent.ToolApprovalRequest -> "ToolApprovalRequest" to ctx.encodeToJsonElement(GenerateContent.ToolApprovalRequest.serializer(), value) is GenerateContent.Text -> "Text" to ctx.encodeToJsonElement(GenerateContent.Text.serializer(), value) is GenerateContent.ToolCall -> "ToolCall" to ctx.encodeToJsonElement(GenerateContent.ToolCall.serializer(), value) is GenerateContent.Source -> "Source" to ctx.encodeToJsonElement(GenerateContent.Source.serializer(), value) @@ -924,7 +1026,7 @@ data class GenerateResult( val usage: Usage = Usage(), val warnings: List = emptyList(), @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - val response: ResponseMetadata = ResponseMetadata(), + val response: GenerateResponse? = null, @SerialName("request_body") val requestBody: JsonElement? = null, @SerialName("response_headers") val responseHeaders: Map? = null, ) { @@ -945,6 +1047,7 @@ data class GenerateResult( @Serializable data class GenerateTextResult( val text: String = "", + val content: List = emptyList(), @SerialName("tool_calls") val toolCalls: List = emptyList(), @SerialName("finish_reason") val finishReason: FinishReason = FinishReason(), val usage: Usage = Usage(), @@ -1003,6 +1106,7 @@ data class GenerateObjectResult( @Serializable data class StreamTextResultAggregated( val text: String = "", + val content: List = emptyList(), // reasoning/sources/files use weak types (JsonElement). val reasoning: List = emptyList(), @SerialName("reasoning_text") val reasoningText: String = "", @@ -1035,6 +1139,27 @@ data class StreamTextResultAggregated( @Serializable(with = StreamPartSerializer::class) sealed interface StreamPart { + @Serializable + data class Custom( + val kind: String, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : StreamPart + + @Serializable + data class ReasoningFile( + val file: GenerateContent.File, + @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, + ) : StreamPart + + @Serializable + data class ToolApprovalRequest( + @SerialName("approval_id") val approvalId: String, + @SerialName("tool_call") val toolCall: ai.arcships.aimux.ToolCall, + val reason: String? = null, + @SerialName("is_automatic") val isAutomatic: Boolean? = null, + val signature: String? = null, + ) : StreamPart + @Serializable data class TextStart( @@ -1175,6 +1300,9 @@ sealed interface StreamPart { /** The externally-tagged variant name for this [StreamPart] (e.g. "TextDelta", "ToolCall"). */ val StreamPart.variantTag: String get() = when (this) { + is StreamPart.Custom -> "Custom" + is StreamPart.ReasoningFile -> "ReasoningFile" + is StreamPart.ToolApprovalRequest -> "ToolApprovalRequest" is StreamPart.TextStart -> "TextStart" is StreamPart.TextDelta -> "TextDelta" is StreamPart.TextEnd -> "TextEnd" @@ -1220,6 +1348,9 @@ object StreamPartSerializer : KSerializer { val innerObj = inner as? JsonObject ?: JsonObject(emptyMap()) val ctx = json.json return when (tag) { + "Custom" -> ctx.decodeFromJsonElement(StreamPart.Custom.serializer(), innerObj) + "ReasoningFile" -> ctx.decodeFromJsonElement(StreamPart.ReasoningFile.serializer(), innerObj) + "ToolApprovalRequest" -> ctx.decodeFromJsonElement(StreamPart.ToolApprovalRequest.serializer(), innerObj) "TextStart" -> ctx.decodeFromJsonElement(StreamPart.TextStart.serializer(), innerObj) "TextDelta" -> ctx.decodeFromJsonElement(StreamPart.TextDelta.serializer(), innerObj) "TextEnd" -> ctx.decodeFromJsonElement(StreamPart.TextEnd.serializer(), innerObj) @@ -1247,6 +1378,9 @@ object StreamPartSerializer : KSerializer { ?: throw SerializationException("StreamPart can only be encoded to JSON") val ctx = json.json val (tag, inner) = when (value) { + is StreamPart.Custom -> "Custom" to ctx.encodeToJsonElement(StreamPart.Custom.serializer(), value) + is StreamPart.ReasoningFile -> "ReasoningFile" to ctx.encodeToJsonElement(StreamPart.ReasoningFile.serializer(), value) + is StreamPart.ToolApprovalRequest -> "ToolApprovalRequest" to ctx.encodeToJsonElement(StreamPart.ToolApprovalRequest.serializer(), value) is StreamPart.TextStart -> "TextStart" to ctx.encodeToJsonElement(StreamPart.TextStart.serializer(), value) is StreamPart.TextDelta -> "TextDelta" to ctx.encodeToJsonElement(StreamPart.TextDelta.serializer(), value) is StreamPart.TextEnd -> "TextEnd" to ctx.encodeToJsonElement(StreamPart.TextEnd.serializer(), value) diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt index bfede7f26..c8d649463 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt @@ -191,8 +191,8 @@ class ContractTest { @Test fun `usage raw preserves vendor fields round-trip`() { val usage = Usage( - inputTokens = TokenUsage(total = 20L), - outputTokens = TokenUsage(total = 5L), + inputTokens = InputTokenUsage(total = 20L), + outputTokens = OutputTokenUsage(total = 5L), raw = buildJsonObject { put("prompt_cache_hit_tokens", 42) put("prompt_tokens", 20) diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt index 578584ac9..520599cd5 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt @@ -319,19 +319,7 @@ class TypedModelRoundTripTest { assertThat(decoded).isEqualTo(original) } - @Test - fun `GenerateContent File round-trips and omits filename`() { - val original = GenerateContent.File( - data = FileData.Text("payload"), - mediaType = "text/plain", - providerMetadata = null, - ) - val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) - // GenerateContent.File (unlike ContentPart.File) has no `filename` field. - assertThat(json).doesNotContain("filename") - val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) - assertThat(decoded).isEqualTo(original) - } + @Test fun `GenerateContent ToolResult round-trips using result not output`() { diff --git a/bindings/node/src/error.rs b/bindings/node/src/error.rs index bea1e27be..4585d05fc 100644 --- a/bindings/node/src/error.rs +++ b/bindings/node/src/error.rs @@ -177,7 +177,9 @@ fn aimux_error_class_name(error: &AiMuxError) -> &'static str { AiMuxError::ApiCall(_) => "APICallError", AiMuxError::Retry(_) => "RetryError", AiMuxError::JsonParse(_) => "JSONParseError", - AiMuxError::InvalidResponseData(_) => "InvalidResponseDataError", + AiMuxError::InvalidResponseData(_) | AiMuxError::ToolCallNotFoundForApproval { .. } => { + "InvalidResponseDataError" + } AiMuxError::NoSuchTool { .. } => "NoSuchToolError", AiMuxError::InvalidToolInput { .. } => "InvalidToolInputError", AiMuxError::ToolCallRepair { .. } => "ToolCallRepairError", diff --git a/bindings/node/src/index.ts b/bindings/node/src/index.ts index 3ffb1e44e..160bee534 100644 --- a/bindings/node/src/index.ts +++ b/bindings/node/src/index.ts @@ -36,6 +36,14 @@ import type { ToolCall, ToolResult, Usage, + InputTokenUsage, + OutputTokenUsage, + TextContent, + ReasoningPart, + GeneratedFile, + GeneratedFileData, + RawToolApprovalRequest, + ToolApprovalRequestOutput, FinishReason, Warning, Role, @@ -138,6 +146,14 @@ export type { ToolCall, ToolResult, Usage, + InputTokenUsage, + OutputTokenUsage, + TextContent, + ReasoningPart, + GeneratedFile, + GeneratedFileData, + RawToolApprovalRequest, + ToolApprovalRequestOutput, FinishReason, Warning, Role, diff --git a/bindings/node/src/types.ts b/bindings/node/src/types.ts index a6211810e..3fa707813 100644 --- a/bindings/node/src/types.ts +++ b/bindings/node/src/types.ts @@ -36,10 +36,12 @@ export type * from './types/FinishReasonUnified' export type * from './types/FunctionTool' 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' export type * from './types/GeneratedFile' +export type * from './types/GeneratedFileData' export type * from './types/HttpExchange' export type * from './types/HttpRecord' export type * from './types/ImageCallOptions' @@ -51,6 +53,7 @@ export type * from './types/ImageResult' export type * from './types/ImageUsage' export type * from './types/InputAudioFormat' export type * from './types/InputRecord' +export type * from './types/InputTokenUsage' export type * from './types/LanguageModelPromptMessage' export type * from './types/MessageContent' export type * from './types/Modality' @@ -65,16 +68,20 @@ export type * from './types/ModelSpec' export type * from './types/ModelType' export type * from './types/OutcomeRecord' export type * from './types/OutcomeStatus' +export type * from './types/OutputTokenUsage' export type * from './types/PrefixBreak' export type * from './types/PromptTokensDetails' export type * from './types/ProviderAuditSpec' export type * from './types/ProviderFamily' export type * from './types/ProviderRecord' export type * from './types/ProviderTool' +export type * from './types/RawToolApprovalRequest' export type * from './types/RawToolCall' export type * from './types/ReasoningEffort' +export type * from './types/ReasoningFileOutput' export type * from './types/ReasoningMode' export type * from './types/ReasoningOutput' +export type * from './types/ReasoningPart' export type * from './types/ReasoningSpec' export type * from './types/ReasoningVisibility' export type * from './types/Recording' @@ -109,11 +116,13 @@ 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/StreamTextResultAggregated' +export type * from './types/TextContent' export type * from './types/TimeoutConfiguration' export type * from './types/TimingRecord' -export type * from './types/TokenUsage' export type * from './types/Tool' +export type * from './types/ToolApprovalRequestOutput' export type * from './types/ToolCall' export type * from './types/ToolCallRepairReply' export type * from './types/ToolChoice' diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index 0dfbed7f1..d542f7de5 100644 --- a/bindings/node/src/types/AiMuxError.ts +++ b/bindings/node/src/types/AiMuxError.ts @@ -9,7 +9,7 @@ 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 } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, +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, /** * Original argument text, when supplied by the provider. Absent in * older serialized errors and errors constructed without a call. diff --git a/bindings/node/src/types/ContentPart.ts b/bindings/node/src/types/ContentPart.ts index a7538a154..79c2d522a 100644 --- a/bindings/node/src/types/ContentPart.ts +++ b/bindings/node/src/types/ContentPart.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GeneratedFileData } from "./GeneratedFileData"; import type { JsonValue } from "./serde_json/JsonValue"; /** @@ -10,7 +11,7 @@ export type ContentPart = { "type": "text", text: string, /** * Provider-specific options for this part (e.g. `openai.promptCacheBreakpoint`). */ -provider_options?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: JsonValue | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: JsonValue | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "reasoning", text: string, signature: string | null, +provider_options?: JsonValue | null, } | { "type": "custom", kind: string, provider_options?: JsonValue | null, } | { "type": "reasoning_file", data: GeneratedFileData, media_type: string, provider_options?: JsonValue | null, } | { "type": "tool_approval_request", approval_id: string, tool_call_id: string, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, input_schema_input?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: JsonValue | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: JsonValue | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "reasoning", text: string, signature: string | null, /** * Provider-specific options for this part (e.g. * `anthropic.signature`, `anthropic.redactedData`, diff --git a/bindings/node/src/types/FileData.ts b/bindings/node/src/types/FileData.ts index 1fdfc2444..cec9eccbb 100644 --- a/bindings/node/src/types/FileData.ts +++ b/bindings/node/src/types/FileData.ts @@ -6,4 +6,4 @@ import type { FileBytes } from "./FileBytes"; * * Aligned with V4 `SharedV4FileData`. */ -export type FileData = { "Data": { data: FileBytes, } } | { "Url": { url: string, } } | { "Reference": { reference: { [key in string]: string }, } } | { "Text": { text: string, } }; +export type FileData = { "Data": { data: FileBytes, } } | { "Url": { url: string, original_url?: string | null, } } | { "Reference": { reference: { [key in string]: string }, } } | { "Text": { text: string, } }; diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index 78bc710ea..7fc35af7f 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { GeneratedFile } from "./GeneratedFile"; +import type { RawToolApprovalRequest } from "./RawToolApprovalRequest"; import type { RawToolCall } from "./RawToolCall"; import type { ReasoningOutput } from "./ReasoningOutput"; import type { Source } from "./Source"; @@ -9,4 +10,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A content item in the generation result. */ -export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": RawToolCall } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ToolResult": ToolResult }; +export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": C } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": R } | { "Custom": { kind: string, provider_metadata?: JsonValue | null, } } | { "ToolApprovalRequest": A } | { "ToolResult": ToolResult }; diff --git a/bindings/node/src/types/GenerateResponse.ts b/bindings/node/src/types/GenerateResponse.ts new file mode 100644 index 000000000..f6d04aab2 --- /dev/null +++ b/bindings/node/src/types/GenerateResponse.ts @@ -0,0 +1,7 @@ +// 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 4667bfc23..260650d48 100644 --- a/bindings/node/src/types/GenerateResult.ts +++ b/bindings/node/src/types/GenerateResult.ts @@ -1,7 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { FinishReason } from "./FinishReason"; import type { GenerateContent } from "./GenerateContent"; -import type { ResponseMetadata } from "./ResponseMetadata"; +import type { GeneratedFile } from "./GeneratedFile"; +import type { RawToolApprovalRequest } from "./RawToolApprovalRequest"; +import type { RawToolCall } from "./RawToolCall"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; import type { JsonValue } from "./serde_json/JsonValue"; @@ -9,36 +11,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Result of `LanguageModel::do_generate` (non-streaming). */ -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: JsonValue | null, -/** - * Response metadata (id, timestamp, model_id). - */ -response: ResponseMetadata, -/** - * The request body that was sent (for debugging). - */ -request_body: JsonValue | null, -/** - * Response headers. - */ -response_headers: { [key in string]: string } | null, }; +export type GenerateResult = { content: Array>, finish_reason: FinishReason, usage: Usage, warnings: Array, provider_metadata: 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, }; diff --git a/bindings/node/src/types/GenerateTextResult.ts b/bindings/node/src/types/GenerateTextResult.ts index c7be4bfd3..0243cf10b 100644 --- a/bindings/node/src/types/GenerateTextResult.ts +++ b/bindings/node/src/types/GenerateTextResult.ts @@ -1,11 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { FinishReason } from "./FinishReason"; +import type { GenerateContent } from "./GenerateContent"; import type { GenerateResult } from "./GenerateResult"; import type { GeneratedFile } from "./GeneratedFile"; import type { ModelMessage } from "./ModelMessage"; -import type { ReasoningOutput } from "./ReasoningOutput"; +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; +import type { ReasoningPart } from "./ReasoningPart"; import type { ResponseMetadata } from "./ResponseMetadata"; import type { Source } from "./Source"; +import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; import type { ToolCall } from "./ToolCall"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; @@ -15,6 +18,10 @@ import type { JsonValue } from "./serde_json/JsonValue"; * Result of `generate_text` (user-facing). */ export type GenerateTextResult = { +/** + * Ordered generated content with parsed tool calls. + */ +content?: Array>, /** * The generated text (concatenated from all text content parts). */ @@ -42,7 +49,7 @@ raw: GenerateResult, /** * Reasoning / thinking segments from the model. */ -reasoning: Array, +reasoning: Array, /** * Concatenated reasoning text (convenience for `reasoning.iter().map(text).join("")`). */ diff --git a/bindings/node/src/types/GeneratedFile.ts b/bindings/node/src/types/GeneratedFile.ts index bd81f2a5e..bf6a39e7c 100644 --- a/bindings/node/src/types/GeneratedFile.ts +++ b/bindings/node/src/types/GeneratedFile.ts @@ -1,11 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileData } from "./FileData"; +import type { GeneratedFileData } from "./GeneratedFileData"; import type { JsonValue } from "./serde_json/JsonValue"; /** * A file generated by the model (e.g. an image or document). Mirrors the Vercel * AI SDK `file` content type: `data` is a tagged union covering base64 data, - * URL, or provider reference. The payload of both `GenerateContent::File` and + * or URL. The payload of both `GenerateContent::File` and * `StreamPart::File`, and the element of the text results' `files`. */ -export type GeneratedFile = { data: FileData, media_type: string, provider_metadata?: JsonValue | null, }; +export type GeneratedFile = { data: GeneratedFileData, media_type: string, provider_metadata?: JsonValue | null, }; diff --git a/bindings/node/src/types/GeneratedFileData.ts b/bindings/node/src/types/GeneratedFileData.ts new file mode 100644 index 000000000..f8b500f58 --- /dev/null +++ b/bindings/node/src/types/GeneratedFileData.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileBytes } from "./FileBytes"; + +/** + * Data or a URL returned for a generated file. + */ +export type GeneratedFileData = { "Data": { data: FileBytes, } } | { "Url": { url: string, original_url?: string | null, } }; diff --git a/bindings/node/src/types/TokenUsage.ts b/bindings/node/src/types/InputTokenUsage.ts similarity index 58% rename from bindings/node/src/types/TokenUsage.ts rename to bindings/node/src/types/InputTokenUsage.ts index ddab251a8..658af2dab 100644 --- a/bindings/node/src/types/TokenUsage.ts +++ b/bindings/node/src/types/InputTokenUsage.ts @@ -1,9 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Token usage detail (with cache breakdown). + * Input token usage detail. */ -export type TokenUsage = { +export type InputTokenUsage = { /** * Total tokens. */ @@ -19,12 +19,4 @@ cache_read: number | null, /** * Tokens written to cache. */ -cache_write: number | null, -/** - * Text (non-reasoning) tokens (output side). - */ -text: number | null, -/** - * Reasoning tokens (output side). - */ -reasoning: number | null, }; +cache_write: number | null, }; diff --git a/bindings/node/src/types/OutputTokenUsage.ts b/bindings/node/src/types/OutputTokenUsage.ts new file mode 100644 index 000000000..47528d6d8 --- /dev/null +++ b/bindings/node/src/types/OutputTokenUsage.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Output token usage detail. + */ +export type OutputTokenUsage = { total: number | null, +/** + * Text (non-reasoning) tokens (output side). + */ +text: number | null, +/** + * Reasoning tokens (output side). + */ +reasoning: number | null, }; diff --git a/bindings/node/src/types/RawToolApprovalRequest.ts b/bindings/node/src/types/RawToolApprovalRequest.ts new file mode 100644 index 000000000..5dc5b93e0 --- /dev/null +++ b/bindings/node/src/types/RawToolApprovalRequest.ts @@ -0,0 +1,7 @@ +// 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"; + +/** + * Provider approval request for a provider-executed call. + */ +export type RawToolApprovalRequest = { approval_id: string, tool_call_id: string, provider_metadata?: JsonValue | null, }; diff --git a/bindings/node/src/types/ReasoningFileOutput.ts b/bindings/node/src/types/ReasoningFileOutput.ts new file mode 100644 index 000000000..efdd57806 --- /dev/null +++ b/bindings/node/src/types/ReasoningFileOutput.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GeneratedFile } from "./GeneratedFile"; +import type { JsonValue } from "./serde_json/JsonValue"; + +export type ReasoningFileOutput = { file: GeneratedFile, provider_metadata?: JsonValue | null, }; diff --git a/bindings/node/src/types/ReasoningPart.ts b/bindings/node/src/types/ReasoningPart.ts new file mode 100644 index 000000000..37a7c5873 --- /dev/null +++ b/bindings/node/src/types/ReasoningPart.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; +import type { ReasoningOutput } from "./ReasoningOutput"; + +/** + * Text or a generated file produced during reasoning. + */ +export type ReasoningPart = ReasoningOutput | ReasoningFileOutput; diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index 1d6722570..032220e5c 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -2,8 +2,10 @@ import type { AiMuxError } from "./AiMuxError"; import type { FinishReason } from "./FinishReason"; import type { GeneratedFile } from "./GeneratedFile"; +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; 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"; @@ -29,7 +31,7 @@ dynamic?: boolean | null, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningStart": { id: string, +title?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: JsonValue | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "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 new file mode 100644 index 000000000..8edb92db1 --- /dev/null +++ b/bindings/node/src/types/StreamResponse.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. + +/** + * HTTP details from a streaming call. + */ +export type StreamResponse = { headers: { [key in string]: string } | null, }; diff --git a/bindings/node/src/types/StreamTextResultAggregated.ts b/bindings/node/src/types/StreamTextResultAggregated.ts index 7bf5f51bf..86f3f1f75 100644 --- a/bindings/node/src/types/StreamTextResultAggregated.ts +++ b/bindings/node/src/types/StreamTextResultAggregated.ts @@ -1,10 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { FinishReason } from "./FinishReason"; +import type { GenerateContent } from "./GenerateContent"; import type { GeneratedFile } from "./GeneratedFile"; import type { ModelMessage } from "./ModelMessage"; -import type { ReasoningOutput } from "./ReasoningOutput"; +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; +import type { ReasoningPart } from "./ReasoningPart"; import type { ResponseMetadata } from "./ResponseMetadata"; import type { Source } from "./Source"; +import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; import type { ToolCall } from "./ToolCall"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; @@ -16,6 +19,10 @@ import type { JsonValue } from "./serde_json/JsonValue"; * has no `GenerateResult` equivalent). */ export type StreamTextResultAggregated = { +/** + * Ordered content from the last generation step. + */ +content?: Array>, /** * The generated text (concatenated `TextDelta`). */ @@ -23,7 +30,7 @@ text: string, /** * Reasoning / thinking segments. */ -reasoning: Array, +reasoning: Array, /** * Concatenated reasoning text. */ diff --git a/bindings/node/src/types/TextContent.ts b/bindings/node/src/types/TextContent.ts new file mode 100644 index 000000000..7c55c1ac1 --- /dev/null +++ b/bindings/node/src/types/TextContent.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GeneratedFile } from "./GeneratedFile"; +import type { RawToolApprovalRequest } from "./RawToolApprovalRequest"; +import type { RawToolCall } from "./RawToolCall"; +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; +import type { ReasoningOutput } from "./ReasoningOutput"; +import type { Source } from "./Source"; +import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; +import type { ToolCall } from "./ToolCall"; +import type { ToolResult } from "./ToolResult"; +import type { JsonValue } from "./serde_json/JsonValue"; + +export type TextContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": ToolCall } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: JsonValue | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ToolResult": ToolResult }; diff --git a/bindings/node/src/types/ToolApprovalRequestOutput.ts b/bindings/node/src/types/ToolApprovalRequestOutput.ts new file mode 100644 index 000000000..8e5d70b34 --- /dev/null +++ b/bindings/node/src/types/ToolApprovalRequestOutput.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolCall } from "./ToolCall"; + +/** + * Approval request exposed by text generation. + */ +export type ToolApprovalRequestOutput = { approval_id: string, tool_call: ToolCall, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, }; diff --git a/bindings/node/src/types/Usage.ts b/bindings/node/src/types/Usage.ts index 76961cf7e..385ac2af6 100644 --- a/bindings/node/src/types/Usage.ts +++ b/bindings/node/src/types/Usage.ts @@ -1,12 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TokenUsage } from "./TokenUsage"; -import type { JsonValue } from "./serde_json/JsonValue"; +import type { InputTokenUsage } from "./InputTokenUsage"; +import type { OutputTokenUsage } from "./OutputTokenUsage"; /** * Token usage statistics. */ -export type Usage = { input_tokens: TokenUsage, output_tokens: TokenUsage, +export type Usage = { input_tokens: InputTokenUsage, output_tokens: OutputTokenUsage, /** * Raw usage information from the provider (opaque, provider-specific). */ -raw?: JsonValue | null, }; +raw?: Record | null, }; diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index 86e683262..eceb911eb 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -39,12 +39,15 @@ "ResponseFormat", "StreamPart", "GenerateContent", + "TextContent", "FileData", "FileBytes", "Warning", "AiMuxErrorValue", # models - "TokenUsage", + "InputTokenUsage", + "OutputTokenUsage", + "GeneratedFileData", "Usage", "FinishReason", "ResponseMetadata", @@ -61,6 +64,7 @@ "GenerateObjectResult", "StreamTextResultAggregated", "GenerateResult", + "GenerateResponseMetadata", # functions "generate_text", "stream_text", @@ -112,13 +116,15 @@ # Simple struct models # ───────────────────────────────────────────────────────────────────────────── -class TokenUsage(BaseModel): - """Token usage detail with cache breakdown (Rust ``TokenUsage``).""" - +class InputTokenUsage(BaseModel): total: Optional[int] = None no_cache: Optional[int] = None cache_read: Optional[int] = None cache_write: Optional[int] = None + + +class OutputTokenUsage(BaseModel): + total: Optional[int] = None text: Optional[int] = None reasoning: Optional[int] = None @@ -126,9 +132,9 @@ class TokenUsage(BaseModel): class Usage(BaseModel): """Token usage statistics (Rust ``Usage``).""" - input_tokens: TokenUsage = Field(default_factory=TokenUsage) - output_tokens: TokenUsage = Field(default_factory=TokenUsage) - raw: Optional[Any] = None + input_tokens: InputTokenUsage = Field(default_factory=InputTokenUsage) + output_tokens: OutputTokenUsage = Field(default_factory=OutputTokenUsage) + raw: Optional[Dict[str, Any]] = None class FinishReason(BaseModel): @@ -321,6 +327,7 @@ class _FileDataData(BaseModel): class _FileDataUrl(BaseModel): type: Literal["Url"] url: str + original_url: Optional[str] = None class _FileDataReference(BaseModel): @@ -363,6 +370,22 @@ def _int_to_ext(self, handler): # type: ignore[no-untyped-def] return _external_tag_serializer(handler(self)) +_GeneratedFileDataUnion = Annotated[ + Union[_FileDataData, _FileDataUrl], Field(discriminator="type") +] + + +class GeneratedFileData(RootModel[_GeneratedFileDataUnion]): + @model_validator(mode="before") + @classmethod + def _ext_to_int(cls, data: Any) -> Any: + return _external_tag_before(data) + + @model_serializer(mode="wrap") + def _int_to_ext(self, handler): + return _external_tag_serializer(handler(self)) + + # ── GenerateContent ───────────────────────────────────────────────────────── class _ContentText(BaseModel): @@ -398,8 +421,8 @@ class _ContentReasoning(BaseModel): class _ContentFile(BaseModel): - type: Literal["File"] - data: FileData + type: Literal["File", "ReasoningFile"] + data: GeneratedFileData media_type: str provider_metadata: Optional[Dict[str, Any]] = None @@ -415,6 +438,19 @@ class _ContentToolResult(BaseModel): provider_metadata: Optional[Any] = None +class _ContentCustom(BaseModel): + type: Literal["Custom"] + kind: str + provider_metadata: Optional[Dict[str, Any]] = None + + +class _ContentToolApprovalRequest(BaseModel): + type: Literal["ToolApprovalRequest"] + approval_id: str + tool_call_id: str + provider_metadata: Optional[Dict[str, Any]] = None + + _GenerateContentUnion = Annotated[ Union[ _ContentText, @@ -423,6 +459,8 @@ class _ContentToolResult(BaseModel): _ContentReasoning, _ContentFile, _ContentToolResult, + _ContentCustom, + _ContentToolApprovalRequest, ], Field(discriminator="type"), ] @@ -554,13 +592,6 @@ class _SPResponseMetadata(BaseModel): model_id: Optional[str] = None -class _SPSource(BaseModel): - type: Literal["Source"] - id: str - source_type: str - url: Optional[str] = None - title: Optional[str] = None - provider_metadata: Optional[Dict[str, Any]] = None class _SPRaw(BaseModel): @@ -570,11 +601,52 @@ class _SPRaw(BaseModel): class _SPFile(BaseModel): type: Literal["File"] - data: FileData + data: GeneratedFileData media_type: str provider_metadata: Optional[Dict[str, Any]] = None +class _SPToolApprovalRequest(BaseModel): + type: Literal["ToolApprovalRequest"] + approval_id: str + tool_call: ToolCall + reason: Optional[str] = None + is_automatic: Optional[bool] = None + signature: Optional[str] = None + + +class _GeneratedFile(BaseModel): + data: GeneratedFileData + media_type: str + provider_metadata: Optional[Dict[str, Any]] = None + + +class _SPReasoningFile(BaseModel): + type: Literal["ReasoningFile"] + file: _GeneratedFile + provider_metadata: Optional[Dict[str, Any]] = None + + +_TextContentUnion = Annotated[ + Union[ + _ContentText, _SPToolCall, _ContentSource, _ContentReasoning, + _SPFile, _SPReasoningFile, _ContentToolResult, _ContentCustom, _SPToolApprovalRequest, + ], + Field(discriminator="type"), +] + + +class TextContent(RootModel[_TextContentUnion]): + @model_validator(mode="before") + @classmethod + def _ext_to_int(cls, data: Any) -> Any: + return _external_tag_before(data) + + @model_serializer(mode="wrap") + def _int_to_ext(self, handler): + return _external_tag_serializer(handler(self)) + + _StreamPartUnion = Annotated[ Union[ _SPTextStart, @@ -589,12 +661,15 @@ class _SPFile(BaseModel): _SPToolCall, _SPToolResult, _SPFile, + _SPReasoningFile, _SPReasoningStart, _SPReasoningDelta, _SPReasoningEnd, _SPResponseMetadata, - _SPSource, + _ContentSource, _SPRaw, + _ContentCustom, + _SPToolApprovalRequest, ], Field(discriminator="type"), ] @@ -768,6 +843,29 @@ class _ToolResultContentPart(BaseModel): provider_options: Optional[Any] = None +class _CustomContentPart(BaseModel): + type: Literal["custom"] = "custom" + kind: str + provider_options: Optional[Any] = None + + +class _ReasoningFileContentPart(BaseModel): + type: Literal["reasoning_file"] = "reasoning_file" + data: GeneratedFileData + media_type: str + provider_options: Optional[Any] = None + + +class _ToolApprovalRequestContentPart(BaseModel): + type: Literal["tool_approval_request"] = "tool_approval_request" + approval_id: str + tool_call_id: str + reason: Optional[str] = None + is_automatic: Optional[bool] = None + signature: Optional[str] = None + input_schema_input: Optional[Any] = None + + ContentPart = Annotated[ Union[ TextContentPart, @@ -779,6 +877,9 @@ class _ToolResultContentPart(BaseModel): _ReasoningContentPart, _ToolCallContentPart, _ToolResultContentPart, + _CustomContentPart, + _ReasoningFileContentPart, + _ToolApprovalRequestContentPart, ], Field(discriminator="type"), ] @@ -930,6 +1031,10 @@ class GenerateTextOptions(BaseModel): """ +class GenerateResponseMetadata(ResponseMetadata): + body: Optional[Any] = None + + class GenerateResult(BaseModel): """Raw provider result (Rust ``GenerateResult``), exposed via ``raw``.""" @@ -938,7 +1043,7 @@ class GenerateResult(BaseModel): usage: Usage warnings: List[Warning] provider_metadata: Optional[Any] = None - response: ResponseMetadata + response: Optional[GenerateResponseMetadata] = None request_body: Optional[Any] = None response_headers: Optional[Dict[str, str]] = None @@ -946,6 +1051,7 @@ class GenerateResult(BaseModel): class GenerateTextResult(BaseModel): """Result of ``generate_text`` (user-facing, Rust ``GenerateTextResult``).""" + content: List[TextContent] = Field(default_factory=list) text: str tool_calls: List[ToolCall] finish_reason: FinishReason @@ -1000,6 +1106,7 @@ class StreamTextResultAggregated(BaseModel): ``GenerateResult`` equivalent). """ + content: List[TextContent] = Field(default_factory=list) text: str = "" # reasoning/sources/files use weak types (Dict) — same strategy as # GenerateTextResult. diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs index be2165d5c..3245887bd 100644 --- a/bindings/python/src/error.rs +++ b/bindings/python/src/error.rs @@ -240,7 +240,9 @@ 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::InvalidResponseData(_) => py.get_type_bound::(), + AiMuxError::InvalidResponseData(_) | AiMuxError::ToolCallNotFoundForApproval { .. } => { + py.get_type_bound::() + } AiMuxError::NoSuchTool { .. } => py.get_type_bound::(), AiMuxError::InvalidToolInput { .. } => py.get_type_bound::(), AiMuxError::ToolCallRepair { .. } => py.get_type_bound::(), diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index 13b0f41d4..da7a0effa 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -138,37 +138,35 @@ public struct FinishReason: Codable, Equatable { } } -/// Token usage detail (cache breakdown). All fields optional on the wire. -public struct TokenUsage: Codable, Equatable { +public struct InputTokenUsage: Codable, Equatable { public var total: UInt32? public var noCache: UInt32? public var cacheRead: UInt32? public var cacheWrite: UInt32? - public var text: UInt32? - public var reasoning: UInt32? - enum CodingKeys: String, CodingKey { - case total - case noCache = "no_cache" - case cacheRead = "cache_read" - case cacheWrite = "cache_write" - case text - case reasoning + case total, noCache = "no_cache", cacheRead = "cache_read", cacheWrite = "cache_write" } - public init(total: UInt32? = nil, noCache: UInt32? = nil, cacheRead: UInt32? = nil, - cacheWrite: UInt32? = nil, text: UInt32? = nil, reasoning: UInt32? = nil) { - self.total = total; self.noCache = noCache; self.cacheRead = cacheRead - self.cacheWrite = cacheWrite; self.text = text; self.reasoning = reasoning + cacheWrite: UInt32? = nil) { + self.total = total; self.noCache = noCache; self.cacheRead = cacheRead; self.cacheWrite = cacheWrite + } +} + +public struct OutputTokenUsage: Codable, Equatable { + public var total: UInt32? + public var text: UInt32? + public var reasoning: UInt32? + public init(total: UInt32? = nil, text: UInt32? = nil, reasoning: UInt32? = nil) { + self.total = total; self.text = text; self.reasoning = reasoning } } /// Token usage statistics. public struct Usage: Codable, Equatable { - public var inputTokens: TokenUsage - public var outputTokens: TokenUsage + public var inputTokens: InputTokenUsage + public var outputTokens: OutputTokenUsage /// Opaque, provider-specific raw usage. - public var raw: JSONValue? + public var raw: [String: JSONValue]? enum CodingKeys: String, CodingKey { case inputTokens = "input_tokens" @@ -176,7 +174,7 @@ public struct Usage: Codable, Equatable { case raw } - public init(inputTokens: TokenUsage, outputTokens: TokenUsage, raw: JSONValue? = nil) { + public init(inputTokens: InputTokenUsage, outputTokens: OutputTokenUsage, raw: [String: JSONValue]? = nil) { self.inputTokens = inputTokens self.outputTokens = outputTokens self.raw = raw @@ -441,7 +439,12 @@ public enum ContentPart: Codable, Equatable { case toolResult(toolCallId: String, result: JSONValue, toolName: String?, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerOptions: JSONValue?) + case custom(kind: String, providerOptions: JSONValue?) + case reasoningFile(data: GeneratedFileData, mediaType: String, providerOptions: JSONValue?) + case toolApprovalRequest(approvalId: String, toolCallId: String, reason: String?, isAutomatic: Bool?, signature: String?, inputSchemaInput: JSONValue?) + private enum Field: String, CodingKey { + case kind, approvalId = "approval_id", reason, isAutomatic = "is_automatic", inputSchemaInput = "input_schema_input" case text, image, data, mediaType = "media_type", filename, url, reference case signature, toolCallId = "tool_call_id", toolName = "tool_name", input, result case providerExecuted = "provider_executed", thoughtSignature = "thought_signature" @@ -454,6 +457,20 @@ public enum ContentPart: Codable, Equatable { let type = try c.decode(String.self, forKey: AnyCodingKey("type")) func po() throws -> JSONValue? { try c.decodeIfPresent(JSONValue.self, forKey: AnyCodingKey("provider_options")) } switch type { + case "custom": + self = .custom(kind: try c.decode(String.self, forKey: AnyCodingKey("kind")), + providerOptions: try c.decodeIfPresent(JSONValue.self, forKey: AnyCodingKey("provider_options"))) + case "reasoning_file": + self = .reasoningFile(data: try c.decode(GeneratedFileData.self, forKey: AnyCodingKey("data")), + mediaType: try c.decode(String.self, forKey: AnyCodingKey("media_type")), + providerOptions: try c.decodeIfPresent(JSONValue.self, forKey: AnyCodingKey("provider_options"))) + case "tool_approval_request": + self = .toolApprovalRequest(approvalId: try c.decode(String.self, forKey: AnyCodingKey("approval_id")), + toolCallId: try c.decode(String.self, forKey: AnyCodingKey("tool_call_id")), + reason: try c.decodeIfPresent(String.self, forKey: AnyCodingKey("reason")), + isAutomatic: try c.decodeIfPresent(Bool.self, forKey: AnyCodingKey("is_automatic")), + signature: try c.decodeIfPresent(String.self, forKey: AnyCodingKey("signature")), + inputSchemaInput: try c.decodeIfPresent(JSONValue.self, forKey: AnyCodingKey("input_schema_input"))) case "text": self = .text(text: try c.decode(String.self, forKey: AnyCodingKey("text")), providerOptions: try po()) @@ -507,6 +524,17 @@ public enum ContentPart: Codable, Equatable { public func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: AnyCodingKey.self) switch self { + case .custom(let kind, let po): + try c.encode("custom", forKey: AnyCodingKey("type")); try c.encode(kind, forKey: AnyCodingKey("kind")) + try c.encodeIfPresent(po, forKey: AnyCodingKey("provider_options")) + case .reasoningFile(let data, let mediaType, let po): + try c.encode("reasoning_file", forKey: AnyCodingKey("type")); try c.encode(data, forKey: AnyCodingKey("data")) + try c.encode(mediaType, forKey: AnyCodingKey("media_type")); try c.encodeIfPresent(po, forKey: AnyCodingKey("provider_options")) + case .toolApprovalRequest(let approvalId, let toolCallId, let reason, let isAutomatic, let signature, let inputSchemaInput): + try c.encode("tool_approval_request", forKey: AnyCodingKey("type")); try c.encode(approvalId, forKey: AnyCodingKey("approval_id")) + try c.encode(toolCallId, forKey: AnyCodingKey("tool_call_id")); try c.encodeIfPresent(reason, forKey: AnyCodingKey("reason")) + try c.encodeIfPresent(isAutomatic, forKey: AnyCodingKey("is_automatic")); try c.encodeIfPresent(signature, forKey: AnyCodingKey("signature")) + try c.encodeIfPresent(inputSchemaInput, forKey: AnyCodingKey("input_schema_input")) case .text(let text, let po): try c.encode("text", forKey: AnyCodingKey("type")) try c.encode(text, forKey: AnyCodingKey("text")) @@ -710,12 +738,12 @@ public enum FileBytes: Codable, Equatable { /// `{"Text":{"text":"…"}}`. public enum FileData: Codable, Equatable { case data(data: FileBytes) - case url(url: String) + case url(url: String, originalUrl: String? = nil) case reference(reference: [String: String]) case text(text: String) private enum Field: String, CodingKey { - case data, url, reference, text + case data, url, reference, text, originalUrl = "original_url" } public init(from decoder: Decoder) throws { @@ -728,7 +756,8 @@ public enum FileData: Codable, Equatable { case "Data": self = .data(data: try n.decode(FileBytes.self, forKey: .data)) case "Url": - self = .url(url: try n.decode(String.self, forKey: .url)) + self = .url(url: try n.decode(String.self, forKey: .url), + originalUrl: try n.decodeIfPresent(String.self, forKey: .originalUrl)) case "Reference": self = .reference(reference: try n.decode([String: String].self, forKey: .reference)) case "Text": @@ -744,9 +773,10 @@ public enum FileData: Codable, Equatable { case .data(let data): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Data")) try n.encode(data, forKey: .data) - case .url(let url): + case .url(let url, let originalUrl): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Url")) try n.encode(url, forKey: .url) + try n.encodeIfPresent(originalUrl, forKey: .originalUrl) case .reference(let reference): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Reference")) try n.encode(reference, forKey: .reference) @@ -757,6 +787,51 @@ public enum FileData: Codable, Equatable { } } +/// Data or a URL returned for a generated file. +public enum GeneratedFileData: Codable, Equatable { + case data(data: FileBytes) + case url(url: String, originalUrl: String? = nil) + private enum Field: String, CodingKey { + case data, url, originalUrl = "original_url" + } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: AnyCodingKey.self) + guard let key = c.allKeys.first?.stringValue else { + throw aimuxDecodingError(c.codingPath, "missing generated-file-data tag") + } + let n = try c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey(key)) + switch key { + case "Data": self = .data(data: try n.decode(FileBytes.self, forKey: .data)) + case "Url": self = .url(url: try n.decode(String.self, forKey: .url), + originalUrl: try n.decodeIfPresent(String.self, forKey: .originalUrl)) + default: throw aimuxDecodingError(c.codingPath, "invalid generated-file-data tag") + } + } + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: AnyCodingKey.self) + switch self { + case .data(let data): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Data")) + try n.encode(data, forKey: .data) + case .url(let url, let originalUrl): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Url")) + try n.encode(url, forKey: .url); try n.encodeIfPresent(originalUrl, forKey: .originalUrl) + } + } +} + +public struct GeneratedFile: Codable, Equatable { + public var data: GeneratedFileData + public var mediaType: String + public var providerMetadata: JSONValue? + enum CodingKeys: String, CodingKey { + case data, mediaType = "media_type", providerMetadata = "provider_metadata" + } + public init(data: GeneratedFileData, mediaType: String, providerMetadata: JSONValue? = nil) { + self.data = data; self.mediaType = mediaType; self.providerMetadata = providerMetadata + } +} + /// A content item in the generation result. /// /// Wire: externally tagged — `{"Text":{...}}`, `{"ToolCall":{...}}`, … @@ -768,12 +843,17 @@ public enum GenerateContent: Codable, Equatable { case source(id: String, sourceType: String, url: String?, title: String?, providerMetadata: JSONValue?) case reasoning(text: String, providerMetadata: JSONValue?) - case file(data: FileData, mediaType: String, providerMetadata: JSONValue?) + case file(data: GeneratedFileData, mediaType: String, providerMetadata: JSONValue?) case toolResult(toolCallId: String, toolName: String, result: JSONValue, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) + case custom(kind: String, providerMetadata: JSONValue?) + case reasoningFile(data: GeneratedFileData, mediaType: String, providerMetadata: JSONValue?) + case toolApprovalRequest(approvalId: String, toolCallId: String, providerMetadata: JSONValue?) + private enum Field: String, CodingKey { case text + case kind, approvalId = "approval_id", toolCall = "tool_call", reason, isAutomatic = "is_automatic", signature case toolCallId = "tool_call_id", toolName = "tool_name", input, result case providerExecuted = "provider_executed", dynamic case thoughtSignature = "thought_signature", providerMetadata = "provider_metadata" @@ -809,8 +889,19 @@ public enum GenerateContent: Codable, Equatable { case "Reasoning": self = .reasoning(text: try n.decode(String.self, forKey: .text), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "Custom": + self = .custom(kind: try n.decode(String.self, forKey: .kind), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "ReasoningFile": + self = .reasoningFile(data: try n.decode(GeneratedFileData.self, forKey: .data), + mediaType: try n.decode(String.self, forKey: .mediaType), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "ToolApprovalRequest": + self = .toolApprovalRequest(approvalId: try n.decode(String.self, forKey: .approvalId), + toolCallId: try n.decode(String.self, forKey: .toolCallId), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "File": - self = .file(data: try n.decode(FileData.self, forKey: .data), + self = .file(data: try n.decode(GeneratedFileData.self, forKey: .data), mediaType: try n.decode(String.self, forKey: .mediaType), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "ToolResult": @@ -853,6 +944,17 @@ public enum GenerateContent: Codable, Equatable { var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Reasoning")) try n.encode(text, forKey: .text) try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .custom(let kind, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Custom")) + try n.encode(kind, forKey: .kind); try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .reasoningFile(let data, let mediaType, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ReasoningFile")) + try n.encode(data, forKey: .data); try n.encode(mediaType, forKey: .mediaType) + try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .toolApprovalRequest(let approvalId, let toolCallId, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolApprovalRequest")) + try n.encode(approvalId, forKey: .approvalId); try n.encode(toolCallId, forKey: .toolCallId) + try n.encodeIfPresent(pm, forKey: .providerMetadata) case .file(let data, let mediaType, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("File")) try n.encode(data, forKey: .data) @@ -871,6 +973,19 @@ public enum GenerateContent: Codable, Equatable { } } +public struct GenerateResponseMetadata: Codable, Equatable { + public var id: String? + public var timestamp: String? + public var modelId: String? + public var body: JSONValue? + enum CodingKeys: String, CodingKey { + case id, timestamp, body, modelId = "model_id" + } + public init(id: String? = nil, timestamp: String? = nil, modelId: String? = nil, body: JSONValue? = nil) { + self.id = id; self.timestamp = timestamp; self.modelId = modelId; self.body = body + } +} + /// Raw provider result (the `raw` field of `GenerateTextResult`). public struct GenerateResult: Codable, Equatable { public var content: [GenerateContent] @@ -878,7 +993,7 @@ public struct GenerateResult: Codable, Equatable { public var usage: Usage? public var warnings: [Warning]? public var providerMetadata: JSONValue? - public var response: ResponseMetadata? + public var response: GenerateResponseMetadata? public var requestBody: JSONValue? public var responseHeaders: [String: String]? @@ -894,7 +1009,7 @@ public struct GenerateResult: Codable, Equatable { public init(content: [GenerateContent], finishReason: FinishReason? = nil, usage: Usage? = nil, warnings: [Warning]? = nil, providerMetadata: JSONValue? = nil, - response: ResponseMetadata? = nil, requestBody: JSONValue? = nil, + response: GenerateResponseMetadata? = nil, requestBody: JSONValue? = nil, responseHeaders: [String: String]? = nil) { self.content = content; self.finishReason = finishReason; self.usage = usage self.warnings = warnings; self.providerMetadata = providerMetadata @@ -905,6 +1020,7 @@ public struct GenerateResult: Codable, Equatable { /// Result of `generate_text` (user-facing). public struct GenerateTextResult: Codable, Equatable { /// The generated text (concatenated from all text content parts). + public var content: [JSONValue]? public var text: String /// Tool calls requested by the model. public var toolCalls: [ToolCall] @@ -939,7 +1055,7 @@ public struct GenerateTextResult: Codable, Equatable { public var totalUsage: Usage enum CodingKeys: String, CodingKey { - case text + case content, text case toolCalls = "tool_calls" case finishReason = "finish_reason" case usage, warnings, raw @@ -961,7 +1077,8 @@ public struct GenerateTextResult: Codable, Equatable { rawFinishReason: String? = nil, providerMetadata: JSONValue? = nil, response: ResponseMetadata = ResponseMetadata(), - totalUsage: Usage = Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage())) { + totalUsage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), content: [JSONValue]? = nil) { + self.content = content self.text = text; self.toolCalls = toolCalls; self.finishReason = finishReason self.usage = usage; self.warnings = warnings; self.raw = raw self.reasoning = reasoning; self.reasoningText = reasoningText @@ -1008,7 +1125,7 @@ public struct GenerateObjectResult: Codable, Equatable { public init(object: JSONValue, finishReason: FinishReason, rawFinishReason: String? = nil, - usage: Usage = Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + usage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), warnings: [Warning] = [], reasoning: String? = nil, providerMetadata: JSONValue? = nil, @@ -1027,6 +1144,7 @@ public struct GenerateObjectResult: Codable, Equatable { /// has no `GenerateResult` equivalent). public struct StreamTextResultAggregated: Codable, Equatable { /// The generated text (concatenated `TextDelta`). + public var content: [JSONValue]? public var text: String /// Reasoning / thinking segments. Weak type. public var reasoning: [JSONValue] @@ -1057,7 +1175,7 @@ public struct StreamTextResultAggregated: Codable, Equatable { public var responseMessages: [ModelMessage] enum CodingKeys: String, CodingKey { - case text + case content, text case reasoning case reasoningText = "reasoning_text" case toolCalls = "tool_calls" @@ -1076,12 +1194,13 @@ public struct StreamTextResultAggregated: Codable, Equatable { reasoningText: String = "", toolCalls: [ToolCall] = [], sources: [JSONValue] = [], files: [JSONValue] = [], finishReason: FinishReason, rawFinishReason: String? = nil, - usage: Usage = Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), - totalUsage: Usage = Usage(inputTokens: TokenUsage(), outputTokens: TokenUsage()), + usage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), + totalUsage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), warnings: [Warning] = [], providerMetadata: JSONValue? = nil, response: ResponseMetadata? = nil, - responseMessages: [ModelMessage] = []) { + responseMessages: [ModelMessage] = [], content: [JSONValue]? = nil) { + self.content = content self.text = text; self.reasoning = reasoning; self.reasoningText = reasoningText self.toolCalls = toolCalls; self.sources = sources; self.files = files self.finishReason = finishReason; self.rawFinishReason = rawFinishReason @@ -1252,7 +1371,7 @@ public enum StreamPart: Codable, Equatable { case toolResult(toolCallId: String, toolName: String, result: JSONValue, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) // P2: file - case file(data: FileData, mediaType: String, providerMetadata: JSONValue?) + case file(data: GeneratedFileData, mediaType: String, providerMetadata: JSONValue?) // P2: reasoning case reasoningStart(id: String, providerMetadata: JSONValue?) case reasoningDelta(id: String, delta: String, providerMetadata: JSONValue?) @@ -1263,7 +1382,12 @@ public enum StreamPart: Codable, Equatable { providerMetadata: JSONValue?) case raw(rawValue: JSONValue) + case custom(kind: String, providerMetadata: JSONValue?) + case reasoningFile(file: GeneratedFile, providerMetadata: JSONValue?) + case toolApprovalRequest(approvalId: String, toolCall: ToolCall, reason: String?, isAutomatic: Bool?, signature: String?) + private enum Field: String, CodingKey { + case file, kind, approvalId = "approval_id", toolCall = "tool_call", reason, isAutomatic = "is_automatic", signature case id, delta, warnings, usage, text case finishReason = "finish_reason", providerMetadata = "provider_metadata" case error @@ -1335,8 +1459,20 @@ public enum StreamPart: Codable, Equatable { preliminary: try n.decodeIfPresent(Bool.self, forKey: .preliminary), dynamic: try n.decodeIfPresent(Bool.self, forKey: .dynamic), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "Custom": + self = .custom(kind: try n.decode(String.self, forKey: .kind), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "ReasoningFile": + self = .reasoningFile(file: try n.decode(GeneratedFile.self, forKey: .file), + providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + case "ToolApprovalRequest": + self = .toolApprovalRequest(approvalId: try n.decode(String.self, forKey: .approvalId), + toolCall: try n.decode(ToolCall.self, forKey: .toolCall), + reason: try n.decodeIfPresent(String.self, forKey: .reason), + isAutomatic: try n.decodeIfPresent(Bool.self, forKey: .isAutomatic), + signature: try n.decodeIfPresent(String.self, forKey: .signature)) case "File": - self = .file(data: try n.decode(FileData.self, forKey: .data), + self = .file(data: try n.decode(GeneratedFileData.self, forKey: .data), mediaType: try n.decode(String.self, forKey: .mediaType), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "ReasoningStart": @@ -1416,6 +1552,18 @@ public enum StreamPart: Codable, Equatable { try n.encodeIfPresent(ie, forKey: .isError); try n.encodeIfPresent(prel, forKey: .preliminary) try n.encodeIfPresent(dyn, forKey: .dynamic) try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .custom(let kind, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Custom")) + try n.encode(kind, forKey: .kind); try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .reasoningFile(let file, let pm): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ReasoningFile")) + try n.encode(file, forKey: .file) + try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .toolApprovalRequest(let approvalId, let toolCall, let reason, let isAutomatic, let signature): + var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolApprovalRequest")) + try n.encode(approvalId, forKey: .approvalId); try n.encode(toolCall, forKey: .toolCall) + try n.encodeIfPresent(reason, forKey: .reason); try n.encodeIfPresent(isAutomatic, forKey: .isAutomatic) + try n.encodeIfPresent(signature, forKey: .signature) case .file(let data, let mediaType, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("File")) try n.encode(data, forKey: .data); try n.encode(mediaType, forKey: .mediaType) From 29df4db01f3b19b24824b827dd31e847fc8d774c Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Tue, 6 Oct 2026 00:23:55 +0800 Subject: [PATCH 4/6] refactor(core)!: a source is a url source or a document source What: Source becomes upstream's union: a url source with a required url, or a document source with required media type and title and an optional filename. The source_type tag is kept, so url-source JSON is unchanged. Vendors that emit sources build the matching variant. The language bindings' types and decoders follow. Why: upstream has exactly these two kinds; the flat type with every field optional could express sources that cannot exist. The Go, Java, Kotlin, Swift and Flutter changes are not compiled here. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/openai_output.rs | 18 +- aimux-core/src/result.rs | 26 +- aimux-providers/src/anthropic/stream.rs | 10 +- aimux-providers/src/cohere/model.rs | 9 +- aimux-providers/src/google/convert.rs | 63 +++-- aimux-providers/src/google/model.rs | 15 +- aimux-providers/src/huggingface/responses.rs | 5 +- aimux-providers/src/openai/model.rs | 12 +- .../src/openai/responses/responses_convert.rs | 12 +- aimux-providers/src/vertex/model.rs | 10 +- aimux-providers/src/xai/model.rs | 10 +- aimux-providers/src/xai/responses/mod.rs | 20 +- .../tests/data_loss_regression_test.rs | 19 +- .../tests/huggingface_responses_test.rs | 12 +- bindings/flutter/lib/types.dart | 127 +++++----- bindings/flutter/lib/types.g.dart | 4 +- .../flutter/test/typed_round_trip_test.dart | 38 --- bindings/go/types.go | 93 +++++++- .../main/java/ai/arcships/aimux/Types.java | 222 ++++++++---------- .../java/ai/arcships/aimux/ContractTest.java | 9 +- .../main/kotlin/ai/arcships/aimux/Types.kt | 60 +++-- .../kotlin/ai/arcships/aimux/ContractTest.kt | 9 +- .../ai/arcships/aimux/TypedModelTest.kt | 14 -- bindings/node/src/index.ts | 2 + bindings/node/src/types/Source.ts | 2 +- bindings/python/python/aimux/wrapper.py | 41 +++- bindings/python/tests/test_contract.py | 9 +- bindings/swift/Sources/Aimux/Types.swift | 89 ++++--- contract-tests/fixtures/wire-format.json | 6 - 29 files changed, 538 insertions(+), 428 deletions(-) diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index f0bb28ed2..2a5fec744 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -258,12 +258,12 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio }, }); } - GenerateContent::Source(Source { url, title, .. }) => { + GenerateContent::Source(Source::Url { url, title, .. }) => { // Map to OpenAI url_citation annotation. let mut ann = serde_json::json!({ "type": "url_citation", "url_citation": { - "url": url.clone().unwrap_or_default(), + "url": url, } }); if let Some(t) = title { @@ -271,12 +271,10 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio } annotations.push(ann); // Also append URL to content so non-annotation-aware clients see it. - if let Some(u) = url { - if !content_text.is_empty() { - content_text.push('\n'); - } - content_text.push_str(u); + if !content_text.is_empty() { + content_text.push('\n'); } + content_text.push_str(url); } GenerateContent::File(GeneratedFile { data, media_type, .. @@ -287,6 +285,7 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio content_text.push_str(&file_data_to_text(data, media_type)); } GenerateContent::Custom { .. } + | GenerateContent::Source(Source::Document { .. }) | GenerateContent::ReasoningFile(_) | GenerateContent::ToolApprovalRequest(_) => {} GenerateContent::ToolResult(ToolResult { @@ -830,11 +829,11 @@ impl StreamState { chunks.push(chunk); } - StreamPart::Source(Source { url, .. }) => { + StreamPart::Source(Source::Url { url, .. }) => { if let Some(c) = self.ensure_started() { chunks.push(c); } - let text = url.clone().unwrap_or_default(); + let text = url.clone(); if !text.is_empty() { let mut chunk = self.base_chunk(); chunk.choices = vec![ChatCompletionChunkChoice { @@ -870,6 +869,7 @@ impl StreamState { } StreamPart::Raw { .. } + | StreamPart::Source(Source::Document { .. }) | StreamPart::Custom { .. } | StreamPart::ReasoningFile(_) | StreamPart::ToolApprovalRequest(_) => { /* no OpenAI chat equivalent */ } diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index 33118f599..8dd03a299 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -88,13 +88,25 @@ pub struct ToolApprovalRequestOutput { /// element of the text results' `sources`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] -pub struct Source { - pub id: String, - pub source_type: String, - pub url: Option, - pub title: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_metadata: Option, +#[serde(tag = "source_type", rename_all = "lowercase")] +pub enum Source { + Url { + id: String, + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + Document { + id: String, + media_type: String, + title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, } /// A file generated by the model (e.g. an image or document). Mirrors the Vercel diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 55b764799..d97837732 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -443,10 +443,9 @@ pub(crate) fn stream_parts_for_result_block( tool_use_id, )]; parts.extend(results.iter().map(|result| { - StreamPart::Source(Source { + StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: str_field(result, "url"), + url: str_field(result, "url").unwrap_or_default(), title: str_field(result, "title"), provider_metadata: Some(json!({ "anthropic": { @@ -671,10 +670,9 @@ pub(crate) fn parse_anthropic_content( // Each result also becomes a `Source` — that is how the // URLs and titles reach `result.sources`. for result in results { - content.push(GenerateContent::Source(Source { + content.push(GenerateContent::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: str_field(result, "url"), + url: str_field(result, "url").unwrap_or_default(), title: str_field(result, "title"), provider_metadata: Some(json!({ "anthropic": { diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index 2163edcb1..c82961250 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -188,6 +188,7 @@ impl LanguageModel for CohereModel { .and_then(|src| src.get("document")) .and_then(|d| d.get("title")) .and_then(|t| t.as_str()) + .filter(|s| !s.is_empty()) .map(std::string::ToString::to_string) .unwrap_or_else(|| "Document".to_string()); @@ -209,11 +210,11 @@ impl LanguageModel for CohereModel { { cohere_meta.insert("citationType".to_string(), Value::String(t.to_string())); } - content.push(GenerateContent::Source(Source { + content.push(GenerateContent::Source(Source::Document { id: format!("citation-{i}"), - source_type: "document".to_string(), - url: None, - title: Some(title), + media_type: "text/plain".to_string(), + title, + filename: None, provider_metadata: Some(json!({ "cohere": cohere_meta })), })); } diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 132f5fe44..ba0fd9064 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -1237,13 +1237,13 @@ pub fn extract_sources( for chunk in chunks { if let Some(web) = chunk.get("web") { - sources.push(GenerateContent::Source(Source { + sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), - source_type: "url".to_string(), url: web .get("uri") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: web .get("title") .and_then(|v| v.as_str()) @@ -1251,13 +1251,13 @@ pub fn extract_sources( provider_metadata: None, })); } else if let Some(image) = chunk.get("image") { - sources.push(GenerateContent::Source(Source { + sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), - source_type: "url".to_string(), url: image .get("sourceUri") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: image .get("title") .and_then(|v| v.as_str()) @@ -1265,35 +1265,53 @@ pub fn extract_sources( provider_metadata: None, })); } else if let Some(rc) = chunk.get("retrievedContext") { - let uri = rc.get("uri").and_then(|v| v.as_str()); - let file_search_store = rc.get("fileSearchStore").and_then(|v| v.as_str()); + let uri = rc + .get("uri") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + let file_search_store = rc + .get("fileSearchStore") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); let title = rc.get("title").and_then(|v| v.as_str()); if let Some(uri) = uri { if uri.starts_with("http://") || uri.starts_with("https://") { - sources.push(GenerateContent::Source(Source { + sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), - source_type: "url".to_string(), - url: Some(uri.to_string()), + url: uri.to_string(), title: title.map(std::string::ToString::to_string), provider_metadata: None, })); } else { // Document with a file path (gs://, etc.). - sources.push(GenerateContent::Source(Source { + let media_type = if uri.ends_with(".pdf") { + "application/pdf" + } else if uri.ends_with(".txt") { + "text/plain" + } else if uri.ends_with(".docx") { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + } else if uri.ends_with(".doc") { + "application/msword" + } else if uri.ends_with(".md") || uri.ends_with(".markdown") { + "text/markdown" + } else { + "application/octet-stream" + }; + sources.push(GenerateContent::Source(Source::Document { id: next_id(id_counter), - source_type: "document".to_string(), - url: None, - title: Some(title.unwrap_or("Unknown Document").to_string()), + media_type: media_type.to_string(), + title: title.unwrap_or("Unknown Document").to_string(), + filename: uri.rsplit('/').next().map(str::to_string), provider_metadata: None, })); } - } else if file_search_store.is_some() { + } else if let Some(file_search_store) = file_search_store { // New File Search format (no uri, has fileSearchStore). - sources.push(GenerateContent::Source(Source { + sources.push(GenerateContent::Source(Source::Document { id: next_id(id_counter), - source_type: "document".to_string(), - url: None, - title: Some(title.unwrap_or("Unknown Document").to_string()), + media_type: "application/octet-stream".to_string(), + title: title.unwrap_or("Unknown Document").to_string(), + filename: file_search_store.rsplit('/').next().map(str::to_string), provider_metadata: None, })); } @@ -1301,10 +1319,9 @@ pub fn extract_sources( } else if let Some(maps) = chunk.get("maps") && let Some(uri) = maps.get("uri").and_then(|v| v.as_str()) { - sources.push(GenerateContent::Source(Source { + sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), - source_type: "url".to_string(), - url: Some(uri.to_string()), + url: uri.to_string(), title: maps .get("title") .and_then(|v| v.as_str()) diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index b85900d80..e2c7e9628 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -347,18 +347,16 @@ impl LanguageModel for GoogleModel { let chunk_sources = extract_sources(candidate.grounding_metadata.as_ref(), &mut source_id); for src in chunk_sources { - if let GenerateContent::Source(Source { - url: Some(url), - source_type, + if let GenerateContent::Source(Source::Url { + url, id, title, provider_metadata: None, }) = src && emitted_source_urls.insert(url.clone()) { - yield Ok(StreamPart::Source(Source { + yield Ok(StreamPart::Source(Source::Url { id, - source_type, - url: Some(url), + url, title, provider_metadata: None, })); @@ -969,7 +967,10 @@ fn set_provider_metadata(item: &mut GenerateContent, meta: ProviderMetadata) { | GenerateContent::File(GeneratedFile { provider_metadata, .. }) - | GenerateContent::Source(Source { + | GenerateContent::Source(Source::Url { + provider_metadata, .. + }) + | GenerateContent::Source(Source::Document { provider_metadata, .. }) | GenerateContent::ReasoningFile(GeneratedFile { diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index 4daa3b7de..f5ffb2956 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -1106,10 +1106,9 @@ fn build_generate_content(response: &Value) -> Result, AiMu for ann in annotations { let url = ann.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = ann.get("title").and_then(|v| v.as_str()); - content.push(GenerateContent::Source(Source { + content.push(GenerateContent::Source(Source::Url { id: format!("id-{source_id_counter}"), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: title.map(std::string::ToString::to_string), provider_metadata: None, })); diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 93283085f..8755f1466 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -335,13 +335,13 @@ pub async fn execute_generate( if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") && let Some(uc) = ann.get("url_citation") { - content.push(GenerateContent::Source(Source { + content.push(GenerateContent::Source(Source::Url { id: format!("annotation-{i}"), - source_type: "url".to_string(), url: uc .get("url") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: uc .get("title") .and_then(|v| v.as_str()) @@ -672,13 +672,13 @@ pub async fn execute_stream( == Some("url_citation") && let Some(uc) = ann.get("url_citation") { - yield Ok(StreamPart::Source(Source { + yield Ok(StreamPart::Source(Source::Url { id: format!("annotation-{i}"), - source_type: "url".to_string(), url: uc .get("url") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: uc .get("title") .and_then(|v| v.as_str()) diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index 503e65682..391aa4c80 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -154,13 +154,13 @@ pub fn build_responses_generate_result( for (i, ann) in annotations.iter().enumerate() { if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { - content.push(GenerateContent::Source(Source { + content.push(GenerateContent::Source(Source::Url { id: format!("annotation-{i}"), - source_type: "url".to_string(), url: ann .get("url") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: ann .get("title") .and_then(|v| v.as_str()) @@ -954,13 +954,13 @@ where && ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { - yield Ok(StreamPart::Source(Source { + yield Ok(StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), url: ann .get("url") .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), + .unwrap_or_default() + .to_string(), title: ann .get("title") .and_then(|v| v.as_str()) diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 0709b2c0a..e2c3a6457 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -388,18 +388,16 @@ impl LanguageModel for VertexModel { let chunk_sources = extract_sources(candidate.grounding_metadata.as_ref(), &mut source_id); for src in chunk_sources { - if let GenerateContent::Source(Source { - url: Some(url), - source_type, + if let GenerateContent::Source(Source::Url { + url, id, title, provider_metadata: None, }) = src && emitted_source_urls.insert(url.clone()) { - yield Ok(StreamPart::Source(Source { + yield Ok(StreamPart::Source(Source::Url { id, - source_type, - url: Some(url), + url, title, provider_metadata: None, })); diff --git a/aimux-providers/src/xai/model.rs b/aimux-providers/src/xai/model.rs index f5a3d4644..ce5544100 100644 --- a/aimux-providers/src/xai/model.rs +++ b/aimux-providers/src/xai/model.rs @@ -187,10 +187,9 @@ impl LanguageModel for XaiModel { // Extract citations if let Some(citations) = &data.citations { for url in citations { - content.push(GenerateContent::Source(Source { + content.push(GenerateContent::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.clone()), + url: url.clone(), title: None, provider_metadata: None, })); @@ -351,10 +350,9 @@ impl LanguageModel for XaiModel { // Emit citations as sources. if let Some(citations) = &chunk.citations { for url in citations { - yield Ok(StreamPart::Source(Source { + yield Ok(StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.clone()), + url: url.clone(), title: None, provider_metadata: None, })); diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index 91d2ad77f..6b9920668 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -230,10 +230,9 @@ impl LanguageModel for XaiResponsesModel { .and_then(|v| v.as_str()) .unwrap_or(url) .to_string(); - content.push(GenerateContent::Source(Source { + content.push(GenerateContent::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: Some(title), provider_metadata: None, })); @@ -522,10 +521,9 @@ impl LanguageModel for XaiResponsesModel { if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { let url = ann.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = ann.get("title").and_then(|v| v.as_str()).unwrap_or(url).to_string(); - yield Ok(StreamPart::Source(Source { + yield Ok(StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: Some(title), provider_metadata: None, })); @@ -541,10 +539,9 @@ impl LanguageModel for XaiResponsesModel { if annotation.get("type").and_then(|v| v.as_str()) == Some("url_citation") { let url = annotation.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = annotation.get("title").and_then(|v| v.as_str()).unwrap_or(url).to_string(); - yield Ok(StreamPart::Source(Source { + yield Ok(StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: Some(title), provider_metadata: None, })); @@ -837,10 +834,9 @@ impl LanguageModel for XaiResponsesModel { if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") { let url = ann.get("url").and_then(|v| v.as_str()).unwrap_or(""); let title = ann.get("title").and_then(|v| v.as_str()).unwrap_or(url).to_string(); - yield Ok(StreamPart::Source(Source { + yield Ok(StreamPart::Source(Source::Url { id: generate_source_id(), - source_type: "url".to_string(), - url: Some(url.to_string()), + url: url.to_string(), title: Some(title), provider_metadata: None, })); diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index 919321597..ab0b4739c 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -269,19 +269,30 @@ fn sources(content: &[GenerateContent]) -> Vec> { content .iter() .filter_map(|c| match c { - GenerateContent::Source(Source { + GenerateContent::Source(Source::Url { id, - source_type, url, title, provider_metadata, }) => Some(( id.as_str(), - source_type.as_str(), - url.as_deref(), + "url", + Some(url.as_str()), title.as_deref(), provider_metadata.as_ref(), )), + GenerateContent::Source(Source::Document { + id, + title, + provider_metadata, + .. + }) => Some(( + id.as_str(), + "document", + None, + Some(title.as_str()), + provider_metadata.as_ref(), + )), _ => None, }) .collect() diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 81a9ac54f..87b604d1e 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -407,17 +407,17 @@ async fn should_generate_text_and_sources_from_annotations() { other => panic!("expected Text at [0], got {other:?}"), } match &result.content[1] { - GenerateContent::Source(Source { id, url, title, .. }) => { + GenerateContent::Source(Source::Url { id, url, title, .. }) => { assert_eq!(id, "id-0"); - assert_eq!(url.as_deref(), Some("https://example.com/article1")); + assert_eq!(url, "https://example.com/article1"); assert_eq!(title.as_deref(), Some("AI Developments Article")); } other => panic!("expected Source at [1], got {other:?}"), } match &result.content[2] { - GenerateContent::Source(Source { id, url, title, .. }) => { + GenerateContent::Source(Source::Url { id, url, title, .. }) => { assert_eq!(id, "id-1"); - assert_eq!(url.as_deref(), Some("https://test.com/article2")); + assert_eq!(url, "https://test.com/article2"); assert_eq!(title.as_deref(), Some("Industry Trends Report")); } other => panic!("expected Source at [2], got {other:?}"), @@ -519,11 +519,11 @@ async fn should_handle_mcp_tools_with_annotations() { other => panic!("expected Text at [2], got {other:?}"), } match &result.content[3] { - GenerateContent::Source(Source { id, .. }) => assert_eq!(id, "id-0"), + GenerateContent::Source(Source::Url { id, .. }) => assert_eq!(id, "id-0"), other => panic!("expected Source at [3], got {other:?}"), } match &result.content[4] { - GenerateContent::Source(Source { id, .. }) => assert_eq!(id, "id-1"), + GenerateContent::Source(Source::Url { id, .. }) => assert_eq!(id, "id-1"), other => panic!("expected Source at [4], got {other:?}"), } diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index 618dd19a9..e6a825c80 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -556,45 +556,75 @@ final class GenerateContentToolCall extends GenerateContent { }; } -/// A source / citation (e.g. URL citation from search-preview models). -final class GenerateContentSource extends GenerateContent { +sealed class Source { + const Source(); + + factory Source.fromJson(Map json) => switch (json['source_type']) { + 'url' => UrlSource( + id: json['id'] as String, + url: json['url'] as String, + title: json['title'] as String?, + providerMetadata: json['provider_metadata'] as Map?), + 'document' => DocumentSource( + id: json['id'] as String, + mediaType: json['media_type'] as String, + title: json['title'] as String, + filename: json['filename'] as String?, + providerMetadata: json['provider_metadata'] as Map?), + _ => throw FormatException('invalid source type: ${json['source_type']}'), + }; + + Map toJson(); +} + +final class UrlSource extends Source { final String id; - final String sourceType; - final String? url; + final String url; final String? title; final Map? providerMetadata; - GenerateContentSource({ - required this.id, - required this.sourceType, - this.url, - this.title, - this.providerMetadata, - }); + const UrlSource({required this.id, required this.url, this.title, this.providerMetadata}); + + @override + Map toJson() => { + 'source_type': 'url', 'id': id, 'url': url, + if (title != null) 'title': title, + if (providerMetadata != null) 'provider_metadata': providerMetadata, + }; +} + +final class DocumentSource extends Source { + final String id; + final String mediaType; + final String title; + final String? filename; + final Map? providerMetadata; + + const DocumentSource({required this.id, required this.mediaType, required this.title, + this.filename, this.providerMetadata}); + + @override + Map toJson() => { + 'source_type': 'document', 'id': id, 'media_type': mediaType, 'title': title, + if (filename != null) 'filename': filename, + if (providerMetadata != null) 'provider_metadata': providerMetadata, + }; +} + +/// A source / citation (e.g. URL citation from search-preview models). +final class GenerateContentSource extends GenerateContent { + final Source source; + + GenerateContentSource({required this.source}); @override String get tag => 'Source'; factory GenerateContentSource.fromJson(Map json) => - GenerateContentSource( - id: json['id'] as String, - sourceType: json['source_type'] as String, - url: json['url'] as String?, - title: json['title'] as String?, - providerMetadata: - json['provider_metadata'] as Map?, - ); + GenerateContentSource(source: Source.fromJson(json)); @override - Map toJson() => { - 'Source': { - 'id': id, - 'source_type': sourceType, - if (url != null) 'url': url, - if (title != null) 'title': title, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, - }; + Map toJson() => {'Source': source.toJson()}; } /// A reasoning / thinking segment produced by the model. @@ -795,7 +825,7 @@ class GenerateTextResult { final List> reasoning; @JsonKey(name: 'reasoning_text') final String reasoningText; - final List> sources; + final List sources; final List> files; @JsonKey(name: 'response_messages') final List responseMessages; @@ -882,19 +912,19 @@ class GenerateObjectResult { /// `GenerateTextResult`'s user-facing fields (without `raw`, since streaming /// has no `GenerateResult` equivalent). /// -/// Mirrors `StreamTextResultAggregated.ts`. reasoning/sources/files use weak +/// Mirrors `StreamTextResultAggregated.ts`. reasoning/files use weak /// types (`Map`) — same strategy as `GenerateTextResult`. @JsonSerializable() class StreamTextResultAggregated { final String text; final List> content; - // reasoning/sources/files use weak types — same strategy as GenerateTextResult. + // reasoning/files use weak types — same strategy as GenerateTextResult. final List> reasoning; @JsonKey(name: 'reasoning_text') final String reasoningText; @JsonKey(name: 'tool_calls') final List toolCalls; - final List> sources; + final List sources; final List> files; @JsonKey(name: 'finish_reason') final FinishReason finishReason; @@ -1715,40 +1745,15 @@ final class StreamPartResponseMetadata extends StreamPart { /// A source / citation (e.g. URL citation from search-preview models). final class StreamPartSource extends StreamPart { - final String id; - final String sourceType; - final String? url; - final String? title; - final Map? providerMetadata; + final Source source; - StreamPartSource({ - required this.id, - required this.sourceType, - this.url, - this.title, - this.providerMetadata, - }); + StreamPartSource({required this.source}); factory StreamPartSource.fromJson(Map json) => - StreamPartSource( - id: json['id'] as String, - sourceType: json['source_type'] as String, - url: json['url'] as String?, - title: json['title'] as String?, - providerMetadata: - json['provider_metadata'] as Map?, - ); + StreamPartSource(source: Source.fromJson(json)); @override - Map toJson() => { - 'Source': { - 'id': id, - 'source_type': sourceType, - if (url != null) 'url': url, - if (title != null) 'title': title, - if (providerMetadata != null) 'provider_metadata': providerMetadata, - }, - }; + Map toJson() => {'Source': source.toJson()}; } /// A raw chunk from the provider (for debugging, when `include_raw_chunks` diff --git a/bindings/flutter/lib/types.g.dart b/bindings/flutter/lib/types.g.dart index 11dfdca51..d8dfb7a9b 100644 --- a/bindings/flutter/lib/types.g.dart +++ b/bindings/flutter/lib/types.g.dart @@ -119,7 +119,7 @@ GenerateTextResult _$GenerateTextResultFromJson(Map json) => const [], reasoningText: json['reasoning_text'] as String? ?? '', sources: (json['sources'] as List?) - ?.map((e) => e as Map) + ?.map((e) => Source.fromJson(e as Map)) .toList() ?? const [], files: (json['files'] as List?) @@ -211,7 +211,7 @@ StreamTextResultAggregated _$StreamTextResultAggregatedFromJson( .toList() ?? const [], sources: (json['sources'] as List?) - ?.map((e) => e as Map) + ?.map((e) => Source.fromJson(e as Map)) .toList() ?? const [], files: (json['files'] as List?) diff --git a/bindings/flutter/test/typed_round_trip_test.dart b/bindings/flutter/test/typed_round_trip_test.dart index 0690373b6..807b2b39d 100644 --- a/bindings/flutter/test/typed_round_trip_test.dart +++ b/bindings/flutter/test/typed_round_trip_test.dart @@ -120,25 +120,6 @@ void main() { expect(tc.isDynamic, false); }); - test('Source variant', () { - final original = GenerateContentSource( - id: 'src_1', - sourceType: 'url', - url: 'https://example.com', - title: 'Example', - providerMetadata: null, - ); - final decoded = GenerateContent.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.tag, 'Source'); - final s = decoded as GenerateContentSource; - expect(s.id, 'src_1'); - expect(s.sourceType, 'url'); - expect(s.url, 'https://example.com'); - expect(s.title, 'Example'); - }); - test('Reasoning variant', () { final original = GenerateContentReasoning(text: 'thinking...', providerMetadata: null); @@ -423,25 +404,6 @@ void main() { expect(m.modelId, 'gpt-4o'); }); - test('Source variant', () { - final original = StreamPartSource( - id: 'src_1', - sourceType: 'url', - url: 'https://example.com', - title: 'Example', - providerMetadata: null, - ); - final decoded = StreamPart.fromJson(original.toJson()); - - expect(decoded, isA()); - expect(decoded.type, 'Source'); - final s = decoded as StreamPartSource; - expect(s.id, 'src_1'); - expect(s.sourceType, 'url'); - expect(s.url, 'https://example.com'); - expect(s.title, 'Example'); - }); - test('Raw variant', () { final original = StreamPartRaw(rawValue: {'raw': 'chunk'}); final decoded = StreamPart.fromJson(original.toJson()); diff --git a/bindings/go/types.go b/bindings/go/types.go index 9167b377e..0f0aee09c 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -77,6 +77,83 @@ func ToolChoiceTool(name string) ToolChoice { // ── Core types ─────────────────────────────────────────────────────────────── +// Source is a URL or document source. Exactly one variant is populated. +type Source struct { + URL *URLSource + Document *DocumentSource +} + +type URLSource struct { + ID string `json:"id"` + URL string `json:"url"` + Title *string `json:"title,omitempty"` + ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` +} + +type DocumentSource struct { + ID string `json:"id"` + MediaType string `json:"media_type"` + Title string `json:"title"` + Filename *string `json:"filename,omitempty"` + ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` +} + +func (s Source) MarshalJSON() ([]byte, error) { + if s.URL != nil && s.Document == nil { + return json.Marshal(struct { + SourceType string `json:"source_type"` + *URLSource + }{"url", s.URL}) + } + if s.Document != nil && s.URL == nil { + return json.Marshal(struct { + SourceType string `json:"source_type"` + *DocumentSource + }{"document", s.Document}) + } + return nil, fmt.Errorf("aimux: Source must have exactly one variant") +} + +func (s *Source) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + var sourceType string + if err := json.Unmarshal(fields["source_type"], &sourceType); err != nil { + return fmt.Errorf("aimux: invalid source_type: %w", err) + } + required := []string{"id"} + switch sourceType { + case "url": + required = append(required, "url") + case "document": + required = append(required, "media_type", "title") + default: + return fmt.Errorf("aimux: unknown source_type %q", sourceType) + } + for _, name := range required { + var value *string + if err := json.Unmarshal(fields[name], &value); err != nil || value == nil { + return fmt.Errorf("aimux: Source requires string %s", name) + } + } + if sourceType == "url" { + var value URLSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = Source{URL: &value} + } else { + var value DocumentSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = Source{Document: &value} + } + return nil +} + // InputTokenUsage is input token usage detail with cache breakdown. type InputTokenUsage struct { Total *uint32 `json:"total,omitempty"` @@ -166,7 +243,7 @@ type GenerateTextResult struct { Raw GenerateResult `json:"raw"` Reasoning []json.RawMessage `json:"reasoning,omitempty"` ReasoningText string `json:"reasoning_text,omitempty"` - Sources []json.RawMessage `json:"sources,omitempty"` + Sources []Source `json:"sources,omitempty"` Files []json.RawMessage `json:"files,omitempty"` ResponseMessages []ModelMessage `json:"response_messages,omitempty"` // RawFinishReason is the raw provider-specific finish reason string (M12). @@ -207,7 +284,7 @@ type StreamTextResultAggregated struct { Reasoning []json.RawMessage `json:"reasoning,omitempty"` ReasoningText string `json:"reasoning_text,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` - Sources []json.RawMessage `json:"sources,omitempty"` + Sources []Source `json:"sources,omitempty"` Files []json.RawMessage `json:"files,omitempty"` FinishReason FinishReason `json:"finish_reason,omitempty"` RawFinishReason *string `json:"raw_finish_reason,omitempty"` @@ -378,6 +455,18 @@ func ParseStreamPart(jsonStr string) (*StreamPart, error) { return &StreamPart{}, nil } +// Source decodes the payload of a Source stream part. +func (p *StreamPart) Source() (*Source, error) { + if p.Tag != "Source" { + return nil, fmt.Errorf("aimux: expected Source stream part, got %s", p.Tag) + } + var source Source + if err := json.Unmarshal(p.Payload, &source); err != nil { + return nil, err + } + return &source, nil +} + // TextDeltaPayload is the payload of a {"TextDelta":{...}} stream part. type TextDeltaPayload struct { ID string `json:"id,omitempty"` 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 5aba7135b..5a9ea02f0 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -2495,6 +2495,82 @@ public GeneratedFileData deserialize(JsonParser p, DeserializationContext ctxt) // for forward compatibility (mirroring StreamPart). // ───────────────────────────────────────────────────────────────────────────── + /** A URL or document used as a source for the response. */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "source_type") + @JsonSubTypes({ + @JsonSubTypes.Type(value = Source.Url.class, name = "url"), + @JsonSubTypes.Type(value = Source.Document.class, name = "document") + }) + public abstract static class Source { + @JsonProperty("id") private final String id; + @JsonProperty("provider_metadata") private final JsonNode providerMetadata; + + protected Source(String id, JsonNode providerMetadata) { + this.id = Objects.requireNonNull(id, "id"); + this.providerMetadata = providerMetadata; + } + + public String getId() { return id; } + public JsonNode getProviderMetadata() { return providerMetadata; } + + public static final class Url extends Source { + @JsonProperty("url") private final String url; + @JsonProperty("title") private final String title; + + @JsonCreator + public Url(@JsonProperty("id") String id, @JsonProperty("url") String url, + @JsonProperty("title") String title, + @JsonProperty("provider_metadata") JsonNode providerMetadata) { + super(id, providerMetadata); + this.url = Objects.requireNonNull(url, "url"); + this.title = title; + } + + public String getUrl() { return url; } + public String getTitle() { return title; } + + @Override public boolean equals(Object o) { + if (!(o instanceof Url)) return false; + Url that = (Url) o; + return Objects.equals(getId(), that.getId()) && Objects.equals(url, that.url) + && Objects.equals(title, that.title) + && Objects.equals(getProviderMetadata(), that.getProviderMetadata()); + } + @Override public int hashCode() { return Objects.hash(getId(), url, title, getProviderMetadata()); } + } + + public static final class Document extends Source { + @JsonProperty("media_type") private final String mediaType; + @JsonProperty("title") private final String title; + @JsonProperty("filename") private final String filename; + + @JsonCreator + public Document(@JsonProperty("id") String id, @JsonProperty("media_type") String mediaType, + @JsonProperty("title") String title, @JsonProperty("filename") String filename, + @JsonProperty("provider_metadata") JsonNode providerMetadata) { + super(id, providerMetadata); + this.mediaType = Objects.requireNonNull(mediaType, "media_type"); + this.title = Objects.requireNonNull(title, "title"); + this.filename = filename; + } + + public String getMediaType() { return mediaType; } + public String getTitle() { return title; } + public String getFilename() { return filename; } + + @Override public boolean equals(Object o) { + if (!(o instanceof Document)) return false; + Document that = (Document) o; + return Objects.equals(getId(), that.getId()) && Objects.equals(mediaType, that.mediaType) + && Objects.equals(title, that.title) && Objects.equals(filename, that.filename) + && Objects.equals(getProviderMetadata(), that.getProviderMetadata()); + } + @Override public int hashCode() { + return Objects.hash(getId(), mediaType, title, filename, getProviderMetadata()); + } + } + } + /** * A content item in the generation result. * @@ -2622,61 +2698,15 @@ public int hashCode() { } public static class Source extends GenerateContent { - @JsonProperty("id") private String id = ""; - @JsonProperty("source_type") private String sourceType = ""; - @JsonProperty("url") private String url; - @JsonProperty("title") private String title; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + private final Types.Source source; - @JsonCreator - Source() {} + public Source(Types.Source source) { this.source = Objects.requireNonNull(source, "source"); } + public Types.Source getSource() { return source; } - private Source(String id, String sourceType, String url, String title, JsonNode providerMetadata) { - this.id = id; - this.sourceType = sourceType; - this.url = url; - this.title = title; - this.providerMetadata = providerMetadata; + @Override public boolean equals(Object o) { + return o instanceof Source && Objects.equals(source, ((Source) o).source); } - - public String getId() { return id; } - public String getSourceType() { return sourceType; } - public String getUrl() { return url; } - public String getTitle() { return title; } - public JsonNode getProviderMetadata() { return providerMetadata; } - - public static Builder builder() { return new Builder(); } - - public static class Builder { - private String id = ""; - private String sourceType = ""; - private String url; - private String title; - private JsonNode providerMetadata; - - public Builder id(String v) { this.id = v; return this; } - public Builder sourceType(String v) { this.sourceType = v; return this; } - public Builder url(String v) { this.url = v; return this; } - public Builder title(String v) { this.title = v; return this; } - public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } - - public Source build() { return new Source(id, sourceType, url, title, providerMetadata); } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Source)) return false; - Source that = (Source) o; - return Objects.equals(id, that.id) - && Objects.equals(sourceType, that.sourceType) - && Objects.equals(url, that.url) - && Objects.equals(title, that.title) - && Objects.equals(providerMetadata, that.providerMetadata); - } - - @Override - public int hashCode() { return Objects.hash(id, sourceType, url, title, providerMetadata); } + @Override public int hashCode() { return Objects.hash(source); } } public static class Reasoning extends GenerateContent { @@ -2979,7 +3009,8 @@ public void serialize(GenerateContent value, JsonGenerator gen, SerializerProvid gen.writeTree(out); return; } - ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree(value); + ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree( + value instanceof GenerateContent.Source ? ((GenerateContent.Source) value).getSource() : value); ObjectNode out = JsonNodeFactory.instance.objectNode(); out.set(tag, node); gen.writeTree(out); @@ -3000,7 +3031,7 @@ public GenerateContent deserialize(JsonParser p, DeserializationContext ctxt) th switch (tag) { case "Text": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.Text.class); case "ToolCall": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.ToolCall.class); - case "Source": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.Source.class); + case "Source": return new GenerateContent.Source(AimuxJson.MAPPER.treeToValue(innerObj, Types.Source.class)); case "Reasoning": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.Reasoning.class); case "File": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.File.class); case "ToolResult": return AimuxJson.MAPPER.treeToValue(innerObj, GenerateContent.ToolResult.class); @@ -3135,7 +3166,7 @@ public static class GenerateTextResult { @JsonProperty("raw") private GenerateResult raw = new GenerateResult(); @JsonProperty("reasoning") private List reasoning = new ArrayList<>(); @JsonProperty("reasoning_text") private String reasoningText = ""; - @JsonProperty("sources") private List sources = new ArrayList<>(); + @JsonProperty("sources") private List sources = new ArrayList<>(); @JsonProperty("files") private List files = new ArrayList<>(); @JsonProperty("response_messages") private List responseMessages = new ArrayList<>(); @JsonProperty("raw_finish_reason") private String rawFinishReason; @@ -3149,7 +3180,7 @@ public static class GenerateTextResult { private GenerateTextResult(String text, List toolCalls, FinishReason finishReason, Usage usage, List warnings, GenerateResult raw, List reasoning, String reasoningText, - List sources, List files, + List sources, List files, List responseMessages, String rawFinishReason, JsonNode providerMetadata, ResponseMetadata response, Usage totalUsage) { this.text = text; @@ -3178,7 +3209,7 @@ private GenerateTextResult(String text, List toolCalls, FinishReason f public GenerateResult getRaw() { return raw; } public List getReasoning() { return reasoning; } public String getReasoningText() { return reasoningText; } - public List getSources() { return sources; } + public List getSources() { return sources; } public List getFiles() { return files; } public List getResponseMessages() { return responseMessages; } public String getRawFinishReason() { return rawFinishReason; } @@ -3197,7 +3228,7 @@ public static class Builder { private GenerateResult raw = new GenerateResult(); private List reasoning = new ArrayList<>(); private String reasoningText = ""; - private List sources = new ArrayList<>(); + private List sources = new ArrayList<>(); private List files = new ArrayList<>(); private List responseMessages = new ArrayList<>(); private String rawFinishReason; @@ -3213,7 +3244,7 @@ public static class Builder { public Builder raw(GenerateResult v) { this.raw = v; return this; } public Builder reasoning(List v) { this.reasoning = v; return this; } public Builder reasoningText(String v) { this.reasoningText = v; return this; } - public Builder sources(List v) { this.sources = v; return this; } + public Builder sources(List v) { this.sources = v; return this; } public Builder files(List v) { this.files = v; return this; } public Builder responseMessages(List v) { this.responseMessages = v; return this; } public Builder rawFinishReason(String v) { this.rawFinishReason = v; return this; } @@ -3371,7 +3402,7 @@ public static class StreamTextResultAggregated { @JsonProperty("reasoning") private List reasoning = new ArrayList<>(); @JsonProperty("reasoning_text") private String reasoningText = ""; @JsonProperty("tool_calls") private List toolCalls = new ArrayList<>(); - @JsonProperty("sources") private List sources = new ArrayList<>(); + @JsonProperty("sources") private List sources = new ArrayList<>(); @JsonProperty("files") private List files = new ArrayList<>(); @JsonProperty("finish_reason") private FinishReason finishReason = new FinishReason(); @JsonProperty("raw_finish_reason") private String rawFinishReason; @@ -3386,7 +3417,7 @@ public static class StreamTextResultAggregated { StreamTextResultAggregated() {} private StreamTextResultAggregated(String text, List reasoning, String reasoningText, - List toolCalls, List sources, List files, + List toolCalls, List sources, List files, FinishReason finishReason, String rawFinishReason, Usage usage, Usage totalUsage, List warnings, JsonNode providerMetadata, ResponseMetadata response, List responseMessages) { @@ -3411,7 +3442,7 @@ private StreamTextResultAggregated(String text, List reasoning, String public List getReasoning() { return reasoning; } public String getReasoningText() { return reasoningText; } public List getToolCalls() { return toolCalls; } - public List getSources() { return sources; } + public List getSources() { return sources; } public List getFiles() { return files; } public FinishReason getFinishReason() { return finishReason; } public String getRawFinishReason() { return rawFinishReason; } @@ -3429,7 +3460,7 @@ public static class Builder { private List reasoning = new ArrayList<>(); private String reasoningText = ""; private List toolCalls = new ArrayList<>(); - private List sources = new ArrayList<>(); + private List sources = new ArrayList<>(); private List files = new ArrayList<>(); private FinishReason finishReason = new FinishReason(); private String rawFinishReason; @@ -3444,7 +3475,7 @@ public static class Builder { public Builder reasoning(List v) { this.reasoning = v; return this; } public Builder reasoningText(String v) { this.reasoningText = v; return this; } public Builder toolCalls(List v) { this.toolCalls = v; return this; } - public Builder sources(List v) { this.sources = v; return this; } + public Builder sources(List v) { this.sources = v; return this; } public Builder files(List v) { this.files = v; return this; } public Builder finishReason(FinishReason v) { this.finishReason = v; return this; } public Builder rawFinishReason(String v) { this.rawFinishReason = v; return this; } @@ -4247,61 +4278,15 @@ public boolean equals(Object o) { } public static class Source extends StreamPart { - @JsonProperty("id") private String id = ""; - @JsonProperty("source_type") private String sourceType = ""; - @JsonProperty("url") private String url; - @JsonProperty("title") private String title; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; + private final Types.Source source; - @JsonCreator - Source() {} + public Source(Types.Source source) { this.source = Objects.requireNonNull(source, "source"); } + public Types.Source getSource() { return source; } - private Source(String id, String sourceType, String url, String title, JsonNode providerMetadata) { - this.id = id; - this.sourceType = sourceType; - this.url = url; - this.title = title; - this.providerMetadata = providerMetadata; + @Override public boolean equals(Object o) { + return o instanceof Source && Objects.equals(source, ((Source) o).source); } - - public String getId() { return id; } - public String getSourceType() { return sourceType; } - public String getUrl() { return url; } - public String getTitle() { return title; } - public JsonNode getProviderMetadata() { return providerMetadata; } - - public static Builder builder() { return new Builder(); } - - public static class Builder { - private String id = ""; - private String sourceType = ""; - private String url; - private String title; - private JsonNode providerMetadata; - - public Builder id(String v) { this.id = v; return this; } - public Builder sourceType(String v) { this.sourceType = v; return this; } - public Builder url(String v) { this.url = v; return this; } - public Builder title(String v) { this.title = v; return this; } - public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } - - public Source build() { return new Source(id, sourceType, url, title, providerMetadata); } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Source)) return false; - Source that = (Source) o; - return Objects.equals(id, that.id) - && Objects.equals(sourceType, that.sourceType) - && Objects.equals(url, that.url) - && Objects.equals(title, that.title) - && Objects.equals(providerMetadata, that.providerMetadata); - } - - @Override - public int hashCode() { return Objects.hash(id, sourceType, url, title, providerMetadata); } + @Override public int hashCode() { return Objects.hash(source); } } public static class Raw extends StreamPart { @@ -4533,7 +4518,8 @@ public void serialize(StreamPart value, JsonGenerator gen, SerializerProvider se gen.writeTree(out); return; } - ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree(value); + ObjectNode node = (ObjectNode) AimuxJson.INNER_MAPPER.valueToTree( + value instanceof StreamPart.Source ? ((StreamPart.Source) value).getSource() : value); ObjectNode out = JsonNodeFactory.instance.objectNode(); out.set(tag, node); gen.writeTree(out); @@ -4567,7 +4553,7 @@ public StreamPart deserialize(JsonParser p, DeserializationContext ctxt) throws case "ReasoningDelta": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningDelta.class); case "ReasoningEnd": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ReasoningEnd.class); case "ResponseMetadata": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.ResponseMetadata.class); - case "Source": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Source.class); + case "Source": return new StreamPart.Source(AimuxJson.MAPPER.treeToValue(innerObj, Types.Source.class)); case "Raw": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Raw.class); case "Error": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Error.class); case "Custom": return AimuxJson.MAPPER.treeToValue(innerObj, StreamPart.Custom.class); diff --git a/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java index 5d376b3a9..be5066022 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java @@ -281,7 +281,7 @@ void generateContentFixturesDecodeIntoTheirVariants() throws Exception { f.get("name").asText(), Types.AimuxJson.MAPPER.readValue(json, Types.GenerateContent.class)); } - assertThat(byName).hasSize(8); + assertThat(byName).hasSize(7); assertThat(byName.get("generate_content_text")) .isInstanceOf(Types.GenerateContent.Text.class); @@ -289,7 +289,7 @@ void generateContentFixturesDecodeIntoTheirVariants() throws Exception { .isInstanceOf(Types.GenerateContent.Reasoning.class); // The shapes a parse-only check leaves invisible: the tool-call input, - // the nested file union, and Source's optionals. + // and the nested file union. Types.GenerateContent.ToolCall toolCall = (Types.GenerateContent.ToolCall) byName.get("generate_content_tool_call"); assertThat(toolCall.getToolCallId()).isEqualTo("call_1"); @@ -304,10 +304,7 @@ void generateContentFixturesDecodeIntoTheirVariants() throws Exception { assertThat(file.getMediaType()).isEqualTo("image/png"); assertThat(file.getData()).isNotNull(); - Types.GenerateContent.Source source = - (Types.GenerateContent.Source) byName.get("generate_content_source_unset_optionals"); - assertThat(source.getUrl()).isNull(); - assertThat(source.getTitle()).isNull(); + } /** 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 b4d4ed092..7d35bfc89 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -13,6 +13,7 @@ package ai.arcships.aimux +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -24,6 +25,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonClassDiscriminator import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder @@ -860,6 +862,34 @@ object GeneratedFileDataSerializer : KSerializer { // [GenerateContent.Unknown] for forward compatibility (mirroring [StreamPart]). // ───────────────────────────────────────────────────────────────────────────── +/** A URL or document used as a source for the response. */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("source_type") +sealed interface Source { + val id: String + val providerMetadata: JsonElement? + + @Serializable + @SerialName("url") + data class Url( + override val id: String, + val url: String, + val title: String? = null, + @SerialName("provider_metadata") override val providerMetadata: JsonElement? = null, + ) : Source + + @Serializable + @SerialName("document") + data class Document( + override val id: String, + @SerialName("media_type") val mediaType: String, + val title: String, + val filename: String? = null, + @SerialName("provider_metadata") override val providerMetadata: JsonElement? = null, + ) : Source +} + /** * A content item in the generation result. * @@ -907,14 +937,7 @@ sealed interface GenerateContent { @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) : GenerateContent - @Serializable - data class Source( - val id: String = "", - @SerialName("source_type") val sourceType: String = "", - val url: String? = null, - val title: String? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : GenerateContent + data class Source(val source: ai.arcships.aimux.Source) : GenerateContent @Serializable data class Reasoning( @@ -988,7 +1011,7 @@ object GenerateContentSerializer : KSerializer { "ToolApprovalRequest" -> ctx.decodeFromJsonElement(GenerateContent.ToolApprovalRequest.serializer(), innerObj) "Text" -> ctx.decodeFromJsonElement(GenerateContent.Text.serializer(), innerObj) "ToolCall" -> ctx.decodeFromJsonElement(GenerateContent.ToolCall.serializer(), innerObj) - "Source" -> ctx.decodeFromJsonElement(GenerateContent.Source.serializer(), innerObj) + "Source" -> GenerateContent.Source(ctx.decodeFromJsonElement(Source.serializer(), innerObj)) "Reasoning" -> ctx.decodeFromJsonElement(GenerateContent.Reasoning.serializer(), innerObj) "File" -> ctx.decodeFromJsonElement(GenerateContent.File.serializer(), innerObj) "ToolResult" -> ctx.decodeFromJsonElement(GenerateContent.ToolResult.serializer(), innerObj) @@ -1006,7 +1029,7 @@ object GenerateContentSerializer : KSerializer { is GenerateContent.ToolApprovalRequest -> "ToolApprovalRequest" to ctx.encodeToJsonElement(GenerateContent.ToolApprovalRequest.serializer(), value) is GenerateContent.Text -> "Text" to ctx.encodeToJsonElement(GenerateContent.Text.serializer(), value) is GenerateContent.ToolCall -> "ToolCall" to ctx.encodeToJsonElement(GenerateContent.ToolCall.serializer(), value) - is GenerateContent.Source -> "Source" to ctx.encodeToJsonElement(GenerateContent.Source.serializer(), value) + is GenerateContent.Source -> "Source" to ctx.encodeToJsonElement(Source.serializer(), value.source) is GenerateContent.Reasoning -> "Reasoning" to ctx.encodeToJsonElement(GenerateContent.Reasoning.serializer(), value) is GenerateContent.File -> "File" to ctx.encodeToJsonElement(GenerateContent.File.serializer(), value) is GenerateContent.ToolResult -> "ToolResult" to ctx.encodeToJsonElement(GenerateContent.ToolResult.serializer(), value) @@ -1059,7 +1082,7 @@ data class GenerateTextResult( // M7: top-level aggregation fields val reasoning: List = emptyList(), @SerialName("reasoning_text") val reasoningText: String = "", - val sources: List = emptyList(), + val sources: List = emptyList(), val files: List = emptyList(), @SerialName("response_messages") val responseMessages: List = emptyList(), // M12: raw provider-specific finish reason string. @@ -1114,7 +1137,7 @@ data class StreamTextResultAggregated( val reasoning: List = emptyList(), @SerialName("reasoning_text") val reasoningText: String = "", @SerialName("tool_calls") val toolCalls: List = emptyList(), - val sources: List = emptyList(), + val sources: List = emptyList(), val files: List = emptyList(), @SerialName("finish_reason") val finishReason: FinishReason = FinishReason(), @SerialName("raw_finish_reason") val rawFinishReason: String? = null, @@ -1279,14 +1302,7 @@ sealed interface StreamPart { @SerialName("model_id") val modelId: String? = null, ) : StreamPart - @Serializable - data class Source( - val id: String = "", - @SerialName("source_type") val sourceType: String = "", - val url: String? = null, - val title: String? = null, - @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, - ) : StreamPart + data class Source(val source: ai.arcships.aimux.Source) : StreamPart @Serializable data class Raw( @@ -1369,7 +1385,7 @@ object StreamPartSerializer : KSerializer { "ReasoningDelta" -> ctx.decodeFromJsonElement(StreamPart.ReasoningDelta.serializer(), innerObj) "ReasoningEnd" -> ctx.decodeFromJsonElement(StreamPart.ReasoningEnd.serializer(), innerObj) "ResponseMetadata" -> ctx.decodeFromJsonElement(StreamPart.ResponseMetadata.serializer(), innerObj) - "Source" -> ctx.decodeFromJsonElement(StreamPart.Source.serializer(), innerObj) + "Source" -> StreamPart.Source(ctx.decodeFromJsonElement(Source.serializer(), innerObj)) "Raw" -> ctx.decodeFromJsonElement(StreamPart.Raw.serializer(), innerObj) "Error" -> ctx.decodeFromJsonElement(StreamPart.Error.serializer(), innerObj) else -> StreamPart.Unknown(tag, inner) @@ -1399,7 +1415,7 @@ object StreamPartSerializer : KSerializer { is StreamPart.ReasoningDelta -> "ReasoningDelta" to ctx.encodeToJsonElement(StreamPart.ReasoningDelta.serializer(), value) is StreamPart.ReasoningEnd -> "ReasoningEnd" to ctx.encodeToJsonElement(StreamPart.ReasoningEnd.serializer(), value) is StreamPart.ResponseMetadata -> "ResponseMetadata" to ctx.encodeToJsonElement(StreamPart.ResponseMetadata.serializer(), value) - is StreamPart.Source -> "Source" to ctx.encodeToJsonElement(StreamPart.Source.serializer(), value) + is StreamPart.Source -> "Source" to ctx.encodeToJsonElement(Source.serializer(), value.source) is StreamPart.Raw -> "Raw" to ctx.encodeToJsonElement(StreamPart.Raw.serializer(), value) is StreamPart.Error -> "Error" to ctx.encodeToJsonElement(StreamPart.Error.serializer(), value) is StreamPart.Unknown -> value.tag to value.data diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt index c8d649463..c60a7f4f5 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt @@ -125,14 +125,14 @@ class ContractTest { val json = obj["json"]!!.jsonPrimitive.content byName[name] = AimuxJson.decodeFromString(json) } - assertThat(byName).hasSize(8) + assertThat(byName).hasSize(7) assertThat(byName["generate_content_text"]).isInstanceOf(GenerateContent.Text::class.java) assertThat(byName["generate_content_reasoning_no_metadata"]) .isInstanceOf(GenerateContent.Reasoning::class.java) // The shapes a parse-only check leaves invisible: the tool-call input, - // the nested file union, and Source's optionals. + // and the nested file union. val toolCall = byName["generate_content_tool_call"] as GenerateContent.ToolCall assertThat(toolCall.toolCallId).isEqualTo("call_1") assertThat(toolCall.input.jsonPrimitive.content).isEqualTo("{\"city\":\"Paris\"}") @@ -141,10 +141,7 @@ class ContractTest { val file = byName["generate_content_file"] as GenerateContent.File assertThat(file.mediaType).isEqualTo("image/png") - val source = - byName["generate_content_source_unset_optionals"] as GenerateContent.Source - assertThat(source.url).isNull() - assertThat(source.title).isNull() + } /// The regression lock for `topK`, which this binding declared as `Long` diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt index 520599cd5..83704cc46 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt @@ -297,20 +297,6 @@ class TypedModelRoundTripTest { assertThat(decoded).isEqualTo(original) } - @Test - fun `GenerateContent Source round-trips`() { - val original = GenerateContent.Source( - id = "src_1", - sourceType = "web", - url = "https://example.com", - title = "Example", - providerMetadata = null, - ) - val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) - val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) - assertThat(decoded).isEqualTo(original) - } - @Test fun `GenerateContent Reasoning round-trips`() { val original = GenerateContent.Reasoning(text = "thinking...", providerMetadata = null) diff --git a/bindings/node/src/index.ts b/bindings/node/src/index.ts index 160bee534..6169abe58 100644 --- a/bindings/node/src/index.ts +++ b/bindings/node/src/index.ts @@ -41,6 +41,7 @@ import type { TextContent, ReasoningPart, GeneratedFile, + Source, GeneratedFileData, RawToolApprovalRequest, ToolApprovalRequestOutput, @@ -151,6 +152,7 @@ export type { TextContent, ReasoningPart, GeneratedFile, + Source, GeneratedFileData, RawToolApprovalRequest, ToolApprovalRequestOutput, diff --git a/bindings/node/src/types/Source.ts b/bindings/node/src/types/Source.ts index 261797b42..31ac3d097 100644 --- a/bindings/node/src/types/Source.ts +++ b/bindings/node/src/types/Source.ts @@ -6,4 +6,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * payload of both `GenerateContent::Source` and `StreamPart::Source`, and the * element of the text results' `sources`. */ -export type Source = { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: JsonValue | null, }; +export type Source = { "source_type": "url", id: string, url: string, title?: string | null, provider_metadata?: JsonValue | null, } | { "source_type": "document", id: string, media_type: string, title: string, filename?: string | null, provider_metadata?: JsonValue | null, }; diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index 9290088f4..d57720b6a 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -39,6 +39,7 @@ "ResponseFormat", "StreamPart", "GenerateContent", + "Source", "TextContent", "FileData", "FileBytes", @@ -406,15 +407,39 @@ class _ContentToolCall(BaseModel): provider_metadata: Optional[Any] = None -class _ContentSource(BaseModel): - type: Literal["Source"] +class _UrlSource(BaseModel): id: str - source_type: str - url: Optional[str] = None + source_type: Literal["url"] + url: str title: Optional[str] = None provider_metadata: Optional[Dict[str, Any]] = None +class _DocumentSource(BaseModel): + id: str + source_type: Literal["document"] + media_type: str + title: str + filename: Optional[str] = None + provider_metadata: Optional[Dict[str, Any]] = None + + +Source = Annotated[Union[_UrlSource, _DocumentSource], Field(discriminator="source_type")] + + +class _ContentUrlSource(_UrlSource): + type: Literal["Source"] + + +class _ContentDocumentSource(_DocumentSource): + type: Literal["Source"] + + +_ContentSource = Annotated[ + Union[_ContentUrlSource, _ContentDocumentSource], Field(discriminator="source_type") +] + + class _ContentReasoning(BaseModel): type: Literal["Reasoning"] text: str @@ -1064,11 +1089,11 @@ class GenerateTextResult(BaseModel): warnings: List[Warning] raw: GenerateResult # M7: top-level aggregation fields. - # reasoning/sources/files use weak types (Dict) — use raw.content for + # reasoning/files use weak types (Dict) — use raw.content for # full typing; response_messages reuses ModelMessage for next-turn prompt. reasoning: List[Dict[str, Any]] = Field(default_factory=list) reasoning_text: str = "" - sources: List[Dict[str, Any]] = Field(default_factory=list) + sources: List[Source] = Field(default_factory=list) files: List[Dict[str, Any]] = Field(default_factory=list) response_messages: List[ModelMessage] = Field(default_factory=list) # M12: raw provider-specific finish reason string (e.g. "stop", "end_turn"). @@ -1113,12 +1138,12 @@ class StreamTextResultAggregated(BaseModel): content: List[TextContent] = Field(default_factory=list) text: str = "" - # reasoning/sources/files use weak types (Dict) — same strategy as + # reasoning/files use weak types (Dict) — same strategy as # GenerateTextResult. reasoning: List[Dict[str, Any]] = Field(default_factory=list) reasoning_text: str = "" tool_calls: List[ToolCall] = Field(default_factory=list) - sources: List[Dict[str, Any]] = Field(default_factory=list) + sources: List[Source] = Field(default_factory=list) files: List[Dict[str, Any]] = Field(default_factory=list) finish_reason: FinishReason raw_finish_reason: Optional[str] = None diff --git a/bindings/python/tests/test_contract.py b/bindings/python/tests/test_contract.py index 66df3eff7..7a20420ce 100644 --- a/bindings/python/tests/test_contract.py +++ b/bindings/python/tests/test_contract.py @@ -83,7 +83,7 @@ def test_generate_content_fixtures_decode_into_typed_variants(): unnoticed. """ fixtures = [f for f in _fixtures() if f["type"] == "GenerateContent"] - assert len(fixtures) == 8, f"expected 8 GenerateContent fixtures, saw {len(fixtures)}" + assert len(fixtures) == 7, f"expected 7 GenerateContent fixtures, saw {len(fixtures)}" by_name = {} for f in fixtures: @@ -91,8 +91,7 @@ def test_generate_content_fixtures_decode_into_typed_variants(): by_name[f["name"]] = content.root # Spot-check the shapes the maintainer flagged as invisible to a - # parse-only check: the tool-call input, the nested file union, and - # Source's optionals. + # parse-only check: the tool-call input and the nested file union. tool_call = by_name["generate_content_tool_call"] assert tool_call.tool_call_id == "call_1" assert tool_call.input == '{"city":"Paris"}' @@ -101,10 +100,6 @@ def test_generate_content_fixtures_decode_into_typed_variants(): file_part = by_name["generate_content_file"] assert file_part.media_type == "image/png" - source = by_name["generate_content_source_unset_optionals"] - assert source.url is None - assert source.title is None - def test_provider_executed_tool_transcript_message_fixture_roundtrips(): message = ModelMessage.model_validate_json( diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index 5263812c4..33b40c364 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -838,6 +838,49 @@ public struct GeneratedFile: Codable, Equatable { } } +public enum Source: Codable, Equatable { + case url(id: String, url: String, title: String?, providerMetadata: JSONValue?) + case document(id: String, mediaType: String, title: String, filename: String?, + providerMetadata: JSONValue?) + + private enum Field: String, CodingKey { + case id, sourceType = "source_type", url, title, filename + case mediaType = "media_type", providerMetadata = "provider_metadata" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: Field.self) + let id = try c.decode(String.self, forKey: .id) + let pm = try c.decodeIfPresent(JSONValue.self, forKey: .providerMetadata) + switch try c.decode(String.self, forKey: .sourceType) { + case "url": + self = .url(id: id, url: try c.decode(String.self, forKey: .url), + title: try c.decodeIfPresent(String.self, forKey: .title), providerMetadata: pm) + case "document": + self = .document(id: id, mediaType: try c.decode(String.self, forKey: .mediaType), + title: try c.decode(String.self, forKey: .title), + filename: try c.decodeIfPresent(String.self, forKey: .filename), + providerMetadata: pm) + default: throw aimuxDecodingError(c.codingPath, "invalid source type") + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: Field.self) + switch self { + case .url(let id, let url, let title, let pm): + try c.encode("url", forKey: .sourceType); try c.encode(id, forKey: .id) + try c.encode(url, forKey: .url); try c.encodeIfPresent(title, forKey: .title) + try c.encodeIfPresent(pm, forKey: .providerMetadata) + case .document(let id, let mediaType, let title, let filename, let pm): + try c.encode("document", forKey: .sourceType); try c.encode(id, forKey: .id) + try c.encode(mediaType, forKey: .mediaType); try c.encode(title, forKey: .title) + try c.encodeIfPresent(filename, forKey: .filename) + try c.encodeIfPresent(pm, forKey: .providerMetadata) + } + } +} + /// A content item in the generation result. /// /// Wire: externally tagged — `{"Text":{...}}`, `{"ToolCall":{...}}`, … @@ -846,8 +889,7 @@ public enum GenerateContent: Codable, Equatable { case toolCall(toolCallId: String, toolName: String, input: JSONValue, providerExecuted: Bool?, dynamic: Bool?, thoughtSignature: String? = nil, providerMetadata: JSONValue?) - case source(id: String, sourceType: String, url: String?, title: String?, - providerMetadata: JSONValue?) + case source(Source) case reasoning(text: String, providerMetadata: JSONValue?) case file(data: GeneratedFileData, mediaType: String, providerMetadata: JSONValue?) case toolResult(toolCallId: String, toolName: String, result: JSONValue, @@ -887,11 +929,7 @@ public enum GenerateContent: Codable, Equatable { thoughtSignature: try n.decodeIfPresent(String.self, forKey: .thoughtSignature), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "Source": - self = .source(id: try n.decode(String.self, forKey: .id), - sourceType: try n.decode(String.self, forKey: .sourceType), - url: try n.decodeIfPresent(String.self, forKey: .url), - title: try n.decodeIfPresent(String.self, forKey: .title), - providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + self = .source(try Source(from: c.superDecoder(forKey: AnyCodingKey("Source")))) case "Reasoning": self = .reasoning(text: try n.decode(String.self, forKey: .text), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) @@ -939,13 +977,8 @@ public enum GenerateContent: Codable, Equatable { try n.encodeIfPresent(dyn, forKey: .dynamic) try n.encodeIfPresent(signature, forKey: .thoughtSignature) try n.encodeIfPresent(pm, forKey: .providerMetadata) - case .source(let id, let sourceType, let url, let title, let pm): - var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Source")) - try n.encode(id, forKey: .id) - try n.encode(sourceType, forKey: .sourceType) - try n.encodeIfPresent(url, forKey: .url) - try n.encodeIfPresent(title, forKey: .title) - try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .source(let source): + try source.encode(to: c.superEncoder(forKey: AnyCodingKey("Source"))) case .reasoning(let text, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Reasoning")) try n.encode(text, forKey: .text) @@ -1042,8 +1075,8 @@ public struct GenerateTextResult: Codable, Equatable { public var reasoning: [JSONValue] /// Concatenated reasoning text (M7). public var reasoningText: String - /// Sources / citations (M7). Weak type. - public var sources: [JSONValue] + /// Sources / citations (M7). + public var sources: [Source] /// Files generated by the model (M7). Weak type. public var files: [JSONValue] /// Assistant messages ready to append for the next turn (M7). @@ -1078,7 +1111,7 @@ public struct GenerateTextResult: Codable, Equatable { public init(text: String, toolCalls: [ToolCall], finishReason: FinishReason, usage: Usage, warnings: [Warning] = [], raw: GenerateResult, reasoning: [JSONValue] = [], reasoningText: String = "", - sources: [JSONValue] = [], files: [JSONValue] = [], + sources: [Source] = [], files: [JSONValue] = [], responseMessages: [ModelMessage] = [], rawFinishReason: String? = nil, providerMetadata: JSONValue? = nil, @@ -1158,8 +1191,8 @@ public struct StreamTextResultAggregated: Codable, Equatable { public var reasoningText: String /// Tool calls requested by the model. public var toolCalls: [ToolCall] - /// Sources / citations. Weak type. - public var sources: [JSONValue] + /// Sources / citations. + public var sources: [Source] /// Files generated by the model. Weak type. public var files: [JSONValue] /// Why generation stopped. @@ -1198,7 +1231,7 @@ public struct StreamTextResultAggregated: Codable, Equatable { public init(text: String = "", reasoning: [JSONValue] = [], reasoningText: String = "", toolCalls: [ToolCall] = [], - sources: [JSONValue] = [], files: [JSONValue] = [], + sources: [Source] = [], files: [JSONValue] = [], finishReason: FinishReason, rawFinishReason: String? = nil, usage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), totalUsage: Usage = Usage(inputTokens: InputTokenUsage(), outputTokens: OutputTokenUsage()), @@ -1384,8 +1417,7 @@ public enum StreamPart: Codable, Equatable { case reasoningEnd(id: String, providerMetadata: JSONValue?) // P2: metadata case responseMetadata(id: String?, timestamp: String?, modelId: String?) - case source(id: String, sourceType: String, url: String?, title: String?, - providerMetadata: JSONValue?) + case source(Source) case raw(rawValue: JSONValue) case custom(kind: String, providerMetadata: JSONValue?) @@ -1496,11 +1528,7 @@ public enum StreamPart: Codable, Equatable { timestamp: try n.decodeIfPresent(String.self, forKey: .timestamp), modelId: try n.decodeIfPresent(String.self, forKey: .modelId)) case "Source": - self = .source(id: try n.decode(String.self, forKey: .id), - sourceType: try n.decode(String.self, forKey: .sourceType), - url: try n.decodeIfPresent(String.self, forKey: .url), - title: try n.decodeIfPresent(String.self, forKey: .title), - providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) + self = .source(try Source(from: c.superDecoder(forKey: AnyCodingKey("Source")))) case "Raw": self = .raw(rawValue: try n.decode(JSONValue.self, forKey: .rawValue)) default: @@ -1589,11 +1617,8 @@ public enum StreamPart: Codable, Equatable { try n.encodeIfPresent(id, forKey: .id) try n.encodeIfPresent(timestamp, forKey: .timestamp) try n.encodeIfPresent(modelId, forKey: .modelId) - case .source(let id, let sourceType, let url, let title, let pm): - var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Source")) - try n.encode(id, forKey: .id); try n.encode(sourceType, forKey: .sourceType) - try n.encodeIfPresent(url, forKey: .url); try n.encodeIfPresent(title, forKey: .title) - try n.encodeIfPresent(pm, forKey: .providerMetadata) + case .source(let source): + try source.encode(to: c.superEncoder(forKey: AnyCodingKey("Source"))) case .raw(let rawValue): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Raw")) try n.encode(rawValue, forKey: .rawValue) diff --git a/contract-tests/fixtures/wire-format.json b/contract-tests/fixtures/wire-format.json index b6f414055..80baf9ff3 100644 --- a/contract-tests/fixtures/wire-format.json +++ b/contract-tests/fixtures/wire-format.json @@ -107,12 +107,6 @@ "json": "{\"Source\":{\"id\":\"src_1\",\"source_type\":\"url\",\"url\":\"https://example.com/a\",\"title\":\"Example\"}}", "description": "Source/citation. Note url and title carry no skip_serializing_if, so they are always emitted (null when unset) — unlike provider_metadata, which is omitted when unset." }, - { - "name": "generate_content_source_unset_optionals", - "type": "GenerateContent", - "json": "{\"Source\":{\"id\":\"src_2\",\"source_type\":\"document\",\"url\":null,\"title\":null}}", - "description": "Source with url and title unset. They carry no skip_serializing_if, so they must appear as explicit nulls — while provider_metadata, which does carry it, stays absent in the same object. Pinning the unset form is what makes that difference load-bearing: without this case, adding skip_serializing_if to url/title would silently turn null into an omitted key and no fixture would notice." - }, { "name": "generate_content_file", "type": "GenerateContent", From bc0d8b17b4e4e4bd69d8cf7597908dffa58e137b Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Tue, 6 Oct 2026 16:04:06 +0800 Subject: [PATCH 5/6] fix(providers): map file citations and grounding documents to document sources; align binding types What: OpenAI Responses maps file_citation, container_file_citation and file_path annotations to document sources in generate and stream; Google grounding treats `web: null` as absent and maps retrievedContext to a document source (Vertex shares the path); Vertex emits thought files as reasoning-file and thought text as reasoning like Google. Java, Kotlin, Go, Swift and Flutter type files follow the Rust shapes for response body, reasoning-file data and the tool approval request. Why: review of this branch against the pinned upstream found these source variants unmapped and the binding types diverging from Rust. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-providers/src/google/convert.rs | 16 +- .../src/openai/responses/responses_convert.rs | 118 +++++++++---- aimux-providers/src/vertex/model.rs | 161 ++++++++++++++++-- bindings/flutter/lib/types.dart | 7 +- bindings/go/types.go | 5 +- .../main/java/ai/arcships/aimux/Types.java | 89 +++++++--- .../main/kotlin/ai/arcships/aimux/Types.kt | 3 +- bindings/swift/Sources/Aimux/Types.swift | 7 +- 8 files changed, 319 insertions(+), 87 deletions(-) diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 5dec17723..a70ba1d82 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -1232,7 +1232,7 @@ pub fn extract_sources( }; for chunk in chunks { - if let Some(web) = chunk.get("web") { + if let Some(web) = chunk.get("web").filter(|value| !value.is_null()) { sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), url: web @@ -1246,7 +1246,7 @@ pub fn extract_sources( .map(std::string::ToString::to_string), provider_metadata: None, })); - } else if let Some(image) = chunk.get("image") { + } else if let Some(image) = chunk.get("image").filter(|value| !value.is_null()) { sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), url: image @@ -1260,7 +1260,10 @@ pub fn extract_sources( .map(std::string::ToString::to_string), provider_metadata: None, })); - } else if let Some(rc) = chunk.get("retrievedContext") { + } else if let Some(rc) = chunk + .get("retrievedContext") + .filter(|value| !value.is_null()) + { let uri = rc .get("uri") .and_then(|v| v.as_str()) @@ -1312,8 +1315,11 @@ pub fn extract_sources( })); } // else: no uri and no fileSearchStore → no source. - } else if let Some(maps) = chunk.get("maps") - && let Some(uri) = maps.get("uri").and_then(|v| v.as_str()) + } else if let Some(maps) = chunk.get("maps").filter(|value| !value.is_null()) + && let Some(uri) = maps + .get("uri") + .and_then(|v| v.as_str()) + .filter(|uri| !uri.is_empty()) { sources.push(GenerateContent::Source(Source::Url { id: next_id(id_counter), diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index 1fae3dec8..493ef382f 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -148,26 +148,13 @@ pub fn build_responses_generate_result( }); } } - // Annotations (url_citation → Source). if let Some(annotations) = cp.get("annotations").and_then(|v| v.as_array()) { - for (i, ann) in annotations.iter().enumerate() { - if ann.get("type").and_then(|v| v.as_str()) == Some("url_citation") - { - content.push(GenerateContent::Source(Source::Url { - id: format!("annotation-{i}"), - url: ann - .get("url") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - title: ann - .get("title") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), - provider_metadata: None, - })); - } + for ann in annotations { + content.push(GenerateContent::Source(annotation_source( + ann, + &provider_key, + )?)); } } } @@ -398,7 +385,74 @@ fn reasoning_stream_metadata( json!({ (provider_key): inner }) } -/// Generate a unique source ID for streaming annotation sources. +fn annotation_source(annotation: &Value, provider_key: &str) -> Result { + let invalid = |field: &str| { + AiMuxError::InvalidResponseData(format!("Invalid Responses annotation field: {field}")) + }; + let string = |field| { + annotation + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| invalid(field)) + }; + let number = |field| { + annotation + .get(field) + .filter(|value| value.is_number()) + .cloned() + .ok_or_else(|| invalid(field)) + }; + let annotation_type = string("type")?; + if annotation_type == "url_citation" { + number("start_index")?; + number("end_index")?; + return Ok(Source::Url { + id: generate_source_id(), + url: string("url")?.to_string(), + title: Some(string("title")?.to_string()), + provider_metadata: None, + }); + } + let file_id = string("file_id")?; + let (media_type, filename, metadata) = match annotation_type { + "file_citation" | "file_path" => ( + if annotation_type == "file_path" { + "application/octet-stream" + } else { + "text/plain" + }, + if annotation_type == "file_path" { + file_id + } else { + string("filename")? + }, + json!({ "type": annotation_type, "fileId": file_id, "index": number("index")? }), + ), + "container_file_citation" => { + number("start_index")?; + number("end_index")?; + ( + "text/plain", + string("filename")?, + json!({ + "type": annotation_type, + "fileId": file_id, + "containerId": string("container_id")?, + }), + ) + } + _ => return Err(invalid("type")), + }; + Ok(Source::Document { + id: generate_source_id(), + media_type: media_type.to_string(), + title: filename.to_string(), + filename: Some(filename.to_string()), + provider_metadata: Some(json!({ (provider_key): metadata })), + }) +} + +/// Generate a unique source ID for annotation sources. /// /// Uses a process-wide atomic counter — consistent with the xAI Responses /// provider (`aimux-providers/src/xai/responses/mod.rs`). Upstream TS uses @@ -945,23 +999,15 @@ where // ── response.output_text.annotation.added → Source ───────── "response.output_text.annotation.added" => { - if let Some(ann) = parsed.get("annotation") - && ann.get("type").and_then(|v| v.as_str()) - == Some("url_citation") - { - yield Ok(StreamPart::Source(Source::Url { - id: generate_source_id(), - url: ann - .get("url") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - title: ann - .get("title") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string), - provider_metadata: None, - })); + match annotation_source( + parsed.get("annotation").unwrap_or(&Value::Null), + &provider_key, + ) { + Ok(source) => yield Ok(StreamPart::Source(source)), + Err(error) => { + stream_errored = true; + yield Ok(StreamPart::Error { error }); + } } } diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 7cfcde49a..1a45f2fc7 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -15,7 +15,11 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult, Source, StreamResult}; +use aimux_core::result::{ + GenerateContent, GenerateResult, GeneratedFile, GeneratedFileData, ReasoningOutput, Source, + StreamResult, +}; +use aimux_core::shared::FileBytes; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -316,6 +320,7 @@ impl LanguageModel for VertexModel { let mut sse_stream = futures::stream::iter(first_event.into_iter()).chain(sse_stream); let mut text_id: Option = None; + let mut reasoning_id: Option = None; let mut block_counter = 0usize; let mut final_usage: Usage = Usage::default(); let mut final_finish_reason: Option = None; @@ -408,30 +413,62 @@ impl LanguageModel for VertexModel { candidate.content.as_ref().and_then(|c| c.parts.as_ref()) { for part in parts { + let thought_signature_metadata = part + .get("thoughtSignature") + .and_then(|v| v.as_str()) + .filter(|signature| !signature.is_empty()) + .map(|signature| { + vertex_provider_metadata(json!({ + "thoughtSignature": signature, + })) + }); if let Some(text) = part.get("text").and_then(|v| v.as_str()) { - if !text.is_empty() { - let text_metadata = part - .get("thoughtSignature") - .and_then(|v| v.as_str()) - .map(|signature| { - vertex_provider_metadata(json!({ - "thoughtSignature": signature, - })) + if text.is_empty() { + if let (Some(metadata), Some(id)) = (&thought_signature_metadata, &text_id) { + yield Ok(StreamPart::TextDelta { + id: id.clone(), + delta: String::new(), + provider_metadata: Some(metadata.clone()), }); + } + } else if part.get("thought").and_then(Value::as_bool) == Some(true) { + if let Some(id) = text_id.take() { + yield Ok(StreamPart::TextEnd { id, provider_metadata: None }); + } + if reasoning_id.is_none() { + let id = format!("{block_counter}"); + block_counter += 1; + reasoning_id = Some(id.clone()); + yield Ok(StreamPart::ReasoningStart { + id, + provider_metadata: thought_signature_metadata.clone(), + }); + } + if let Some(id) = &reasoning_id { + yield Ok(StreamPart::ReasoningDelta { + id: id.clone(), + delta: text.to_string(), + provider_metadata: thought_signature_metadata, + }); + } + } else { + if let Some(id) = reasoning_id.take() { + yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None }); + } if text_id.is_none() { let id = format!("{block_counter}"); block_counter += 1; text_id = Some(id.clone()); yield Ok(StreamPart::TextStart { id, - provider_metadata: text_metadata.clone(), + provider_metadata: thought_signature_metadata.clone(), }); } if let Some(id) = &text_id { yield Ok(StreamPart::TextDelta { id: id.clone(), delta: text.to_string(), - provider_metadata: text_metadata, + provider_metadata: thought_signature_metadata, }); } } @@ -484,6 +521,28 @@ impl LanguageModel for VertexModel { provider_metadata: tool_metadata, })); has_tool_calls = true; + } else if let Some(inline) = part.get("inlineData") { + if let Some(id) = text_id.take() { + yield Ok(StreamPart::TextEnd { id, provider_metadata: None }); + } + if let Some(id) = reasoning_id.take() { + yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None }); + } + if let (Some(data), Some(media_type)) = ( + inline.get("data").and_then(Value::as_str), + inline.get("mimeType").and_then(Value::as_str), + ) { + let file = GeneratedFile { + data: GeneratedFileData::Data { data: FileBytes::Base64(data.to_string()) }, + media_type: media_type.to_string(), + provider_metadata: thought_signature_metadata, + }; + yield Ok(if part.get("thought").and_then(Value::as_bool) == Some(true) { + StreamPart::ReasoningFile(file) + } else { + StreamPart::File(file) + }); + } } else if let Some(ec) = part.get("executableCode") { // Provider-executed code execution. let has_code = ec @@ -609,6 +668,9 @@ impl LanguageModel for VertexModel { if let Some(id) = text_id.take() { yield Ok(StreamPart::TextEnd { id, provider_metadata: None}); } + if let Some(id) = reasoning_id.take() { + yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None }); + } // Snapshot the finishReason-chunk metadata. if let Some(sr) = &candidate.safety_ratings { last_safety_ratings = @@ -647,6 +709,10 @@ impl LanguageModel for VertexModel { yield Ok(StreamPart::TextEnd { id, provider_metadata: None}); } + if let Some(id) = reasoning_id.take() { + yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None }); + } + let provider_metadata = Some(vertex_provider_metadata(json!({ "promptFeedback": last_prompt_feedback, "groundingMetadata": last_grounding_metadata, @@ -766,13 +832,46 @@ fn extract_content_from_candidate( })); } } else if let Some(text) = part.get("text").and_then(|v| v.as_str()) { - if !text.is_empty() { - let provider_metadata = part - .get("thoughtSignature") - .and_then(|v| v.as_str()) - .map(|signature| { - vertex_provider_metadata(json!({ "thoughtSignature": signature })) - }); + let provider_metadata = part + .get("thoughtSignature") + .and_then(Value::as_str) + .filter(|signature| !signature.is_empty()) + .map(|signature| { + vertex_provider_metadata(json!({ "thoughtSignature": signature })) + }); + if text.is_empty() { + if let ( + Some(metadata), + Some( + GenerateContent::Text { + provider_metadata, .. + } + | GenerateContent::Reasoning(ReasoningOutput { + provider_metadata, .. + }) + | GenerateContent::File(GeneratedFile { + provider_metadata, .. + }) + | GenerateContent::ReasoningFile(GeneratedFile { + provider_metadata, .. + }) + | GenerateContent::ToolCall(RawToolCall { + provider_metadata, .. + }) + | GenerateContent::ToolResult(ToolResult { + provider_metadata, .. + }), + ), + ) = (provider_metadata, content.last_mut()) + { + *provider_metadata = Some(metadata); + } + } else if part.get("thought").and_then(Value::as_bool) == Some(true) { + content.push(GenerateContent::Reasoning(ReasoningOutput { + text: text.to_string(), + provider_metadata, + })); + } else { content.push(GenerateContent::Text { text: text.to_string(), provider_metadata, @@ -806,6 +905,32 @@ fn extract_content_from_candidate( provider_metadata, })); has_tool_calls = true; + } else if let Some(inline) = part.get("inlineData") { + if let (Some(data), Some(media_type)) = ( + inline.get("data").and_then(Value::as_str), + inline.get("mimeType").and_then(Value::as_str), + ) { + let file = GeneratedFile { + data: GeneratedFileData::Data { + data: FileBytes::Base64(data.to_string()), + }, + media_type: media_type.to_string(), + provider_metadata: part + .get("thoughtSignature") + .and_then(Value::as_str) + .filter(|signature| !signature.is_empty()) + .map(|signature| { + vertex_provider_metadata(json!({ "thoughtSignature": signature })) + }), + }; + content.push( + if part.get("thought").and_then(Value::as_bool) == Some(true) { + GenerateContent::ReasoningFile(file) + } else { + GenerateContent::File(file) + }, + ); + } } else if let Some(tc) = part.get("toolCall") { let tool_type = tc.get("toolType").and_then(|v| v.as_str()).unwrap_or(""); let id = tc diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index 9a94bcc87..07fe1e7df 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -419,9 +419,10 @@ class GenerateResponse { final String? timestamp; final String? modelId; final dynamic body; - GenerateResponse({this.id, this.timestamp, this.modelId, this.body}); - 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']); - Map toJson() => {'id': id, 'timestamp': timestamp, 'model_id': modelId, if (body != null) 'body': 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}; } /// A content item in a `GenerateResult`. Mirrors `GenerateContent.ts` diff --git a/bindings/go/types.go b/bindings/go/types.go index e16799222..845c2fa40 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -211,10 +211,11 @@ type ResponseMetadata struct { ModelID *string `json:"model_id,omitempty"` } -// GenerateResponseMetadata is response metadata with an optional HTTP body. +// GenerateResponseMetadata is response metadata with optional HTTP details. type GenerateResponseMetadata struct { ResponseMetadata - Body json.RawMessage `json:"body,omitempty"` + Body json.RawMessage `json:"body,omitempty"` + Headers map[string]string `json:"headers,omitempty"` } // GenerateResult is the raw provider result. 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 0aab3285a..f2eb8f775 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -384,6 +384,67 @@ 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. * @@ -3033,7 +3094,7 @@ 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 ResponseMetadata response = new ResponseMetadata(); + @JsonProperty("response") private GenerateResponse response = new GenerateResponse(); @JsonProperty("request_body") private JsonNode requestBody; @JsonProperty("response_headers") private Map responseHeaders; @@ -3041,7 +3102,7 @@ public static class GenerateResult { GenerateResult() {} private GenerateResult(List content, FinishReason finishReason, Usage usage, - List warnings, JsonNode providerMetadata, ResponseMetadata response, + List warnings, JsonNode providerMetadata, GenerateResponse response, JsonNode requestBody, Map responseHeaders) { this.content = content; this.finishReason = finishReason; @@ -3058,7 +3119,7 @@ private GenerateResult(List content, FinishReason finishReason, public Usage getUsage() { return usage; } public List getWarnings() { return warnings; } public JsonNode getProviderMetadata() { return providerMetadata; } - public ResponseMetadata getResponse() { return response; } + public GenerateResponse getResponse() { return response; } public JsonNode getRequestBody() { return requestBody; } public Map getResponseHeaders() { return responseHeaders; } @@ -3087,7 +3148,7 @@ public static class Builder { private Usage usage = new Usage(); private List warnings = new ArrayList<>(); private JsonNode providerMetadata; - private ResponseMetadata response = new ResponseMetadata(); + private GenerateResponse response = new GenerateResponse(); private JsonNode requestBody; private Map responseHeaders; @@ -3096,7 +3157,7 @@ public static class Builder { public Builder usage(Usage v) { this.usage = v; return this; } public Builder warnings(List v) { this.warnings = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } - public Builder response(ResponseMetadata v) { this.response = 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; } @@ -4378,25 +4439,19 @@ public static class ToolApprovalRequest extends StreamPart { @JsonProperty("reason") private String reason; @JsonProperty("is_automatic") private Boolean isAutomatic; @JsonProperty("signature") private String signature; - @JsonProperty("tool_call_id") private String toolCallId; - @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonCreator ToolApprovalRequest() {} - public ToolApprovalRequest(String approvalId, Types.ToolCall toolCall, String reason, Boolean isAutomatic, String signature, String toolCallId, JsonNode providerMetadata) { + public ToolApprovalRequest(String approvalId, Types.ToolCall toolCall, String reason, Boolean isAutomatic, String signature) { this.approvalId = approvalId; this.toolCall = toolCall; this.reason = reason; this.isAutomatic = isAutomatic; this.signature = signature; - this.toolCallId = toolCallId; - this.providerMetadata = providerMetadata; } public String getApprovalId() { return approvalId; } public Types.ToolCall getToolCall() { return toolCall; } public String getReason() { return reason; } public Boolean getIsAutomatic() { return isAutomatic; } public String getSignature() { return signature; } - public String getToolCallId() { return toolCallId; } - public JsonNode getProviderMetadata() { return providerMetadata; } public static Builder builder() { return new Builder(); } public static class Builder { private String approvalId; @@ -4404,23 +4459,19 @@ public static class Builder { private String reason; private Boolean isAutomatic; private String signature; - private String toolCallId; - private JsonNode providerMetadata; public Builder approvalId(String value) { this.approvalId = value; return this; } public Builder toolCall(Types.ToolCall value) { this.toolCall = value; return this; } public Builder reason(String value) { this.reason = value; return this; } public Builder isAutomatic(Boolean value) { this.isAutomatic = value; return this; } public Builder signature(String value) { this.signature = value; return this; } - public Builder toolCallId(String value) { this.toolCallId = value; return this; } - public Builder providerMetadata(JsonNode value) { this.providerMetadata = value; return this; } - public ToolApprovalRequest build() { return new ToolApprovalRequest(approvalId, toolCall, reason, isAutomatic, signature, toolCallId, providerMetadata); } + public ToolApprovalRequest build() { return new ToolApprovalRequest(approvalId, toolCall, reason, isAutomatic, signature); } } @Override public boolean equals(Object value) { if (!(value instanceof ToolApprovalRequest)) return false; ToolApprovalRequest other = (ToolApprovalRequest) value; - return Objects.equals(approvalId, other.approvalId) && Objects.equals(toolCall, other.toolCall) && Objects.equals(reason, other.reason) && Objects.equals(isAutomatic, other.isAutomatic) && Objects.equals(signature, other.signature) && Objects.equals(toolCallId, other.toolCallId) && Objects.equals(providerMetadata, other.providerMetadata); + return Objects.equals(approvalId, other.approvalId) && Objects.equals(toolCall, other.toolCall) && Objects.equals(reason, other.reason) && Objects.equals(isAutomatic, other.isAutomatic) && Objects.equals(signature, other.signature); } - @Override public int hashCode() { return Objects.hash(approvalId, toolCall, reason, isAutomatic, signature, toolCallId, providerMetadata); } + @Override public int hashCode() { return Objects.hash(approvalId, toolCall, reason, isAutomatic, signature); } } /** Fallback for variants introduced after this wrapper was written. */ public static class Unknown extends StreamPart { 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 fe66dbc10..2ece2846d 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -151,6 +151,7 @@ data class GenerateResponse( val timestamp: String? = null, @SerialName("model_id") val modelId: String? = null, val body: JsonElement? = null, + val headers: Map? = null, ) /** @@ -905,7 +906,7 @@ sealed interface GenerateContent { @Serializable data class ReasoningFile( - val data: FileData, + val data: GeneratedFileData, @SerialName("media_type") val mediaType: String, @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) : GenerateContent diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index 4b83e9283..27167fe0b 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -1008,11 +1008,12 @@ public struct GenerateResponseMetadata: Codable, Equatable { public var timestamp: String? public var modelId: String? public var body: JSONValue? + public var headers: [String: String]? enum CodingKeys: String, CodingKey { - case id, timestamp, body, modelId = "model_id" + case id, timestamp, body, headers, modelId = "model_id" } - public init(id: String? = nil, timestamp: String? = nil, modelId: String? = nil, body: JSONValue? = nil) { - self.id = id; self.timestamp = timestamp; self.modelId = modelId; self.body = body + 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 } } From 271c994a1ff2ae9bac6e61a301ceaf8e76a1aee4 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Wed, 7 Oct 2026 20:42:52 +0800 Subject: [PATCH 6/6] fix(jev): adapt decision usage to split input and output token types --- aimux-providers/src/jev.rs | 8 ++++---- aimux-providers/tests/jev_test.rs | 10 ++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/aimux-providers/src/jev.rs b/aimux-providers/src/jev.rs index a0f4cc5a8..51429d14e 100644 --- a/aimux-providers/src/jev.rs +++ b/aimux-providers/src/jev.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use serde_json::{Value, json}; use aimux_core::decision_model::*; -use aimux_core::types::{TokenUsage, Usage}; +use aimux_core::types::{InputTokenUsage, OutputTokenUsage, Usage}; use aimux_core::{AiMuxError, ApiCallError, Provider}; use aimux_provider_utils::{HttpRequest, load_api_key}; @@ -264,15 +264,15 @@ fn convert_response( answers.insert(id, answer); } let usage = Some(Usage { - input_tokens: TokenUsage { + input_tokens: InputTokenUsage { total: data.usage.input_tokens, ..Default::default() }, - output_tokens: TokenUsage { + output_tokens: OutputTokenUsage { total: data.usage.output_tokens, ..Default::default() }, - raw: raw.get("usage").cloned(), + raw: raw.get("usage").and_then(Value::as_object).cloned(), }); Ok(DecisionResult { rounding: JEV_ROUNDING, diff --git a/aimux-providers/tests/jev_test.rs b/aimux-providers/tests/jev_test.rs index 36334d398..5f8feb1df 100644 --- a/aimux-providers/tests/jev_test.rs +++ b/aimux-providers/tests/jev_test.rs @@ -40,7 +40,7 @@ async fn optional_usage_counts_do_not_discard_valid_answers() { actual.output_tokens.total, usage["output_tokens"].as_u64().map(|n| n as u32) ); - assert_eq!(actual.raw, Some(usage)); + assert_eq!(actual.raw.map(Value::Object), Some(usage)); } let server = MockServer::start().await; let mut response = fixture(); @@ -193,7 +193,13 @@ async fn official_contract_round_trip_preserves_types_and_raw_metadata() { assert_eq!(result.usage.as_ref().unwrap().input_tokens.total, Some(392)); assert_eq!(result.usage.as_ref().unwrap().output_tokens.total, Some(65)); assert_eq!( - result.usage.as_ref().unwrap().raw, + result + .usage + .as_ref() + .unwrap() + .raw + .clone() + .map(Value::Object), Some(fixture()["usage"].clone()) ); assert_eq!(