From fca91dc3cb72e9321e7142716f8fa5ecaffdef03 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Sun, 4 Oct 2026 23:21:41 +0800 Subject: [PATCH 1/8] refactor(core): one typed shape for provider options and provider metadata The AI SDK types both as Record (namespace -> object). aimux-core carried four looser representations: ProviderMetadata was an arbitrary serde_json::Value, SharedProvider{Options,Metadata} mapped a namespace to any Value, and per-part / per-message options were Option. Now JsonObject is serde_json::Map, SharedProviderOptions and SharedProviderMetadata are HashMap, and types::ProviderMetadata aliases SharedProviderMetadata. Every provider_options field (parts, messages, call/generate options, tools) is Option and every provider_metadata field (content, stream parts, results, tool types) is Option. Valid {"ns": {...}} JSON serializes as before; shapes whose namespace value is not an object are now rejected on deserialization. shared::provider_namespace(namespace, object) builds a one-namespace map from a json! literal, replacing the several hundred json!({"ns": {...}}) sites in the vendor packages. Core and provider-utils tests are converted; generated TypeScript bindings are regenerated for aimux-core. aimux-providers, ffi and the aimux-web target do not compile until they are migrated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/content.rs | 20 ++--- aimux-core/src/generate.rs | 11 +-- aimux-core/src/language_model_message.rs | 4 +- aimux-core/src/lib.rs | 4 +- aimux-core/src/options.rs | 3 +- aimux-core/src/replay.rs | 28 +++---- aimux-core/src/response_messages.rs | 29 ++++--- aimux-core/src/result.rs | 2 +- aimux-core/src/shared.rs | 24 +++++- aimux-core/src/tool.rs | 5 +- aimux-core/src/types.rs | 4 +- aimux-core/src/video_model.rs | 19 ++--- aimux-core/tests/m11_m12_m6_test.rs | 10 ++- aimux-core/tests/m7_aggregation_test.rs | 25 ++++-- .../tests/response_messages_tool_test.rs | 80 +++++++++++-------- .../tests/streaming_tool_call_tracker_test.rs | 18 +++-- bindings/node/src/types/CallOptions.ts | 2 +- bindings/node/src/types/ContentPart.ts | 8 +- .../node/src/types/EmbeddingCallOptions.ts | 2 +- bindings/node/src/types/EmbeddingResult.ts | 2 +- bindings/node/src/types/FunctionTool.ts | 2 +- bindings/node/src/types/GenerateContent.ts | 2 +- .../node/src/types/GenerateObjectResult.ts | 2 +- bindings/node/src/types/GenerateResult.ts | 2 +- .../node/src/types/GenerateTextOptions.ts | 2 +- bindings/node/src/types/GenerateTextResult.ts | 2 +- bindings/node/src/types/GeneratedFile.ts | 2 +- bindings/node/src/types/ImageCallOptions.ts | 2 +- bindings/node/src/types/ImageResult.ts | 2 +- .../src/types/LanguageModelPromptMessage.ts | 2 +- bindings/node/src/types/RawToolCall.ts | 2 +- bindings/node/src/types/ReasoningOutput.ts | 2 +- .../node/src/types/RerankingCallOptions.ts | 2 +- bindings/node/src/types/RerankingResult.ts | 2 +- bindings/node/src/types/SearchCallOptions.ts | 2 +- bindings/node/src/types/SearchResult.ts | 2 +- bindings/node/src/types/SearchResultItem.ts | 2 +- bindings/node/src/types/Source.ts | 2 +- bindings/node/src/types/SpeechCallOptions.ts | 2 +- bindings/node/src/types/SpeechResult.ts | 2 +- bindings/node/src/types/StreamPart.ts | 10 +-- .../src/types/StreamTextResultAggregated.ts | 2 +- bindings/node/src/types/ToolCall.ts | 2 +- bindings/node/src/types/ToolResult.ts | 2 +- .../src/types/TranscriptionCallOptions.ts | 2 +- .../node/src/types/TranscriptionResult.ts | 2 +- .../node/src/types/TranscriptionStreamPart.ts | 2 +- .../node/src/types/UploadFileCallOptions.ts | 2 +- bindings/node/src/types/UploadFileResult.ts | 2 +- bindings/node/src/types/VideoCallOptions.ts | 2 +- bindings/node/src/types/VideoResult.ts | 2 +- 51 files changed, 211 insertions(+), 159 deletions(-) diff --git a/aimux-core/src/content.rs b/aimux-core/src/content.rs index e6dfe464a..e10366c0f 100644 --- a/aimux-core/src/content.rs +++ b/aimux-core/src/content.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use ts_rs::TS; +use crate::shared::SharedProviderOptions; + /// A part of a multi-part message. /// /// Shared between `ModelMessage` (user-facing) and `LanguageModelPrompt` (provider-facing). @@ -16,7 +18,7 @@ pub enum ContentPart { text: String, /// Provider-specific options for this part (e.g. `openai.promptCacheBreakpoint`). #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// An image (raw bytes + MIME type). @@ -24,7 +26,7 @@ pub enum ContentPart { image: Vec, media_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A file (raw bytes + MIME type). @@ -34,7 +36,7 @@ pub enum ContentPart { #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A file whose inline data is an already-base64-encoded string. @@ -49,7 +51,7 @@ pub enum ContentPart { #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A file referenced by URL. @@ -59,7 +61,7 @@ pub enum ContentPart { url: String, media_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A file referenced by a provider-specific reference (e.g. an OpenAI file ID). @@ -73,7 +75,7 @@ pub enum ContentPart { #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A reasoning / thinking segment produced by the model. @@ -90,7 +92,7 @@ pub enum ContentPart { /// `anthropic.signature`, `anthropic.redactedData`, /// `anthropic.cacheControl`). #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A tool call requested by the model. @@ -112,7 +114,7 @@ pub enum ContentPart { /// Provider-specific options for this part (e.g. /// `anthropic.cacheControl`). #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// The result of executing a tool call. @@ -143,7 +145,7 @@ pub enum ContentPart { /// Provider-specific options for this part (e.g. /// `anthropic.cacheControl`). #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, } diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index dd083c501..bcc4a2ecf 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -26,9 +26,10 @@ use crate::result::{ GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, StreamResult, StreamTextResultAggregated, }; +use crate::shared::SharedProviderOptions; use crate::stream_part::{StreamPart, TextStreamPart}; use crate::tool::Tool; -use crate::types::{FinishReason, ReasoningEffort, Usage, Warning}; +use crate::types::{FinishReason, ProviderMetadata, ReasoningEffort, Usage, Warning}; use crate::{AbortSignal, retry, timeout}; /// Matches the AI SDK's `isOutputChunk`: only chunks containing model output @@ -67,7 +68,7 @@ pub struct GenerateTextOptions { pub tools: Option>, pub tool_choice: Option, pub headers: Option>, - pub provider_options: Option>, + pub provider_options: Option, /// Top-level reasoning effort. pub reasoning: Option, /// System instructions prepended to the prompt. @@ -179,7 +180,7 @@ pub struct GenerateTextResult { /// Provider-specific metadata (e.g. Anthropic cache info). Mirrored from /// `raw.provider_metadata` for top-level convenience. #[serde(default)] - pub provider_metadata: Option, + pub provider_metadata: Option, /// Response metadata (id, timestamp, model_id). Mirrored from /// `raw.response` for top-level convenience. #[serde(default)] @@ -211,7 +212,7 @@ pub struct GenerateObjectResult { pub reasoning: Option, /// Provider-specific metadata (e.g. Anthropic cache info). #[serde(default)] - pub provider_metadata: Option, + pub provider_metadata: Option, /// Response metadata (id, timestamp, model_id). #[serde(default)] pub response: crate::types::ResponseMetadata, @@ -321,7 +322,7 @@ impl StreamTextResult { }; let mut raw_finish_reason: Option = None; let mut usage = Usage::default(); - let mut finish_provider_metadata: Option = None; + let mut finish_provider_metadata: Option = None; let mut response: Option = None; let mut rm = crate::response_messages::ResponseMessageBuilder::new(); diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index 3f9500c6e..23c8321d1 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -6,8 +6,8 @@ use crate::content::ContentPart; use crate::message::Role; +use crate::shared::SharedProviderOptions; use serde::{Deserialize, Serialize}; -use serde_json::Value; use ts_rs::TS; /// A single provider-facing message. @@ -23,7 +23,7 @@ pub struct LanguageModelPromptMessage { /// `anthropic.cacheControl`), applied to the last part of the message when /// converting to a provider prompt. Mirrors the Vercel AI SDK /// `LanguageModelV4Message.providerOptions`. - pub provider_options: Option, + pub provider_options: Option, } /// The standardized prompt passed to `LanguageModel::do_generate` / `do_stream`. diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index c976579e2..888cf3f43 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -107,8 +107,8 @@ pub mod prelude { init_session_store, list_sessions, session_calls, }; pub use crate::shared::{ - AspectRatio, FileBytes, FileData, SharedHeaders, SharedProviderMetadata, - SharedProviderOptions, SharedProviderReference, Size, + AspectRatio, FileBytes, FileData, JsonObject, SharedHeaders, SharedProviderMetadata, + SharedProviderOptions, SharedProviderReference, Size, provider_namespace, }; pub use crate::speech_model::{SpeechCallOptions, SpeechModel, SpeechResult, generate_speech}; pub use crate::stream_part::{StreamPart, TextStreamPart}; diff --git a/aimux-core/src/options.rs b/aimux-core/src/options.rs index 3d2891396..20b04ddd7 100644 --- a/aimux-core/src/options.rs +++ b/aimux-core/src/options.rs @@ -10,6 +10,7 @@ use ts_rs::TS; use crate::AbortSignal; use crate::language_model_message::LanguageModelPrompt; +use crate::shared::SharedProviderOptions; pub use crate::tool::{FunctionTool, ProviderTool, Tool, ToolChoice}; use crate::types::ReasoningEffort; @@ -108,7 +109,7 @@ pub struct CallOptions { pub headers: Option>, /// Provider-specific options (keyed by provider name). - pub provider_options: Option>, + pub provider_options: Option, /// Top-level reasoning effort. Maps to OpenAI `reasoning_effort` and /// Anthropic `thinking` config. diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index fe1116842..210bc4f5d 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -1005,6 +1005,7 @@ mod tests { use crate::recording::{ HttpExchange, HttpRecord, InputRecord, ProviderRecord, ResponseRecord, TimingRecord, }; + use crate::shared::provider_namespace; use futures::StreamExt; fn sample_options(text: &str, temperature: Option) -> CallOptions { @@ -1197,31 +1198,28 @@ mod tests { // A8:provider_options 纳入规范键——非脱敏值不同 → miss。 let mut rec = openai_recording("t1", "ping", "pong", "stop"); let mut call = sample_options("ping", Some(0.7)); - call.provider_options = Some( - [("openai".into(), serde_json::json!({ "foo": 1 }))] - .into_iter() - .collect(), - ); + call.provider_options = Some(provider_namespace( + "openai", + serde_json::json!({ "foo": 1 }), + )); rec.input.options = serde_json::to_value(&call).unwrap(); let recs = [rec]; let matcher = ExactMatcher::new("openai", "gpt-4o"); // foo 值不同 → miss。 let mut req = sample_options("ping", Some(0.7)); - req.provider_options = Some( - [("openai".into(), serde_json::json!({ "foo": 2 }))] - .into_iter() - .collect(), - ); + req.provider_options = Some(provider_namespace( + "openai", + serde_json::json!({ "foo": 2 }), + )); assert!(matcher.r#match(&req, &recs).is_err()); // 完全一致 → hit(对照)。 let mut req2 = sample_options("ping", Some(0.7)); - req2.provider_options = Some( - [("openai".into(), serde_json::json!({ "foo": 1 }))] - .into_iter() - .collect(), - ); + req2.provider_options = Some(provider_namespace( + "openai", + serde_json::json!({ "foo": 1 }), + )); assert!(matcher.r#match(&req2, &recs).is_ok()); } diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index cc54128c6..bbf7fdded 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -12,6 +12,7 @@ use serde_json::Value; use crate::content::ContentPart; use crate::message::{MessageContent, ModelMessage, Role}; use crate::result::ReasoningOutput; +use crate::shared::{SharedProviderMetadata, SharedProviderOptions}; /// 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. @@ -28,10 +29,12 @@ pub(crate) fn response_tool_call_input(input: &Value, invalid: Option) -> /// Reasoning signature echoed back on the next turn (Anthropic: /// `provider_metadata.anthropic.signature`; Bedrock: `.bedrock.signature` / /// `.amazonBedrock.signature`). -pub(crate) fn extract_reasoning_signature(provider_metadata: Option<&Value>) -> Option { +pub(crate) fn extract_reasoning_signature( + provider_metadata: Option<&SharedProviderMetadata>, +) -> Option { let metadata = provider_metadata?; ["anthropic", "bedrock", "amazonBedrock"] - .iter() + .into_iter() .find_map(|ns| metadata.get(ns)?.get("signature")?.as_str()) .map(str::to_owned) } @@ -45,9 +48,9 @@ pub(crate) fn extract_reasoning_signature(provider_metadata: Option<&Value>) -> pub(crate) struct ResponseMessageBuilder { parts: Vec, text_buf: String, - text_provider_options: Option, + text_provider_options: Option, reasoning_buf: String, - reasoning_provider_options: Option, + reasoning_provider_options: Option, reasoning: Vec, } @@ -65,7 +68,7 @@ impl ResponseMessageBuilder { // ── Streaming events ──────────────────────────────────────────────── - pub fn text_start(&mut self, provider_metadata: Option) { + 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. @@ -73,7 +76,7 @@ impl ResponseMessageBuilder { self.text_provider_options = provider_metadata; } - pub fn text_delta(&mut self, delta: &str, provider_metadata: Option) { + 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() { @@ -81,20 +84,24 @@ impl ResponseMessageBuilder { } } - pub fn text_end(&mut self, provider_metadata: Option) { + 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 reasoning_start(&mut self, provider_metadata: Option) { + pub fn reasoning_start(&mut self, provider_metadata: Option) { self.flush_text(); self.flush_reasoning(); self.reasoning_provider_options = provider_metadata; } - pub fn reasoning_delta(&mut self, delta: &str, provider_metadata: Option) { + 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() { @@ -102,7 +109,7 @@ impl ResponseMessageBuilder { } } - pub fn reasoning_end(&mut self, provider_metadata: Option) { + pub fn reasoning_end(&mut self, provider_metadata: Option) { self.flush_text(); if provider_metadata.is_some() { self.reasoning_provider_options = provider_metadata; @@ -112,7 +119,7 @@ impl ResponseMessageBuilder { // ── Whole segments (non-streaming) ────────────────────────────────── - pub fn text(&mut self, text: &str, provider_metadata: Option<&Value>) { + pub fn text(&mut self, text: &str, provider_metadata: Option<&SharedProviderMetadata>) { if !text.is_empty() { self.parts.push(ContentPart::Text { text: text.to_owned(), diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index f6a933ed1..5102bf14b 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -117,7 +117,7 @@ pub struct StreamTextResultAggregated { pub warnings: Vec, /// Provider-specific metadata from the Finish chunk. #[serde(default)] - pub provider_metadata: Option, + pub provider_metadata: Option, /// Response metadata (id, timestamp, model_id) if emitted by the stream. #[serde(default)] pub response: Option, diff --git a/aimux-core/src/shared.rs b/aimux-core/src/shared.rs index 705f3c945..480cfbda7 100644 --- a/aimux-core/src/shared.rs +++ b/aimux-core/src/shared.rs @@ -33,12 +33,32 @@ pub type SharedHeaders = HashMap; /// ```json /// { "anthropic": { "cacheControl": { "type": "ephemeral" } } } /// ``` -pub type SharedProviderOptions = HashMap; +pub type SharedProviderOptions = HashMap; /// Additional provider-specific metadata (outputs), keyed by provider name. /// /// Aligned with V4 `SharedV4ProviderMetadata` (`Record`). -pub type SharedProviderMetadata = HashMap; +pub type SharedProviderMetadata = HashMap; + +/// A JSON object (`JSONObject` in the AI SDK). +pub type JsonObject = serde_json::Map; + +/// Build a one-namespace [`SharedProviderMetadata`] / [`SharedProviderOptions`] +/// (they are the same type) from a JSON object literal. +/// +/// `provider_namespace("openai", json!({ "itemId": "x" }))` is +/// `{ "openai": { "itemId": "x" } }`. +/// +/// A value that is not a JSON object yields an empty namespace. Several +/// namespaces: `extend` one map with another. +#[must_use] +pub fn provider_namespace(namespace: &str, object: Value) -> SharedProviderMetadata { + let object = match object { + Value::Object(object) => object, + _ => JsonObject::new(), + }; + HashMap::from([(namespace.to_string(), object)]) +} /// A mapping of provider names to provider-specific file identifiers. /// diff --git a/aimux-core/src/tool.rs b/aimux-core/src/tool.rs index 1cc17c06c..675dba340 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -1,12 +1,11 @@ //! Tool / function-calling types. -use std::collections::HashMap; - use serde::{Deserialize, Serialize}; use serde_json::Value; use ts_rs::TS; use crate::error::AiMuxError; +use crate::shared::SharedProviderOptions; use crate::types::ProviderMetadata; /// A tool definition passed to the model in `CallOptions.tools`. @@ -27,7 +26,7 @@ pub struct FunctionTool { /// (e.g. `{"anthropic": {"eagerInputStreaming": true}}`). Aligned with the /// V4 `providerOptions` field on function tools. #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_options: Option>, + pub provider_options: Option, /// Example inputs for the tool (V4 `inputExamples`), used by some providers /// to emit `input_examples` in the request body. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/aimux-core/src/types.rs b/aimux-core/src/types.rs index 5ed3ce700..8fd578a9e 100644 --- a/aimux-core/src/types.rs +++ b/aimux-core/src/types.rs @@ -158,5 +158,5 @@ pub struct ResponseMetadata { pub model_id: Option, } -/// Provider-specific metadata (opaque to core). -pub type ProviderMetadata = Value; +/// Provider-specific metadata: namespace -> JSON object. +pub type ProviderMetadata = crate::shared::SharedProviderMetadata; diff --git a/aimux-core/src/video_model.rs b/aimux-core/src/video_model.rs index 8833c4eed..11f095115 100644 --- a/aimux-core/src/video_model.rs +++ b/aimux-core/src/video_model.rs @@ -517,22 +517,14 @@ async fn start_and_poll_one_batch( /// the other phase set once both phases report the *same* provider key — e.g. /// a start call's `job_id` disappearing once the completion call also reports /// metadata under `x_provider`. Objects under a shared key are unioned -/// instead, with `b` (the later phase) winning a field collision. Anything -/// that is not a pair of objects has nothing to union, so `b` replaces `a`. +/// instead, with `b` (the later phase) winning a field collision. fn merge_provider_metadata( a: Option, b: Option, ) -> Option { let Some(mut merged) = a else { return b }; - for (provider, b_value) in b.into_iter().flatten() { - let value = match (merged.remove(&provider), b_value) { - (Some(serde_json::Value::Object(mut a_obj)), serde_json::Value::Object(b_obj)) => { - a_obj.extend(b_obj); - serde_json::Value::Object(a_obj) - } - (_, b_value) => b_value, - }; - merged.insert(provider, value); + for (provider, b_obj) in b.into_iter().flatten() { + merged.entry(provider).or_default().extend(b_obj); } Some(merged) } @@ -544,6 +536,7 @@ mod tests { use super::*; use crate::error::ApiCallError; + use crate::shared::provider_namespace; /// `do_status` behavior per call index, cycled through in order. enum StatusStep { @@ -792,7 +785,7 @@ mod tests { #[test] fn merge_provider_metadata_unions_same_provider_key_across_phases() { - let phase = |value| Some(SharedProviderMetadata::from([("fal".to_string(), value)])); + let phase = |value| Some(provider_namespace("fal", value)); let merged = merge_provider_metadata( phase(serde_json::json!({ "job_id": "job-1", "region": "us-east" })), phase(serde_json::json!({ "region": "eu-west", "seed": 42 })), @@ -802,7 +795,7 @@ mod tests { // `job_id` is start-only and must survive instead of being dropped by // an `entry().or_insert()` collision on the shared provider key. assert_eq!( - merged["fal"], + serde_json::Value::Object(merged["fal"].clone()), serde_json::json!({ "job_id": "job-1", "region": "eu-west", "seed": 42 }) ); } diff --git a/aimux-core/tests/m11_m12_m6_test.rs b/aimux-core/tests/m11_m12_m6_test.rs index e705aec64..149eacd8c 100644 --- a/aimux-core/tests/m11_m12_m6_test.rs +++ b/aimux-core/tests/m11_m12_m6_test.rs @@ -6,6 +6,7 @@ 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::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; @@ -276,12 +277,13 @@ impl LanguageModel for MetadataOnlyReasoningModel { unimplemented!() } async fn do_stream(&self, _options: &CallOptions) -> Result { - let meta = serde_json::json!({ - "openai": { + let meta = provider_namespace( + "openai", + serde_json::json!({ "itemId": "rs_1", "reasoningEncryptedContent": "enc-blob-123", - } - }); + }), + ); let parts: Vec> = vec![ Ok(StreamPart::StreamStart { warnings: vec![] }), Ok(StreamPart::ReasoningStart { diff --git a/aimux-core/tests/m7_aggregation_test.rs b/aimux-core/tests/m7_aggregation_test.rs index cdd2dead0..aa80bcd5b 100644 --- a/aimux-core/tests/m7_aggregation_test.rs +++ b/aimux-core/tests/m7_aggregation_test.rs @@ -11,7 +11,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, FileData, provider_namespace}; use aimux_core::tool::RawToolCall; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; @@ -33,9 +33,10 @@ impl LanguageModel for RichModel { content: vec![ GenerateContent::Reasoning(ReasoningOutput { text: "Let me think...".into(), - provider_metadata: Some(serde_json::json!({ - "anthropic": { "signature": "sig-abc123" } - })), + provider_metadata: Some(provider_namespace( + "anthropic", + serde_json::json!({ "signature": "sig-abc123" }), + )), }), GenerateContent::Text { text: "Hello!".into(), @@ -285,10 +286,18 @@ impl LanguageModel for BedrockModel { 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" } - })), + provider_metadata: Some( + ["amazonBedrock", "bedrock"] + .map(|ns| { + provider_namespace( + ns, + serde_json::json!({ "signature": "bedrock-sig-xyz" }), + ) + }) + .into_iter() + .flatten() + .collect(), + ), }), ], finish_reason: FinishReason { diff --git a/aimux-core/tests/response_messages_tool_test.rs b/aimux-core/tests/response_messages_tool_test.rs index 0ab5134a5..13938a7fc 100644 --- a/aimux-core/tests/response_messages_tool_test.rs +++ b/aimux-core/tests/response_messages_tool_test.rs @@ -4,6 +4,7 @@ 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::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{RawToolCall, ToolResult}; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; @@ -43,9 +44,10 @@ impl LanguageModel for ProviderTranscriptModel { provider_executed: Some(true), dynamic: Some(true), thought_signature: None, - provider_metadata: Some(json!({ - "mock": { "serverCallId": "wire-1" } - })), + provider_metadata: Some(provider_namespace( + "mock", + json!({ "serverCallId": "wire-1" }), + )), }), GenerateContent::ToolResult(ToolResult { tool_call_id: "srv-1".to_string(), @@ -54,9 +56,10 @@ impl LanguageModel for ProviderTranscriptModel { is_error: Some(false), preliminary: Some(true), dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-preliminary-1" } - })), + provider_metadata: Some(provider_namespace( + "mock", + json!({ "serverResultId": "wire-preliminary-1" }), + )), }), GenerateContent::ToolResult(ToolResult { tool_call_id: "srv-1".to_string(), @@ -65,9 +68,10 @@ impl LanguageModel for ProviderTranscriptModel { is_error: Some(false), preliminary: Some(false), dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-result-1" } - })), + provider_metadata: Some(provider_namespace( + "mock", + json!({ "serverResultId": "wire-result-1" }), + )), }), GenerateContent::Text { text: "after".to_string(), @@ -99,9 +103,10 @@ impl LanguageModel for ProviderTranscriptModel { provider_executed: Some(true), dynamic: Some(true), thought_signature: None, - provider_metadata: Some(json!({ - "mock": { "serverCallId": "wire-1" } - })), + provider_metadata: Some(provider_namespace( + "mock", + json!({ "serverCallId": "wire-1" }), + )), })), Ok(StreamPart::ToolResult(ToolResult { tool_call_id: "srv-1".to_string(), @@ -110,9 +115,10 @@ impl LanguageModel for ProviderTranscriptModel { is_error: Some(false), preliminary: Some(true), dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-preliminary-1" } - })), + provider_metadata: Some(provider_namespace( + "mock", + json!({ "serverResultId": "wire-preliminary-1" }), + )), })), Ok(StreamPart::ToolResult(ToolResult { tool_call_id: "srv-1".to_string(), @@ -121,9 +127,10 @@ impl LanguageModel for ProviderTranscriptModel { is_error: Some(false), preliminary: Some(false), dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-result-1" } - })), + provider_metadata: Some(provider_namespace( + "mock", + json!({ "serverResultId": "wire-result-1" }), + )), })), Ok(StreamPart::TextDelta { id: "text-2".to_string(), @@ -340,15 +347,17 @@ impl LanguageModel for ContentMetadataModel { content: vec![ GenerateContent::Text { text: "answer".to_string(), - provider_metadata: Some(json!({ - "google": { "thoughtSignature": "text-generate" } - })), + provider_metadata: Some(provider_namespace( + "google", + json!({ "thoughtSignature": "text-generate" }), + )), }, GenerateContent::Reasoning(ReasoningOutput { text: "think".to_string(), - provider_metadata: Some(json!({ - "anthropic": { "signature": "reason-generate" } - })), + provider_metadata: Some(provider_namespace( + "anthropic", + json!({ "signature": "reason-generate" }), + )), }), ], finish_reason: finish_reason(), @@ -365,14 +374,15 @@ impl LanguageModel for ContentMetadataModel { let parts = vec![ Ok(StreamPart::TextStart { id: "text-1".to_string(), - provider_metadata: Some(json!({ "mock": { "phase": "start" } })), + provider_metadata: Some(provider_namespace("mock", json!({ "phase": "start" }))), }), Ok(StreamPart::TextDelta { id: "text-1".to_string(), delta: "answer".to_string(), - provider_metadata: Some(json!({ - "google": { "thoughtSignature": "text-delta" } - })), + provider_metadata: Some(provider_namespace( + "google", + json!({ "thoughtSignature": "text-delta" }), + )), }), Ok(StreamPart::TextEnd { id: "text-1".to_string(), @@ -380,9 +390,10 @@ impl LanguageModel for ContentMetadataModel { }), Ok(StreamPart::ReasoningStart { id: "reason-1".to_string(), - provider_metadata: Some(json!({ - "anthropic": { "signature": "reason-start" } - })), + provider_metadata: Some(provider_namespace( + "anthropic", + json!({ "signature": "reason-start" }), + )), }), Ok(StreamPart::ReasoningDelta { id: "reason-1".to_string(), @@ -400,9 +411,10 @@ impl LanguageModel for ContentMetadataModel { Ok(StreamPart::ReasoningDelta { id: "reason-2".to_string(), delta: "again".to_string(), - provider_metadata: Some(json!({ - "anthropic": { "signature": "reason-delta" } - })), + provider_metadata: Some(provider_namespace( + "anthropic", + json!({ "signature": "reason-delta" }), + )), }), Ok(StreamPart::ReasoningEnd { id: "reason-2".to_string(), diff --git a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs index 636fc2da0..a788aeff9 100644 --- a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs +++ b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs @@ -7,8 +7,10 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::RawToolCall; +use aimux_core::types::ProviderMetadata; use aimux_provider_utils::{ StreamingToolCallDelta, StreamingToolCallFunction, StreamingToolCallTracker, TrackerError, TypeValidation, @@ -39,7 +41,7 @@ enum Part { tool_name: String, /// The raw argument text. input: String, - provider_metadata: Option, + provider_metadata: Option, }, } @@ -1062,7 +1064,7 @@ mod metadata { .with_build_provider_metadata(|metadata| { metadata .and_then(|m| m.get("thoughtSignature")) - .map(|sig| json!({ "google": { "thoughtSignature": sig } })) + .map(|sig| provider_namespace("google", json!({ "thoughtSignature": sig }))) }) } @@ -1084,7 +1086,10 @@ mod metadata { tool_call_id: "call_1".into(), tool_name: "fn".into(), input: "{}".into(), - provider_metadata: Some(json!({ "google": { "thoughtSignature": "sig123" } })), + provider_metadata: Some(provider_namespace( + "google", + json!({ "thoughtSignature": "sig123" }) + )), }) ); } @@ -1095,7 +1100,7 @@ mod metadata { StreamingToolCallTracker::new() .with_extract_metadata(|_| Some(json!({ "custom": { "key": "value" } }))) .with_build_provider_metadata(|metadata| { - metadata.map(|m| json!({ "provider": m })) + metadata.map(|m| provider_namespace("provider", m.clone())) }), ); @@ -1110,7 +1115,10 @@ mod metadata { tool_call_id: "call_1".into(), tool_name: "fn".into(), input: "{\"incomplete".into(), - provider_metadata: Some(json!({ "provider": { "custom": { "key": "value" } } })), + provider_metadata: Some(provider_namespace( + "provider", + json!({ "custom": { "key": "value" } }) + )), }) ); } diff --git a/bindings/node/src/types/CallOptions.ts b/bindings/node/src/types/CallOptions.ts index 53c072370..fdc56c0e7 100644 --- a/bindings/node/src/types/CallOptions.ts +++ b/bindings/node/src/types/CallOptions.ts @@ -70,7 +70,7 @@ headers: { [key in string]: string } | null, /** * Provider-specific options (keyed by provider name). */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Top-level reasoning effort. Maps to OpenAI `reasoning_effort` and * Anthropic `thinking` config. diff --git a/bindings/node/src/types/ContentPart.ts b/bindings/node/src/types/ContentPart.ts index a7538a154..2e73a2eee 100644 --- a/bindings/node/src/types/ContentPart.ts +++ b/bindings/node/src/types/ContentPart.ts @@ -10,13 +10,13 @@ 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?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "image", image: Array, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning", text: string, signature: string | null, /** * Provider-specific options for this part (e.g. * `anthropic.signature`, `anthropic.redactedData`, * `anthropic.cacheControl`). */ -provider_options?: JsonValue | null, } | { "type": "tool_call", tool_call_id: string, tool_name: string, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_call", tool_call_id: string, tool_name: string, /** * Arguments as a JSON value (usually an object). */ @@ -37,7 +37,7 @@ thought_signature?: string | null, * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). */ -provider_options?: JsonValue | null, } | { "type": "tool_result", tool_call_id: string, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_result", tool_call_id: string, /** * The tool's output (usually a JSON value or plain text). * @@ -69,4 +69,4 @@ dynamic?: boolean | null, * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). */ -provider_options?: JsonValue | null, }; +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/EmbeddingCallOptions.ts b/bindings/node/src/types/EmbeddingCallOptions.ts index 996b05919..0d16b0ea1 100644 --- a/bindings/node/src/types/EmbeddingCallOptions.ts +++ b/bindings/node/src/types/EmbeddingCallOptions.ts @@ -23,7 +23,7 @@ timeout: TimeoutConfiguration | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Additional HTTP headers to send with the request. */ diff --git a/bindings/node/src/types/EmbeddingResult.ts b/bindings/node/src/types/EmbeddingResult.ts index 81b741d23..fe4fc025a 100644 --- a/bindings/node/src/types/EmbeddingResult.ts +++ b/bindings/node/src/types/EmbeddingResult.ts @@ -21,7 +21,7 @@ usage: EmbeddingUsage | null, /** * Additional provider-specific metadata. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Optional response information for debugging. */ diff --git a/bindings/node/src/types/FunctionTool.ts b/bindings/node/src/types/FunctionTool.ts index a79887e0c..323c59879 100644 --- a/bindings/node/src/types/FunctionTool.ts +++ b/bindings/node/src/types/FunctionTool.ts @@ -26,7 +26,7 @@ strict: boolean | null, * (e.g. `{"anthropic": {"eagerInputStreaming": true}}`). Aligned with the * V4 `providerOptions` field on function tools. */ -provider_options?: { [key in string]: JsonValue } | null, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, /** * Example inputs for the tool (V4 `inputExamples`), used by some providers * to emit `input_examples` in the request body. diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index 78bc710ea..c04bc21d5 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -9,4 +9,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?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": RawToolCall } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ToolResult": ToolResult }; diff --git a/bindings/node/src/types/GenerateObjectResult.ts b/bindings/node/src/types/GenerateObjectResult.ts index 8c46e3e1a..89e5eb445 100644 --- a/bindings/node/src/types/GenerateObjectResult.ts +++ b/bindings/node/src/types/GenerateObjectResult.ts @@ -38,7 +38,7 @@ reasoning: string | null, /** * Provider-specific metadata (e.g. Anthropic cache info). */ -provider_metadata: JsonValue | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response metadata (id, timestamp, model_id). */ diff --git a/bindings/node/src/types/GenerateResult.ts b/bindings/node/src/types/GenerateResult.ts index 4667bfc23..10c3c1f3e 100644 --- a/bindings/node/src/types/GenerateResult.ts +++ b/bindings/node/src/types/GenerateResult.ts @@ -29,7 +29,7 @@ warnings: Array, /** * Provider-specific metadata. */ -provider_metadata: JsonValue | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response metadata (id, timestamp, model_id). */ diff --git a/bindings/node/src/types/GenerateTextOptions.ts b/bindings/node/src/types/GenerateTextOptions.ts index 00b49da24..68aec90e8 100644 --- a/bindings/node/src/types/GenerateTextOptions.ts +++ b/bindings/node/src/types/GenerateTextOptions.ts @@ -12,7 +12,7 @@ import type { JsonValue } from "./serde_json/JsonValue"; * Unlike `CallOptions` (provider-facing), this does not include `prompt` * (passed separately) and defaults are more ergonomic. */ -export type GenerateTextOptions = { max_output_tokens: number | null, temperature: number | null, stop_sequences: Array | null, top_p: number | null, top_k: number | null, presence_penalty: number | null, frequency_penalty: number | null, response_format: ResponseFormat | null, seed: number | null, tools: Array | null, tool_choice: ToolChoice | null, headers: { [key in string]: string } | null, provider_options: { [key in string]: JsonValue } | null, +export type GenerateTextOptions = { max_output_tokens: number | null, temperature: number | null, stop_sequences: Array | null, top_p: number | null, top_k: number | null, presence_penalty: number | null, frequency_penalty: number | null, response_format: ResponseFormat | null, seed: number | null, tools: Array | null, tool_choice: ToolChoice | null, headers: { [key in string]: string } | null, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Top-level reasoning effort. */ diff --git a/bindings/node/src/types/GenerateTextResult.ts b/bindings/node/src/types/GenerateTextResult.ts index c7be4bfd3..ad4a7cf25 100644 --- a/bindings/node/src/types/GenerateTextResult.ts +++ b/bindings/node/src/types/GenerateTextResult.ts @@ -69,7 +69,7 @@ raw_finish_reason: string | null, * Provider-specific metadata (e.g. Anthropic cache info). Mirrored from * `raw.provider_metadata` for top-level convenience. */ -provider_metadata: JsonValue | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response metadata (id, timestamp, model_id). Mirrored from * `raw.response` for top-level convenience. diff --git a/bindings/node/src/types/GeneratedFile.ts b/bindings/node/src/types/GeneratedFile.ts index bd81f2a5e..fe699939d 100644 --- a/bindings/node/src/types/GeneratedFile.ts +++ b/bindings/node/src/types/GeneratedFile.ts @@ -8,4 +8,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * 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, }; +export type GeneratedFile = { data: FileData, media_type: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ImageCallOptions.ts b/bindings/node/src/types/ImageCallOptions.ts index 93cd38902..1b490a4cb 100644 --- a/bindings/node/src/types/ImageCallOptions.ts +++ b/bindings/node/src/types/ImageCallOptions.ts @@ -45,7 +45,7 @@ mask: ImageFile | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue }, +provider_options: { [key in string]: { [key in string]: JsonValue } }, /** * Per-call retry override. `None` uses the model default. */ diff --git a/bindings/node/src/types/ImageResult.ts b/bindings/node/src/types/ImageResult.ts index fbf5cbdec..4276c580f 100644 --- a/bindings/node/src/types/ImageResult.ts +++ b/bindings/node/src/types/ImageResult.ts @@ -22,7 +22,7 @@ warnings: Array, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response information for telemetry and debugging. */ diff --git a/bindings/node/src/types/LanguageModelPromptMessage.ts b/bindings/node/src/types/LanguageModelPromptMessage.ts index b2e3efa50..459e4fd5b 100644 --- a/bindings/node/src/types/LanguageModelPromptMessage.ts +++ b/bindings/node/src/types/LanguageModelPromptMessage.ts @@ -16,4 +16,4 @@ export type LanguageModelPromptMessage = { role: Role, content: Array, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Warnings for the call, e.g. unsupported settings. */ diff --git a/bindings/node/src/types/SearchCallOptions.ts b/bindings/node/src/types/SearchCallOptions.ts index 0a7152b32..1658b5e28 100644 --- a/bindings/node/src/types/SearchCallOptions.ts +++ b/bindings/node/src/types/SearchCallOptions.ts @@ -45,7 +45,7 @@ timeout: TimeoutConfiguration | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Additional HTTP headers to send with the request. */ diff --git a/bindings/node/src/types/SearchResult.ts b/bindings/node/src/types/SearchResult.ts index d1e49ac60..ee3f6a49b 100644 --- a/bindings/node/src/types/SearchResult.ts +++ b/bindings/node/src/types/SearchResult.ts @@ -20,7 +20,7 @@ answer: string | null, /** * Additional provider-specific metadata. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Warnings for the call. */ diff --git a/bindings/node/src/types/SearchResultItem.ts b/bindings/node/src/types/SearchResultItem.ts index 5d5c81218..0c8b8d67a 100644 --- a/bindings/node/src/types/SearchResultItem.ts +++ b/bindings/node/src/types/SearchResultItem.ts @@ -29,4 +29,4 @@ score: number | null, /** * Provider-specific metadata for this result. */ -provider_metadata: { [key in string]: JsonValue } | null, }; +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/Source.ts b/bindings/node/src/types/Source.ts index 261797b42..43967923d 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 = { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/SpeechCallOptions.ts b/bindings/node/src/types/SpeechCallOptions.ts index 0872b66bf..d9424d439 100644 --- a/bindings/node/src/types/SpeechCallOptions.ts +++ b/bindings/node/src/types/SpeechCallOptions.ts @@ -38,7 +38,7 @@ language: string | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Per-call retry override. `None` uses the model default. */ diff --git a/bindings/node/src/types/SpeechResult.ts b/bindings/node/src/types/SpeechResult.ts index ab9b5304f..4dd161ffe 100644 --- a/bindings/node/src/types/SpeechResult.ts +++ b/bindings/node/src/types/SpeechResult.ts @@ -30,4 +30,4 @@ response: SpeechResponse, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, }; +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index 1d6722570..abf4b87df 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -17,7 +17,7 @@ import type { JsonValue } from "./serde_json/JsonValue"; * [`StreamPart`] (raw argument text); `stream_text` emits [`TextStreamPart`] * (parsed and validated [`ToolCall`]). */ -export type StreamPart = { "TextStart": { id: string, provider_metadata?: JsonValue | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "TextEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: JsonValue | null, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, +export type StreamPart = { "TextStart": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, /** * Whether the tool call will be executed by the provider. */ @@ -29,16 +29,16 @@ 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?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningStart": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: JsonValue | null, } } | { "ReasoningDelta": { id: string, delta: string, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningDelta": { id: string, delta: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: JsonValue | null, } } | { "ReasoningEnd": { id: string, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningEnd": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). */ -provider_metadata: JsonValue | null, } } | { "ResponseMetadata": ResponseMetadata } | { "Source": Source } | { "Raw": { raw_value: JsonValue, } }; +provider_metadata: { [key in string]: { [key in string]: 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 7bf5f51bf..1c86aa07f 100644 --- a/bindings/node/src/types/StreamTextResultAggregated.ts +++ b/bindings/node/src/types/StreamTextResultAggregated.ts @@ -64,7 +64,7 @@ warnings: Array, /** * Provider-specific metadata from the Finish chunk. */ -provider_metadata: JsonValue | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response metadata (id, timestamp, model_id) if emitted by the stream. */ diff --git a/bindings/node/src/types/ToolCall.ts b/bindings/node/src/types/ToolCall.ts index c6b72530d..4c8f33e3a 100644 --- a/bindings/node/src/types/ToolCall.ts +++ b/bindings/node/src/types/ToolCall.ts @@ -37,7 +37,7 @@ thought_signature?: string | null, /** * Additional provider-specific metadata associated with this call. */ -provider_metadata?: JsonValue | null, +provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, /** * Set when lookup, JSON parsing, or schema validation still failed after * the optional repair attempt. diff --git a/bindings/node/src/types/ToolResult.ts b/bindings/node/src/types/ToolResult.ts index 9c5039b52..544c2e653 100644 --- a/bindings/node/src/types/ToolResult.ts +++ b/bindings/node/src/types/ToolResult.ts @@ -29,4 +29,4 @@ dynamic?: boolean | null, /** * Additional provider-specific metadata for the tool result. */ -provider_metadata?: JsonValue | null, }; +provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/TranscriptionCallOptions.ts b/bindings/node/src/types/TranscriptionCallOptions.ts index d43f937a1..e5e7debdc 100644 --- a/bindings/node/src/types/TranscriptionCallOptions.ts +++ b/bindings/node/src/types/TranscriptionCallOptions.ts @@ -20,7 +20,7 @@ media_type: string, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, /** * Per-call retry override. `None` uses the model default. */ diff --git a/bindings/node/src/types/TranscriptionResult.ts b/bindings/node/src/types/TranscriptionResult.ts index ee229f7c2..2c5356fc1 100644 --- a/bindings/node/src/types/TranscriptionResult.ts +++ b/bindings/node/src/types/TranscriptionResult.ts @@ -42,4 +42,4 @@ response: TranscriptionResponse, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, }; +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/TranscriptionStreamPart.ts b/bindings/node/src/types/TranscriptionStreamPart.ts index 45228feec..ecfa20e24 100644 --- a/bindings/node/src/types/TranscriptionStreamPart.ts +++ b/bindings/node/src/types/TranscriptionStreamPart.ts @@ -8,4 +8,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * * Aligned with V4 `TranscriptionModelV4StreamPart`. */ -export type TranscriptionStreamPart = { "StreamStart": { warnings: Array, } } | { "TranscriptDelta": { id: string | null, delta: string, provider_metadata: { [key in string]: JsonValue } | null, } } | { "TranscriptPartial": { id: string | null, text: string, start_second: number | null, duration_in_seconds: number | null, channel_index: number | null, provider_metadata: { [key in string]: JsonValue } | null, } } | { "TranscriptFinal": { id: string | null, text: string, start_second: number | null, end_second: number | null, channel_index: number | null, provider_metadata: { [key in string]: JsonValue } | null, } } | { "ResponseMetadata": { timestamp: string | null, model_id: string | null, headers: { [key in string]: string } | null, body: JsonValue | null, } } | { "Finish": { text: string, segments: Array, language: string | null, duration_in_seconds: number | null, provider_metadata: { [key in string]: JsonValue } | null, } } | { "Raw": { raw_value: JsonValue, } } | { "Error": { error: JsonValue, } }; +export type TranscriptionStreamPart = { "StreamStart": { warnings: Array, } } | { "TranscriptDelta": { id: string | null, delta: string, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TranscriptPartial": { id: string | null, text: string, start_second: number | null, duration_in_seconds: number | null, channel_index: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TranscriptFinal": { id: string | null, text: string, start_second: number | null, end_second: number | null, channel_index: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ResponseMetadata": { timestamp: string | null, model_id: string | null, headers: { [key in string]: string } | null, body: JsonValue | null, } } | { "Finish": { text: string, segments: Array, language: string | null, duration_in_seconds: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Raw": { raw_value: JsonValue, } } | { "Error": { error: JsonValue, } }; diff --git a/bindings/node/src/types/UploadFileCallOptions.ts b/bindings/node/src/types/UploadFileCallOptions.ts index 027e634aa..b39d69c19 100644 --- a/bindings/node/src/types/UploadFileCallOptions.ts +++ b/bindings/node/src/types/UploadFileCallOptions.ts @@ -23,4 +23,4 @@ filename: string | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue } | null, }; +provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/UploadFileResult.ts b/bindings/node/src/types/UploadFileResult.ts index dc9c7412e..981c04b59 100644 --- a/bindings/node/src/types/UploadFileResult.ts +++ b/bindings/node/src/types/UploadFileResult.ts @@ -25,7 +25,7 @@ filename: string | null, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Warnings from the provider. */ diff --git a/bindings/node/src/types/VideoCallOptions.ts b/bindings/node/src/types/VideoCallOptions.ts index 22bf461a2..9f3791316 100644 --- a/bindings/node/src/types/VideoCallOptions.ts +++ b/bindings/node/src/types/VideoCallOptions.ts @@ -61,7 +61,7 @@ generate_audio: boolean | null, /** * Additional provider-specific options, keyed by provider name. */ -provider_options: { [key in string]: JsonValue }, +provider_options: { [key in string]: { [key in string]: JsonValue } }, /** * Per-call retry override. `None` uses the model default. */ diff --git a/bindings/node/src/types/VideoResult.ts b/bindings/node/src/types/VideoResult.ts index 8a8a96bd1..8ab4fec8f 100644 --- a/bindings/node/src/types/VideoResult.ts +++ b/bindings/node/src/types/VideoResult.ts @@ -21,7 +21,7 @@ warnings: Array, /** * Additional provider-specific metadata, keyed by provider name. */ -provider_metadata: { [key in string]: JsonValue } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** * Response information for telemetry and debugging. */ From 4579dd78622e8e0b8a713fb4ce79aeaac9b14bbb Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Sun, 4 Oct 2026 23:27:02 +0800 Subject: [PATCH 2/8] refactor(providers): vendor packages build and read typed provider options and metadata Follows the core change: provider options and provider metadata are `HashMap` (namespace -> JSON object), the AI SDK's `Record`. Every vendor package now builds its metadata with `provider_namespace(ns, json!({..}))` or by inserting an object under its namespace, and reads options with `options.get(ns)`, which yields the object directly. The emitted JSON is unchanged: same namespaces, same keys. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- .../src/anthropic/cache_control.rs | 16 ++-- aimux-providers/src/anthropic/convert.rs | 76 +++++++++++------ aimux-providers/src/anthropic/files.rs | 6 +- aimux-providers/src/anthropic/stream.rs | 80 +++++++++-------- aimux-providers/src/assemblyai.rs | 5 +- aimux-providers/src/aws_polly.rs | 3 +- aimux-providers/src/bedrock/convert.rs | 9 +- aimux-providers/src/bedrock/model.rs | 41 ++++----- aimux-providers/src/bedrock/reranking.rs | 3 +- aimux-providers/src/black_forest_labs.rs | 4 +- aimux-providers/src/cartesia.rs | 3 +- aimux-providers/src/cohere/convert.rs | 3 +- aimux-providers/src/cohere/model.rs | 5 +- aimux-providers/src/cohere/reranking.rs | 3 +- aimux-providers/src/deepgram.rs | 4 +- aimux-providers/src/elevenlabs.rs | 3 +- aimux-providers/src/fal.rs | 4 +- aimux-providers/src/gladia.rs | 15 ++-- aimux-providers/src/google/convert.rs | 39 +++++---- aimux-providers/src/google/files.rs | 8 +- aimux-providers/src/google/image.rs | 14 ++- aimux-providers/src/google/model.rs | 85 +++++++++---------- aimux-providers/src/google_pse.rs | 3 +- aimux-providers/src/huggingface/responses.rs | 34 ++++---- aimux-providers/src/hume.rs | 3 +- aimux-providers/src/jina_ai.rs | 3 +- aimux-providers/src/linkup.rs | 3 +- aimux-providers/src/lmnt.rs | 3 +- aimux-providers/src/luma.rs | 2 +- aimux-providers/src/mistral/model.rs | 3 +- aimux-providers/src/openai/convert.rs | 32 +++---- aimux-providers/src/openai/files.rs | 10 +-- aimux-providers/src/openai/image.rs | 8 +- aimux-providers/src/openai/model.rs | 5 +- .../src/openai/responses/convert.rs | 33 +++---- .../src/openai/responses/responses_convert.rs | 49 ++++++----- aimux-providers/src/openai/transcription.rs | 4 +- aimux-providers/src/prodia.rs | 2 +- aimux-providers/src/recraft.rs | 4 +- aimux-providers/src/replicate.rs | 2 +- aimux-providers/src/stability.rs | 2 +- aimux-providers/src/vertex/anthropic_model.rs | 23 ++--- aimux-providers/src/vertex/image.rs | 16 ++-- aimux-providers/src/vertex/model.rs | 16 ++-- aimux-providers/src/voyage/reranking.rs | 3 +- aimux-providers/src/xai/convert.rs | 9 +- aimux-providers/src/xai/responses/convert.rs | 8 +- aimux-providers/src/xai/responses/mod.rs | 24 +++--- 48 files changed, 380 insertions(+), 353 deletions(-) diff --git a/aimux-providers/src/anthropic/cache_control.rs b/aimux-providers/src/anthropic/cache_control.rs index 9216eda9e..66e1d0ffb 100644 --- a/aimux-providers/src/anthropic/cache_control.rs +++ b/aimux-providers/src/anthropic/cache_control.rs @@ -7,6 +7,7 @@ //! set on a non-cacheable context such as a thinking block) are dropped with a //! `Warning::Unsupported`. +use aimux_core::shared::JsonObject; use aimux_core::types::Warning; use serde_json::Value; @@ -15,13 +16,12 @@ const MAX_CACHE_BREAKPOINTS: u32 = 4; /// Extract the `cache_control` value from provider metadata, mirroring the TS /// `getCacheControl` helper. Accepts both the camelCase `cacheControl` and the -/// snake_case `cache_control` keys under `providerOptions.anthropic`. +/// snake_case `cache_control` keys in the `providerOptions.anthropic` namespace. /// /// The value is passed through unchanged (the Anthropic API validates it). #[must_use] -pub fn extract_cache_control(provider_options: Option<&Value>) -> Option { - let opts = provider_options?; - let anthropic = opts.get("anthropic")?; +pub fn extract_cache_control(anthropic: Option<&JsonObject>) -> Option { + let anthropic = anthropic?; anthropic .get("cacheControl") .or_else(|| anthropic.get("cache_control")) @@ -46,8 +46,8 @@ impl CacheControlValidator { /// Resolve the `cache_control` value (if any) for a content block, applying /// the validation rules. /// - /// - `provider_options` is the part- or message-level provider metadata to - /// read `anthropic.cacheControl` from. + /// - `anthropic` is the part- or message-level Anthropic namespace to + /// read `cacheControl` from. /// - `context_type` is a human-readable label for the block kind (e.g. /// `"user message part"`, `"thinking block"`) used in warning messages. /// - `can_cache` is `false` for contexts that cannot carry cache_control @@ -58,11 +58,11 @@ impl CacheControlValidator { /// or `None` when there is none or it was rejected. pub fn get_cache_control( &mut self, - provider_options: Option<&Value>, + anthropic: Option<&JsonObject>, context_type: &str, can_cache: bool, ) -> Option { - let cache_control_value = extract_cache_control(provider_options); + let cache_control_value = extract_cache_control(anthropic); let cc = cache_control_value?; diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index c6e98ca52..e32efa386 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -18,13 +18,14 @@ //! conversion with `send_reasoning = false`, discarding the betas and //! warnings. -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashSet}; use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, Tool}; +use aimux_core::shared::SharedProviderOptions; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde_json::{Map, Value, json}; @@ -149,7 +150,7 @@ pub fn convert_prompt_to_anthropic_full_with_tools( // for the last text part (system content is a string in // the TS model, so message-level is the primary path). let cache_control = match validator.get_cache_control( - provider_options.as_ref(), + provider_options.as_ref().and_then(|o| o.get("anthropic")), "system message part", true, ) { @@ -157,7 +158,9 @@ pub fn convert_prompt_to_anthropic_full_with_tools( None => { if is_last { validator.get_cache_control( - msg.provider_options.as_ref(), + msg.provider_options + .as_ref() + .and_then(|o| o.get("anthropic")), "system message", true, ) @@ -357,20 +360,23 @@ fn convert_part_to_anthropic( warnings: &mut Vec, validator: &mut CacheControlValidator, is_last_part: bool, - message_provider_options: Option<&Value>, + message_provider_options: Option<&SharedProviderOptions>, part_context_type: &str, message_context_type: &str, ) -> Result, AiMuxError> { // Resolve cache_control = part-level ?? (is_last_part ? message-level). - let resolve_cc = - |validator: &mut CacheControlValidator, part_opts: Option<&Value>| match validator - .get_cache_control(part_opts, part_context_type, true) - { + let resolve_cc = |validator: &mut CacheControlValidator, + part_opts: Option<&SharedProviderOptions>| { + match validator.get_cache_control( + part_opts.and_then(|o| o.get("anthropic")), + part_context_type, + true, + ) { Some(v) => Some(v), None => { if is_last_part { validator.get_cache_control( - message_provider_options, + message_provider_options.and_then(|o| o.get("anthropic")), message_context_type, true, ) @@ -378,7 +384,8 @@ fn convert_part_to_anthropic( None } } - }; + } + }; let apply_cc = |block: Value, cc: Option| -> Value { match cc { @@ -645,19 +652,21 @@ fn convert_part_to_anthropic( } // cache_control: part ?? output ?? (is_last_part ? message). let cc = match validator.get_cache_control( - provider_options.as_ref(), + provider_options.as_ref().and_then(|o| o.get("anthropic")), part_context_type, true, ) { Some(v) => Some(v), None => match extract_tool_result_output_provider_options(result) { - Some(out_opts) => { - validator.get_cache_control(Some(out_opts), "tool result output", true) - } + Some(out_opts) => validator.get_cache_control( + out_opts.get("anthropic").and_then(Value::as_object), + "tool result output", + true, + ), None => { if is_last_part { validator.get_cache_control( - message_provider_options, + message_provider_options.and_then(|o| o.get("anthropic")), message_context_type, true, ) @@ -742,7 +751,7 @@ fn convert_assistant_tool_result( warnings: &mut Vec, validator: &mut CacheControlValidator, is_last_part: bool, - message_provider_options: Option<&Value>, + message_provider_options: Option<&SharedProviderOptions>, ) -> Option { let ContentPart::ToolResult { tool_call_id, @@ -759,18 +768,24 @@ fn convert_assistant_tool_result( // cache_control: part ?? output ?? (is_last_part ? message) — the same // resolution order the bare `tool_result` path uses. let cache_control = match validator.get_cache_control( - provider_options.as_ref(), + provider_options.as_ref().and_then(|o| o.get("anthropic")), "assistant message part", true, ) { Some(v) => Some(v), None => match extract_tool_result_output_provider_options(result) { - Some(out_opts) => { - validator.get_cache_control(Some(out_opts), "tool result output", true) - } + Some(out_opts) => validator.get_cache_control( + out_opts.get("anthropic").and_then(Value::as_object), + "tool result output", + true, + ), None => { if is_last_part { - validator.get_cache_control(message_provider_options, "assistant message", true) + validator.get_cache_control( + message_provider_options.and_then(|o| o.get("anthropic")), + "assistant message", + true, + ) } else { None } @@ -1225,7 +1240,7 @@ fn resolve_full_media_type(media_type: &str, bytes: &[u8]) -> Result, - provider_options: Option<&Value>, + provider_options: Option<&SharedProviderOptions>, send_reasoning: bool, warnings: &mut Vec, validator: &mut CacheControlValidator, @@ -1257,7 +1272,11 @@ fn convert_reasoning_part( // Thinking blocks cannot carry cache_control directly — they are cached // implicitly when in previous assistant turns. Validate to emit a // helpful warning if a value was set. - validator.get_cache_control(provider_options, "thinking block", false); + validator.get_cache_control( + provider_options.and_then(|o| o.get("anthropic")), + "thinking block", + false, + ); Some(json!({ "type": "thinking", "thinking": text, @@ -1265,7 +1284,11 @@ fn convert_reasoning_part( })) } else if let Some(data) = redacted_data { // Redacted thinking blocks likewise cannot carry cache_control. - validator.get_cache_control(provider_options, "redacted thinking block", false); + validator.get_cache_control( + provider_options.and_then(|o| o.get("anthropic")), + "redacted thinking block", + false, + ); Some(json!({ "type": "redacted_thinking", "data": data, @@ -1567,7 +1590,7 @@ pub struct RequestBodyResult { } /// Read a value from `provider_options["anthropic"][key]`. -fn anthropic_option(options: &Option>, key: &str) -> Option { +fn anthropic_option(options: &Option, key: &str) -> Option { options .as_ref() .and_then(|m| m.get("anthropic")) @@ -2226,8 +2249,7 @@ mod tests { use super::*; fn opts_with_anthropic_thinking(thinking: serde_json::Value) -> CallOptions { - let mut provider = std::collections::HashMap::new(); - provider.insert("anthropic".to_string(), thinking); + let provider = aimux_core::shared::provider_namespace("anthropic", thinking); CallOptions { provider_options: Some(provider), ..CallOptions::new(LanguageModelPrompt::default()) diff --git a/aimux-providers/src/anthropic/files.rs b/aimux-providers/src/anthropic/files.rs index 2f08101b1..4bc7b46cc 100644 --- a/aimux-providers/src/anthropic/files.rs +++ b/aimux-providers/src/anthropic/files.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use async_trait::async_trait; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::json; use aimux_core::error::AiMuxError; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; @@ -184,9 +184,7 @@ impl Files for AnthropicFiles { provider_reference: provider_ref, media_type: Some(result_media_type), filename: result_filename, - provider_metadata: Some( - std::iter::once(("anthropic".to_string(), Value::Object(metadata))).collect(), - ), + provider_metadata: Some(HashMap::from([("anthropic".to_string(), metadata)])), warnings: Vec::new(), }) } diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index e637015af..8216be07b 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -25,9 +25,10 @@ use futures::StreamExt; use aimux_core::AbortSignal; use aimux_core::error::AiMuxError; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; -use aimux_core::types::Warning; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, TokenUsage, Usage}; +use aimux_core::types::{ProviderMetadata, Warning}; use aimux_provider_utils::{HttpBody, HttpRequest}; use serde_json::{Value, json}; @@ -347,7 +348,9 @@ pub(crate) fn initial_tool_input(input: &Value) -> String { } } -pub(crate) fn tool_call_caller_metadata(caller: Option<&ToolCallCaller>) -> Option { +pub(crate) fn tool_call_caller_metadata( + caller: Option<&ToolCallCaller>, +) -> Option { let caller = match caller? { ToolCallCaller::CodeExecution20250825 { tool_id } => json!({ "type": "code_execution_20250825", @@ -359,7 +362,7 @@ pub(crate) fn tool_call_caller_metadata(caller: Option<&ToolCallCaller>) -> Opti }), ToolCallCaller::Direct => json!({ "type": "direct" }), }; - Some(json!({ "anthropic": { "caller": caller } })) + Some(provider_namespace("anthropic", json!({ "caller": caller }))) } fn is_tool_search_provider_name(name: &str) -> bool { @@ -448,11 +451,12 @@ pub(crate) fn stream_parts_for_result_block( source_type: "url".to_string(), url: str_field(result, "url"), title: str_field(result, "title"), - provider_metadata: Some(json!({ - "anthropic": { + provider_metadata: Some(provider_namespace( + "anthropic", + json!({ "pageAge": result.get("page_age").cloned().unwrap_or(Value::Null), - } - })), + }), + )), }) })); parts @@ -520,9 +524,10 @@ pub(crate) fn stream_parts_for_result_block( preliminary: None, dynamic: Some(true), provider_metadata: call.map(|(_, server)| { - json!({ - "anthropic": { "type": "mcp-tool-use", "serverName": server } - }) + provider_namespace( + "anthropic", + json!({ "type": "mcp-tool-use", "serverName": server }), + ) }), })] } @@ -595,9 +600,10 @@ pub(crate) fn parse_anthropic_content( } => { content.push(GenerateContent::Reasoning(ReasoningOutput { text: thinking.clone(), - provider_metadata: Some(json!({ - "anthropic": { "signature": signature } - })), + provider_metadata: Some(provider_namespace( + "anthropic", + json!({ "signature": signature }), + )), })); } // Provider-executed (server-side) tool calls are surfaced as tool @@ -630,18 +636,20 @@ pub(crate) fn parse_anthropic_content( provider_executed: Some(true), dynamic: Some(true), thought_signature: None, - provider_metadata: Some(json!({ - "anthropic": { "type": "mcp-tool-use", "serverName": server_name } - })), + provider_metadata: Some(provider_namespace( + "anthropic", + json!({ "type": "mcp-tool-use", "serverName": server_name }), + )), })); } // Redacted thinking — upstream emits as reasoning with redactedData ContentBlock::RedactedThinking { data } => { content.push(GenerateContent::Reasoning(ReasoningOutput { text: String::new(), - provider_metadata: Some(json!({ - "anthropic": { "redactedData": data } - })), + provider_metadata: Some(provider_namespace( + "anthropic", + json!({ "redactedData": data }), + )), })); } // ── Server-tool result blocks → GenerateContent::ToolResult ── @@ -676,12 +684,13 @@ pub(crate) fn parse_anthropic_content( source_type: "url".to_string(), url: str_field(result, "url"), title: str_field(result, "title"), - provider_metadata: Some(json!({ - "anthropic": { + provider_metadata: Some(provider_namespace( + "anthropic", + json!({ "pageAge": result.get("page_age").cloned() .unwrap_or(Value::Null), - } - })), + }), + )), })); } } @@ -802,9 +811,10 @@ pub(crate) fn parse_anthropic_content( preliminary: None, dynamic: Some(true), provider_metadata: call.map(|(_, server)| { - json!({ - "anthropic": { "type": "mcp-tool-use", "serverName": server } - }) + provider_namespace( + "anthropic", + json!({ "type": "mcp-tool-use", "serverName": server }), + ) }), })); } @@ -900,7 +910,7 @@ enum BlockState { dynamic: Option, provider_tool_name: Option, provider_tool_input_type: Option, - provider_metadata: Option, + provider_metadata: Option, first_delta: bool, }, Thinking { @@ -1099,12 +1109,10 @@ pub(crate) async fn anthropic_stream_core( provider_executed: Some(true), dynamic: Some(true), thought_signature: None, - provider_metadata: Some(json!({ - "anthropic": { - "type": "mcp-tool-use", - "serverName": server_name, - } - })), + provider_metadata: Some(provider_namespace("anthropic", json!({ + "type": "mcp-tool-use", + "serverName": server_name, + }))), })); } // Redacted thinking — emit as ReasoningStart. @@ -1112,9 +1120,7 @@ pub(crate) async fn anthropic_stream_core( let id = index.to_string(); yield Ok(StreamPart::ReasoningStart { id: id.clone(), - provider_metadata: Some(json!({ - "anthropic": { "redactedData": data } - })), + provider_metadata: Some(provider_namespace("anthropic", json!({ "redactedData": data }))), }); blocks.insert( index, @@ -1279,7 +1285,7 @@ pub(crate) async fn anthropic_stream_core( yield Ok(StreamPart::ReasoningEnd { id: index.to_string(), provider_metadata: signature.map(|s| { - json!({ "anthropic": { "signature": s } }) + provider_namespace("anthropic", json!({ "signature": s })) }), }); } diff --git a/aimux-providers/src/assemblyai.rs b/aimux-providers/src/assemblyai.rs index 3bae05556..8c0683aa0 100644 --- a/aimux-providers/src/assemblyai.rs +++ b/aimux-providers/src/assemblyai.rs @@ -281,8 +281,7 @@ impl TranscriptionModel for AssemblyAITranscriptionModel { // Parse and forward provider options. if let Some(ref po) = options.provider_options - && let Some(aai) = po.get("assemblyai") - && let Some(obj) = aai.as_object() + && let Some(obj) = po.get("assemblyai") { for (k, v) in obj { body.insert(k.clone(), v.clone()); @@ -412,7 +411,7 @@ impl TranscriptionModel for AssemblyAITranscriptionModel { ); } if !aai_meta.is_empty() { - md.insert("assemblyai".to_string(), Value::Object(aai_meta)); + md.insert("assemblyai".to_string(), aai_meta); provider_metadata = Some(md); } diff --git a/aimux-providers/src/aws_polly.rs b/aimux-providers/src/aws_polly.rs index 5216192fd..63f9d4c67 100644 --- a/aimux-providers/src/aws_polly.rs +++ b/aimux-providers/src/aws_polly.rs @@ -446,8 +446,7 @@ struct PollySpeechProviderOptions { fn parse_polly_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("aws_polly"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("aws_polly"))?; Some(PollySpeechProviderOptions { engine: opts diff --git a/aimux-providers/src/bedrock/convert.rs b/aimux-providers/src/bedrock/convert.rs index 9b7d8dc5c..f01122eea 100644 --- a/aimux-providers/src/bedrock/convert.rs +++ b/aimux-providers/src/bedrock/convert.rs @@ -18,6 +18,7 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ToolChoice}; +use aimux_core::shared::SharedProviderOptions; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified}; use base64::Engine; @@ -243,7 +244,7 @@ fn push_file_block( b64: &str, media_type: &str, filename: Option<&str>, - provider_options: &Option, + provider_options: &Option, content: &mut Vec, doc_counter: &mut u32, ) { @@ -291,7 +292,7 @@ fn part_cache_point(part: &ContentPart) -> Option { } => provider_options.as_ref()?, _ => return None, }; - for key in &["bedrock", "amazonBedrock"] { + for key in ["bedrock", "amazonBedrock"] { if let Some(cp) = po.get(key).and_then(|v| v.get("cachePoint")) { return Some(json!({ "cachePoint": cp.clone() })); } @@ -301,11 +302,11 @@ fn part_cache_point(part: &ContentPart) -> Option { /// Whether `citations.enabled` is set on a part's `bedrock`/`amazonBedrock` /// provider options. -fn citations_enabled(provider_options: &Option) -> bool { +fn citations_enabled(provider_options: &Option) -> bool { let Some(po) = provider_options.as_ref() else { return false; }; - for key in &["bedrock", "amazonBedrock"] { + for key in ["bedrock", "amazonBedrock"] { if let Some(enabled) = po .get(key) .and_then(|b| b.get("citations")) diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index be387cc87..f9c0d7870 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -16,8 +16,11 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; +use aimux_core::types::{ + FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, +}; use serde_json::json; @@ -593,11 +596,10 @@ impl LanguageModel for BedrockModel { let provider_metadata = if finish_meta.is_empty() { None } else { - let payload = serde_json::Value::Object(finish_meta); - Some(json!({ - "amazonBedrock": payload, - "bedrock": payload, - })) + Some(HashMap::from([ + ("amazonBedrock".to_string(), finish_meta.clone()), + ("bedrock".to_string(), finish_meta), + ])) }; yield Ok(StreamPart::Finish { @@ -621,12 +623,11 @@ impl LanguageModel for BedrockModel { /// Wrap the accumulated reasoning signature as provider_metadata in the same /// dual-key shape the non-streaming path emits (`amazonBedrock` + `bedrock`), /// so consumers reading either key see it. -fn reasoning_signature_meta(sig: Option) -> Option { +fn reasoning_signature_meta(sig: Option) -> Option { sig.map(|s| { - json!({ - "amazonBedrock": { "signature": &s }, - "bedrock": { "signature": s } - }) + let mut metadata = provider_namespace("amazonBedrock", json!({ "signature": &s })); + metadata.extend(provider_namespace("bedrock", json!({ "signature": s }))); + metadata }) } @@ -666,10 +667,9 @@ fn extract_content(block: &BedrockContentBlock, content: &mut Vec>, + provider_options: Option<&SharedProviderOptions>, ) -> BedrockRerankingOptions { let mut opts = BedrockRerankingOptions::default(); if let Some(po) = provider_options { diff --git a/aimux-providers/src/black_forest_labs.rs b/aimux-providers/src/black_forest_labs.rs index ebfa09af5..1482fc9e8 100644 --- a/aimux-providers/src/black_forest_labs.rs +++ b/aimux-providers/src/black_forest_labs.rs @@ -286,7 +286,7 @@ impl ImageModel for BlackForestLabsImageModel { } // Forward BFL provider options - if let Some(bfl) = bfl_opts.and_then(|v| v.as_object()) { + if let Some(bfl) = bfl_opts { let map: &[(&str, &str)] = &[ ("steps", "steps"), ("guidance", "guidance"), @@ -520,7 +520,7 @@ impl ImageModel for BlackForestLabsImageModel { img_meta.insert("outputMegapixels".into(), o.clone()); } bfl_meta.insert("images".into(), json!([Value::Object(img_meta)])); - metadata.insert("blackForestLabs".into(), Value::Object(bfl_meta)); + metadata.insert("blackForestLabs".into(), bfl_meta); Ok(ImageResult { images: ImageOutputs::Binary(vec![image_bytes]), diff --git a/aimux-providers/src/cartesia.rs b/aimux-providers/src/cartesia.rs index cc0bb87eb..a6123a18e 100644 --- a/aimux-providers/src/cartesia.rs +++ b/aimux-providers/src/cartesia.rs @@ -576,8 +576,7 @@ struct CartesiaSpeechProviderOptions { fn parse_cartesia_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("cartesia"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("cartesia"))?; Some(CartesiaSpeechProviderOptions { container: opts diff --git a/aimux-providers/src/cohere/convert.rs b/aimux-providers/src/cohere/convert.rs index 57b04c86a..cf546feb4 100644 --- a/aimux-providers/src/cohere/convert.rs +++ b/aimux-providers/src/cohere/convert.rs @@ -7,6 +7,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::shared::SharedProviderOptions; use aimux_core::tool::Tool; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde_json::{Value, json}; @@ -416,7 +417,7 @@ fn reasoning_budget_percentage(reasoning: ReasoningEffort) -> Option { #[must_use] pub fn resolve_cohere_thinking( reasoning: Option, - provider_options: &Option>, + provider_options: &Option, ) -> Option { // Provider options take precedence. if let Some(po) = provider_options diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index 1f3cad8c3..adf22b789 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -15,6 +15,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -212,7 +213,7 @@ impl LanguageModel for CohereModel { source_type: "document".to_string(), url: None, title: Some(title), - provider_metadata: Some(json!({ "cohere": cohere_meta })), + provider_metadata: Some(HashMap::from([("cohere".to_string(), cohere_meta)])), })); } } @@ -568,7 +569,7 @@ impl LanguageModel for CohereModel { } else { final_usage }, - provider_metadata: Some(serde_json::json!({ "cohere": {} })), + provider_metadata: Some(provider_namespace("cohere", json!({}))), }); }; diff --git a/aimux-providers/src/cohere/reranking.rs b/aimux-providers/src/cohere/reranking.rs index 9a2c91814..e8b58a9a4 100644 --- a/aimux-providers/src/cohere/reranking.rs +++ b/aimux-providers/src/cohere/reranking.rs @@ -14,6 +14,7 @@ use aimux_core::reranking_model::{ RerankingCallOptions, RerankingDocuments, RerankingModel, RerankingRank, RerankingResponse, RerankingResult, }; +use aimux_core::shared::SharedProviderOptions; use aimux_core::types::Warning; use aimux_provider_utils::HttpRequest; @@ -28,7 +29,7 @@ struct CohereRerankingOptions { } fn parse_cohere_reranking_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> CohereRerankingOptions { let mut opts = CohereRerankingOptions::default(); if let Some(po) = provider_options diff --git a/aimux-providers/src/deepgram.rs b/aimux-providers/src/deepgram.rs index 2647b7359..1fcfa51c9 100644 --- a/aimux-providers/src/deepgram.rs +++ b/aimux-providers/src/deepgram.rs @@ -17,7 +17,7 @@ use serde::Deserialize; use serde_json::Value; use aimux_core::error::AiMuxError; -use aimux_core::shared::Warning; +use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_core::transcription_model::{ AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest, TranscriptionResponse, TranscriptionResult, TranscriptionSegment, @@ -136,7 +136,7 @@ struct DeepgramOptions { diarize: Option, } -fn parse_deepgram_options(provider_options: Option<&HashMap>) -> DeepgramOptions { +fn parse_deepgram_options(provider_options: Option<&SharedProviderOptions>) -> DeepgramOptions { let mut opts = DeepgramOptions::default(); if let Some(po) = provider_options && let Some(dg) = po.get("deepgram") diff --git a/aimux-providers/src/elevenlabs.rs b/aimux-providers/src/elevenlabs.rs index 8ab7f5e59..5664f933a 100644 --- a/aimux-providers/src/elevenlabs.rs +++ b/aimux-providers/src/elevenlabs.rs @@ -457,8 +457,7 @@ struct ElevenLabsPronunciationLocator { fn parse_elevenlabs_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("elevenlabs"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("elevenlabs"))?; let voice_settings = opts .get("voiceSettings") diff --git a/aimux-providers/src/fal.rs b/aimux-providers/src/fal.rs index 32460f8fd..4bd3daa9c 100644 --- a/aimux-providers/src/fal.rs +++ b/aimux-providers/src/fal.rs @@ -540,7 +540,7 @@ impl ImageModel for FalImageModel { body.insert("mask_url".into(), json!(Self::file_to_data_uri(mask)?)); } - if let Some(fal) = fal_opts.and_then(|v| v.as_object()) { + if let Some(fal) = fal_opts { let map: &[(&str, &str)] = &[ ("imageUrl", "image_url"), ("maskUrl", "mask_url"), @@ -678,7 +678,7 @@ impl ImageModel for FalImageModel { } } } - metadata.insert("fal".into(), Value::Object(fm)); + metadata.insert("fal".into(), fm); Ok(ImageResult { images: ImageOutputs::Binary(downloaded), diff --git a/aimux-providers/src/gladia.rs b/aimux-providers/src/gladia.rs index fbaa7af68..2bf7429b7 100644 --- a/aimux-providers/src/gladia.rs +++ b/aimux-providers/src/gladia.rs @@ -16,7 +16,7 @@ use serde_json::{Map, Value, json}; use aimux_core::error::{AiMuxError, ApiCallError}; use aimux_core::retry; -use aimux_core::shared::{SharedProviderMetadata, Warning}; +use aimux_core::shared::{SharedProviderMetadata, Warning, provider_namespace}; use aimux_core::transcription_model::{ AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest, TranscriptionResponse, TranscriptionResult, TranscriptionSegment, @@ -265,10 +265,8 @@ impl TranscriptionModel for GladiaTranscriptionModel { && let Some(gladia) = po.get("gladia") { // Forward all provider options as-is (the API uses snake_case). - if let Some(obj) = gladia.as_object() { - for (k, v) in obj { - body.insert(k.clone(), v.clone()); - } + for (k, v) in gladia { + body.insert(k.clone(), v.clone()); } } @@ -359,11 +357,8 @@ impl TranscriptionModel for GladiaTranscriptionModel { let timestamp = chrono::Utc::now().to_rfc3339(); - let provider_metadata: Option = { - let mut md = HashMap::new(); - md.insert("gladia".to_string(), raw_body.clone()); - Some(md) - }; + let provider_metadata: Option = + Some(provider_namespace("gladia", raw_body.clone())); return Ok(TranscriptionResult { text: result.transcription.full_transcript, segments, diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index c87514155..0bdb54b03 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -23,6 +23,7 @@ use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; use aimux_core::result::{GenerateContent, Source}; +use aimux_core::shared::{JsonObject, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, Warning}; use base64::Engine; @@ -69,9 +70,9 @@ enum ProviderMetadataNamespace { /// cross-provider fallback. The public Google provider uses the inverse /// fallback so transcripts survive gateway/provider failover. fn read_provider_options( - provider_options: Option<&Value>, + provider_options: Option<&SharedProviderOptions>, namespace: ProviderMetadataNamespace, -) -> Option<&Value> { +) -> Option<&JsonObject> { let provider_options = provider_options?; match namespace { ProviderMetadataNamespace::Google => provider_options @@ -1295,6 +1296,7 @@ pub fn extract_sources( mod tests { use super::*; use aimux_core::language_model_message::LanguageModelPromptMessage; + use aimux_core::shared::provider_namespace; fn assistant_prompt(content: Vec) -> LanguageModelPrompt { vec![LanguageModelPromptMessage { @@ -1304,13 +1306,14 @@ mod tests { }] } - fn code_metadata(namespace: &str, id: &str) -> Value { - json!({ - (namespace): { + fn code_metadata(namespace: &str, id: &str) -> SharedProviderOptions { + provider_namespace( + namespace, + json!({ "serverToolCallId": id, "serverToolType": "code_execution", - } - }) + }), + ) } #[test] @@ -1365,11 +1368,14 @@ mod tests { input: json!({}), provider_executed: None, thought_signature: None, - provider_options: Some(json!({ - "googleVertex": { "thoughtSignature": "google-vertex" }, - "vertex": { "thoughtSignature": "vertex" }, - "google": { "thoughtSignature": "google" }, - })), + provider_options: Some( + serde_json::from_value(json!({ + "googleVertex": { "thoughtSignature": "google-vertex" }, + "vertex": { "thoughtSignature": "vertex" }, + "google": { "thoughtSignature": "google" }, + })) + .unwrap(), + ), }; let prompt = assistant_prompt(vec![content]); @@ -1393,9 +1399,12 @@ mod tests { input: json!({}), provider_executed: None, thought_signature: None, - provider_options: Some(json!({ - "google": { "thoughtSignature": "gateway-signature" }, - })), + provider_options: Some(provider_namespace( + "google", + json!({ + "thoughtSignature": "gateway-signature", + }), + )), }]); let converted = diff --git a/aimux-providers/src/google/files.rs b/aimux-providers/src/google/files.rs index d0256391a..ecab1f955 100644 --- a/aimux-providers/src/google/files.rs +++ b/aimux-providers/src/google/files.rs @@ -22,7 +22,7 @@ use serde_json::{Value, json}; use aimux_core::error::{AiMuxError, ApiCallError}; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; -use aimux_core::shared::FileBytes; +use aimux_core::shared::{FileBytes, SharedProviderOptions}; use aimux_core::types::Warning; use aimux_provider_utils::{HttpBody, HttpRequest, sleep_or_abort}; @@ -40,7 +40,7 @@ struct GoogleFilesUploadOptions { /// Parse provider options for the Google files provider. fn parse_google_files_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> GoogleFilesUploadOptions { let mut opts = GoogleFilesUploadOptions::default(); if let Some(po) = provider_options @@ -402,9 +402,7 @@ impl Files for GoogleFiles { provider_reference: provider_ref, media_type: result_media_type, filename: None, - provider_metadata: Some( - std::iter::once(("google".to_string(), Value::Object(metadata))).collect(), - ), + provider_metadata: Some(HashMap::from([("google".to_string(), metadata)])), warnings, }) } diff --git a/aimux-providers/src/google/image.rs b/aimux-providers/src/google/image.rs index 75ff4da8f..05f203392 100644 --- a/aimux-providers/src/google/image.rs +++ b/aimux-providers/src/google/image.rs @@ -17,7 +17,7 @@ use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, ImageResult, ImageUsage, }; -use aimux_core::shared::{SharedProviderMetadata, Warning}; +use aimux_core::shared::{SharedProviderMetadata, SharedProviderOptions, Warning}; use aimux_provider_utils::HttpRequest; @@ -284,11 +284,7 @@ impl GoogleImageModel { } // Passthrough provider options (excluding googleSearch) - if let Some(google) = options - .provider_options - .get("google") - .and_then(|v| v.as_object()) - { + if let Some(google) = options.provider_options.get("google") { for (key, value) in google { if key == "googleSearch" || key == "personGeneration" || key == "aspectRatio" { continue; @@ -386,7 +382,7 @@ struct GoogleImageOptions { } /// Parse Google image provider options from the `"google"` key. -fn parse_google_image_options(provider_options: &HashMap) -> GoogleImageOptions { +fn parse_google_image_options(provider_options: &SharedProviderOptions) -> GoogleImageOptions { let google = provider_options.get("google"); GoogleImageOptions { person_generation: google @@ -427,7 +423,7 @@ fn extract_imagen_metadata(response: &Value) -> SharedProviderMetadata { let images: Vec = (0..predictions).map(|_| json!({})).collect(); let mut google_meta = Map::new(); google_meta.insert("images".to_string(), json!(images)); - metadata.insert("google".to_string(), Value::Object(google_meta)); + metadata.insert("google".to_string(), google_meta); metadata } @@ -493,7 +489,7 @@ fn extract_gemini_result( if let Some(gm) = grounding_metadata { google_meta.insert("groundingMetadata".to_string(), gm); } - metadata.insert("google".to_string(), Value::Object(google_meta)); + metadata.insert("google".to_string(), google_meta); (ImageOutputs::Base64(images), metadata, usage) } diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index 0c295d0f5..f25513657 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, FileData, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{ FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, @@ -166,16 +166,17 @@ impl LanguageModel for GoogleModel { // Provider metadata: wrap the raw Google metadata under a `google` // key (matching the TS `wrapProviderMetadata`). - let provider_metadata = Some(serde_json::json!({ - "google": { + let provider_metadata = Some(provider_namespace( + "google", + json!({ "promptFeedback": data.prompt_feedback, "groundingMetadata": candidate.grounding_metadata, "urlContextMetadata": candidate.url_context_metadata, "safetyRatings": candidate.safety_ratings, "usageMetadata": data.usage_metadata, "finishMessage": candidate.finish_message, - } - })); + }), + )); Ok(GenerateResult { content, @@ -328,10 +329,10 @@ impl LanguageModel for GoogleModel { { for part in parts { // thoughtSignature → provider_metadata (upstream :778-782) - let thought_sig_meta: Option = part + let thought_sig_meta: Option = part .get("thoughtSignature") .and_then(|v| v.as_str()) - .map(|s| json!({ "google": { "thoughtSignature": s } })); + .map(|s| provider_namespace("google", json!({ "thoughtSignature": s }))); // text part if let Some(text) = part.get("text").and_then(|v| v.as_str()) { @@ -515,12 +516,11 @@ impl LanguageModel for GoogleModel { block_counter += 1; last_server_tool_call_id = Some(id.clone()); let args = tc.get("args").cloned().unwrap_or(json!({})); - let mut server_meta = json!({ - "google": { "serverToolCallId": id, "serverToolType": tool_type } - }); - if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { - server_meta["google"]["thoughtSignature"] = json!(s); - } + let server_meta = server_tool_metadata( + &id, + tool_type, + part.get("thoughtSignature").and_then(|v| v.as_str()), + ); yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), @@ -545,12 +545,11 @@ impl LanguageModel for GoogleModel { block_counter += 1; let response = tr.get("response").cloned().unwrap_or(json!({})); - let mut server_meta = json!({ - "google": { "serverToolCallId": id, "serverToolType": tool_type } - }); - if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { - server_meta["google"]["thoughtSignature"] = json!(s); - } + let server_meta = server_tool_metadata( + &id, + tool_type, + part.get("thoughtSignature").and_then(|v| v.as_str()), + ); yield Ok(StreamPart::ToolResult(ToolResult { tool_call_id: id, tool_name: format!("server:{tool_type}"), @@ -635,16 +634,14 @@ impl LanguageModel for GoogleModel { yield Ok(StreamPart::ReasoningEnd { id, provider_metadata: None}); } - let provider_metadata = Some(serde_json::json!({ - "google": { - "promptFeedback": last_prompt_feedback, - "groundingMetadata": last_grounding_metadata, - "urlContextMetadata": last_url_context_metadata, - "safetyRatings": last_safety_ratings, - "usageMetadata": last_usage_metadata_value, - "finishMessage": last_finish_message, - } - })); + let provider_metadata = Some(provider_namespace("google", json!({ + "promptFeedback": last_prompt_feedback, + "groundingMetadata": last_grounding_metadata, + "urlContextMetadata": last_url_context_metadata, + "safetyRatings": last_safety_ratings, + "usageMetadata": last_usage_metadata_value, + "finishMessage": last_finish_message, + }))); yield Ok(StreamPart::Finish { finish_reason: if stream_errored { @@ -677,7 +674,7 @@ fn server_tool_metadata( tool_call_id: &str, server_tool_type: &str, thought_signature: Option<&str>, -) -> Value { +) -> ProviderMetadata { let mut payload = json!({ "serverToolCallId": tool_call_id, "serverToolType": server_tool_type, @@ -685,7 +682,7 @@ fn server_tool_metadata( if let Some(signature) = thought_signature { payload["thoughtSignature"] = json!(signature); } - json!({ "google": payload }) + provider_namespace("google", payload) } /// Extract `GenerateContent` items from a non-streaming candidate. @@ -716,10 +713,10 @@ fn extract_content_from_candidate( if let Some(parts) = parts { for part in parts { // thoughtSignature → provider_metadata (upstream :448-451) - let thought_sig_meta: Option = part + let thought_sig_meta: Option = part .get("thoughtSignature") .and_then(|v| v.as_str()) - .map(|s| json!({ "google": { "thoughtSignature": s } })); + .map(|s| provider_namespace("google", json!({ "thoughtSignature": s }))); // Branch order matches upstream (google-language-model.ts:420-534): // executableCode → codeExecutionResult → text → functionCall @@ -846,12 +843,11 @@ fn extract_content_from_candidate( .get("thoughtSignature") .and_then(|v| v.as_str()) .map(std::string::ToString::to_string); - let mut server_meta = json!({ - "google": { "serverToolCallId": id, "serverToolType": tool_type } - }); - if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { - server_meta["google"]["thoughtSignature"] = json!(s); - } + let server_meta = server_tool_metadata( + &id, + tool_type, + part.get("thoughtSignature").and_then(|v| v.as_str()), + ); content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), @@ -874,12 +870,11 @@ fn extract_content_from_candidate( }) .unwrap_or_default(); let response = tr.get("response").cloned().unwrap_or(json!({})); - let mut server_meta = json!({ - "google": { "serverToolCallId": id, "serverToolType": tool_type } - }); - if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { - server_meta["google"]["thoughtSignature"] = json!(s); - } + let server_meta = server_tool_metadata( + &id, + tool_type, + part.get("thoughtSignature").and_then(|v| v.as_str()), + ); content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: id, tool_name: format!("server:{tool_type}"), diff --git a/aimux-providers/src/google_pse.rs b/aimux-providers/src/google_pse.rs index 7be6d7e66..cfe817bf9 100644 --- a/aimux-providers/src/google_pse.rs +++ b/aimux-providers/src/google_pse.rs @@ -20,6 +20,7 @@ use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; +use aimux_core::shared::SharedProviderOptions; use aimux_provider_utils::{HttpRequest, load_api_key, without_trailing_slash}; /// Fixed model ID for the Google PSE search model. @@ -130,7 +131,7 @@ impl Provider for GooglePseProvider { /// `provider_options["google_pse"]["cx"]`. fn resolve_cx( config_cx: Option<&str>, - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> Option { if let Some(cx) = config_cx { return Some(cx.to_string()); diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index e0ad79fbe..60a780cc2 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -31,6 +31,7 @@ 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, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::Tool; use aimux_core::types::{ @@ -222,9 +223,10 @@ impl LanguageModel for HuggingFaceResponsesModel { finish_reason, usage, warnings: request.warnings, - provider_metadata: Some(json!({ - "huggingface": { "responseId": response_id } - })), + provider_metadata: Some(provider_namespace( + "huggingface", + json!({ "responseId": response_id }), + )), response: ResponseMetadata { id: response_id, timestamp: format_timestamp(created_at), @@ -341,9 +343,7 @@ impl LanguageModel for HuggingFaceResponsesModel { .to_string(); yield Ok(StreamPart::TextStart { id: id.clone(), - provider_metadata: Some(json!({ - "huggingface": { "itemId": id } - })), + provider_metadata: Some(provider_namespace("huggingface", json!({ "itemId": id }))), }); } } @@ -375,9 +375,7 @@ impl LanguageModel for HuggingFaceResponsesModel { .to_string(); yield Ok(StreamPart::ReasoningStart { id: id.clone(), - provider_metadata: Some(json!({ - "huggingface": { "itemId": id } - })), + provider_metadata: Some(provider_namespace("huggingface", json!({ "itemId": id }))), }); } _ => {} @@ -547,9 +545,7 @@ impl LanguageModel for HuggingFaceResponsesModel { yield Ok(StreamPart::Finish { finish_reason, usage, - provider_metadata: Some(json!({ - "huggingface": { "responseId": response_id } - })), + provider_metadata: Some(provider_namespace("huggingface", json!({ "responseId": response_id }))), }); }; @@ -1092,9 +1088,10 @@ fn build_generate_content(response: &Value) -> Result, AiMu let item_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); content.push(GenerateContent::Text { text: text.to_string(), - provider_metadata: Some(json!({ - "huggingface": { "itemId": item_id } - })), + provider_metadata: Some(provider_namespace( + "huggingface", + json!({ "itemId": item_id }), + )), }); // Process annotations → source parts. @@ -1125,9 +1122,10 @@ fn build_generate_content(response: &Value) -> Result, AiMu let item_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); content.push(GenerateContent::Reasoning(ReasoningOutput { text: text.to_string(), - provider_metadata: Some(json!({ - "huggingface": { "itemId": item_id } - })), + provider_metadata: Some(provider_namespace( + "huggingface", + json!({ "itemId": item_id }), + )), })); } } diff --git a/aimux-providers/src/hume.rs b/aimux-providers/src/hume.rs index d71b4a0a6..687b9b71c 100644 --- a/aimux-providers/src/hume.rs +++ b/aimux-providers/src/hume.rs @@ -318,8 +318,7 @@ struct HumeSpeechProviderOptions { fn parse_hume_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("hume"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("hume"))?; let context = opts.get("context").and_then(|c| c.as_object())?; // The context can be either { generationId: "..." } or { utterances: [...] }. diff --git a/aimux-providers/src/jina_ai.rs b/aimux-providers/src/jina_ai.rs index cea88fbb9..51ab3dd7e 100644 --- a/aimux-providers/src/jina_ai.rs +++ b/aimux-providers/src/jina_ai.rs @@ -19,6 +19,7 @@ use aimux_core::reranking_model::{ RerankingCallOptions, RerankingDocuments, RerankingModel, RerankingRank, RerankingResponse, RerankingResult, }; +use aimux_core::shared::SharedProviderOptions; use aimux_core::types::Warning; use aimux_provider_utils::{HttpRequest, load_api_key, without_trailing_slash}; @@ -119,7 +120,7 @@ struct JinaRerankingOptions { } fn parse_jina_reranking_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> JinaRerankingOptions { let mut opts = JinaRerankingOptions::default(); if let Some(po) = provider_options diff --git a/aimux-providers/src/linkup.rs b/aimux-providers/src/linkup.rs index 2cb7ab4a9..697931187 100644 --- a/aimux-providers/src/linkup.rs +++ b/aimux-providers/src/linkup.rs @@ -19,6 +19,7 @@ use aimux_core::provider::Provider; use aimux_core::search_model::{ SearchCallOptions, SearchModel, SearchResponse, SearchResult, SearchResultItem, }; +use aimux_core::shared::SharedProviderOptions; use aimux_provider_utils::{HttpRequest, load_api_key, without_trailing_slash}; /// Fixed model ID for the Linkup search model. @@ -34,7 +35,7 @@ struct LinkupOptions { output_type: Option, } -fn parse_linkup_options(provider_options: Option<&HashMap>) -> LinkupOptions { +fn parse_linkup_options(provider_options: Option<&SharedProviderOptions>) -> LinkupOptions { let mut opts = LinkupOptions::default(); if let Some(po) = provider_options && let Some(linkup) = po.get("linkup") diff --git a/aimux-providers/src/lmnt.rs b/aimux-providers/src/lmnt.rs index 2c224d840..6fe407a01 100644 --- a/aimux-providers/src/lmnt.rs +++ b/aimux-providers/src/lmnt.rs @@ -298,8 +298,7 @@ struct LMNTSpeechProviderOptions { fn parse_lmnt_provider_options( options: Option<&SharedProviderOptions>, ) -> Option { - let provider_opts = options.and_then(|opts| opts.get("lmnt"))?; - let opts = provider_opts.as_object()?; + let opts = options.and_then(|opts| opts.get("lmnt"))?; Some(LMNTSpeechProviderOptions { conversational: opts diff --git a/aimux-providers/src/luma.rs b/aimux-providers/src/luma.rs index 56d8d239f..f99e163a1 100644 --- a/aimux-providers/src/luma.rs +++ b/aimux-providers/src/luma.rs @@ -301,7 +301,7 @@ impl ImageModel for LumaImageModel { } // Forward luma provider options (excluding non-request options) - if let Some(luma) = luma_opts.and_then(|v| v.as_object()) { + if let Some(luma) = luma_opts { for (k, v) in luma { if matches!( k.as_str(), diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 2d7f2d080..1561a4f3e 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -21,6 +21,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -599,7 +600,7 @@ impl LanguageModel for MistralModel { } else { final_usage }, - provider_metadata: Some(serde_json::json!({ "mistral": {} })), + provider_metadata: Some(provider_namespace("mistral", serde_json::json!({}))), }); }; diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index 4c018dba1..25222b647 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -5,6 +5,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::SharedProviderOptions; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde::Serialize; @@ -15,7 +16,6 @@ use super::OpenAICompatProfile; /// `convert_common` in M10; re-exported for API compatibility). pub use super::convert_common::SystemMessageMode; use super::convert_common::{ModelCapabilities, get_model_capabilities}; -use std::collections::HashMap; // ── Model capabilities ────────────────────────────────────────────────────── // `GptVersion` / `get_gpt_version` / `get_o_series_version` / @@ -282,7 +282,7 @@ pub fn convert_prompt_to_openai_messages_with_provider( } /// Get the prompt cache breakpoint from provider options. -fn get_prompt_cache_breakpoint(provider_options: &Option) -> Option { +fn get_prompt_cache_breakpoint(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|po| po.get("openai")) @@ -291,7 +291,7 @@ fn get_prompt_cache_breakpoint(provider_options: &Option) -> Option) -> Option { +fn get_image_detail(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|po| po.get("openai")) @@ -357,7 +357,7 @@ fn convert_file_part_to_openai( url: Option<&str>, reference: Option<&Value>, filename: Option<&str>, - provider_options: &Option, + provider_options: &Option, part_index: usize, ) -> Result { let prompt_cache_breakpoint = get_prompt_cache_breakpoint(provider_options); @@ -898,14 +898,10 @@ pub struct RequestBodyResult { } /// Get a value from provider_options.openai.. -fn openai_option( - options: &Option>, - key: &str, -) -> Option { - use std::collections::HashMap; +fn openai_option(options: &Option, key: &str) -> Option { options .as_ref() - .and_then(|m: &HashMap| m.get("openai")) + .and_then(|m| m.get("openai")) .and_then(|o| o.get(key)) .cloned() } @@ -943,7 +939,7 @@ pub fn build_request_body( /// Look up a key from the provider-specific options (groq → "groq" then /// "openai"; otherwise "openai"). fn provider_option( - provider_opts: &Option>, + provider_opts: &Option, provider: &str, key: &str, ) -> Option { @@ -963,7 +959,7 @@ fn provider_option( /// vendor normalization). `providerOptions.reasoningEffort` wins over top-level /// `reasoning`; custom top-level levels map verbatim to `reasoning_effort`. fn resolve_reasoning_effort( - provider_opts: &Option>, + provider_opts: &Option, provider: &str, reasoning: &Option, ) -> Option { @@ -983,7 +979,7 @@ fn resolve_reasoning_effort( } fn resolve_is_reasoning_model( - provider_opts: &Option>, + provider_opts: &Option, provider: &str, caps: &ModelCapabilities, ) -> bool { @@ -993,7 +989,7 @@ fn resolve_is_reasoning_model( } fn resolve_system_message_mode( - provider_opts: &Option>, + provider_opts: &Option, provider: &str, is_reasoning_model: bool, caps: &ModelCapabilities, @@ -1021,7 +1017,7 @@ fn resolve_system_message_mode( fn apply_max_tokens( body: &mut Value, options: &CallOptions, - provider_opts: &Option>, + provider_opts: &Option, provider: &str, profile: &OpenAICompatProfile, is_reasoning_model: bool, @@ -1157,7 +1153,7 @@ fn insert_sampling_params(body: &mut Value, params: &SamplingParams, options: &C fn apply_response_format( body: &mut Value, options: &CallOptions, - provider_opts: &Option>, + provider_opts: &Option, provider: &str, profile: &OpenAICompatProfile, warnings: &mut Vec, @@ -1237,7 +1233,7 @@ fn apply_response_format( /// field (plus Groq's `reasoning_format`). fn apply_provider_option_passthrough( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, provider: &str, ) { let mut set = |key: &str, body_key: &str| { @@ -1274,7 +1270,7 @@ fn apply_provider_option_passthrough( /// without validation). fn apply_service_tier( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, provider: &str, caps: &ModelCapabilities, warnings: &mut Vec, diff --git a/aimux-providers/src/openai/files.rs b/aimux-providers/src/openai/files.rs index 2574cd76c..ba9e875df 100644 --- a/aimux-providers/src/openai/files.rs +++ b/aimux-providers/src/openai/files.rs @@ -8,11 +8,11 @@ use std::collections::HashMap; use async_trait::async_trait; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::json; use aimux_core::error::AiMuxError; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData, UploadFileResult}; -use aimux_core::shared::FileBytes; +use aimux_core::shared::{FileBytes, SharedProviderOptions}; use aimux_provider_utils::{HttpBody, HttpRequest}; @@ -31,7 +31,7 @@ struct OpenAIFilesOptions { /// Parse provider options for the OpenAI files provider. fn parse_openai_files_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> OpenAIFilesOptions { let mut opts = OpenAIFilesOptions::default(); if let Some(po) = provider_options @@ -221,9 +221,7 @@ impl Files for OpenAIFiles { provider_reference: provider_ref, media_type: Some(options.media_type.clone()), filename: result_filename, - provider_metadata: Some( - std::iter::once(("openai".to_string(), Value::Object(metadata))).collect(), - ), + provider_metadata: Some(HashMap::from([("openai".to_string(), metadata)])), warnings: Vec::new(), }) } diff --git a/aimux-providers/src/openai/image.rs b/aimux-providers/src/openai/image.rs index ec157c4c5..df80a6133 100644 --- a/aimux-providers/src/openai/image.rs +++ b/aimux-providers/src/openai/image.rs @@ -18,7 +18,7 @@ use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, ImageResult, ImageUsage, }; -use aimux_core::shared::{SharedProviderMetadata, Warning}; +use aimux_core::shared::{SharedProviderMetadata, SharedProviderOptions, Warning}; use aimux_provider_utils::{HttpBody, HttpRequest}; @@ -231,7 +231,7 @@ struct GenerationOptions { /// Parse generation provider options from the `"openai"` key. fn parse_generation_provider_options( - provider_options: &HashMap, + provider_options: &SharedProviderOptions, ) -> GenerationOptions { let openai = provider_options.get("openai"); GenerationOptions { @@ -256,7 +256,7 @@ struct EditOptions { } /// Parse edit provider options from the `"openai"` key. -fn parse_edit_provider_options(provider_options: &HashMap) -> EditOptions { +fn parse_edit_provider_options(provider_options: &SharedProviderOptions) -> EditOptions { let openai = provider_options.get("openai"); EditOptions { quality: openai.and_then(|o| o.get("quality")).cloned(), @@ -600,6 +600,6 @@ fn extract_provider_metadata(response: &Value) -> SharedProviderMetadata { let mut openai_meta = Map::new(); openai_meta.insert("images".to_string(), json!(images)); - metadata.insert("openai".to_string(), Value::Object(openai_meta)); + metadata.insert("openai".to_string(), openai_meta); metadata } diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 208fec778..953a7241c 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -18,6 +18,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -381,7 +382,7 @@ pub async fn execute_generate( pm_openai["rejectedPredictionTokens"] = json!(rpt); } } - let provider_metadata = Some(serde_json::json!({ "openai": pm_openai })); + let provider_metadata = Some(provider_namespace("openai", pm_openai)); Ok(GenerateResult { content, @@ -759,7 +760,7 @@ pub async fn execute_stream( pm_openai["rejectedPredictionTokens"] = json!(rpt); } } - let provider_metadata = serde_json::json!({ "openai": pm_openai }); + let provider_metadata = provider_namespace("openai", pm_openai); // Final part: Finish. yield Ok(StreamPart::Finish { diff --git a/aimux-providers/src/openai/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs index fa7c99f7d..6ae5f8575 100644 --- a/aimux-providers/src/openai/responses/convert.rs +++ b/aimux-providers/src/openai/responses/convert.rs @@ -10,14 +10,13 @@ //! - `convert-openai-responses-usage.ts` -> [`convert_responses_usage`] //! - `map-openai-responses-finish-reason.ts` -> [`map_responses_finish_reason`] -use std::collections::HashMap; - use serde_json::{Value, json}; 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::shared::SharedProviderOptions; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Usage, Warning}; @@ -40,7 +39,7 @@ pub use crate::openai::convert_common::SystemMessageMode as ResponsesSystemMessa // -- Provider options helper ------------------------------------------------- /// Get a value from `provider_options.openai.`. -fn openai_option(options: &Option>, key: &str) -> Option { +fn openai_option(options: &Option, key: &str) -> Option { options .as_ref() .and_then(|m| m.get("openai")) @@ -346,7 +345,7 @@ fn serialize_arguments(input: &Value) -> String { } /// Read the `itemId` from a content part's `providerOptions.openai.itemId`. -fn item_id(provider_options: &Option) -> Option { +fn item_id(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|v| v.get("openai")) @@ -356,7 +355,7 @@ fn item_id(provider_options: &Option) -> Option { } /// Read the `phase` from a content part's `providerOptions.openai.phase`. -fn phase_from_provider_options(provider_options: &Option) -> Option { +fn phase_from_provider_options(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|v| v.get("openai")) @@ -366,7 +365,9 @@ fn phase_from_provider_options(provider_options: &Option) -> Option) -> Option { +fn namespace_from_provider_options( + provider_options: &Option, +) -> Option { provider_options .as_ref() .and_then(|v| v.get("openai")) @@ -376,7 +377,7 @@ fn namespace_from_provider_options(provider_options: &Option) -> Option` on a content part. -fn openai_sub_option(provider_options: &Option, key: &str) -> Option { +fn openai_sub_option(provider_options: &Option, key: &str) -> Option { provider_options .as_ref() .and_then(|v| v.get("openai")) @@ -513,7 +514,7 @@ fn push_unsupported_call_option_warnings(options: &CallOptions, warnings: &mut V /// wins over top-level `reasoning`), `reasoningSummary` (defaults to "detailed" /// when an effort other than "none" applies), and whether the model reasons. fn resolve_responses_reasoning( - provider_opts: &Option>, + provider_opts: &Option, options: &CallOptions, caps: &ModelCapabilities, ) -> (Option, Option, bool) { @@ -562,7 +563,7 @@ fn resolve_responses_reasoning( /// Warn when `conversation` and `previousResponseId` are both set. fn warn_conversation_conflict( - provider_opts: &Option>, + provider_opts: &Option, warnings: &mut Vec, ) { let has_conversation = openai_option(provider_opts, "conversation").is_some(); @@ -578,7 +579,7 @@ fn warn_conversation_conflict( } fn resolve_responses_system_message_mode( - provider_opts: &Option>, + provider_opts: &Option, is_reasoning_model: bool, caps: &ModelCapabilities, ) -> SystemMessageMode { @@ -600,7 +601,7 @@ fn resolve_responses_system_message_mode( fn apply_responses_sampling( body: &mut Value, options: &CallOptions, - provider_opts: &Option>, + provider_opts: &Option, caps: &ModelCapabilities, is_reasoning_model: bool, resolved_reasoning_effort: &Option, @@ -656,7 +657,7 @@ fn apply_responses_sampling( fn apply_responses_text_format( body: &mut Value, options: &CallOptions, - provider_opts: &Option>, + provider_opts: &Option, ) { if let Some(ref rf) = options.response_format { match rf { @@ -705,7 +706,7 @@ fn apply_responses_text_format( /// The computed `include` list (store=false on reasoning models adds /// `reasoning.encrypted_content`). fn resolve_responses_include( - provider_opts: &Option>, + provider_opts: &Option, is_reasoning_model: bool, ) -> Option> { let mut include: Option> = @@ -736,7 +737,7 @@ fn resolve_responses_include( /// set). fn apply_responses_provider_options( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, ) { let mut set = |key: &str, body_key: &str| { if let Some(v) = openai_option(provider_opts, key) { @@ -760,7 +761,7 @@ fn apply_responses_provider_options( /// `service_tier` with model-capability validation. fn apply_responses_service_tier( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, caps: &ModelCapabilities, warnings: &mut Vec, ) { @@ -793,7 +794,7 @@ fn apply_responses_service_tier( /// `reasoning` block for reasoning models. fn apply_responses_reasoning_block( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, is_reasoning_model: bool, resolved_reasoning_effort: &Option, resolved_reasoning_summary: &Option, diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index 0f3b29b03..243d3eb33 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -23,8 +23,11 @@ use serde_json::{Value, json}; use aimux_core::error::AiMuxError; use aimux_core::error::ApiCallError; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage, Warning}; +use aimux_core::types::{ + FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, Warning, +}; use super::convert::{convert_responses_usage, map_responses_finish_reason, parse_usage}; use super::types::ResponsesUsage; @@ -140,11 +143,12 @@ pub fn build_responses_generate_result( if !text.is_empty() { content.push(GenerateContent::Text { text, - provider_metadata: Some(json!({ - (provider_key.clone()): { + provider_metadata: Some(provider_namespace( + &provider_key, + json!({ "itemId": part.get("id").cloned().unwrap_or(Value::Null), - } - })), + }), + )), }); } } @@ -198,11 +202,12 @@ pub fn build_responses_generate_result( provider_executed: None, dynamic: None, thought_signature: None, - provider_metadata: Some(json!({ - (provider_key.clone()): { + provider_metadata: Some(provider_namespace( + &provider_key, + json!({ "itemId": part.get("id").cloned().unwrap_or(Value::Null), - } - })), + }), + )), })); } Some("custom_tool_call") => { @@ -227,22 +232,24 @@ pub fn build_responses_generate_result( provider_executed: None, dynamic: None, thought_signature: None, - provider_metadata: Some(json!({ - (provider_key.clone()): { + provider_metadata: Some(provider_namespace( + &provider_key, + json!({ "itemId": part.get("id").cloned().unwrap_or(Value::Null), - } - })), + }), + )), })); } Some("reasoning") => { let summary = part.get("summary").and_then(|v| v.as_array()); let parts: Vec<&Value> = summary.map(|s| s.iter().collect()).unwrap_or_default(); - let reasoning_metadata = Some(json!({ - (provider_key.clone()): { + let reasoning_metadata = Some(provider_namespace( + &provider_key, + json!({ "itemId": part.get("id").cloned().unwrap_or(Value::Null), "reasoningEncryptedContent": part.get("encrypted_content").cloned().unwrap_or(Value::Null), - } - })); + }), + )); if parts.is_empty() { content.push(GenerateContent::Reasoning(ReasoningOutput { text: String::new(), @@ -288,7 +295,7 @@ pub fn build_responses_generate_result( if let Some(st) = data.get("service_tier").and_then(|v| v.as_str()) { pm["serviceTier"] = json!(st); } - let provider_metadata = Some(json!({ provider_key: pm })); + let provider_metadata = Some(provider_namespace(&provider_key, pm)); let response_id = data .get("id") @@ -350,12 +357,12 @@ fn reasoning_stream_metadata( provider_key: &str, item_id: &str, encrypted_content: Option<&str>, -) -> Value { +) -> ProviderMetadata { let mut inner = json!({ "itemId": item_id }); if let Some(enc) = encrypted_content { inner["reasoningEncryptedContent"] = json!(enc); } - json!({ (provider_key): inner }) + provider_namespace(provider_key, inner) } /// Generate a unique source ID for streaming annotation sources. @@ -1052,7 +1059,7 @@ where if let Some(ctx) = final_reasoning_context { pm["reasoningContext"] = ctx; } - let provider_metadata = Some(json!({ provider_key: pm })); + let provider_metadata = Some(provider_namespace(&provider_key, pm)); yield Ok(StreamPart::Finish { finish_reason: if stream_errored { diff --git a/aimux-providers/src/openai/transcription.rs b/aimux-providers/src/openai/transcription.rs index b0ca5777c..cf96ceea7 100644 --- a/aimux-providers/src/openai/transcription.rs +++ b/aimux-providers/src/openai/transcription.rs @@ -24,7 +24,7 @@ use serde::Deserialize; use serde_json::Value; use aimux_core::error::AiMuxError; -use aimux_core::shared::Warning; +use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_core::transcription_model::{ AudioInput, TranscriptionCallOptions, TranscriptionModel, TranscriptionRequest, TranscriptionResponse, TranscriptionResult, TranscriptionSegment, @@ -120,7 +120,7 @@ struct OpenAITranscriptionOptions { /// Parse provider options from the shared options map. fn parse_openai_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> OpenAITranscriptionOptions { let mut opts = OpenAITranscriptionOptions::default(); if let Some(po) = provider_options diff --git a/aimux-providers/src/prodia.rs b/aimux-providers/src/prodia.rs index fd0cba856..5ef3262bb 100644 --- a/aimux-providers/src/prodia.rs +++ b/aimux-providers/src/prodia.rs @@ -310,7 +310,7 @@ impl ImageModel for ProdiaImageModel { let mut metadata = HashMap::new(); let mut prodia_meta = Map::new(); prodia_meta.insert("images".into(), json!([job_result])); - metadata.insert("prodia".into(), Value::Object(prodia_meta)); + metadata.insert("prodia".into(), prodia_meta); Ok(ImageResult { images: ImageOutputs::Binary(vec![image_bytes]), diff --git a/aimux-providers/src/recraft.rs b/aimux-providers/src/recraft.rs index ab208cbe0..0196615c2 100644 --- a/aimux-providers/src/recraft.rs +++ b/aimux-providers/src/recraft.rs @@ -20,7 +20,7 @@ use aimux_core::image_model::{ }; use aimux_core::provider::Provider; use aimux_core::retry; -use aimux_core::shared::Warning; +use aimux_core::shared::{SharedProviderOptions, Warning}; use aimux_provider_utils::{HttpRequest, load_api_key, without_trailing_slash}; fn recraft_failed_response_handler() -> aimux_provider_utils::ResponseHandler { @@ -161,7 +161,7 @@ struct RecraftOptions { /// /// Unknown fields are ignored (safe degradation); values are passed through as /// raw JSON so that unrecognised-but-valid enum values do not cause errors. -fn parse_recraft_options(provider_options: &HashMap) -> RecraftOptions { +fn parse_recraft_options(provider_options: &SharedProviderOptions) -> RecraftOptions { let recraft = provider_options.get("recraft"); RecraftOptions { style: recraft.and_then(|o| o.get("style")).cloned(), diff --git a/aimux-providers/src/replicate.rs b/aimux-providers/src/replicate.rs index 09eb8b219..b0dca270d 100644 --- a/aimux-providers/src/replicate.rs +++ b/aimux-providers/src/replicate.rs @@ -266,7 +266,7 @@ impl ImageModel for ReplicateImageModel { } // Forward replicate provider options (excluding maxWaitTimeInSeconds) - if let Some(ro) = replicate_opts.and_then(|v| v.as_object()) { + if let Some(ro) = replicate_opts { for (k, v) in ro { if k != "maxWaitTimeInSeconds" { input.insert(k.clone(), v.clone()); diff --git a/aimux-providers/src/stability.rs b/aimux-providers/src/stability.rs index d727cba94..a5aa918f1 100644 --- a/aimux-providers/src/stability.rs +++ b/aimux-providers/src/stability.rs @@ -303,7 +303,7 @@ impl ImageModel for StabilityImageModel { form.text("output_format", output_format)?; // Forward remaining stability provider options as scalar form fields. - if let Some(stab) = stability_opts.and_then(|v| v.as_object()) { + if let Some(stab) = stability_opts { for (k, v) in stab { if matches!( k.as_str(), diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index 1b2475148..09eebc78f 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -25,8 +25,11 @@ 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::shared::provider_namespace; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; +use aimux_core::types::{ + FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, +}; use aimux_provider_utils::{HttpRequest, RetryConfig}; @@ -394,20 +397,18 @@ impl LanguageModel for VertexAnthropicModel { provider_executed: Some(true), dynamic: Some(true), thought_signature: None, - provider_metadata: Some(json!({ - "anthropic": { - "type": "mcp-tool-use", - "serverName": server_name, - } - })), + provider_metadata: Some(provider_namespace("anthropic", json!({ + "type": "mcp-tool-use", + "serverName": server_name, + }))), })); } ContentBlock::RedactedThinking { data } => { yield Ok(StreamPart::ReasoningStart { id: index.to_string(), - provider_metadata: Some(json!({ - "anthropic": { "redactedData": data } - })), + provider_metadata: Some(provider_namespace("anthropic", json!({ + "redactedData": data + }))), }); blocks.insert(index, BlockState::Thinking { started: true }); } @@ -625,7 +626,7 @@ enum BlockState { dynamic: Option, provider_tool_name: Option, provider_tool_input_type: Option, - provider_metadata: Option, + provider_metadata: Option, first_delta: bool, }, Thinking { diff --git a/aimux-providers/src/vertex/image.rs b/aimux-providers/src/vertex/image.rs index f93b59245..caf80530b 100644 --- a/aimux-providers/src/vertex/image.rs +++ b/aimux-providers/src/vertex/image.rs @@ -19,7 +19,7 @@ use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, ImageResponse, ImageResult, ImageUsage, }; -use aimux_core::shared::Warning; +use aimux_core::shared::{Warning, provider_namespace}; use aimux_provider_utils::HttpRequest; @@ -121,7 +121,7 @@ impl VertexImageModel { // Build other options (excluding "edit") let mut other_options = Map::new(); - if let Some(obj) = gv_opts.and_then(|v| v.as_object()) { + if let Some(obj) = gv_opts { for (k, v) in obj { if k != "edit" { other_options.insert(k.clone(), v.clone()); @@ -235,9 +235,8 @@ impl VertexImageModel { .unwrap_or_default(); let payload = json!({ "images": image_metas }); - let mut metadata = HashMap::new(); - metadata.insert("googleVertex".into(), payload.clone()); - metadata.insert("vertex".into(), payload); + let mut metadata = provider_namespace("googleVertex", payload.clone()); + metadata.extend(provider_namespace("vertex", payload)); Ok(ImageResult { images: ImageOutputs::Base64(images), @@ -318,7 +317,7 @@ impl VertexImageModel { .provider_options .get("googleVertex") .or_else(|| options.provider_options.get("vertex")); - if let Some(obj) = gv_opts.and_then(|v| v.as_object()) { + if let Some(obj) = gv_opts { for (k, v) in obj { if matches!(k.as_str(), "responseModalities" | "imageConfig") { continue; @@ -382,9 +381,8 @@ impl VertexImageModel { }); let payload = json!({ "images": images.iter().map(|_| json!({})).collect::>() }); - let mut metadata = HashMap::new(); - metadata.insert("googleVertex".into(), payload.clone()); - metadata.insert("vertex".into(), payload); + let mut metadata = provider_namespace("googleVertex", payload.clone()); + metadata.extend(provider_namespace("vertex", payload)); Ok(ImageResult { images: ImageOutputs::Base64(images), diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 0af5cac85..0dd2a7988 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -16,8 +16,11 @@ 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::shared::provider_namespace; use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; +use aimux_core::types::{ + FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, +}; use aimux_provider_utils::{HttpRequest, RetryConfig}; @@ -646,18 +649,17 @@ impl LanguageModel for VertexModel { // ── Helpers ────────────────────────────────────────────────────────────────── -fn vertex_provider_metadata(payload: Value) -> Value { - json!({ - "googleVertex": payload.clone(), - "vertex": payload, - }) +fn vertex_provider_metadata(payload: Value) -> ProviderMetadata { + let mut metadata = provider_namespace("googleVertex", payload.clone()); + metadata.extend(provider_namespace("vertex", payload)); + metadata } fn vertex_server_tool_metadata( tool_call_id: &str, server_tool_type: &str, thought_signature: Option<&str>, -) -> Value { +) -> ProviderMetadata { let mut payload = json!({ "serverToolCallId": tool_call_id, "serverToolType": server_tool_type, diff --git a/aimux-providers/src/voyage/reranking.rs b/aimux-providers/src/voyage/reranking.rs index 013692869..8fffe0b86 100644 --- a/aimux-providers/src/voyage/reranking.rs +++ b/aimux-providers/src/voyage/reranking.rs @@ -14,6 +14,7 @@ use aimux_core::reranking_model::{ RerankingCallOptions, RerankingDocuments, RerankingModel, RerankingRank, RerankingResponse, RerankingResult, }; +use aimux_core::shared::SharedProviderOptions; use aimux_core::types::Warning; use aimux_provider_utils::HttpRequest; @@ -28,7 +29,7 @@ struct VoyageRerankingOptions { } fn parse_voyage_reranking_options( - provider_options: Option<&HashMap>, + provider_options: Option<&SharedProviderOptions>, ) -> VoyageRerankingOptions { let mut opts = VoyageRerankingOptions::default(); if let Some(po) = provider_options diff --git a/aimux-providers/src/xai/convert.rs b/aimux-providers/src/xai/convert.rs index 3662539d6..4bd0b7f56 100644 --- a/aimux-providers/src/xai/convert.rs +++ b/aimux-providers/src/xai/convert.rs @@ -5,13 +5,12 @@ //! `supports-reasoning-effort.ts`, `map-xai-finish-reason.ts`, and //! `remove-additional-properties.ts`. -use std::collections::HashMap; - use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::SharedProviderOptions; use aimux_core::tool::Tool; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; @@ -206,7 +205,7 @@ pub fn remove_additional_properties_false(value: &Value) -> Value { // ── Provider options helpers ───────────────────────────────────────────────── -fn xai_option(options: &Option>, key: &str) -> Option { +fn xai_option(options: &Option, key: &str) -> Option { options .as_ref() .and_then(|m| m.get("xai")) @@ -214,7 +213,7 @@ fn xai_option(options: &Option>, key: &str) -> Option) -> Option { +fn get_image_detail(provider_options: &Option) -> Option { provider_options .as_ref() .and_then(|po| po.get("xai")) @@ -420,7 +419,7 @@ fn convert_image_part( media_type: &str, b64_data: Option<&str>, url: Option<&str>, - provider_options: &Option, + provider_options: &Option, ) -> Result { let top_level = get_top_level_media_type(media_type); if top_level != "image" { diff --git a/aimux-providers/src/xai/responses/convert.rs b/aimux-providers/src/xai/responses/convert.rs index 3226bf6e5..9297d9f40 100644 --- a/aimux-providers/src/xai/responses/convert.rs +++ b/aimux-providers/src/xai/responses/convert.rs @@ -11,6 +11,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::SharedProviderOptions; use aimux_core::tool::Tool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort, Warning}; @@ -271,10 +272,7 @@ fn prepare_provider_tool( // ── Provider options helpers ───────────────────────────────────────────────── -fn xai_option( - options: &Option>, - key: &str, -) -> Option { +fn xai_option(options: &Option, key: &str) -> Option { options .as_ref() .and_then(|m| m.get("xai")) @@ -538,7 +536,7 @@ fn convert_image_part( media_type: &str, b64_data: Option<&str>, url: Option<&str>, - provider_options: &Option, + provider_options: &Option, ) -> Value { let image_url = if let Some(url_str) = url { url_str.to_string() diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index 62a88beac..27fe3fd39 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -28,6 +28,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -305,7 +306,7 @@ impl LanguageModel for XaiResponsesModel { } content.push(GenerateContent::Reasoning(ReasoningOutput { text: reasoning_text, - provider_metadata: Some(json!({ "xai": meta })), + provider_metadata: Some(provider_namespace("xai", meta)), })); } } @@ -327,9 +328,10 @@ impl LanguageModel for XaiResponsesModel { let (usage, provider_metadata) = if let Some(u) = &data.usage { let meta = if u.cost_in_usd_ticks.is_some() { - Some(json!({ - "xai": { "costInUsdTicks": u.cost_in_usd_ticks } - })) + Some(provider_namespace( + "xai", + json!({ "costInUsdTicks": u.cost_in_usd_ticks }), + )) } else { None }; @@ -446,7 +448,7 @@ impl LanguageModel for XaiResponsesModel { active_reasoning.insert(item_id.to_string(), ()); yield Ok(StreamPart::ReasoningStart { id: block_id, - provider_metadata: Some(json!({ "xai": { "itemId": item_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))), }); } continue; @@ -460,7 +462,7 @@ impl LanguageModel for XaiResponsesModel { yield Ok(StreamPart::ReasoningDelta { id: block_id, delta: delta.to_string(), - provider_metadata: Some(json!({ "xai": { "itemId": item_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))), }); continue; } @@ -479,13 +481,13 @@ impl LanguageModel for XaiResponsesModel { active_reasoning.insert(item_id.to_string(), ()); yield Ok(StreamPart::ReasoningStart { id: block_id.clone(), - provider_metadata: Some(json!({ "xai": { "itemId": item_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))), }); } yield Ok(StreamPart::ReasoningDelta { id: block_id, delta: delta.to_string(), - provider_metadata: Some(json!({ "xai": { "itemId": item_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))), }); continue; } @@ -670,7 +672,7 @@ impl LanguageModel for XaiResponsesModel { active_reasoning.insert(part_id.to_string(), ()); yield Ok(StreamPart::ReasoningStart { id: block_id.clone(), - provider_metadata: Some(json!({ "xai": { "itemId": part_id } })), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": part_id }))), }); } @@ -680,7 +682,7 @@ impl LanguageModel for XaiResponsesModel { } yield Ok(StreamPart::ReasoningEnd { id: block_id, - provider_metadata: Some(json!({ "xai": meta })), + provider_metadata: Some(provider_namespace("xai", meta)), }); active_reasoning.remove(part_id); } @@ -907,7 +909,7 @@ impl LanguageModel for XaiResponsesModel { } // Final part: Finish. - let provider_meta = cost_in_usd_ticks.map(|cost| json!({ "xai": { "costInUsdTicks": cost } })); + let provider_meta = cost_in_usd_ticks.map(|cost| provider_namespace("xai", json!({ "costInUsdTicks": cost }))); yield Ok(StreamPart::Finish { finish_reason: final_finish_reason, From dc3f6d8091d48f2868be663e84d14133cf818e47 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Sun, 4 Oct 2026 23:40:08 +0800 Subject: [PATCH 3/8] refactor: tests, FFI and bindings follow the typed provider options and metadata - Provider tests build options with `provider_namespace` or from a JSON literal and compare metadata by namespace; assertions are unchanged. - FFI, Node and Python: the streaming-transcription session options carry `SharedProviderOptions`, so a non-object namespace value is rejected when the options JSON is parsed instead of reaching the provider. - CHANGELOG: the typed shape and what it rejects. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- CHANGELOG.md | 12 +++ aimux-ffi/src/lib.rs | 2 +- aimux-ffi/src/transcription_session.rs | 2 +- .../tests/amazon_bedrock_image_test.rs | 4 +- .../anthropic_assistant_tool_result_test.rs | 26 +++-- .../tests/anthropic_cache_control_test.rs | 26 +++-- .../tests/anthropic_convert_full_test.rs | 29 +++-- .../tests/anthropic_convert_test.rs | 9 +- aimux-providers/tests/anthropic_files_test.rs | 2 +- .../tests/anthropic_provider_tools_test.rs | 18 +--- aimux-providers/tests/aws_polly_test.rs | 5 +- aimux-providers/tests/azure_responses_test.rs | 2 +- aimux-providers/tests/bedrock_convert_test.rs | 28 +++-- .../tests/bedrock_embedding_test.rs | 10 +- .../tests/bedrock_reasoning_test.rs | 20 ++-- .../tests/bedrock_remaining_test.rs | 7 +- .../tests/bedrock_reranking_test.rs | 13 +-- aimux-providers/tests/cartesia_speech_test.rs | 10 +- .../tests/cartesia_transcription_test.rs | 12 +-- .../tests/cohere_embedding_test.rs | 11 +- .../tests/cohere_remaining_test.rs | 12 ++- .../tests/cohere_reranking_test.rs | 12 +-- aimux-providers/tests/convert_golden_test.rs | 10 +- .../tests/data_loss_regression_test.rs | 20 ++-- .../tests/deepgram_transcription_test.rs | 9 +- aimux-providers/tests/deepseek_chat_test.rs | 8 +- .../tests/deepseek_reasoning_test.rs | 8 +- .../tests/elevenlabs_realtime_stream_test.rs | 5 +- .../tests/elevenlabs_speech_test.rs | 5 +- aimux-providers/tests/fal_image_test.rs | 5 +- .../tests/google_embedding_test.rs | 10 +- aimux-providers/tests/google_files_test.rs | 21 +--- aimux-providers/tests/google_image_test.rs | 26 ++--- aimux-providers/tests/google_model_test.rs | 19 ++-- .../tests/google_provider_tools_test.rs | 25 +++-- aimux-providers/tests/google_pse_test.rs | 6 +- aimux-providers/tests/groq_test.rs | 21 ++-- .../tests/huggingface_responses_test.rs | 15 ++- aimux-providers/tests/jina_ai_test.rs | 11 +- aimux-providers/tests/linkup_test.rs | 15 ++- aimux-providers/tests/lmnt_speech_test.rs | 5 +- aimux-providers/tests/open_responses_test.rs | 6 +- .../tests/openai_convert_extended_test.rs | 19 ++-- aimux-providers/tests/openai_convert_test.rs | 5 +- .../tests/openai_embedding_test.rs | 4 +- aimux-providers/tests/openai_files_test.rs | 19 ++-- aimux-providers/tests/openai_image_test.rs | 22 ++-- .../tests/openai_model_extended_test.rs | 7 +- .../tests/openai_responses_test.rs | 100 ++++++------------ .../tests/openai_transcription_test.rs | 33 ++---- aimux-providers/tests/recraft_test.rs | 4 +- aimux-providers/tests/replicate_image_test.rs | 5 +- .../tests/vertex_embedding_test.rs | 24 ++--- aimux-providers/tests/vertex_model_test.rs | 25 +++-- .../tests/vertex_transcription_test.rs | 5 +- .../tests/voyage_embedding_test.rs | 8 +- aimux-providers/tests/xai_responses_test.rs | 48 +++++---- aimux-providers/tests/xai_test.rs | 18 ++-- bindings/node/src/multimodal.rs | 2 +- bindings/python/src/multimodal.rs | 2 +- 60 files changed, 400 insertions(+), 472 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0653d69..40b2215f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- Provider options and provider metadata have one typed shape, the AI SDK's + `Record`: `SharedProviderOptions` and + `SharedProviderMetadata` (`ProviderMetadata` is an alias) are + `HashMap`, namespace to JSON object. They replace + `serde_json::Value`, `HashMap` and the per-part + `Option`. Every `provider_options` / `provider_metadata` field of + content parts, messages, call options, tools, results and stream parts uses + them. `{"namespace": {"key": value}}` serializes as before; a value that is + not namespace -> object (a number, string or array under a namespace, or a + non-object at the top) is rejected when deserialized. Build one with + `provider_namespace(ns, json!({..}))`. + - Tool-call and tool-result types are unified, one per protocol layer (`aimux-core`). A provider emits `tool::RawToolCall` (`input` is the raw argument text) in `GenerateContent::ToolCall(..)` and diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index b60842765..726540f9b 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -2486,7 +2486,7 @@ pub extern "C" fn aimux_transcription_generate( #[derive(serde::Deserialize, Default)] struct TranscriptionSessionFfiOptions { input_audio_format: Option, - provider_options: Option>, + provider_options: Option, headers: Option>, include_raw_chunks: Option, timeout: Option, diff --git a/aimux-ffi/src/transcription_session.rs b/aimux-ffi/src/transcription_session.rs index d6d71054b..a40909b12 100644 --- a/aimux-ffi/src/transcription_session.rs +++ b/aimux-ffi/src/transcription_session.rs @@ -51,7 +51,7 @@ const JOIN_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Default)] pub struct SessionOptions { pub input_audio_format: Option, - pub provider_options: Option>, + pub provider_options: Option, pub headers: Option>, pub include_raw_chunks: bool, pub timeout: Option, diff --git a/aimux-providers/tests/amazon_bedrock_image_test.rs b/aimux-providers/tests/amazon_bedrock_image_test.rs index a93d7070d..b2ffbb896 100644 --- a/aimux-providers/tests/amazon_bedrock_image_test.rs +++ b/aimux-providers/tests/amazon_bedrock_image_test.rs @@ -194,8 +194,8 @@ async fn should_pass_negative_text() { let model = provider.image("amazon.titan-image-generator-v1"); let mut opts = options(PROMPT); opts.n = 1; - opts.provider_options.insert( - "amazonBedrock".into(), + opts.provider_options = aimux_core::shared::provider_namespace( + "amazonBedrock", json!({ "negativeText": "ugly, blurry" }), ); model.do_generate(&opts).await.unwrap(); diff --git a/aimux-providers/tests/anthropic_assistant_tool_result_test.rs b/aimux-providers/tests/anthropic_assistant_tool_result_test.rs index 35c02adb2..9e38d8eff 100644 --- a/aimux-providers/tests/anthropic_assistant_tool_result_test.rs +++ b/aimux-providers/tests/anthropic_assistant_tool_result_test.rs @@ -93,14 +93,17 @@ fn assistant_tool_call_replays_programmatic_caller() { input: json!({ "sql": "SELECT 1" }), provider_executed: None, thought_signature: None, - provider_options: Some(json!({ - "anthropic": { - "caller": { - "type": "code_execution_20260120", - "toolId": "srvtoolu_code", + provider_options: Some( + serde_json::from_value(json!({ + "anthropic": { + "caller": { + "type": "code_execution_20260120", + "toolId": "srvtoolu_code", + } } - } - })), + })) + .unwrap(), + ), }; let (blocks, warnings) = convert(vec![msg(Role::Assistant, vec![call])], vec![]); @@ -200,9 +203,12 @@ fn assistant_mcp_result_becomes_mcp_tool_result() { input: json!({ "text": "hi" }), provider_executed: Some(true), thought_signature: None, - provider_options: Some(json!({ - "anthropic": { "type": "mcp-tool-use", "serverName": "my-server" } - })), + provider_options: Some( + serde_json::from_value(json!({ + "anthropic": { "type": "mcp-tool-use", "serverName": "my-server" } + })) + .unwrap(), + ), }; let (blocks, warnings) = convert( diff --git a/aimux-providers/tests/anthropic_cache_control_test.rs b/aimux-providers/tests/anthropic_cache_control_test.rs index ae63e2380..4485de6d0 100644 --- a/aimux-providers/tests/anthropic_cache_control_test.rs +++ b/aimux-providers/tests/anthropic_cache_control_test.rs @@ -27,6 +27,7 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::types::Warning; use aimux_providers::anthropic::convert::convert_prompt_to_anthropic_full; use serde_json::{Value, json}; @@ -45,7 +46,11 @@ fn msg(role: Role, parts: Vec) -> LanguageModelPromptMessage { /// Build a message with message-level `provider_options` (for message-level /// cache_control). -fn msg_with_opts(role: Role, parts: Vec, opts: Value) -> LanguageModelPromptMessage { +fn msg_with_opts( + role: Role, + parts: Vec, + opts: SharedProviderOptions, +) -> LanguageModelPromptMessage { LanguageModelPromptMessage { role, content: parts, @@ -66,8 +71,8 @@ fn convert_full( /// The message-level / part-level `providerOptions` shape carrying /// `anthropic.cacheControl`. -fn cache_control_opts(cache_control: Value) -> Value { - json!({ "anthropic": { "cacheControl": cache_control } }) +fn cache_control_opts(cache_control: Value) -> SharedProviderOptions { + provider_namespace("anthropic", json!({ "cacheControl": cache_control })) } /// Create a text part with `provider_options` set (for part-level cache_control). @@ -504,12 +509,15 @@ mod cache_control_validation { vec![ContentPart::Reasoning { text: "redacted".to_string(), signature: None, - provider_options: Some(json!({ - "anthropic": { - "redactedData": "abc123", - "cacheControl": { "type": "ephemeral" }, - } - })), + provider_options: Some( + serde_json::from_value(json!({ + "anthropic": { + "redactedData": "abc123", + "cacheControl": { "type": "ephemeral" }, + } + })) + .unwrap(), + ), }], )]); let result = convert_full(p, true); diff --git a/aimux-providers/tests/anthropic_convert_full_test.rs b/aimux-providers/tests/anthropic_convert_full_test.rs index 8fa080e20..fc463d68a 100644 --- a/aimux-providers/tests/anthropic_convert_full_test.rs +++ b/aimux-providers/tests/anthropic_convert_full_test.rs @@ -15,6 +15,7 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; +use aimux_core::shared::provider_namespace; use aimux_core::types::Warning; use aimux_providers::anthropic::convert::convert_prompt_to_anthropic_full; use serde_json::{Value, json}; @@ -325,14 +326,10 @@ fn thinking_enabled_echoes_reasoning_parts_as_thinking_blocks() { text_part("Here is the answer."), ], )]); - options.provider_options = Some( - [( - "anthropic".to_string(), - json!({ "thinking": { "type": "enabled", "budgetTokens": 1024 } }), - )] - .into_iter() - .collect(), - ); + options.provider_options = Some(provider_namespace( + "anthropic", + json!({ "thinking": { "type": "enabled", "budgetTokens": 1024 } }), + )); let result = build_request_body_with_warnings("claude-sonnet-4-20250514", &options, false).unwrap(); let blocks = result.body["messages"][0]["content"].as_array().unwrap(); @@ -364,14 +361,10 @@ fn thinking_disabled_omits_reasoning_parts() { text_part("Here is the answer."), ], )]); - options.provider_options = Some( - [( - "anthropic".to_string(), - json!({ "thinking": { "type": "disabled" } }), - )] - .into_iter() - .collect(), - ); + options.provider_options = Some(provider_namespace( + "anthropic", + json!({ "thinking": { "type": "disabled" } }), + )); let result = build_request_body_with_warnings("claude-sonnet-4-20250514", &options, false).unwrap(); let blocks = result.body["messages"][0]["content"].as_array().unwrap(); @@ -743,7 +736,9 @@ fn should_convert_provider_referenced_file_parts_to_container_uploads_when_reque media_type: "text/csv".to_string(), reference: json!({ "anthropic": "file-csv-12345" }), filename: None, - provider_options: Some(json!({ "anthropic": { "containerUpload": true } })), + provider_options: Some( + serde_json::from_value(json!({ "anthropic": { "containerUpload": true } })).unwrap(), + ), }; let p = prompt(vec![msg( Role::User, diff --git a/aimux-providers/tests/anthropic_convert_test.rs b/aimux-providers/tests/anthropic_convert_test.rs index 8dcba218f..0b835c6cb 100644 --- a/aimux-providers/tests/anthropic_convert_test.rs +++ b/aimux-providers/tests/anthropic_convert_test.rs @@ -14,11 +14,10 @@ //! mid-conversation system messages, citations, ...) are intentionally omitted //! and documented in the task summary rather than translated here. -use std::collections::HashMap; - use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::tool::{FunctionTool, ToolChoice}; use aimux_core::types::Warning; use aimux_providers::anthropic::convert::convert_prompt_to_anthropic; @@ -55,10 +54,8 @@ fn ftool(name: &str, desc: &str, schema: Value) -> FunctionTool { FunctionTool::new(name.to_string(), schema).with_description(desc.to_string()) } -fn anthropic_opts(value: Value) -> Option> { - let mut map = HashMap::new(); - map.insert("anthropic".to_string(), value); - Some(map) +fn anthropic_opts(value: Value) -> Option { + Some(provider_namespace("anthropic", value)) } /// Decode a base64 string into raw bytes (mirrors the TS `data: { type: 'data', diff --git a/aimux-providers/tests/anthropic_files_test.rs b/aimux-providers/tests/anthropic_files_test.rs index 4f6127f79..0dbc8f7e4 100644 --- a/aimux-providers/tests/anthropic_files_test.rs +++ b/aimux-providers/tests/anthropic_files_test.rs @@ -259,7 +259,7 @@ async fn omits_downloadable_when_null() { let anthropic = metadata.get("anthropic").expect("anthropic metadata"); assert!( anthropic.get("downloadable").is_none(), - "downloadable should be omitted when null: {anthropic}" + "downloadable should be omitted when null: {anthropic:?}" ); } diff --git a/aimux-providers/tests/anthropic_provider_tools_test.rs b/aimux-providers/tests/anthropic_provider_tools_test.rs index 0c1c2bfbb..5df2c81aa 100644 --- a/aimux-providers/tests/anthropic_provider_tools_test.rs +++ b/aimux-providers/tests/anthropic_provider_tools_test.rs @@ -52,6 +52,7 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult}; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; use aimux_providers::anthropic::AnthropicConfig; @@ -152,10 +153,8 @@ async fn get_request_headers(server: &MockServer) -> Vec<(String, String)> { } /// Helper to build `provider_options` from a JSON value. -fn anthropic_opts(value: Value) -> Option> { - let mut map = HashMap::new(); - map.insert("anthropic".to_string(), value); - Some(map) +fn anthropic_opts(value: Value) -> Option { + Some(provider_namespace("anthropic", value)) } /// Build a provider-defined tool: `Tool::Provider(ProviderTool { id, name, args })`. @@ -172,12 +171,7 @@ fn provider_tool(id: &str, name: &str, args: Value) -> Tool { /// mixes a client-side tool with a provider-defined tool). fn calculator_tool(description: &str, eager: bool) -> Tool { let provider_options = if eager { - let mut m = HashMap::new(); - m.insert( - "anthropic".to_string(), - json!({ "eagerInputStreaming": true }), - ); - Some(m) + anthropic_opts(json!({ "eagerInputStreaming": true })) } else { None }; @@ -921,8 +915,6 @@ mod tool_search_tool { /// Helper: a function tool with deferLoading provider option. #[allow(dead_code)] fn deferred_function_tool(name: &str, desc: &str) -> FunctionTool { - let mut po = HashMap::new(); - po.insert("anthropic".to_string(), json!({ "deferLoading": true })); FunctionTool { name: name.to_string(), description: Some(desc.to_string()), @@ -931,7 +923,7 @@ mod tool_search_tool { "properties": { "location": { "type": "string" } }, }), strict: None, - provider_options: Some(po), + provider_options: anthropic_opts(json!({ "deferLoading": true })), input_examples: None, } } diff --git a/aimux-providers/tests/aws_polly_test.rs b/aimux-providers/tests/aws_polly_test.rs index 34c553f1c..1aa13be7f 100644 --- a/aimux-providers/tests/aws_polly_test.rs +++ b/aimux-providers/tests/aws_polly_test.rs @@ -271,9 +271,8 @@ async fn provider_options_forward_polly_fields() { let model = provider.speech("aws_polly/standard"); let mut options = speech_options("Hello"); - let mut provider_options = std::collections::HashMap::new(); - provider_options.insert( - "aws_polly".to_string(), + let provider_options = aimux_core::shared::provider_namespace( + "aws_polly", json!({ "engine": "neural", "sampleRate": "22050", diff --git a/aimux-providers/tests/azure_responses_test.rs b/aimux-providers/tests/azure_responses_test.rs index 928042998..31df3d0a8 100644 --- a/aimux-providers/tests/azure_responses_test.rs +++ b/aimux-providers/tests/azure_responses_test.rs @@ -605,7 +605,7 @@ async fn should_use_azure_provider_metadata_namespace() { .expect("should succeed"); let pm = result.provider_metadata.expect("provider_metadata"); - assert!(pm.get("azure").is_some(), "should have 'azure' key"); + assert!(pm.contains_key("azure"), "should have 'azure' key"); assert_eq!( pm["azure"]["responseId"], "resp_67c97c0203188190a025beb4a75242bc" diff --git a/aimux-providers/tests/bedrock_convert_test.rs b/aimux-providers/tests/bedrock_convert_test.rs index fc7945103..a5a536629 100644 --- a/aimux-providers/tests/bedrock_convert_test.rs +++ b/aimux-providers/tests/bedrock_convert_test.rs @@ -33,6 +33,7 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; use aimux_core::options::ToolChoice; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::tool::FunctionTool; use aimux_providers::bedrock::convert::{ @@ -72,7 +73,7 @@ fn file_base64( data: &str, media_type: &str, filename: Option<&str>, - provider_options: Option, + provider_options: Option, ) -> ContentPart { ContentPart::FileBase64 { data: data.to_string(), @@ -90,7 +91,10 @@ fn text_with_cache(text: &str, cache_type: &str, ttl: Option<&str>) -> ContentPa } ContentPart::Text { text: text.to_string(), - provider_options: Some(json!({ "bedrock": { "cachePoint": Value::Object(cp) } })), + provider_options: Some(provider_namespace( + "bedrock", + json!({ "cachePoint": Value::Object(cp) }), + )), } } @@ -747,7 +751,10 @@ fn citations_enabled_for_pdf() { "AAECAw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": true } } })), + Some(provider_namespace( + "bedrock", + json!({ "citations": { "enabled": true } }), + )), )])]); assert_eq!( messages[0]["content"][0], @@ -769,7 +776,10 @@ fn citations_disabled_for_pdf() { "AAECAw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": false } } })), + Some(provider_namespace( + "bedrock", + json!({ "citations": { "enabled": false } }), + )), )])]); assert_eq!( messages[0]["content"][0], @@ -814,13 +824,19 @@ fn citations_multiple_pdfs() { "AAECAw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": true } } })), + Some(provider_namespace( + "bedrock", + json!({ "citations": { "enabled": true } }), + )), ), file_base64( "BAUGBw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": false } } })), + Some(provider_namespace( + "bedrock", + json!({ "citations": { "enabled": false } }), + )), ), ])]); assert_eq!( diff --git a/aimux-providers/tests/bedrock_embedding_test.rs b/aimux-providers/tests/bedrock_embedding_test.rs index 3ccce5bd9..dd663c511 100644 --- a/aimux-providers/tests/bedrock_embedding_test.rs +++ b/aimux-providers/tests/bedrock_embedding_test.rs @@ -6,8 +6,6 @@ //! Uses Bearer token auth to avoid SigV4 signing complexity in tests (the TS //! tests use a `fakeFetchWithAuth` that injects auth headers). -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -272,8 +270,8 @@ async fn should_pass_output_dimension_cohere_v4() { let provider = test_provider(server.uri()); let model = provider.embedding_model("cohere.embed-v4:0"); - let mut provider_options = HashMap::new(); - provider_options.insert("bedrock".to_string(), json!({"outputDimension": 256})); + let provider_options = + aimux_core::shared::provider_namespace("bedrock", json!({"outputDimension": 256})); let options = EmbeddingCallOptions { values: vec![TEST_VALUES[0].to_string()], abort_signal: None, @@ -355,8 +353,8 @@ async fn should_pass_nova_embedding_dimension() { let provider = test_provider(server.uri()); let model = provider.embedding_model("amazon.nova-2-multimodal-embeddings-v1:0"); - let mut provider_options = HashMap::new(); - provider_options.insert("bedrock".to_string(), json!({"embeddingDimension": 256})); + let provider_options = + aimux_core::shared::provider_namespace("bedrock", json!({"embeddingDimension": 256})); let options = EmbeddingCallOptions { values: vec![TEST_VALUES[0].to_string()], abort_signal: None, diff --git a/aimux-providers/tests/bedrock_reasoning_test.rs b/aimux-providers/tests/bedrock_reasoning_test.rs index 232fc4271..e5a6f8266 100644 --- a/aimux-providers/tests/bedrock_reasoning_test.rs +++ b/aimux-providers/tests/bedrock_reasoning_test.rs @@ -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}; @@ -38,8 +36,9 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ProviderMetadata}; use aimux_providers::bedrock::convert::convert_prompt_to_bedrock; use aimux_providers::bedrock::event_stream; @@ -91,10 +90,8 @@ 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) +fn bedrock_provider_options(value: Value) -> Option { + Some(provider_namespace("bedrock", value)) } /// Mock the non-streaming `/converse` endpoint with a JSON body (HTTP 200). @@ -151,7 +148,7 @@ fn as_text(item: &GenerateContent) -> &str { } } -fn as_reasoning(item: &GenerateContent) -> (&str, &Option) { +fn as_reasoning(item: &GenerateContent) -> (&str, &Option) { match item { GenerateContent::Reasoning(ReasoningOutput { text, @@ -1010,9 +1007,10 @@ fn convert_reasoning_redacted_content_type() { assistant(vec![ContentPart::Reasoning { text: String::new(), signature: None, - provider_options: Some( - json!({ "bedrock": { "redactedData": "Redacted reasoning information" } }), - ), + provider_options: Some(provider_namespace( + "bedrock", + json!({ "redactedData": "Redacted reasoning information" }), + )), }]), ]; let (system, messages) = convert_prompt_to_bedrock(&prompt); diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs index 446ce9ba1..5debaf66e 100644 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ b/aimux-providers/tests/bedrock_remaining_test.rs @@ -20,8 +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}; use serial_test::serial; @@ -782,9 +780,8 @@ async fn guardrail_config_in_request_body() { let model = make_model(&server); let mut opts = default_options(test_prompt()); - let mut po = HashMap::new(); - po.insert( - "bedrock".to_string(), + let po = aimux_core::shared::provider_namespace( + "bedrock", json!({ "guardrailConfig": { "guardrailIdentifier": "-1", diff --git a/aimux-providers/tests/bedrock_reranking_test.rs b/aimux-providers/tests/bedrock_reranking_test.rs index 8ce074a20..27728418f 100644 --- a/aimux-providers/tests/bedrock_reranking_test.rs +++ b/aimux-providers/tests/bedrock_reranking_test.rs @@ -3,13 +3,12 @@ //! Source: `reference/ai/packages/amazon-bedrock/src/reranking/amazon-bedrock-reranking-model.test.ts` //! (13 test cases across "json documents" and "text documents" groups). -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::reranking_model::{RerankingCallOptions, RerankingDocuments, RerankingModel}; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_providers::{BedrockProvider, BedrockProviderConfig}; // -- helpers ----------------------------------------------------------------- @@ -29,16 +28,14 @@ fn provider(server: &MockServer) -> BedrockProvider { BedrockProvider::new(config) } -fn bedrock_provider_options() -> HashMap { - let mut po = HashMap::new(); - po.insert( - "bedrock".to_string(), +fn bedrock_provider_options() -> SharedProviderOptions { + provider_namespace( + "bedrock", json!({ "nextToken": "test-token", "additionalModelRequestFields": { "test": "test-value" } }), - ); - po + ) } fn text_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { diff --git a/aimux-providers/tests/cartesia_speech_test.rs b/aimux-providers/tests/cartesia_speech_test.rs index 18b5ae6e3..441d8ca0c 100644 --- a/aimux-providers/tests/cartesia_speech_test.rs +++ b/aimux-providers/tests/cartesia_speech_test.rs @@ -292,12 +292,13 @@ async fn should_pass_provider_specific_options() { let mut provider_options = HashMap::new(); provider_options.insert( "cartesia".to_string(), - json!({ + serde_json::from_value(json!({ "container": "raw", "encoding": "pcm_s16le", "sampleRate": 16000, "speed": 0.8, - }), + })) + .unwrap(), ); options.provider_options = Some(provider_options); @@ -330,7 +331,10 @@ async fn should_ignore_encoding_for_mp3_output() { options.voice = Some("test-voice-id".to_string()); let mut provider_options = HashMap::new(); - provider_options.insert("cartesia".to_string(), json!({ "encoding": "pcm_s16le" })); + provider_options.insert( + "cartesia".to_string(), + serde_json::from_value(json!({ "encoding": "pcm_s16le" })).unwrap(), + ); options.provider_options = Some(provider_options); let result = model.do_generate(&options).await.unwrap(); diff --git a/aimux-providers/tests/cartesia_transcription_test.rs b/aimux-providers/tests/cartesia_transcription_test.rs index 18440f426..b68ae4691 100644 --- a/aimux-providers/tests/cartesia_transcription_test.rs +++ b/aimux-providers/tests/cartesia_transcription_test.rs @@ -218,11 +218,8 @@ async fn should_warn_about_streaming_options() { let model = provider.transcription("best"); let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "cartesia".to_string(), - json!({"streaming": {"delay": "low"}}), - ); + let po = + aimux_core::shared::provider_namespace("cartesia", json!({"streaming": {"delay": "low"}})); opts.provider_options = Some(po); let result = model.do_generate(&opts).await.unwrap(); @@ -246,9 +243,8 @@ async fn should_pass_language_and_timestamp_granularities() { let model = provider.transcription("best"); let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "cartesia".to_string(), + let po = aimux_core::shared::provider_namespace( + "cartesia", json!({"language": "en", "timestampGranularities": ["word"]}), ); opts.provider_options = Some(po); diff --git a/aimux-providers/tests/cohere_embedding_test.rs b/aimux-providers/tests/cohere_embedding_test.rs index 67427c7c2..bdbd04471 100644 --- a/aimux-providers/tests/cohere_embedding_test.rs +++ b/aimux-providers/tests/cohere_embedding_test.rs @@ -183,11 +183,8 @@ async fn should_pass_input_type_setting() { let provider = CohereProvider::new(config); let model = provider.embedding_model("embed-english-v3.0"); - let mut provider_options = HashMap::new(); - provider_options.insert( - "cohere".to_string(), - json!({"inputType": "search_document"}), - ); + let provider_options = + aimux_core::shared::provider_namespace("cohere", json!({"inputType": "search_document"})); let options = EmbeddingCallOptions { values: test_values(), abort_signal: None, @@ -218,8 +215,8 @@ async fn should_pass_output_dimension_setting() { let provider = CohereProvider::new(config); let model = provider.embedding_model("embed-v4.0"); - let mut provider_options = HashMap::new(); - provider_options.insert("cohere".to_string(), json!({"outputDimension": 256})); + let provider_options = + aimux_core::shared::provider_namespace("cohere", json!({"outputDimension": 256})); let options = EmbeddingCallOptions { values: test_values(), abort_signal: None, diff --git a/aimux-providers/tests/cohere_remaining_test.rs b/aimux-providers/tests/cohere_remaining_test.rs index 5443980d2..a894c7d04 100644 --- a/aimux-providers/tests/cohere_remaining_test.rs +++ b/aimux-providers/tests/cohere_remaining_test.rs @@ -312,7 +312,10 @@ fn convert_image_file_with_detail_provider_option() { data: vec![0, 1, 2, 3], media_type: "image/png".to_string(), filename: None, - provider_options: Some(json!({ "cohere": { "detail": "high" } })), + provider_options: Some(aimux_core::shared::provider_namespace( + "cohere", + json!({ "detail": "high" }), + )), }], ..Default::default() }]; @@ -667,7 +670,7 @@ async fn should_prefer_provider_options_over_reasoning() { let mut po = std::collections::HashMap::new(); po.insert( "cohere".to_string(), - json!({ "thinking": { "type": "enabled" } }), + serde_json::from_value(json!({ "thinking": { "type": "enabled" } })).unwrap(), ); let options = CallOptions { reasoning: Some(ReasoningEffort::None), @@ -738,7 +741,8 @@ fn resolve_thinking_provider_options_override() { let mut po = std::collections::HashMap::new(); po.insert( "cohere".to_string(), - json!({ "thinking": { "type": "enabled", "tokenBudget": 5000 } }), + serde_json::from_value(json!({ "thinking": { "type": "enabled", "tokenBudget": 5000 } })) + .unwrap(), ); let result = resolve_cohere_thinking(Some(ReasoningEffort::None), &Some(po)); assert_eq!( @@ -752,7 +756,7 @@ fn resolve_thinking_provider_options_default_type() { let mut po = std::collections::HashMap::new(); po.insert( "cohere".to_string(), - json!({ "thinking": { "tokenBudget": 1000 } }), + serde_json::from_value(json!({ "thinking": { "tokenBudget": 1000 } })).unwrap(), ); let result = resolve_cohere_thinking(None, &Some(po)); // type defaults to "enabled" when not specified. diff --git a/aimux-providers/tests/cohere_reranking_test.rs b/aimux-providers/tests/cohere_reranking_test.rs index 66c31f0c7..28cafe299 100644 --- a/aimux-providers/tests/cohere_reranking_test.rs +++ b/aimux-providers/tests/cohere_reranking_test.rs @@ -3,8 +3,6 @@ //! Source: `reference/ai/packages/cohere/src/reranking/cohere-reranking-model.test.ts` //! (12 test cases across "json documents" and "text documents" groups). -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -33,13 +31,11 @@ fn provider(server: &MockServer) -> CohereProvider { CohereProvider::new(config) } -fn cohere_provider_options() -> HashMap { - let mut po = HashMap::new(); - po.insert( - "cohere".to_string(), +fn cohere_provider_options() -> aimux_core::shared::SharedProviderOptions { + aimux_core::shared::provider_namespace( + "cohere", json!({ "maxTokensPerDoc": 1000, "priority": 1 }), - ); - po + ) } fn text_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { diff --git a/aimux-providers/tests/convert_golden_test.rs b/aimux-providers/tests/convert_golden_test.rs index b0d197ffa..0a3ae341e 100644 --- a/aimux-providers/tests/convert_golden_test.rs +++ b/aimux-providers/tests/convert_golden_test.rs @@ -134,9 +134,8 @@ fn openai_chat_golden() { #[test] fn responses_golden() { - let mut provider = std::collections::HashMap::new(); - provider.insert( - "openai".to_string(), + let provider = aimux_core::shared::provider_namespace( + "openai", json!({ "reasoningEffort": "high", "textVerbosity": "low", @@ -197,9 +196,8 @@ fn responses_golden() { #[test] fn anthropic_golden() { - let mut provider = std::collections::HashMap::new(); - provider.insert( - "anthropic".to_string(), + let provider = aimux_core::shared::provider_namespace( + "anthropic", json!({ "thinking": { "type": "enabled", "budgetTokens": 4096 } }), diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index bc41d9188..e9c35c5cd 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -41,6 +41,7 @@ use aimux_core::result::{GenerateContent, GeneratedFile, ReasoningOutput, Source use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{ProviderTool, Tool}; +use aimux_core::types::ProviderMetadata; use aimux_providers::anthropic::AnthropicConfig; use aimux_providers::anthropic::model::AnthropicModel; @@ -184,7 +185,7 @@ fn texts(content: &[GenerateContent]) -> Vec<&str> { .collect() } -fn reasonings(content: &[GenerateContent]) -> Vec<(&str, Option<&Value>)> { +fn reasonings(content: &[GenerateContent]) -> Vec<(&str, Option<&ProviderMetadata>)> { content .iter() .filter_map(|c| match c { @@ -205,7 +206,7 @@ type ToolCallView<'a> = ( Option, Option, Option<&'a str>, - Option<&'a Value>, + Option<&'a ProviderMetadata>, ); fn tool_calls(content: &[GenerateContent]) -> Vec> { @@ -248,7 +249,7 @@ type ToolResultView<'a> = ( &'a Value, Option, Option, - Option<&'a Value>, + Option<&'a ProviderMetadata>, ); fn tool_results(content: &[GenerateContent]) -> Vec> { @@ -282,7 +283,7 @@ type SourceView<'a> = ( &'a str, Option<&'a str>, Option<&'a str>, - Option<&'a Value>, + Option<&'a ProviderMetadata>, ); fn sources(content: &[GenerateContent]) -> Vec> { @@ -784,7 +785,7 @@ async fn finding_2_anthropic_web_search_result_mapped_and_sources_emitted() { // assertion agree with whatever the code produced, including a renamed key. // (`get("pageAge").is_some()` is no good either — a missing key and a // recorded `null` both read back as `Some(Value::Null)`.) - let by_url: BTreeMap<&str, &Value> = s + let by_url: BTreeMap<&str, &ProviderMetadata> = s .iter() .filter_map(|(_, _, url, _, m)| Some(((*url)?, (*m)?))) .collect(); @@ -800,7 +801,10 @@ async fn finding_2_anthropic_web_search_result_mapped_and_sources_emitted() { ] { assert_eq!( by_url.get(url).copied(), - Some(&json!({ "anthropic": { "pageAge": expected_page_age } })), + Some(&aimux_core::shared::provider_namespace( + "anthropic", + json!({ "pageAge": expected_page_age }) + )), "source {url}: providerMetadata must hold exactly anthropic.pageAge" ); } @@ -1043,7 +1047,7 @@ async fn finding_2_anthropic_mcp_tool_use_and_result_are_dynamic_and_named() { assert_eq!(provider_executed, Some(true)); assert_eq!(dynamic, Some(true)); assert_eq!( - meta.expect("mcp_tool_use metadata")["anthropic"], + serde_json::to_value(&meta.expect("mcp_tool_use metadata")["anthropic"]).unwrap(), json!({ "type": "mcp-tool-use", "serverName": "deepwiki" }) ); @@ -1059,7 +1063,7 @@ async fn finding_2_anthropic_mcp_tool_use_and_result_are_dynamic_and_named() { assert_eq!(is_error, Some(false), "`is_error: false` must be preserved"); assert_eq!(rdynamic, Some(true)); assert_eq!( - rmeta.expect("mcp_tool_result metadata")["anthropic"], + serde_json::to_value(&rmeta.expect("mcp_tool_result metadata")["anthropic"]).unwrap(), json!({ "type": "mcp-tool-use", "serverName": "deepwiki" }) ); assert!( diff --git a/aimux-providers/tests/deepgram_transcription_test.rs b/aimux-providers/tests/deepgram_transcription_test.rs index 099948aac..11e3fa406 100644 --- a/aimux-providers/tests/deepgram_transcription_test.rs +++ b/aimux-providers/tests/deepgram_transcription_test.rs @@ -150,8 +150,7 @@ async fn should_pass_detect_language_query_param() { let model = provider.transcription("nova-3"); let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert("deepgram".to_string(), json!({"detectLanguage": true})); + let po = aimux_core::shared::provider_namespace("deepgram", json!({"detectLanguage": true})); opts.provider_options = Some(po); model.do_generate(&opts).await.unwrap(); @@ -170,8 +169,7 @@ async fn should_return_detected_language() { let model = provider.transcription("nova-3"); let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert("deepgram".to_string(), json!({"detectLanguage": true})); + let po = aimux_core::shared::provider_namespace("deepgram", json!({"detectLanguage": true})); opts.provider_options = Some(po); let result = model.do_generate(&opts).await.unwrap(); @@ -248,8 +246,7 @@ async fn should_return_language_from_inline_response() { let model = provider.transcription("nova-3"); let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert("deepgram".to_string(), json!({"detectLanguage": true})); + let po = aimux_core::shared::provider_namespace("deepgram", json!({"detectLanguage": true})); opts.provider_options = Some(po); let result = model.do_generate(&opts).await.unwrap(); diff --git a/aimux-providers/tests/deepseek_chat_test.rs b/aimux-providers/tests/deepseek_chat_test.rs index 79c399d9c..166fd70b8 100644 --- a/aimux-providers/tests/deepseek_chat_test.rs +++ b/aimux-providers/tests/deepseek_chat_test.rs @@ -26,8 +26,8 @@ //! overridden with the mock server's root URI (no `/v1` suffix), so the //! resulting request path is `/chat/completions`. +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::tool::RawToolCall; -use std::collections::HashMap; use futures::StreamExt; use serde_json::{Value, json}; @@ -81,10 +81,8 @@ fn make_provider(server: &MockServer, model_id: &str) -> Box } /// Wrap a value as `providerOptions.deepseek.`. -fn deepseek_opts(value: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("deepseek".to_string(), value); - Some(m) +fn deepseek_opts(value: Value) -> Option { + Some(provider_namespace("deepseek", value)) } /// Mount a JSON chat-completion response on `/chat/completions`. diff --git a/aimux-providers/tests/deepseek_reasoning_test.rs b/aimux-providers/tests/deepseek_reasoning_test.rs index 237c00e02..f8296eef1 100644 --- a/aimux-providers/tests/deepseek_reasoning_test.rs +++ b/aimux-providers/tests/deepseek_reasoning_test.rs @@ -45,7 +45,7 @@ //! resulting request path is `/chat/completions` — matching the convention in //! `openai_compatible_test.rs`. -use std::collections::HashMap; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use futures::StreamExt; use serde_json::{Value, json}; @@ -99,10 +99,8 @@ fn make_provider(server: &MockServer) -> Box { /// /// 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) +fn deepseek_opts(value: Value) -> Option { + Some(provider_namespace("deepseek", value)) } /// Mount a JSON chat-completion response on `/chat/completions`. diff --git a/aimux-providers/tests/elevenlabs_realtime_stream_test.rs b/aimux-providers/tests/elevenlabs_realtime_stream_test.rs index e00f15837..a7614c9f9 100644 --- a/aimux-providers/tests/elevenlabs_realtime_stream_test.rs +++ b/aimux-providers/tests/elevenlabs_realtime_stream_test.rs @@ -287,9 +287,8 @@ async fn stream_language_and_timestamps() { let model = realtime_model(&base_url, "scribe_v2_realtime"); let mut options = stream_options(vec![AudioChunk::Binary(vec![1])], None); - let mut provider_options = std::collections::HashMap::new(); - provider_options.insert( - "elevenlabs".to_string(), + let provider_options = aimux_core::shared::provider_namespace( + "elevenlabs", serde_json::json!({ "languageCode": "fr", "includeTimestamps": true }), ); options.provider_options = Some(provider_options); diff --git a/aimux-providers/tests/elevenlabs_speech_test.rs b/aimux-providers/tests/elevenlabs_speech_test.rs index f296c385e..690d4404b 100644 --- a/aimux-providers/tests/elevenlabs_speech_test.rs +++ b/aimux-providers/tests/elevenlabs_speech_test.rs @@ -203,13 +203,14 @@ async fn should_pass_provider_specific_options() { let mut provider_options = HashMap::new(); provider_options.insert( "elevenlabs".to_string(), - json!({ + serde_json::from_value(json!({ "voiceSettings": { "stability": 0.5, "similarityBoost": 0.75, }, "seed": 123, - }), + })) + .unwrap(), ); options.provider_options = Some(provider_options); diff --git a/aimux-providers/tests/fal_image_test.rs b/aimux-providers/tests/fal_image_test.rs index b6dfb77e0..3628ed656 100644 --- a/aimux-providers/tests/fal_image_test.rs +++ b/aimux-providers/tests/fal_image_test.rs @@ -137,7 +137,10 @@ async fn should_forward_provider_options() { opts.n = 1; opts.provider_options.insert( "fal".into(), - json!({ "numInferenceSteps": 30, "guidanceScale": 7.5, "outputFormat": "png" }), + serde_json::from_value( + json!({ "numInferenceSteps": 30, "guidanceScale": 7.5, "outputFormat": "png" }), + ) + .unwrap(), ); model.do_generate(&opts).await.unwrap(); let reqs = server.received_requests().await.unwrap(); diff --git a/aimux-providers/tests/google_embedding_test.rs b/aimux-providers/tests/google_embedding_test.rs index 33854bf8f..51bcaeac2 100644 --- a/aimux-providers/tests/google_embedding_test.rs +++ b/aimux-providers/tests/google_embedding_test.rs @@ -11,6 +11,7 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; +use aimux_core::shared::provider_namespace; use aimux_providers::{GoogleConfig, GoogleProvider}; const TEST_VALUES: &[&str] = &["sunny day at the beach", "rainy day in the city"]; @@ -150,8 +151,7 @@ async fn should_pass_output_dimensionality() { let provider = GoogleProvider::new(config); let model = provider.embedding_model("gemini-embedding-001"); - let mut provider_options = HashMap::new(); - provider_options.insert("google".to_string(), json!({"outputDimensionality": 64})); + let provider_options = provider_namespace("google", json!({"outputDimensionality": 64})); let options = EmbeddingCallOptions { values: test_values(), abort_signal: None, @@ -182,11 +182,7 @@ async fn should_pass_task_type() { let provider = GoogleProvider::new(config); let model = provider.embedding_model("gemini-embedding-001"); - let mut provider_options = HashMap::new(); - provider_options.insert( - "google".to_string(), - json!({"taskType": "SEMANTIC_SIMILARITY"}), - ); + let provider_options = provider_namespace("google", json!({"taskType": "SEMANTIC_SIMILARITY"})); let options = EmbeddingCallOptions { values: test_values(), abort_signal: None, diff --git a/aimux-providers/tests/google_files_test.rs b/aimux-providers/tests/google_files_test.rs index 17f6a132b..bd85807c5 100644 --- a/aimux-providers/tests/google_files_test.rs +++ b/aimux-providers/tests/google_files_test.rs @@ -8,15 +8,13 @@ //! 2. POST to the upload URL - returns `{ "file": { ... } }`. //! 3. GET `/{file.name}` (poll) - returns the file resource with updated state. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData}; use aimux_core::retry::RetryConfig; -use aimux_core::shared::{FileBytes, SharedProviderOptions}; +use aimux_core::shared::{FileBytes, provider_namespace}; use aimux_providers::{GoogleConfig, GoogleProvider}; // -- helpers ----------------------------------------------------------------- @@ -161,11 +159,7 @@ async fn should_include_display_name_in_initiation_body_when_provided() { let provider = provider(&server); let files = provider.files(); - let mut po = HashMap::new(); - po.insert( - "google".to_string(), - json!({ "displayName": "my-document" }), - ); + let po = provider_namespace("google", json!({ "displayName": "my-document" })); let opts = UploadFileCallOptions { data: UploadFileData::Data { data: FileBytes::Binary(vec![1]), @@ -490,9 +484,8 @@ async fn should_accept_valid_provider_options() { let provider = provider(&server); let files = provider.files(); - let mut po = HashMap::new(); - po.insert( - "google".to_string(), + let po = provider_namespace( + "google", json!({ "displayName": "test", "pollIntervalMs": 5000, "pollTimeoutMs": 60000 }), ); let opts = UploadFileCallOptions { @@ -531,11 +524,7 @@ async fn should_pass_through_unknown_properties() { let provider = provider(&server); let files = provider.files(); - let mut po: SharedProviderOptions = HashMap::new(); - po.insert( - "google".to_string(), - json!({ "customField": "custom-value" }), - ); + let po = provider_namespace("google", json!({ "customField": "custom-value" })); let opts = UploadFileCallOptions { data: UploadFileData::Data { data: FileBytes::Binary(vec![1]), diff --git a/aimux-providers/tests/google_image_test.rs b/aimux-providers/tests/google_image_test.rs index d68b53cbf..259fe1a93 100644 --- a/aimux-providers/tests/google_image_test.rs +++ b/aimux-providers/tests/google_image_test.rs @@ -13,7 +13,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, }; -use aimux_core::shared::{AspectRatio, Size}; +use aimux_core::shared::{AspectRatio, Size, provider_namespace}; use aimux_core::types::Warning; use aimux_providers::{GoogleConfig, GoogleImageSettings, GoogleProvider}; @@ -226,10 +226,10 @@ async fn should_combine_aspect_ratio_and_provider_options() { let mut opts = options("test prompt"); opts.n = 1; opts.aspect_ratio = Some(AspectRatio::new(1, 1)); - opts.provider_options.insert( - "google".to_string(), + opts.provider_options.extend(provider_namespace( + "google", json!({ "personGeneration": "dont_allow" }), - ); + )); model.do_generate(&opts).await.unwrap(); @@ -363,15 +363,15 @@ async fn should_only_pass_valid_provider_options() { let mut opts = options(PROMPT); opts.n = 2; opts.aspect_ratio = Some(AspectRatio::new(16, 9)); - opts.provider_options.insert( - "google".to_string(), + opts.provider_options.extend(provider_namespace( + "google", json!({ "addWatermark": false, "personGeneration": "allow_all", "foo": "bar", "negativePrompt": "negative prompt" }), - ); + )); model.do_generate(&opts).await.unwrap(); @@ -400,7 +400,7 @@ async fn should_emit_warning_for_google_search_on_imagen() { let mut opts = options(PROMPT); opts.n = 1; opts.provider_options - .insert("google".to_string(), json!({ "googleSearch": {} })); + .extend(provider_namespace("google", json!({ "googleSearch": {} }))); let result = model.do_generate(&opts).await.unwrap(); @@ -697,10 +697,10 @@ async fn gemini_should_forward_google_search_as_tool() { let mut opts = options("A beautiful sunset"); opts.n = 1; - opts.provider_options.insert( - "google".to_string(), + opts.provider_options.extend(provider_namespace( + "google", json!({ "googleSearch": { "searchTypes": { "imageSearch": {} } } }), - ); + )); model.do_generate(&opts).await.unwrap(); @@ -725,7 +725,7 @@ async fn gemini_should_not_leak_google_search_into_passthrough() { let mut opts = options("A beautiful sunset"); opts.n = 1; opts.provider_options - .insert("google".to_string(), json!({ "googleSearch": {} })); + .extend(provider_namespace("google", json!({ "googleSearch": {} }))); model.do_generate(&opts).await.unwrap(); @@ -842,7 +842,7 @@ async fn gemini_should_forward_grounding_metadata() { let mut opts = options("A beautiful sunset"); opts.n = 1; opts.provider_options - .insert("google".to_string(), json!({ "googleSearch": {} })); + .extend(provider_namespace("google", json!({ "googleSearch": {} }))); let result = model.do_generate(&opts).await.unwrap(); diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs index e8654f43b..9ff55e675 100644 --- a/aimux-providers/tests/google_model_test.rs +++ b/aimux-providers/tests/google_model_test.rs @@ -21,6 +21,7 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; @@ -1927,13 +1928,14 @@ mod request_body { input: json!(r#"{"query":"Singapore weather"}"#), provider_executed: Some(true), thought_signature: None, - provider_options: Some(json!({ - "google": { + provider_options: Some(provider_namespace( + "google", + json!({ "serverToolCallId": "server-call-1", "serverToolType": "GOOGLE_SEARCH_WEB", "thoughtSignature": "call-signature" - } - })), + }), + )), }, ContentPart::ToolResult { tool_call_id: "logical-call-id".to_string(), @@ -1942,13 +1944,14 @@ mod request_body { is_error: None, preliminary: None, dynamic: None, - provider_options: Some(json!({ - "google": { + provider_options: Some(provider_namespace( + "google", + json!({ "serverToolCallId": "server-call-1", "serverToolType": "GOOGLE_SEARCH_WEB", "thoughtSignature": "result-signature" - } - })), + }), + )), }, ], ..Default::default() diff --git a/aimux-providers/tests/google_provider_tools_test.rs b/aimux-providers/tests/google_provider_tools_test.rs index f24929afd..ce68a8692 100644 --- a/aimux-providers/tests/google_provider_tools_test.rs +++ b/aimux-providers/tests/google_provider_tools_test.rs @@ -42,7 +42,7 @@ use aimux_core::message::{ModelMessage, Role}; use aimux_core::options::{CallOptions, ProviderTool, Tool}; use aimux_core::result::{GenerateContent, Source, StreamResult}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::Warning; +use aimux_core::types::{ProviderMetadata, Warning}; use aimux_providers::google::convert::build_request_body; use aimux_providers::{GoogleConfig, GoogleProvider}; @@ -231,7 +231,7 @@ fn gen_tool_results(content: &[GenerateContent]) -> Vec<(String, String, Value)> /// The `provider_metadata` of the `GenerateContent::ToolCall` / `ToolResult` /// whose `tool_name` is `name`. -fn gen_server_metadata(content: &[GenerateContent], name: &str) -> Vec { +fn gen_server_metadata(content: &[GenerateContent], name: &str) -> Vec { content .iter() .filter_map(|c| match c { @@ -1190,7 +1190,10 @@ mod do_generate { assert_eq!(call.invalid, None); assert_eq!(call.input["code"], "print(2)"); assert_eq!( - call.provider_metadata.as_ref().expect("call metadata")["google"], + serde_json::to_value( + &call.provider_metadata.as_ref().expect("call metadata")["google"] + ) + .unwrap(), json!({ "serverToolCallId": call.tool_call_id, "serverToolType": "code_execution", @@ -1596,7 +1599,7 @@ mod do_generate { let meta = gen_server_metadata(&result.content, "server:GOOGLE_SEARCH_WEB"); assert_eq!(meta.len(), 2, "both the call and the result carry metadata"); assert_eq!( - meta[0]["google"], + serde_json::to_value(&meta[0]["google"]).unwrap(), json!({ "serverToolCallId": "server-call-1", "serverToolType": "GOOGLE_SEARCH_WEB", @@ -1604,7 +1607,7 @@ mod do_generate { }) ); assert_eq!( - meta[1]["google"], + serde_json::to_value(&meta[1]["google"]).unwrap(), json!({ "serverToolCallId": "server-call-1", "serverToolType": "GOOGLE_SEARCH_WEB", @@ -1701,7 +1704,7 @@ mod do_stream { } /// Extract the `Finish` part's provider metadata from a collected stream. - fn finish_provider_metadata(parts: &[StreamPart]) -> Option { + fn finish_provider_metadata(parts: &[StreamPart]) -> Option { parts.iter().find_map(|p| match p { StreamPart::Finish { provider_metadata, .. @@ -1953,7 +1956,7 @@ mod do_stream { provider_metadata: Some(metadata), .. }) if tool_name == "runCode" - && metadata["google"] == json!({ + && serde_json::to_value(&metadata["google"]).unwrap() == json!({ "serverToolCallId": tool_call_id, "serverToolType": "code_execution", }) @@ -1966,7 +1969,7 @@ mod do_stream { provider_metadata: Some(metadata), .. }) if tool_name == "runCode" - && metadata["google"] == json!({ + && serde_json::to_value(&metadata["google"]).unwrap() == json!({ "serverToolCallId": tool_call_id, "serverToolType": "code_execution", }) @@ -2206,7 +2209,7 @@ mod do_stream { // Per-part providerMetadata (ids/types + the thoughtSignature that must // be echoed back on the follow-up turn). - let meta: Vec = parts + let meta: Vec = parts .iter() .filter_map(|p| match p { StreamPart::ToolCall(RawToolCall { @@ -2224,7 +2227,7 @@ mod do_stream { .collect(); assert_eq!(meta.len(), 2, "both the call and the result carry metadata"); assert_eq!( - meta[0]["google"], + serde_json::to_value(&meta[0]["google"]).unwrap(), json!({ "serverToolCallId": "server-call-1", "serverToolType": "GOOGLE_SEARCH_WEB", @@ -2232,7 +2235,7 @@ mod do_stream { }) ); assert_eq!( - meta[1]["google"], + serde_json::to_value(&meta[1]["google"]).unwrap(), json!({ "serverToolCallId": "server-call-1", "serverToolType": "GOOGLE_SEARCH_WEB", diff --git a/aimux-providers/tests/google_pse_test.rs b/aimux-providers/tests/google_pse_test.rs index 79e01fe07..e693da8a3 100644 --- a/aimux-providers/tests/google_pse_test.rs +++ b/aimux-providers/tests/google_pse_test.rs @@ -6,8 +6,6 @@ //! //! Tests do not hit the public network and do not read real credentials. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -15,6 +13,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::Provider; use aimux_core::error::AiMuxError; use aimux_core::search_model::{SearchCallOptions, SearchModel}; +use aimux_core::shared::provider_namespace; use aimux_providers::{GooglePseConfig, GooglePseProvider}; const API_KEY: &str = "test-google-key"; @@ -173,8 +172,7 @@ async fn cx_resolved_from_provider_options_when_config_has_none() { let model = provider.search_model(); let mut options = opts("rust language"); - let mut po = HashMap::new(); - po.insert("google_pse".to_string(), json!({ "cx": "options-cx" })); + let po = provider_namespace("google_pse", json!({ "cx": "options-cx" })); options.provider_options = Some(po); model.do_search(&options).await.unwrap(); diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 500de49cb..326409b88 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -30,6 +30,7 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, ReasoningOutput}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -1196,8 +1197,7 @@ mod do_generate { let model = make_provider(&server); - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"reasoningEffort": "high"})); + let provider_opts = provider_namespace("groq", json!({"reasoningEffort": "high"})); let options = CallOptions { reasoning: Some(ReasoningEffort::Medium), provider_options: Some(provider_opts), @@ -1355,9 +1355,8 @@ mod do_generate { let model = make_provider(&server); - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert( - "groq".to_string(), + let provider_opts = provider_namespace( + "groq", json!({ "reasoningFormat": "hidden", "user": "test-user-id", @@ -1384,8 +1383,7 @@ mod do_generate { let model = make_provider(&server); - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"serviceTier": "flex"})); + let provider_opts = provider_namespace("groq", json!({"serviceTier": "flex"})); let options = CallOptions { provider_options: Some(provider_opts), ..default_options(test_prompt()) @@ -1404,8 +1402,7 @@ mod do_generate { let model = make_provider(&server); - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"serviceTier": "performance"})); + let provider_opts = provider_namespace("groq", json!({"serviceTier": "performance"})); let options = CallOptions { provider_options: Some(provider_opts), ..default_options(test_prompt()) @@ -1489,8 +1486,7 @@ mod do_generate { let model = make_provider(&server); - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"structuredOutputs": false})); + let provider_opts = provider_namespace("groq", json!({"structuredOutputs": false})); let options = CallOptions { provider_options: Some(provider_opts), response_format: Some(ResponseFormat::Json { @@ -1525,8 +1521,7 @@ mod do_generate { let model = make_provider(&server); - let mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), json!({"strictJsonSchema": false})); + let provider_opts = provider_namespace("groq", json!({"strictJsonSchema": false})); let options = CallOptions { provider_options: Some(provider_opts), response_format: Some(ResponseFormat::Json { diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index a00387a8f..190613acf 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -1427,7 +1427,10 @@ async fn should_handle_reasoning_content_in_responses() { assert_eq!(text, "Let me think about this problem step by step..."); assert_eq!( provider_metadata.as_ref(), - Some(&json!({ "huggingface": { "itemId": "reasoning_1" } })) + Some(&aimux_core::shared::provider_namespace( + "huggingface", + json!({ "itemId": "reasoning_1" }) + )) ); } other => panic!("expected Reasoning at [0], got {other:?}"), @@ -1512,7 +1515,10 @@ async fn should_stream_reasoning_content() { assert_eq!(id, "reasoning_stream"); assert_eq!( provider_metadata.as_ref(), - Some(&json!({ "huggingface": { "itemId": "reasoning_stream" } })) + Some(&aimux_core::shared::provider_namespace( + "huggingface", + json!({ "itemId": "reasoning_stream" }) + )) ); } other => panic!("expected ReasoningStart, got {other:?}"), @@ -1610,11 +1616,12 @@ async fn should_send_provider_specific_options() { let mut po = HashMap::new(); po.insert( "huggingface".to_string(), - json!({ + serde_json::from_value(json!({ "metadata": { "key": "value" }, "instructions": "Be concise", "strictJsonSchema": true - }), + })) + .unwrap(), ); options.provider_options = Some(po); options.response_format = Some(ResponseFormat::Json { diff --git a/aimux-providers/tests/jina_ai_test.rs b/aimux-providers/tests/jina_ai_test.rs index 7729fd34a..4fdce3cd5 100644 --- a/aimux-providers/tests/jina_ai_test.rs +++ b/aimux-providers/tests/jina_ai_test.rs @@ -6,8 +6,6 @@ //! //! Tests do not hit the public network and do not read real credentials. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -91,13 +89,8 @@ fn json_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { } } -fn jina_provider_options(return_documents: bool) -> HashMap { - let mut po = HashMap::new(); - po.insert( - "jina".to_string(), - json!({ "returnDocuments": return_documents }), - ); - po +fn jina_provider_options(return_documents: bool) -> aimux_core::shared::SharedProviderOptions { + aimux_core::shared::provider_namespace("jina", json!({ "returnDocuments": return_documents })) } async fn mount_rerank_mock(server: &MockServer) { diff --git a/aimux-providers/tests/linkup_test.rs b/aimux-providers/tests/linkup_test.rs index 898b2178e..891e4114f 100644 --- a/aimux-providers/tests/linkup_test.rs +++ b/aimux-providers/tests/linkup_test.rs @@ -6,8 +6,6 @@ //! //! Tests do not hit the public network and do not read real credentials. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -62,13 +60,14 @@ fn opts(query: &str) -> SearchCallOptions { SearchCallOptions::new(query) } -fn linkup_provider_options(depth: &str, output_type: &str) -> HashMap { - let mut po = HashMap::new(); - po.insert( - "linkup".to_string(), +fn linkup_provider_options( + depth: &str, + output_type: &str, +) -> aimux_core::shared::SharedProviderOptions { + aimux_core::shared::provider_namespace( + "linkup", json!({ "depth": depth, "outputType": output_type }), - ); - po + ) } async fn mount_search_mock(server: &MockServer) { diff --git a/aimux-providers/tests/lmnt_speech_test.rs b/aimux-providers/tests/lmnt_speech_test.rs index 382959dab..456481a49 100644 --- a/aimux-providers/tests/lmnt_speech_test.rs +++ b/aimux-providers/tests/lmnt_speech_test.rs @@ -247,7 +247,10 @@ async fn should_handle_different_audio_formats() { let mut options = speech_options("Hello from the AI SDK!"); let mut provider_options = HashMap::new(); - provider_options.insert("lmnt".to_string(), json!({ "format": format })); + provider_options.insert( + "lmnt".to_string(), + serde_json::from_value(json!({ "format": format })).unwrap(), + ); options.provider_options = Some(provider_options); let result = model.do_generate(&options).await.unwrap(); diff --git a/aimux-providers/tests/open_responses_test.rs b/aimux-providers/tests/open_responses_test.rs index 037959263..576f3a911 100644 --- a/aimux-providers/tests/open_responses_test.rs +++ b/aimux-providers/tests/open_responses_test.rs @@ -1333,10 +1333,8 @@ mod do_generate_tests { // -- ProviderOptions reasoning tests -- - fn lmstudio_opts(value: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("lmstudio".to_string(), value); - Some(m) + fn lmstudio_opts(value: Value) -> Option { + Some(aimux_core::shared::provider_namespace("lmstudio", value)) } #[tokio::test] diff --git a/aimux-providers/tests/openai_convert_extended_test.rs b/aimux-providers/tests/openai_convert_extended_test.rs index 6e6f71d17..761f991f4 100644 --- a/aimux-providers/tests/openai_convert_extended_test.rs +++ b/aimux-providers/tests/openai_convert_extended_test.rs @@ -11,6 +11,7 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ToolChoice}; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::types::ReasoningEffort; use aimux_providers::openai::OpenAICompatProfile; use aimux_providers::openai::convert::{ @@ -70,10 +71,8 @@ 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 po(map: Value) -> Option { + Some(provider_namespace("openai", map)) } // �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T @@ -106,9 +105,7 @@ mod convert_extended { role: Role::System, content: vec![ContentPart::Text { text: "You are a helpful assistant.".into(), - provider_options: Some( - json!({ "openai": { "promptCacheBreakpoint": { "mode": "explicit" } } }), - ), + provider_options: po(json!({ "promptCacheBreakpoint": { "mode": "explicit" } })), }], ..Default::default() }]; @@ -122,7 +119,7 @@ mod convert_extended { #[test] fn adds_bpt_to_user_content() { let bpt = json!({ "mode": "explicit" }); - let o = Some(json!({ "openai": { "promptCacheBreakpoint": bpt } })); + let o = po(json!({ "promptCacheBreakpoint": bpt })); let p: LanguageModelPrompt = vec![up(vec![ ContentPart::Text { text: "Hello".into(), @@ -164,7 +161,7 @@ mod convert_extended { data: "AAECAw==".into(), media_type: "image/png".into(), filename: None, - provider_options: Some(json!({ "openai": { "imageDetail": "low" } })), + provider_options: po(json!({ "imageDetail": "low" })), }])]; let r = convert_prompt_to_openai_messages(&p); assert_eq!( @@ -346,9 +343,7 @@ mod convert_extended { role: Role::Assistant, content: vec![ContentPart::Text { text: "Cached assistant content".into(), - provider_options: Some( - json!({ "openai": { "promptCacheBreakpoint": { "mode": "explicit" } } }), - ), + provider_options: po(json!({ "promptCacheBreakpoint": { "mode": "explicit" } })), }], ..Default::default() }]; diff --git a/aimux-providers/tests/openai_convert_test.rs b/aimux-providers/tests/openai_convert_test.rs index 5f1222740..3f8955677 100644 --- a/aimux-providers/tests/openai_convert_test.rs +++ b/aimux-providers/tests/openai_convert_test.rs @@ -532,9 +532,8 @@ mod build_request_body_tests { /// provider_options whitelist (the only "quietly no-op" option). #[test] fn passes_logprobs_and_top_logprobs() { - let mut provider_options = std::collections::HashMap::new(); - provider_options.insert( - "openai".to_string(), + let provider_options = aimux_core::shared::provider_namespace( + "openai", json!({ "logprobs": true, "topLogprobs": 3 }), ); let options = CallOptions { diff --git a/aimux-providers/tests/openai_embedding_test.rs b/aimux-providers/tests/openai_embedding_test.rs index 1c7bc3eae..4000dbb49 100644 --- a/aimux-providers/tests/openai_embedding_test.rs +++ b/aimux-providers/tests/openai_embedding_test.rs @@ -14,6 +14,7 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; +use aimux_core::shared::provider_namespace; use aimux_providers::{OpenAIConfig, OpenAIProvider}; // ── helpers ───────────────────────────────────────────────────────────────── @@ -241,8 +242,7 @@ async fn should_pass_dimensions_setting() { let provider = OpenAIProvider::new(config); let model = provider.embedding_model("text-embedding-3-large"); - let mut provider_options = HashMap::new(); - provider_options.insert("openai".to_string(), json!({"dimensions": 64})); + let provider_options = provider_namespace("openai", json!({"dimensions": 64})); let options = EmbeddingCallOptions { values: test_values(), abort_signal: None, diff --git a/aimux-providers/tests/openai_files_test.rs b/aimux-providers/tests/openai_files_test.rs index ab503b3ba..f25a29653 100644 --- a/aimux-providers/tests/openai_files_test.rs +++ b/aimux-providers/tests/openai_files_test.rs @@ -14,7 +14,7 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData}; -use aimux_core::shared::{FileBytes, SharedProviderOptions}; +use aimux_core::shared::{FileBytes, SharedProviderOptions, provider_namespace}; use aimux_providers::{OpenAIConfig, OpenAIProvider}; // ── helpers ───────────────────────────────────────────────────────────────── @@ -67,8 +67,7 @@ async fn should_send_multipart_request_with_purpose() { let files = provider.files(); let mut opts = upload_options(None); - let mut po = HashMap::new(); - po.insert("openai".to_string(), json!({ "purpose": "assistants" })); + let po = provider_namespace("openai", json!({ "purpose": "assistants" })); opts.provider_options = Some(po); files.upload_file(&opts).await.unwrap(); @@ -100,8 +99,7 @@ async fn should_return_provider_reference_with_openai_key() { let provider = provider(&server); let files = provider.files(); - let mut po = HashMap::new(); - po.insert("openai".to_string(), json!({ "purpose": "assistants" })); + let po = provider_namespace("openai", json!({ "purpose": "assistants" })); let opts = upload_options(Some(po)); let result = files.upload_file(&opts).await.unwrap(); @@ -124,8 +122,7 @@ async fn should_return_provider_metadata_from_response() { let provider = provider(&server); let files = provider.files(); - let mut po = HashMap::new(); - po.insert("openai".to_string(), json!({ "purpose": "assistants" })); + let po = provider_namespace("openai", json!({ "purpose": "assistants" })); let opts = upload_options(Some(po)); let result = files.upload_file(&opts).await.unwrap(); @@ -175,9 +172,8 @@ async fn should_pass_expires_after_when_provided() { let provider = provider(&server); let files = provider.files(); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), + let po = provider_namespace( + "openai", json!({ "purpose": "assistants", "expiresAfter": 3600 }), ); let opts = upload_options(Some(po)); @@ -212,8 +208,7 @@ async fn should_pass_auth_headers() { let provider = OpenAIProvider::new(config); let files = provider.files(); - let mut po = HashMap::new(); - po.insert("openai".to_string(), json!({ "purpose": "assistants" })); + let po = provider_namespace("openai", json!({ "purpose": "assistants" })); let opts = upload_options(Some(po)); files.upload_file(&opts).await.unwrap(); diff --git a/aimux-providers/tests/openai_image_test.rs b/aimux-providers/tests/openai_image_test.rs index a76deedda..7a8c23d84 100644 --- a/aimux-providers/tests/openai_image_test.rs +++ b/aimux-providers/tests/openai_image_test.rs @@ -20,6 +20,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, }; +use aimux_core::shared::provider_namespace; use aimux_core::shared::{AspectRatio, Size}; use aimux_providers::{OpenAIConfig, OpenAIProvider}; @@ -141,8 +142,7 @@ async fn should_pass_the_model_and_the_settings() { let mut opts = options(PROMPT); opts.n = 1; opts.size = Some(Size::new(1024, 1024)); - opts.provider_options - .insert("openai".to_string(), json!({ "style": "vivid" })); + opts.provider_options = provider_namespace("openai", json!({ "style": "vivid" })); model.do_generate(&opts).await.unwrap(); @@ -170,8 +170,8 @@ async fn should_map_provider_options_to_snake_case_for_generations() { let mut opts = options(PROMPT); opts.n = 1; opts.size = Some(Size::new(1024, 1024)); - opts.provider_options.insert( - "openai".to_string(), + opts.provider_options = provider_namespace( + "openai", json!({ "quality": "high", "background": "transparent", @@ -224,8 +224,7 @@ async fn should_pass_headers() { let mut opts = options(PROMPT); opts.n = 1; opts.size = Some(Size::new(1024, 1024)); - opts.provider_options - .insert("openai".to_string(), json!({ "style": "vivid" })); + opts.provider_options = provider_namespace("openai", json!({ "style": "vivid" })); let mut req_headers = HashMap::new(); req_headers.insert( "Custom-Request-Header".to_string(), @@ -605,8 +604,7 @@ async fn should_return_image_meta_data() { let mut opts = options(PROMPT); opts.n = 1; opts.size = Some(Size::new(1024, 1024)); - opts.provider_options - .insert("openai".to_string(), json!({ "style": "vivid" })); + opts.provider_options = provider_namespace("openai", json!({ "style": "vivid" })); let result = model.do_generate(&opts).await.unwrap(); @@ -876,8 +874,8 @@ async fn should_pass_provider_options_in_form_data() { opts.n = 1; opts.size = Some(Size::new(1024, 1024)); opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - opts.provider_options.insert( - "openai".to_string(), + opts.provider_options = provider_namespace( + "openai", json!({ "quality": "high", "background": "transparent" @@ -920,8 +918,8 @@ async fn should_map_provider_options_to_snake_case_for_edits() { opts.n = 1; opts.size = Some(Size::new(1024, 1024)); opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - opts.provider_options.insert( - "openai".to_string(), + opts.provider_options = provider_namespace( + "openai", json!({ "inputFidelity": "high", "outputFormat": "webp", diff --git a/aimux-providers/tests/openai_model_extended_test.rs b/aimux-providers/tests/openai_model_extended_test.rs index ad84a04a9..adaee7465 100644 --- a/aimux-providers/tests/openai_model_extended_test.rs +++ b/aimux-providers/tests/openai_model_extended_test.rs @@ -14,6 +14,7 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::{CallOptions, ToolChoice}; use aimux_core::result::{GenerateContent, Source}; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -53,10 +54,8 @@ 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 po(map: Value) -> Option { + Some(provider_namespace("openai", map)) } fn sse_event(json_str: &str) -> String { format!("data: {json_str}\n\n") diff --git a/aimux-providers/tests/openai_responses_test.rs b/aimux-providers/tests/openai_responses_test.rs index db1e30bcb..527aa2dbc 100644 --- a/aimux-providers/tests/openai_responses_test.rs +++ b/aimux-providers/tests/openai_responses_test.rs @@ -30,6 +30,7 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::{CallOptions, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; @@ -202,11 +203,7 @@ mod do_generate_request { let options = CallOptions { temperature: Some(0.5), top_p: Some(0.3), - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "maxToolCalls": 10 })); - m - }), + provider_options: Some(provider_namespace("openai", json!({ "maxToolCalls": 10 }))), ..CallOptions::new(prompt) }; @@ -347,11 +344,7 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("gpt-5-mini"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "store": false })); - m - }), + provider_options: Some(provider_namespace("openai", json!({ "store": false }))), ..CallOptions::new(test_prompt()) }; @@ -377,11 +370,7 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("gpt-4o"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "store": false })); - m - }), + provider_options: Some(provider_namespace("openai", json!({ "store": false }))), ..CallOptions::new(test_prompt()) }; @@ -406,11 +395,7 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("gpt-4o"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "store": true })); - m - }), + provider_options: Some(provider_namespace("openai", json!({ "store": true }))), ..CallOptions::new(test_prompt()) }; @@ -434,14 +419,10 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("gpt-4o"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert( - "openai".to_string(), - json!({ "previousResponseId": "resp_123" }), - ); - m - }), + provider_options: Some(provider_namespace( + "openai", + json!({ "previousResponseId": "resp_123" }), + )), ..CallOptions::new(test_prompt()) }; @@ -465,14 +446,10 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("gpt-4o"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert( - "openai".to_string(), - json!({ "conversation": "conv_123", "previousResponseId": "resp_123" }), - ); - m - }), + provider_options: Some(provider_namespace( + "openai", + json!({ "conversation": "conv_123", "previousResponseId": "resp_123" }), + )), ..CallOptions::new(test_prompt()) }; @@ -503,14 +480,10 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("o3-mini"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert( - "openai".to_string(), - json!({ "reasoningEffort": "low", "reasoningSummary": "auto" }), - ); - m - }), + provider_options: Some(provider_namespace( + "openai", + json!({ "reasoningEffort": "low", "reasoningSummary": "auto" }), + )), ..CallOptions::new(test_prompt()) }; @@ -536,11 +509,10 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("gpt-5.1-codex-max"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "reasoningEffort": "xhigh" })); - m - }), + provider_options: Some(provider_namespace( + "openai", + json!({ "reasoningEffort": "xhigh" }), + )), ..CallOptions::new(test_prompt()) }; @@ -564,11 +536,10 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("gpt-4o"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "parallelToolCalls": false })); - m - }), + provider_options: Some(provider_namespace( + "openai", + json!({ "parallelToolCalls": false }), + )), ..CallOptions::new(test_prompt()) }; @@ -591,11 +562,7 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("gpt-4o"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "user": "user_123" })); - m - }), + provider_options: Some(provider_namespace("openai", json!({ "user": "user_123" }))), ..CallOptions::new(test_prompt()) }; @@ -618,11 +585,10 @@ mod do_generate_request { let model = OpenAIProvider::new(config).responses_model("gpt-4o"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "conversation": "conv_123" })); - m - }), + provider_options: Some(provider_namespace( + "openai", + json!({ "conversation": "conv_123" }), + )), ..CallOptions::new(test_prompt()) }; @@ -1453,11 +1419,7 @@ mod do_stream { let model = OpenAIProvider::new(config).responses_model("o3-mini"); let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "store": true })); - m - }), + provider_options: Some(provider_namespace("openai", json!({ "store": true }))), ..CallOptions::new(test_prompt()) }; diff --git a/aimux-providers/tests/openai_transcription_test.rs b/aimux-providers/tests/openai_transcription_test.rs index 73d930a33..83e90cc9c 100644 --- a/aimux-providers/tests/openai_transcription_test.rs +++ b/aimux-providers/tests/openai_transcription_test.rs @@ -1,4 +1,4 @@ -//! Rust translation of the OpenAI transcription (STT) model tests. +//! Rust translation of the OpenAI transcription (STT) model tests. //! //! Source: `reference/ai/packages/openai/src/transcription/openai-transcription-model.test.ts` //! @@ -21,6 +21,7 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::error::AiMuxError; +use aimux_core::shared::provider_namespace; use aimux_core::transcription_model::{AudioInput, TranscriptionCallOptions, TranscriptionModel}; use aimux_providers::{OpenAIConfig, OpenAIProvider}; @@ -329,11 +330,7 @@ async fn should_pass_response_format_with_timestamp_granularities_word() { let model = provider.transcription("whisper-1"); let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["word"]}), - ); + let po = provider_namespace("openai", json!({"timestampGranularities": ["word"]})); options.provider_options = Some(po); model.do_generate(&options).await.unwrap(); @@ -358,11 +355,7 @@ async fn should_use_json_for_gpt4o_transcribe() { let model = provider.transcription("gpt-4o-transcribe"); let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["word"]}), - ); + let po = provider_namespace("openai", json!({"timestampGranularities": ["word"]})); options.provider_options = Some(po); model.do_generate(&options).await.unwrap(); @@ -387,11 +380,7 @@ async fn should_pass_timestamp_granularities_segment() { let model = provider.transcription("whisper-1"); let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["segment"]}), - ); + let po = provider_namespace("openai", json!({"timestampGranularities": ["segment"]})); options.provider_options = Some(po); model.do_generate(&options).await.unwrap(); @@ -474,11 +463,7 @@ async fn should_parse_segments() { let model = provider.transcription("whisper-1"); let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["segment"]}), - ); + let po = provider_namespace("openai", json!({"timestampGranularities": ["segment"]})); options.provider_options = Some(po); let result = model.do_generate(&options).await.unwrap(); @@ -520,11 +505,7 @@ async fn should_fallback_to_words() { let model = provider.transcription("whisper-1"); let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({"timestampGranularities": ["word"]}), - ); + let po = provider_namespace("openai", json!({"timestampGranularities": ["word"]})); options.provider_options = Some(po); let result = model.do_generate(&options).await.unwrap(); diff --git a/aimux-providers/tests/recraft_test.rs b/aimux-providers/tests/recraft_test.rs index ad3d2705f..24f510534 100644 --- a/aimux-providers/tests/recraft_test.rs +++ b/aimux-providers/tests/recraft_test.rs @@ -135,8 +135,8 @@ async fn should_forward_recraft_extension_fields() { let model = make_model(&server); let mut opts = options(PROMPT); - opts.provider_options.insert( - "recraft".to_string(), + opts.provider_options = aimux_core::shared::provider_namespace( + "recraft", json!({ "style": "digital_illustration", "styleId": "50c0b14e-3e4f-4a18-9d8e-2b1f0a1c2d3e", diff --git a/aimux-providers/tests/replicate_image_test.rs b/aimux-providers/tests/replicate_image_test.rs index 8d5206282..e4b9f64a5 100644 --- a/aimux-providers/tests/replicate_image_test.rs +++ b/aimux-providers/tests/replicate_image_test.rs @@ -121,7 +121,10 @@ async fn should_use_custom_wait_time() { let mut opts = options(PROMPT); opts.n = 1; opts.provider_options - .insert("replicate".into(), json!({ "maxWaitTimeInSeconds": 30 })); + .extend(aimux_core::shared::provider_namespace( + "replicate", + json!({ "maxWaitTimeInSeconds": 30 }), + )); model.do_generate(&opts).await.unwrap(); let reqs = server.received_requests().await.unwrap(); assert_eq!( diff --git a/aimux-providers/tests/vertex_embedding_test.rs b/aimux-providers/tests/vertex_embedding_test.rs index 856c7dd54..3c4b37450 100644 --- a/aimux-providers/tests/vertex_embedding_test.rs +++ b/aimux-providers/tests/vertex_embedding_test.rs @@ -3,29 +3,26 @@ //! Source: `reference/ai/packages/google-vertex/src/google-vertex-embedding-model.test.ts` //! (460 lines, 12 cases). -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_providers::{VertexAuth, VertexProvider, VertexProviderConfig}; const TEST_VALUES: &[&str] = &["test text one", "test text two"]; -fn mock_provider_options() -> HashMap { - let mut opts = HashMap::new(); - opts.insert( - "google".to_string(), +fn mock_provider_options() -> SharedProviderOptions { + provider_namespace( + "google", json!({ "outputDimensionality": 768, "taskType": "SEMANTIC_SIMILARITY", "title": "test title", "autoTruncate": false }), - ); - opts + ) } /// The fixture response body from `__fixtures__/google-vertex-embedding.json`. @@ -190,9 +187,8 @@ async fn should_accept_google_vertex_key() { let provider = test_provider(server.uri()); let model = provider.embedding_model("textembedding-gecko@001"); - let mut provider_options = HashMap::new(); - provider_options.insert( - "googleVertex".to_string(), + let provider_options = provider_namespace( + "googleVertex", json!({ "outputDimensionality": 768, "taskType": "SEMANTIC_SIMILARITY", @@ -230,11 +226,7 @@ async fn should_pass_task_type_only() { let provider = test_provider(server.uri()); let model = provider.embedding_model("textembedding-gecko@001"); - let mut provider_options = HashMap::new(); - provider_options.insert( - "google".to_string(), - json!({"taskType": "SEMANTIC_SIMILARITY"}), - ); + let provider_options = provider_namespace("google", json!({"taskType": "SEMANTIC_SIMILARITY"})); let options = EmbeddingCallOptions { values: test_values(), abort_signal: None, diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs index 8b66ed7c8..814d7da06 100644 --- a/aimux-providers/tests/vertex_model_test.rs +++ b/aimux-providers/tests/vertex_model_test.rs @@ -25,7 +25,7 @@ use aimux_core::message::{ModelMessage, Role}; use aimux_core::options::{CallOptions, ProviderTool, Tool}; use aimux_core::result::{GenerateContent, Source, StreamResult}; use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; +use aimux_core::types::{FinishReasonUnified, ProviderMetadata}; use aimux_providers::vertex::{VertexAuth, VertexConfig, VertexModel, VertexProviderConfig}; @@ -254,7 +254,7 @@ async fn vertex_renamed_code_execution_passes_core_generate_boundary() { assert_eq!(call.invalid, None); let call_metadata = call.provider_metadata.as_ref().expect("call metadata"); assert_eq!( - call_metadata["googleVertex"], + serde_json::to_value(&call_metadata["googleVertex"]).unwrap(), json!({ "serverToolCallId": call.tool_call_id, "serverToolType": "code_execution", @@ -352,7 +352,10 @@ async fn vertex_server_tool_call_and_response_pass_core_generate_boundary() { assert_eq!(call.dynamic, Some(true)); assert_eq!(call.invalid, None); assert_eq!( - call.provider_metadata.as_ref().expect("call metadata")["googleVertex"], + serde_json::to_value( + &call.provider_metadata.as_ref().expect("call metadata")["googleVertex"] + ) + .unwrap(), json!({ "serverToolCallId": "server-call-1", "serverToolType": "GOOGLE_SEARCH_WEB", @@ -382,7 +385,7 @@ async fn vertex_server_tool_call_and_response_pass_core_generate_boundary() { }) if tool_call_id == "server-call-1" && tool_name == "server:GOOGLE_SEARCH_WEB" && *result == json!({ "results": [{ "title": "Sunny" }] }) - && metadata["googleVertex"] == json!({ + && serde_json::to_value(&metadata["googleVertex"]).unwrap() == json!({ "serverToolCallId": "server-call-1", "serverToolType": "GOOGLE_SEARCH_WEB", "thoughtSignature": "result-signature" @@ -852,7 +855,7 @@ fn stream_chunk( } /// Extract the `Finish` part's provider metadata from a collected stream. -fn finish_provider_metadata(parts: &[StreamPart]) -> Option { +fn finish_provider_metadata(parts: &[StreamPart]) -> Option { parts.iter().find_map(|p| match p { StreamPart::Finish { provider_metadata, .. @@ -998,7 +1001,7 @@ async fn vertex_stream_code_execution_tool_calls_and_results() { provider_metadata: Some(metadata), .. }) if tool_name == "runCode" - && metadata["googleVertex"] == json!({ + && serde_json::to_value(&metadata["googleVertex"]).unwrap() == json!({ "serverToolCallId": tool_call_id, "serverToolType": "code_execution", }) @@ -1013,7 +1016,7 @@ async fn vertex_stream_code_execution_tool_calls_and_results() { provider_metadata: Some(metadata), .. }) if tool_name == "runCode" - && metadata["googleVertex"] == json!({ + && serde_json::to_value(&metadata["googleVertex"]).unwrap() == json!({ "serverToolCallId": tool_call_id, "serverToolType": "code_execution", }) @@ -1365,10 +1368,10 @@ async fn vertex_stream_finish_provider_metadata() { let pm = finish_provider_metadata(&parts).expect("finish part"); let vertex = &pm["googleVertex"]; assert_eq!(&pm["vertex"], vertex); - assert!(pm.get("google").is_none()); + assert!(!pm.contains_key("google")); assert!( - !vertex.is_null() && vertex.as_object().map(|o| !o.is_empty()).unwrap_or(false), - "googleVertex provider metadata should be non-empty, got {pm}" + !vertex.is_empty(), + "googleVertex provider metadata should be non-empty, got {pm:?}" ); assert_eq!( vertex["groundingMetadata"], @@ -1404,7 +1407,7 @@ async fn vertex_stream_finish_provider_metadata() { ] { assert!( vertex.get(key).is_some(), - "expected key `{key}` in googleVertex metadata, got {pm}" + "expected key `{key}` in googleVertex metadata, got {pm:?}" ); } // …and the three snapshot fields carry their captured values, not just diff --git a/aimux-providers/tests/vertex_transcription_test.rs b/aimux-providers/tests/vertex_transcription_test.rs index 4498d0bcd..f1738464c 100644 --- a/aimux-providers/tests/vertex_transcription_test.rs +++ b/aimux-providers/tests/vertex_transcription_test.rs @@ -208,9 +208,8 @@ async fn should_use_provider_options_for_region_and_language() { let model = provider.transcription("chirp_2").unwrap(); let mut opts = options(mock_audio(), "audio/wav"); - let mut po = HashMap::new(); - po.insert( - "googleVertex".to_string(), + let po = aimux_core::shared::provider_namespace( + "googleVertex", json!({"region": "us", "languageCodes": ["en", "es"]}), ); opts.provider_options = Some(po); diff --git a/aimux-providers/tests/voyage_embedding_test.rs b/aimux-providers/tests/voyage_embedding_test.rs index 8c32565ec..7588a4ac9 100644 --- a/aimux-providers/tests/voyage_embedding_test.rs +++ b/aimux-providers/tests/voyage_embedding_test.rs @@ -187,8 +187,8 @@ async fn should_pass_input_type_setting() { let provider = VoyageProvider::new(config); let model = provider.embedding_model("voyage-3.5"); - let mut provider_options = HashMap::new(); - provider_options.insert("voyage".to_string(), json!({"inputType": "document"})); + let provider_options = + aimux_core::shared::provider_namespace("voyage", json!({"inputType": "document"})); let options = EmbeddingCallOptions { values: test_values(), abort_signal: None, @@ -219,8 +219,8 @@ async fn should_pass_output_dimension_setting() { let provider = VoyageProvider::new(config); let model = provider.embedding_model("voyage-3.5"); - let mut provider_options = HashMap::new(); - provider_options.insert("voyage".to_string(), json!({"outputDimension": 256})); + let provider_options = + aimux_core::shared::provider_namespace("voyage", json!({"outputDimension": 256})); let options = EmbeddingCallOptions { values: test_values(), abort_signal: None, diff --git a/aimux-providers/tests/xai_responses_test.rs b/aimux-providers/tests/xai_responses_test.rs index 88008b4b0..c482a561a 100644 --- a/aimux-providers/tests/xai_responses_test.rs +++ b/aimux-providers/tests/xai_responses_test.rs @@ -10,8 +10,8 @@ //! or SSE response, creates an `XaiResponsesModel` via `XAIProvider`, calls //! `do_generate` / `do_stream`, and asserts on the result. +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::tool::{RawToolCall, ToolResult}; -use std::collections::HashMap; use futures::StreamExt; use serde_json::{Value, json}; @@ -75,10 +75,8 @@ 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) +fn xai_provider_options(opts: Value) -> Option { + Some(provider_namespace("xai", opts)) } /// A standard Responses API JSON body returning a text message. @@ -198,7 +196,10 @@ mod do_generate { assert_eq!( result.provider_metadata, - Some(json!({ "xai": { "costInUsdTicks": 113500 } })) + Some(provider_namespace( + "xai", + json!({ "costInUsdTicks": 113500 }) + )) ); } @@ -363,9 +364,10 @@ mod reasoning { assert_eq!(text, "First, analyze the question carefully."); assert_eq!( provider_metadata, - &Some( - json!({ "xai": { "itemId": "rs_456", "reasoningEncryptedContent": "abc123encryptedcontent" } }) - ) + &Some(provider_namespace( + "xai", + json!({ "itemId": "rs_456", "reasoningEncryptedContent": "abc123encryptedcontent" }) + )) ); } other => panic!("expected Reasoning, got {other:?}"), @@ -422,7 +424,7 @@ mod reasoning { assert_eq!(text, "Thinking through the problem."); assert_eq!( provider_metadata, - &Some(json!({ "xai": { "itemId": "rs_456" } })) + &Some(provider_namespace("xai", json!({ "itemId": "rs_456" }))) ); } other => panic!("expected Reasoning, got {other:?}"), @@ -523,9 +525,10 @@ mod reasoning { assert_eq!(text, ""); assert_eq!( provider_metadata, - &Some( - json!({ "xai": { "itemId": "rs_789", "reasoningEncryptedContent": "encrypted_zdr_content_xyz" } }) - ) + &Some(provider_namespace( + "xai", + json!({ "itemId": "rs_789", "reasoningEncryptedContent": "encrypted_zdr_content_xyz" }) + )) ); } other => panic!("expected Reasoning, got {other:?}"), @@ -1966,9 +1969,10 @@ mod do_stream { assert_eq!(id, "reasoning-rs_456"); assert_eq!( provider_metadata, - &Some( - json!({ "xai": { "itemId": "rs_456", "reasoningEncryptedContent": "encrypted_data_abc123" } }) - ) + &Some(provider_namespace( + "xai", + json!({ "itemId": "rs_456", "reasoningEncryptedContent": "encrypted_data_abc123" }) + )) ); } } @@ -2174,7 +2178,10 @@ mod do_stream { { assert_eq!( provider_metadata, - &Some(json!({ "xai": { "costInUsdTicks": 113500 } })) + &Some(provider_namespace( + "xai", + json!({ "costInUsdTicks": 113500 }) + )) ); } else { panic!("no finish part found"); @@ -2987,9 +2994,10 @@ mod convert_input { input: json!({ "city": "Singapore" }), provider_executed: Some(false), thought_signature: None, - provider_options: Some(json!({ - "xai": { "providerExecuted": true } - })), + provider_options: Some(provider_namespace( + "xai", + json!({ "providerExecuted": true }), + )), }], ..Default::default() }]; diff --git a/aimux-providers/tests/xai_test.rs b/aimux-providers/tests/xai_test.rs index 37c6d0c4d..27acc8d5d 100644 --- a/aimux-providers/tests/xai_test.rs +++ b/aimux-providers/tests/xai_test.rs @@ -13,6 +13,7 @@ //! or SSE response, creates an `XaiModel` via `XAIProvider`, calls //! `do_generate` / `do_stream`, and asserts on the result. +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::tool::RawToolCall; use std::collections::HashMap; @@ -162,17 +163,13 @@ fn make_provider(server: &MockServer) -> XAIProvider { } /// Provider options with a single xai key. -fn xai_options(key: &str, value: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("xai".to_string(), json!({ key: value })); - Some(m) +fn xai_options(key: &str, value: Value) -> Option { + Some(provider_namespace("xai", json!({ key: value }))) } /// Build provider options from a JSON object. -fn xai_provider_options(opts: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("xai".to_string(), opts); - Some(m) +fn xai_provider_options(opts: Value) -> Option { + Some(provider_namespace("xai", opts)) } // ════════════════════════════════════════════════════════════════════════════ @@ -1269,7 +1266,10 @@ mod convert_messages { data: vec![0, 1, 2, 3], media_type: "image/png".to_string(), filename: None, - provider_options: Some(json!({"xai": {"imageDetail": "low"}})), + provider_options: Some(provider_namespace( + "xai", + json!({"imageDetail": "low"}), + )), }, ], ..Default::default() diff --git a/bindings/node/src/multimodal.rs b/bindings/node/src/multimodal.rs index 00666cc09..f3818c936 100644 --- a/bindings/node/src/multimodal.rs +++ b/bindings/node/src/multimodal.rs @@ -743,7 +743,7 @@ pub async fn start_transcription_session( struct SessionOpts { input_audio_format: Option, provider_options: - Option>, + Option, headers: Option>, include_raw_chunks: Option, timeout: Option, diff --git a/bindings/python/src/multimodal.rs b/bindings/python/src/multimodal.rs index 0bef00f90..4212dbcc9 100644 --- a/bindings/python/src/multimodal.rs +++ b/bindings/python/src/multimodal.rs @@ -631,7 +631,7 @@ pub fn start_transcription_session( #[derive(serde::Deserialize, Default)] struct SessionOpts { input_audio_format: Option, - provider_options: Option>, + provider_options: Option, headers: Option>, include_raw_chunks: Option, timeout: Option, From 55a8cfe638ef96aa7fa7baf4f4e745e97b4700f8 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Mon, 5 Oct 2026 21:24:21 +0800 Subject: [PATCH 4/8] refactor(core)!: a provider option that is not an object is an error What: provider_namespace returns an error for a non-object value instead of replacing it with an empty object. Callers follow. Hand-written tests that pinned the old behaviour are deleted rather than rewritten. Why: upstream types provider options as namespace to JSON object; silently replacing a caller's value hid mistakes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/replay.rs | 31 - aimux-core/src/shared.rs | 24 +- aimux-core/src/video_model.rs | 18 - aimux-core/tests/m11_m12_m6_test.rs | 368 --------- aimux-core/tests/m7_aggregation_test.rs | 345 --------- .../tests/response_messages_tool_test.rs | 511 ------------ .../tests/streaming_tool_call_tracker_test.rs | 72 +- aimux-providers/src/anthropic/convert.rs | 40 - aimux-providers/src/anthropic/stream.rs | 72 +- aimux-providers/src/bedrock/model.rs | 27 +- aimux-providers/src/cohere/model.rs | 2 +- aimux-providers/src/gladia.rs | 2 +- aimux-providers/src/google/convert.rs | 124 --- aimux-providers/src/google/model.rs | 36 +- aimux-providers/src/huggingface/responses.rs | 30 +- aimux-providers/src/mistral/model.rs | 2 +- aimux-providers/src/openai/model.rs | 5 +- .../src/openai/responses/responses_convert.rs | 41 +- aimux-providers/src/vertex/anthropic_model.rs | 4 +- aimux-providers/src/vertex/image.rs | 14 +- aimux-providers/src/vertex/model.rs | 7 +- aimux-providers/src/xai/responses/mod.rs | 27 +- .../tests/amazon_bedrock_image_test.rs | 18 - .../tests/anthropic_cache_control_test.rs | 729 ------------------ .../tests/anthropic_convert_full_test.rs | 61 -- .../tests/anthropic_convert_test.rs | 140 ---- .../tests/anthropic_provider_tools_test.rs | 579 +------------- aimux-providers/tests/aws_polly_test.rs | 35 +- aimux-providers/tests/bedrock_convert_test.rs | 144 +--- .../tests/bedrock_embedding_test.rs | 75 -- .../tests/bedrock_reasoning_test.rs | 142 ---- .../tests/bedrock_remaining_test.rs | 34 - .../tests/bedrock_reranking_test.rs | 389 ---------- .../tests/cartesia_transcription_test.rs | 49 -- .../tests/cohere_embedding_test.rs | 65 -- .../tests/cohere_remaining_test.rs | 32 - .../tests/cohere_reranking_test.rs | 346 --------- aimux-providers/tests/convert_golden_test.rs | 243 ------ .../tests/data_loss_regression_test.rs | 11 +- .../tests/deepgram_transcription_test.rs | 67 -- aimux-providers/tests/deepseek_chat_test.rs | 371 +-------- .../tests/deepseek_reasoning_test.rs | 141 ---- .../tests/elevenlabs_realtime_stream_test.rs | 68 -- .../tests/google_embedding_test.rs | 63 -- aimux-providers/tests/google_files_test.rs | 78 +- aimux-providers/tests/google_image_test.rs | 197 +---- aimux-providers/tests/google_model_test.rs | 72 -- aimux-providers/tests/google_pse_test.rs | 27 - aimux-providers/tests/groq_test.rs | 152 ---- .../tests/huggingface_responses_test.rs | 214 +---- aimux-providers/tests/jina_ai_test.rs | 21 - aimux-providers/tests/linkup_test.rs | 28 - aimux-providers/tests/open_responses_test.rs | 76 -- .../tests/openai_convert_extended_test.rs | 447 ----------- aimux-providers/tests/openai_convert_test.rs | 18 - .../tests/openai_embedding_test.rs | 40 - aimux-providers/tests/openai_files_test.rs | 316 -------- aimux-providers/tests/openai_image_test.rs | 261 ------- .../tests/openai_model_extended_test.rs | 319 +------- .../tests/openai_responses_test.rs | 348 --------- .../tests/openai_transcription_test.rs | 167 ---- aimux-providers/tests/recraft_test.rs | 28 - aimux-providers/tests/replicate_image_test.rs | 21 - .../tests/vertex_embedding_test.rs | 327 -------- .../tests/vertex_transcription_test.rs | 35 - .../tests/voyage_embedding_test.rs | 64 -- aimux-providers/tests/xai_responses_test.rs | 593 -------------- aimux-providers/tests/xai_test.rs | 570 +------------- 68 files changed, 191 insertions(+), 9832 deletions(-) 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/response_messages_tool_test.rs delete mode 100644 aimux-providers/tests/anthropic_cache_control_test.rs delete mode 100644 aimux-providers/tests/bedrock_reranking_test.rs delete mode 100644 aimux-providers/tests/cohere_reranking_test.rs delete mode 100644 aimux-providers/tests/convert_golden_test.rs delete mode 100644 aimux-providers/tests/openai_files_test.rs delete mode 100644 aimux-providers/tests/vertex_embedding_test.rs diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index 210bc4f5d..8e4f69a84 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -1005,7 +1005,6 @@ mod tests { use crate::recording::{ HttpExchange, HttpRecord, InputRecord, ProviderRecord, ResponseRecord, TimingRecord, }; - use crate::shared::provider_namespace; use futures::StreamExt; fn sample_options(text: &str, temperature: Option) -> CallOptions { @@ -1193,36 +1192,6 @@ mod tests { assert!(matcher.r#match(&req3, &recs).is_err()); } - #[test] - fn exact_matcher_different_provider_options_misses() { - // A8:provider_options 纳入规范键——非脱敏值不同 → miss。 - let mut rec = openai_recording("t1", "ping", "pong", "stop"); - let mut call = sample_options("ping", Some(0.7)); - call.provider_options = Some(provider_namespace( - "openai", - serde_json::json!({ "foo": 1 }), - )); - rec.input.options = serde_json::to_value(&call).unwrap(); - let recs = [rec]; - let matcher = ExactMatcher::new("openai", "gpt-4o"); - - // foo 值不同 → miss。 - let mut req = sample_options("ping", Some(0.7)); - req.provider_options = Some(provider_namespace( - "openai", - serde_json::json!({ "foo": 2 }), - )); - assert!(matcher.r#match(&req, &recs).is_err()); - - // 完全一致 → hit(对照)。 - let mut req2 = sample_options("ping", Some(0.7)); - req2.provider_options = Some(provider_namespace( - "openai", - serde_json::json!({ "foo": 1 }), - )); - assert!(matcher.r#match(&req2, &recs).is_ok()); - } - #[test] fn exact_matcher_redacted_values_match_any() { // A8:录制侧 headers 经 redact_json 脱敏(authorization→"[REDACTED]"); diff --git a/aimux-core/src/shared.rs b/aimux-core/src/shared.rs index 480cfbda7..0a8f83163 100644 --- a/aimux-core/src/shared.rs +++ b/aimux-core/src/shared.rs @@ -46,18 +46,24 @@ pub type JsonObject = serde_json::Map; /// Build a one-namespace [`SharedProviderMetadata`] / [`SharedProviderOptions`] /// (they are the same type) from a JSON object literal. /// -/// `provider_namespace("openai", json!({ "itemId": "x" }))` is +/// `provider_namespace("openai", json!({ "itemId": "x" }))?` is /// `{ "openai": { "itemId": "x" } }`. /// -/// A value that is not a JSON object yields an empty namespace. Several -/// namespaces: `extend` one map with another. -#[must_use] -pub fn provider_namespace(namespace: &str, object: Value) -> SharedProviderMetadata { - let object = match object { - Value::Object(object) => object, - _ => JsonObject::new(), +/// Several namespaces: `extend` one map with another. +/// +/// # Errors +/// +/// Returns an invalid argument error when the value is not a JSON object. +pub fn provider_namespace( + namespace: &str, + object: Value, +) -> Result { + let Value::Object(object) = object else { + return Err(crate::AiMuxError::InvalidArgument(format!( + "invalid {namespace} provider options: expected a JSON object" + ))); }; - HashMap::from([(namespace.to_string(), object)]) + Ok(HashMap::from([(namespace.to_string(), object)])) } /// A mapping of provider names to provider-specific file identifiers. diff --git a/aimux-core/src/video_model.rs b/aimux-core/src/video_model.rs index 11f095115..459086e03 100644 --- a/aimux-core/src/video_model.rs +++ b/aimux-core/src/video_model.rs @@ -536,7 +536,6 @@ mod tests { use super::*; use crate::error::ApiCallError; - use crate::shared::provider_namespace; /// `do_status` behavior per call index, cycled through in order. enum StatusStep { @@ -783,23 +782,6 @@ mod tests { assert!(messages[1].contains("from status"), "{messages:?}"); } - #[test] - fn merge_provider_metadata_unions_same_provider_key_across_phases() { - let phase = |value| Some(provider_namespace("fal", value)); - let merged = merge_provider_metadata( - phase(serde_json::json!({ "job_id": "job-1", "region": "us-east" })), - phase(serde_json::json!({ "region": "eu-west", "seed": 42 })), - ) - .expect("both phases reported metadata"); - - // `job_id` is start-only and must survive instead of being dropped by - // an `entry().or_insert()` collision on the shared provider key. - assert_eq!( - serde_json::Value::Object(merged["fal"].clone()), - serde_json::json!({ "job_id": "job-1", "region": "eu-west", "seed": 42 }) - ); - } - #[tokio::test] async fn poll_timeout_fails_a_generation_that_never_completes() { let model = ScriptedVideoModel::new(vec![]); 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 149eacd8c..000000000 --- a/aimux-core/tests/m11_m12_m6_test.rs +++ /dev/null @@ -1,368 +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::shared::provider_namespace; -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 = provider_namespace( - "openai", - serde_json::json!({ - "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 aa80bcd5b..000000000 --- a/aimux-core/tests/m7_aggregation_test.rs +++ /dev/null @@ -1,345 +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, provider_namespace}; -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(provider_namespace( - "anthropic", - serde_json::json!({ "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( - ["amazonBedrock", "bedrock"] - .map(|ns| { - provider_namespace( - ns, - serde_json::json!({ "signature": "bedrock-sig-xyz" }), - ) - }) - .into_iter() - .flatten() - .collect(), - ), - }), - ], - 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/response_messages_tool_test.rs b/aimux-core/tests/response_messages_tool_test.rs deleted file mode 100644 index 13938a7fc..000000000 --- a/aimux-core/tests/response_messages_tool_test.rs +++ /dev/null @@ -1,511 +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::shared::provider_namespace; -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(provider_namespace( - "mock", - json!({ "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(provider_namespace( - "mock", - json!({ "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(provider_namespace( - "mock", - json!({ "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(provider_namespace( - "mock", - json!({ "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(provider_namespace( - "mock", - json!({ "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(provider_namespace( - "mock", - json!({ "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(provider_namespace( - "google", - json!({ "thoughtSignature": "text-generate" }), - )), - }, - GenerateContent::Reasoning(ReasoningOutput { - text: "think".to_string(), - provider_metadata: Some(provider_namespace( - "anthropic", - json!({ "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(provider_namespace("mock", json!({ "phase": "start" }))), - }), - Ok(StreamPart::TextDelta { - id: "text-1".to_string(), - delta: "answer".to_string(), - provider_metadata: Some(provider_namespace( - "google", - json!({ "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(provider_namespace( - "anthropic", - json!({ "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(provider_namespace( - "anthropic", - json!({ "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-provider-utils/tests/streaming_tool_call_tracker_test.rs b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs index a788aeff9..8fde2d755 100644 --- a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs +++ b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs @@ -7,7 +7,6 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::RawToolCall; use aimux_core::types::ProviderMetadata; @@ -15,7 +14,7 @@ use aimux_provider_utils::{ StreamingToolCallDelta, StreamingToolCallFunction, StreamingToolCallTracker, TrackerError, TypeValidation, }; -use serde_json::{Value, json}; +use serde_json::Value; /// The TS tracker's four events, projected out of [`StreamPart`] so the /// assertions below read like the upstream suite. @@ -1054,75 +1053,6 @@ mod flush { mod metadata { use super::*; - fn google_tracker() -> StreamingToolCallTracker { - StreamingToolCallTracker::new() - .with_extract_metadata(|delta| { - delta.extra["extra_content"]["google"]["thought_signature"] - .as_str() - .map(|sig| json!({ "thoughtSignature": sig })) - }) - .with_build_provider_metadata(|metadata| { - metadata - .and_then(|m| m.get("thoughtSignature")) - .map(|sig| provider_namespace("google", json!({ "thoughtSignature": sig }))) - }) - } - - #[test] - fn extracts_and_includes_provider_metadata_in_tool_call_parts() { - let mut h = Harness::with(google_tracker()); - - h.delta( - start(0, "call_1", "fn", "{}") - .extra(json!({ "extra_content": { "google": { "thought_signature": "sig123" } } })), - ) - .unwrap(); - h.flush(); - - let tool_call = h.parts.iter().find(|p| matches!(p, Part::ToolCall { .. })); - assert_eq!( - tool_call, - Some(&Part::ToolCall { - tool_call_id: "call_1".into(), - tool_name: "fn".into(), - input: "{}".into(), - provider_metadata: Some(provider_namespace( - "google", - json!({ "thoughtSignature": "sig123" }) - )), - }) - ); - } - - #[test] - fn includes_provider_metadata_for_unfinished_tool_calls_finalized_in_flush() { - let mut h = Harness::with( - StreamingToolCallTracker::new() - .with_extract_metadata(|_| Some(json!({ "custom": { "key": "value" } }))) - .with_build_provider_metadata(|metadata| { - metadata.map(|m| provider_namespace("provider", m.clone())) - }), - ); - - h.delta(start(0, "call_1", "fn", "{\"incomplete")).unwrap(); - h.clear(); - - h.flush(); - - assert_eq!( - h.parts.last(), - Some(&Part::ToolCall { - tool_call_id: "call_1".into(), - tool_name: "fn".into(), - input: "{\"incomplete".into(), - provider_metadata: Some(provider_namespace( - "provider", - json!({ "custom": { "key": "value" } }) - )), - }) - ); - } - #[test] fn omits_provider_metadata_when_the_builder_returns_none() { let mut h = Harness::with( diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index e32efa386..4088cef7c 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -2243,43 +2243,3 @@ pub fn parse_stop_reason(s: &str) -> FinishReason { raw: Some(s.to_string()), } } - -#[cfg(test)] -mod tests { - use super::*; - - fn opts_with_anthropic_thinking(thinking: serde_json::Value) -> CallOptions { - let provider = aimux_core::shared::provider_namespace("anthropic", thinking); - CallOptions { - provider_options: Some(provider), - ..CallOptions::new(LanguageModelPrompt::default()) - } - } - - /// Regression (audit finding on the M11 split): when `thinking.enabled` is - /// set without an explicit `budgetTokens`, the body must carry both the - /// default `budget_tokens: 1024` **and** the adjusted `max_tokens` - /// (base + 1024), exactly like the pre-split implementation. - #[test] - fn thinking_enabled_without_budget_writes_default_budget() { - let opts = opts_with_anthropic_thinking(json!({ - "thinking": { "type": "enabled" } - })); - let req = build_request_body_with_warnings("claude-sonnet-4-5", &opts, false).unwrap(); - assert_eq!(req.body["thinking"]["type"], json!("enabled")); - assert_eq!(req.body["thinking"]["budget_tokens"], json!(1024)); - // claude-sonnet-4-5 caps: 64000 tokens, +1024 thinking budget. - assert_eq!(req.body["max_tokens"], json!(65024)); - } - - /// Explicit `budgetTokens` must be preserved (no default override). - #[test] - fn thinking_enabled_with_explicit_budget_keeps_it() { - let opts = opts_with_anthropic_thinking(json!({ - "thinking": { "type": "enabled", "budgetTokens": 4096 } - })); - let req = build_request_body_with_warnings("claude-sonnet-4-5", &opts, false).unwrap(); - assert_eq!(req.body["thinking"]["budget_tokens"], json!(4096)); - assert_eq!(req.body["max_tokens"], json!(64000 + 4096)); - } -} diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 8216be07b..b42f285d0 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -362,7 +362,10 @@ pub(crate) fn tool_call_caller_metadata( }), ToolCallCaller::Direct => json!({ "type": "direct" }), }; - Some(provider_namespace("anthropic", json!({ "caller": caller }))) + Some( + provider_namespace("anthropic", json!({ "caller": caller })) + .expect("provider metadata must be an object"), + ) } fn is_tool_search_provider_name(name: &str) -> bool { @@ -451,12 +454,15 @@ pub(crate) fn stream_parts_for_result_block( source_type: "url".to_string(), url: str_field(result, "url"), title: str_field(result, "title"), - provider_metadata: Some(provider_namespace( - "anthropic", - json!({ - "pageAge": result.get("page_age").cloned().unwrap_or(Value::Null), - }), - )), + provider_metadata: Some( + provider_namespace( + "anthropic", + json!({ + "pageAge": result.get("page_age").cloned().unwrap_or(Value::Null), + }), + ) + .expect("provider metadata must be an object"), + ), }) })); parts @@ -528,6 +534,7 @@ pub(crate) fn stream_parts_for_result_block( "anthropic", json!({ "type": "mcp-tool-use", "serverName": server }), ) + .expect("provider metadata must be an object") }), })] } @@ -600,10 +607,10 @@ pub(crate) fn parse_anthropic_content( } => { content.push(GenerateContent::Reasoning(ReasoningOutput { text: thinking.clone(), - provider_metadata: Some(provider_namespace( - "anthropic", - json!({ "signature": signature }), - )), + provider_metadata: Some( + provider_namespace("anthropic", json!({ "signature": signature })) + .expect("provider metadata must be an object"), + ), })); } // Provider-executed (server-side) tool calls are surfaced as tool @@ -636,20 +643,23 @@ pub(crate) fn parse_anthropic_content( provider_executed: Some(true), dynamic: Some(true), thought_signature: None, - provider_metadata: Some(provider_namespace( - "anthropic", - json!({ "type": "mcp-tool-use", "serverName": server_name }), - )), + provider_metadata: Some( + provider_namespace( + "anthropic", + json!({ "type": "mcp-tool-use", "serverName": server_name }), + ) + .expect("provider metadata must be an object"), + ), })); } // Redacted thinking — upstream emits as reasoning with redactedData ContentBlock::RedactedThinking { data } => { content.push(GenerateContent::Reasoning(ReasoningOutput { text: String::new(), - provider_metadata: Some(provider_namespace( - "anthropic", - json!({ "redactedData": data }), - )), + provider_metadata: Some( + provider_namespace("anthropic", json!({ "redactedData": data })) + .expect("provider metadata must be an object"), + ), })); } // ── Server-tool result blocks → GenerateContent::ToolResult ── @@ -684,13 +694,16 @@ pub(crate) fn parse_anthropic_content( source_type: "url".to_string(), url: str_field(result, "url"), title: str_field(result, "title"), - provider_metadata: Some(provider_namespace( - "anthropic", - json!({ - "pageAge": result.get("page_age").cloned() - .unwrap_or(Value::Null), - }), - )), + provider_metadata: Some( + provider_namespace( + "anthropic", + json!({ + "pageAge": result.get("page_age").cloned() + .unwrap_or(Value::Null), + }), + ) + .expect("provider metadata must be an object"), + ), })); } } @@ -815,6 +828,7 @@ pub(crate) fn parse_anthropic_content( "anthropic", json!({ "type": "mcp-tool-use", "serverName": server }), ) + .expect("provider metadata must be an object") }), })); } @@ -1112,7 +1126,7 @@ pub(crate) async fn anthropic_stream_core( provider_metadata: Some(provider_namespace("anthropic", json!({ "type": "mcp-tool-use", "serverName": server_name, - }))), + })).expect("provider metadata must be an object")), })); } // Redacted thinking — emit as ReasoningStart. @@ -1120,7 +1134,7 @@ pub(crate) async fn anthropic_stream_core( let id = index.to_string(); yield Ok(StreamPart::ReasoningStart { id: id.clone(), - provider_metadata: Some(provider_namespace("anthropic", json!({ "redactedData": data }))), + provider_metadata: Some(provider_namespace("anthropic", json!({ "redactedData": data })).expect("provider metadata must be an object")), }); blocks.insert( index, @@ -1285,7 +1299,7 @@ pub(crate) async fn anthropic_stream_core( yield Ok(StreamPart::ReasoningEnd { id: index.to_string(), provider_metadata: signature.map(|s| { - provider_namespace("anthropic", json!({ "signature": s })) + provider_namespace("anthropic", json!({ "signature": s })).expect("provider metadata must be an object") }), }); } diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index f9c0d7870..4cda4d306 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -625,8 +625,12 @@ impl LanguageModel for BedrockModel { /// so consumers reading either key see it. fn reasoning_signature_meta(sig: Option) -> Option { sig.map(|s| { - let mut metadata = provider_namespace("amazonBedrock", json!({ "signature": &s })); - metadata.extend(provider_namespace("bedrock", json!({ "signature": s }))); + let mut metadata = provider_namespace("amazonBedrock", json!({ "signature": &s })) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("bedrock", json!({ "signature": s })) + .expect("provider metadata must be an object"), + ); metadata }) } @@ -667,8 +671,12 @@ fn extract_content(block: &BedrockContentBlock, content: &mut Vec = - Some(provider_namespace("gladia", raw_body.clone())); + Some(provider_namespace("gladia", raw_body.clone())?); return Ok(TranscriptionResult { text: result.transcription.full_transcript, segments, diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 0bdb54b03..ab8b8aee3 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -1291,127 +1291,3 @@ pub fn extract_sources( sources } - -#[cfg(test)] -mod tests { - use super::*; - use aimux_core::language_model_message::LanguageModelPromptMessage; - use aimux_core::shared::provider_namespace; - - fn assistant_prompt(content: Vec) -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::Assistant, - content, - provider_options: None, - }] - } - - fn code_metadata(namespace: &str, id: &str) -> SharedProviderOptions { - provider_namespace( - namespace, - json!({ - "serverToolCallId": id, - "serverToolType": "code_execution", - }), - ) - } - - #[test] - fn vertex_replays_native_code_execution_with_multiple_results() { - let call_id = "code-1"; - let prompt = assistant_prompt(vec![ - ContentPart::ToolCall { - tool_call_id: call_id.to_string(), - tool_name: "runCode".to_string(), - input: json!({ "language": "PYTHON", "code": "print(2)" }), - provider_executed: Some(true), - thought_signature: None, - provider_options: Some(code_metadata("googleVertex", call_id)), - }, - ContentPart::ToolResult { - tool_call_id: call_id.to_string(), - tool_name: Some("runCode".to_string()), - result: json!({ "outcome": "OUTCOME_OK", "output": "2" }), - is_error: None, - preliminary: None, - dynamic: None, - provider_options: Some(code_metadata("googleVertex", call_id)), - }, - ContentPart::ToolResult { - tool_call_id: call_id.to_string(), - tool_name: Some("runCode".to_string()), - result: json!({ "outcome": "OUTCOME_OK", "output": "still 2" }), - is_error: None, - preliminary: None, - dynamic: None, - provider_options: Some(code_metadata("googleVertex", call_id)), - }, - ]); - - let converted = - convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); - assert_eq!( - converted.contents[0]["parts"], - json!([ - { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } }, - ]) - ); - } - - #[test] - fn provider_metadata_namespace_precedence_matches_ai_sdk() { - let content = ContentPart::ToolCall { - tool_call_id: "call-1".to_string(), - tool_name: "weather".to_string(), - input: json!({}), - provider_executed: None, - thought_signature: None, - provider_options: Some( - serde_json::from_value(json!({ - "googleVertex": { "thoughtSignature": "google-vertex" }, - "vertex": { "thoughtSignature": "vertex" }, - "google": { "thoughtSignature": "google" }, - })) - .unwrap(), - ), - }; - let prompt = assistant_prompt(vec![content]); - - let vertex = - convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); - assert_eq!( - vertex.contents[0]["parts"][0]["thoughtSignature"], - "google-vertex" - ); - - let google = - convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Google); - assert_eq!(google.contents[0]["parts"][0]["thoughtSignature"], "google"); - } - - #[test] - fn vertex_reads_google_as_cross_namespace_fallback() { - let prompt = assistant_prompt(vec![ContentPart::ToolCall { - tool_call_id: "call-1".to_string(), - tool_name: "weather".to_string(), - input: json!({}), - provider_executed: None, - thought_signature: None, - provider_options: Some(provider_namespace( - "google", - json!({ - "thoughtSignature": "gateway-signature", - }), - )), - }]); - - let converted = - convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); - assert_eq!( - converted.contents[0]["parts"][0]["thoughtSignature"], - "gateway-signature" - ); - } -} diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index f25513657..e67361d6d 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -166,17 +166,20 @@ impl LanguageModel for GoogleModel { // Provider metadata: wrap the raw Google metadata under a `google` // key (matching the TS `wrapProviderMetadata`). - let provider_metadata = Some(provider_namespace( - "google", - json!({ - "promptFeedback": data.prompt_feedback, - "groundingMetadata": candidate.grounding_metadata, - "urlContextMetadata": candidate.url_context_metadata, - "safetyRatings": candidate.safety_ratings, - "usageMetadata": data.usage_metadata, - "finishMessage": candidate.finish_message, - }), - )); + let provider_metadata = Some( + provider_namespace( + "google", + json!({ + "promptFeedback": data.prompt_feedback, + "groundingMetadata": candidate.grounding_metadata, + "urlContextMetadata": candidate.url_context_metadata, + "safetyRatings": candidate.safety_ratings, + "usageMetadata": data.usage_metadata, + "finishMessage": candidate.finish_message, + }), + ) + .expect("provider metadata must be an object"), + ); Ok(GenerateResult { content, @@ -332,7 +335,7 @@ impl LanguageModel for GoogleModel { let thought_sig_meta: Option = part .get("thoughtSignature") .and_then(|v| v.as_str()) - .map(|s| provider_namespace("google", json!({ "thoughtSignature": s }))); + .map(|s| provider_namespace("google", json!({ "thoughtSignature": s })).expect("provider metadata must be an object")); // text part if let Some(text) = part.get("text").and_then(|v| v.as_str()) { @@ -641,7 +644,7 @@ impl LanguageModel for GoogleModel { "safetyRatings": last_safety_ratings, "usageMetadata": last_usage_metadata_value, "finishMessage": last_finish_message, - }))); + })).expect("provider metadata must be an object")); yield Ok(StreamPart::Finish { finish_reason: if stream_errored { @@ -682,7 +685,7 @@ fn server_tool_metadata( if let Some(signature) = thought_signature { payload["thoughtSignature"] = json!(signature); } - provider_namespace("google", payload) + provider_namespace("google", payload).expect("provider metadata must be an object") } /// Extract `GenerateContent` items from a non-streaming candidate. @@ -716,7 +719,10 @@ fn extract_content_from_candidate( let thought_sig_meta: Option = part .get("thoughtSignature") .and_then(|v| v.as_str()) - .map(|s| provider_namespace("google", json!({ "thoughtSignature": s }))); + .map(|s| { + provider_namespace("google", json!({ "thoughtSignature": s })) + .expect("provider metadata must be an object") + }); // Branch order matches upstream (google-language-model.ts:420-534): // executableCode → codeExecutionResult → text → functionCall diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index 60a780cc2..86a201364 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -223,10 +223,10 @@ impl LanguageModel for HuggingFaceResponsesModel { finish_reason, usage, warnings: request.warnings, - provider_metadata: Some(provider_namespace( - "huggingface", - json!({ "responseId": response_id }), - )), + provider_metadata: Some( + provider_namespace("huggingface", json!({ "responseId": response_id })) + .expect("provider metadata must be an object"), + ), response: ResponseMetadata { id: response_id, timestamp: format_timestamp(created_at), @@ -343,7 +343,7 @@ impl LanguageModel for HuggingFaceResponsesModel { .to_string(); yield Ok(StreamPart::TextStart { id: id.clone(), - provider_metadata: Some(provider_namespace("huggingface", json!({ "itemId": id }))), + provider_metadata: Some(provider_namespace("huggingface", json!({ "itemId": id })).expect("provider metadata must be an object")), }); } } @@ -375,7 +375,7 @@ impl LanguageModel for HuggingFaceResponsesModel { .to_string(); yield Ok(StreamPart::ReasoningStart { id: id.clone(), - provider_metadata: Some(provider_namespace("huggingface", json!({ "itemId": id }))), + provider_metadata: Some(provider_namespace("huggingface", json!({ "itemId": id })).expect("provider metadata must be an object")), }); } _ => {} @@ -545,7 +545,7 @@ impl LanguageModel for HuggingFaceResponsesModel { yield Ok(StreamPart::Finish { finish_reason, usage, - provider_metadata: Some(provider_namespace("huggingface", json!({ "responseId": response_id }))), + provider_metadata: Some(provider_namespace("huggingface", json!({ "responseId": response_id })).expect("provider metadata must be an object")), }); }; @@ -1088,10 +1088,10 @@ fn build_generate_content(response: &Value) -> Result, AiMu let item_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); content.push(GenerateContent::Text { text: text.to_string(), - provider_metadata: Some(provider_namespace( - "huggingface", - json!({ "itemId": item_id }), - )), + provider_metadata: Some( + provider_namespace("huggingface", json!({ "itemId": item_id })) + .expect("provider metadata must be an object"), + ), }); // Process annotations → source parts. @@ -1122,10 +1122,10 @@ fn build_generate_content(response: &Value) -> Result, AiMu let item_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); content.push(GenerateContent::Reasoning(ReasoningOutput { text: text.to_string(), - provider_metadata: Some(provider_namespace( - "huggingface", - json!({ "itemId": item_id }), - )), + provider_metadata: Some( + provider_namespace("huggingface", json!({ "itemId": item_id })) + .expect("provider metadata must be an object"), + ), })); } } diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 1561a4f3e..557fe36ca 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -600,7 +600,7 @@ impl LanguageModel for MistralModel { } else { final_usage }, - provider_metadata: Some(provider_namespace("mistral", serde_json::json!({}))), + provider_metadata: Some(provider_namespace("mistral", serde_json::json!({})).expect("provider metadata must be an object")), }); }; diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 953a7241c..92d769393 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -382,7 +382,8 @@ pub async fn execute_generate( pm_openai["rejectedPredictionTokens"] = json!(rpt); } } - let provider_metadata = Some(provider_namespace("openai", pm_openai)); + let provider_metadata = + Some(provider_namespace("openai", pm_openai).expect("provider metadata must be an object")); Ok(GenerateResult { content, @@ -760,7 +761,7 @@ pub async fn execute_stream( pm_openai["rejectedPredictionTokens"] = json!(rpt); } } - let provider_metadata = provider_namespace("openai", pm_openai); + let provider_metadata = provider_namespace("openai", pm_openai).expect("provider metadata must be an object"); // Final part: Finish. yield Ok(StreamPart::Finish { diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index 243d3eb33..5dcbaac68 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -148,7 +148,7 @@ pub fn build_responses_generate_result( json!({ "itemId": part.get("id").cloned().unwrap_or(Value::Null), }), - )), + ).expect("provider metadata must be an object")), }); } } @@ -202,12 +202,15 @@ pub fn build_responses_generate_result( provider_executed: None, dynamic: None, thought_signature: None, - provider_metadata: Some(provider_namespace( - &provider_key, - json!({ - "itemId": part.get("id").cloned().unwrap_or(Value::Null), - }), - )), + provider_metadata: Some( + provider_namespace( + &provider_key, + json!({ + "itemId": part.get("id").cloned().unwrap_or(Value::Null), + }), + ) + .expect("provider metadata must be an object"), + ), })); } Some("custom_tool_call") => { @@ -232,12 +235,15 @@ pub fn build_responses_generate_result( provider_executed: None, dynamic: None, thought_signature: None, - provider_metadata: Some(provider_namespace( - &provider_key, - json!({ - "itemId": part.get("id").cloned().unwrap_or(Value::Null), - }), - )), + provider_metadata: Some( + provider_namespace( + &provider_key, + json!({ + "itemId": part.get("id").cloned().unwrap_or(Value::Null), + }), + ) + .expect("provider metadata must be an object"), + ), })); } Some("reasoning") => { @@ -249,7 +255,7 @@ pub fn build_responses_generate_result( "itemId": part.get("id").cloned().unwrap_or(Value::Null), "reasoningEncryptedContent": part.get("encrypted_content").cloned().unwrap_or(Value::Null), }), - )); + ).expect("provider metadata must be an object")); if parts.is_empty() { content.push(GenerateContent::Reasoning(ReasoningOutput { text: String::new(), @@ -295,7 +301,8 @@ pub fn build_responses_generate_result( if let Some(st) = data.get("service_tier").and_then(|v| v.as_str()) { pm["serviceTier"] = json!(st); } - let provider_metadata = Some(provider_namespace(&provider_key, pm)); + let provider_metadata = + Some(provider_namespace(&provider_key, pm).expect("provider metadata must be an object")); let response_id = data .get("id") @@ -362,7 +369,7 @@ fn reasoning_stream_metadata( if let Some(enc) = encrypted_content { inner["reasoningEncryptedContent"] = json!(enc); } - provider_namespace(provider_key, inner) + provider_namespace(provider_key, inner).expect("provider metadata must be an object") } /// Generate a unique source ID for streaming annotation sources. @@ -1059,7 +1066,7 @@ where if let Some(ctx) = final_reasoning_context { pm["reasoningContext"] = ctx; } - let provider_metadata = Some(provider_namespace(&provider_key, pm)); + let provider_metadata = Some(provider_namespace(&provider_key, pm).expect("provider metadata must be an object")); yield Ok(StreamPart::Finish { finish_reason: if stream_errored { diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index 09eebc78f..d4714546d 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -400,7 +400,7 @@ impl LanguageModel for VertexAnthropicModel { provider_metadata: Some(provider_namespace("anthropic", json!({ "type": "mcp-tool-use", "serverName": server_name, - }))), + })).expect("provider metadata must be an object")), })); } ContentBlock::RedactedThinking { data } => { @@ -408,7 +408,7 @@ impl LanguageModel for VertexAnthropicModel { id: index.to_string(), provider_metadata: Some(provider_namespace("anthropic", json!({ "redactedData": data - }))), + })).expect("provider metadata must be an object")), }); blocks.insert(index, BlockState::Thinking { started: true }); } diff --git a/aimux-providers/src/vertex/image.rs b/aimux-providers/src/vertex/image.rs index caf80530b..51e1394b7 100644 --- a/aimux-providers/src/vertex/image.rs +++ b/aimux-providers/src/vertex/image.rs @@ -235,8 +235,11 @@ impl VertexImageModel { .unwrap_or_default(); let payload = json!({ "images": image_metas }); - let mut metadata = provider_namespace("googleVertex", payload.clone()); - metadata.extend(provider_namespace("vertex", payload)); + let mut metadata = provider_namespace("googleVertex", payload.clone()) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("vertex", payload).expect("provider metadata must be an object"), + ); Ok(ImageResult { images: ImageOutputs::Base64(images), @@ -381,8 +384,11 @@ impl VertexImageModel { }); let payload = json!({ "images": images.iter().map(|_| json!({})).collect::>() }); - let mut metadata = provider_namespace("googleVertex", payload.clone()); - metadata.extend(provider_namespace("vertex", payload)); + let mut metadata = provider_namespace("googleVertex", payload.clone()) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("vertex", payload).expect("provider metadata must be an object"), + ); Ok(ImageResult { images: ImageOutputs::Base64(images), diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 0dd2a7988..08d507fb5 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -650,8 +650,11 @@ impl LanguageModel for VertexModel { // ── Helpers ────────────────────────────────────────────────────────────────── fn vertex_provider_metadata(payload: Value) -> ProviderMetadata { - let mut metadata = provider_namespace("googleVertex", payload.clone()); - metadata.extend(provider_namespace("vertex", payload)); + let mut metadata = provider_namespace("googleVertex", payload.clone()) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("vertex", payload).expect("provider metadata must be an object"), + ); metadata } diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index 27fe3fd39..c1c9415e8 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -306,7 +306,10 @@ impl LanguageModel for XaiResponsesModel { } content.push(GenerateContent::Reasoning(ReasoningOutput { text: reasoning_text, - provider_metadata: Some(provider_namespace("xai", meta)), + provider_metadata: Some( + provider_namespace("xai", meta) + .expect("provider metadata must be an object"), + ), })); } } @@ -328,10 +331,10 @@ impl LanguageModel for XaiResponsesModel { let (usage, provider_metadata) = if let Some(u) = &data.usage { let meta = if u.cost_in_usd_ticks.is_some() { - Some(provider_namespace( - "xai", - json!({ "costInUsdTicks": u.cost_in_usd_ticks }), - )) + Some( + provider_namespace("xai", json!({ "costInUsdTicks": u.cost_in_usd_ticks })) + .expect("provider metadata must be an object"), + ) } else { None }; @@ -448,7 +451,7 @@ impl LanguageModel for XaiResponsesModel { active_reasoning.insert(item_id.to_string(), ()); yield Ok(StreamPart::ReasoningStart { id: block_id, - provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id })).expect("provider metadata must be an object")), }); } continue; @@ -462,7 +465,7 @@ impl LanguageModel for XaiResponsesModel { yield Ok(StreamPart::ReasoningDelta { id: block_id, delta: delta.to_string(), - provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id })).expect("provider metadata must be an object")), }); continue; } @@ -481,13 +484,13 @@ impl LanguageModel for XaiResponsesModel { active_reasoning.insert(item_id.to_string(), ()); yield Ok(StreamPart::ReasoningStart { id: block_id.clone(), - provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id })).expect("provider metadata must be an object")), }); } yield Ok(StreamPart::ReasoningDelta { id: block_id, delta: delta.to_string(), - provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id })).expect("provider metadata must be an object")), }); continue; } @@ -672,7 +675,7 @@ impl LanguageModel for XaiResponsesModel { active_reasoning.insert(part_id.to_string(), ()); yield Ok(StreamPart::ReasoningStart { id: block_id.clone(), - provider_metadata: Some(provider_namespace("xai", json!({ "itemId": part_id }))), + provider_metadata: Some(provider_namespace("xai", json!({ "itemId": part_id })).expect("provider metadata must be an object")), }); } @@ -682,7 +685,7 @@ impl LanguageModel for XaiResponsesModel { } yield Ok(StreamPart::ReasoningEnd { id: block_id, - provider_metadata: Some(provider_namespace("xai", meta)), + provider_metadata: Some(provider_namespace("xai", meta).expect("provider metadata must be an object")), }); active_reasoning.remove(part_id); } @@ -909,7 +912,7 @@ impl LanguageModel for XaiResponsesModel { } // Final part: Finish. - let provider_meta = cost_in_usd_ticks.map(|cost| provider_namespace("xai", json!({ "costInUsdTicks": cost }))); + let provider_meta = cost_in_usd_ticks.map(|cost| provider_namespace("xai", json!({ "costInUsdTicks": cost })).expect("provider metadata must be an object")); yield Ok(StreamPart::Finish { finish_reason: final_finish_reason, diff --git a/aimux-providers/tests/amazon_bedrock_image_test.rs b/aimux-providers/tests/amazon_bedrock_image_test.rs index b2ffbb896..de55218a4 100644 --- a/aimux-providers/tests/amazon_bedrock_image_test.rs +++ b/aimux-providers/tests/amazon_bedrock_image_test.rs @@ -185,21 +185,3 @@ async fn should_handle_moderated_response() { let err = result.unwrap_err().to_string(); assert!(err.contains("moderated")); } - -#[tokio::test] -async fn should_pass_negative_text() { - let server = MockServer::start().await; - mock_bedrock(&server, bedrock_response()).await; - let provider = BedrockProvider::new(config(&server)); - let model = provider.image("amazon.titan-image-generator-v1"); - let mut opts = options(PROMPT); - opts.n = 1; - opts.provider_options = aimux_core::shared::provider_namespace( - "amazonBedrock", - json!({ "negativeText": "ugly, blurry" }), - ); - model.do_generate(&opts).await.unwrap(); - let reqs = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&reqs[0].body).unwrap(); - assert_eq!(body["textToImageParams"]["negativeText"], "ugly, blurry"); -} diff --git a/aimux-providers/tests/anthropic_cache_control_test.rs b/aimux-providers/tests/anthropic_cache_control_test.rs deleted file mode 100644 index 4485de6d0..000000000 --- a/aimux-providers/tests/anthropic_cache_control_test.rs +++ /dev/null @@ -1,729 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust port of the Anthropic `cache_control` tests. -//! -//! Translated from the Vercel AI SDK TypeScript test suites: -//! - `packages/anthropic/src/convert-to-anthropic-prompt.test.ts` -//! `describe('cache control')` (L3557-4263) — conversion-level tests that -//! verify `cache_control` is emitted on the correct Anthropic content blocks. -//! - `packages/anthropic/src/anthropic-language-model.test.ts` -//! `it('should support cache control')` (L2298) and -//! `it('should support cache control and return extra fields in provider metadata')` (L2457) -//! — exercised here at the conversion level (the request-body shape that -//! `doGenerate` builds), since the cache_control feature lives in -//! `convert_prompt_to_anthropic_full`. -//! -//! `cache_control` is read from `providerOptions.anthropic.cacheControl` -//! (`{ "type": "ephemeral" }`) at three levels: -//! 1. **Part-level** — on `ContentPart` (`Text`, `ToolCall`, `ToolResult`, …). -//! 2. **Message-level** — on `LanguageModelPromptMessage.provider_options`, -//! applied to the last part of the message. -//! 3. **Tool-result output-level** — on a structured `output.providerOptions` -//! or on the first content part of a `content` output. -//! -//! A `CacheControlValidator` caps the total breakpoints at 4 and rejects -//! `cache_control` on thinking / redacted-thinking blocks (emitting a warning). - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::types::Warning; -use aimux_providers::anthropic::convert::convert_prompt_to_anthropic_full; -use serde_json::{Value, json}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn msg(role: Role, parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: None, - } -} - -/// Build a message with message-level `provider_options` (for message-level -/// cache_control). -fn msg_with_opts( - role: Role, - parts: Vec, - opts: SharedProviderOptions, -) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: Some(opts), - } -} - -fn prompt(msgs: Vec) -> Vec { - msgs -} - -fn convert_full( - msgs: Vec, - send_reasoning: bool, -) -> aimux_providers::anthropic::convert::AnthropicPromptConversion { - convert_prompt_to_anthropic_full(&msgs, send_reasoning) -} - -/// The message-level / part-level `providerOptions` shape carrying -/// `anthropic.cacheControl`. -fn cache_control_opts(cache_control: Value) -> SharedProviderOptions { - provider_namespace("anthropic", json!({ "cacheControl": cache_control })) -} - -/// Create a text part with `provider_options` set (for part-level cache_control). -fn text_part_with_cache_control(t: &str, cache_control: Value) -> ContentPart { - ContentPart::Text { - text: t.to_string(), - provider_options: Some(cache_control_opts(cache_control)), - } -} - -/// Extract the content array from the first message in the conversion result. -fn first_message_content( - result: &aimux_providers::anthropic::convert::AnthropicPromptConversion, -) -> &Vec { - result - .messages - .first() - .expect("expected at least one message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array") -} - -/// Extract the first system block from the conversion result. -fn first_system_block( - result: &aimux_providers::anthropic::convert::AnthropicPromptConversion, -) -> &Value { - result - .system - .as_ref() - .expect("expected system blocks") - .first() - .expect("expected at least one system block") -} - -// =========================================================================== -// describe('cache control') > describe('system message') -// =========================================================================== - -mod system_message { - use super::*; - - // TS: "should set cache_control on system message with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl` on a system - // message. The cache_control is applied to the system text block. - #[test] - fn should_set_cache_control_on_system_message_with_message_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::System, - vec![ContentPart::text("system message")], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let system = first_system_block(&result); - assert_eq!( - system.get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('user message') -// =========================================================================== - -mod user_message { - use super::*; - - // TS: "should set cache_control on user message part with part cache control" - // - // Part-level `providerOptions.anthropic.cacheControl` on a text part. - #[test] - fn should_set_cache_control_on_user_message_part_with_part_cache_control() { - let p = prompt(vec![msg( - Role::User, - vec![text_part_with_cache_control( - "test", - json!({ "type": "ephemeral" }), - )], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content.len(), 1); - assert_eq!(content[0].get("type"), Some(&json!("text"))); - assert_eq!(content[0].get("text"), Some(&json!("test"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on last user message part with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl` applied to the - // last part of the message. - #[test] - fn should_set_cache_control_on_last_user_message_part_with_message_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::User, - vec![ContentPart::text("part1"), ContentPart::text("part2")], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content.len(), 2); - // First part: no cache_control - assert!(content[0].get("cache_control").is_none()); - // Last part: cache_control from message-level providerOptions - assert_eq!( - content[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('assistant message') -// =========================================================================== - -mod assistant_message { - use super::*; - - // TS: "should set cache_control on assistant message text part with part cache control" - // - // Part-level `providerOptions.anthropic.cacheControl` on an assistant text part. - #[test] - fn should_set_cache_control_on_assistant_message_text_part_with_part_cache_control() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg( - Role::Assistant, - vec![text_part_with_cache_control( - "test", - json!({ "type": "ephemeral" }), - )], - ), - ]); - let result = convert_full(p, true); - // messages[0] = user, messages[1] = assistant - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content.len(), 1); - assert_eq!(assistant_content[0].get("type"), Some(&json!("text"))); - assert_eq!(assistant_content[0].get("text"), Some(&json!("test"))); - assert_eq!( - assistant_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on assistant tool call part with part cache control" - // - // Part-level `providerOptions` on a `tool-call` content part. - #[test] - fn should_set_cache_control_on_assistant_tool_call_part_with_part_cache_control() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg( - Role::Assistant, - vec![ContentPart::ToolCall { - tool_call_id: "test-id".to_string(), - tool_name: "test-tool".to_string(), - input: json!({ "some": "arg" }), - provider_executed: None, - thought_signature: None, - provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), - }], - ), - ]); - let result = convert_full(p, true); - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content[0].get("type"), Some(&json!("tool_use"))); - assert_eq!(assistant_content[0].get("name"), Some(&json!("test-tool"))); - assert_eq!(assistant_content[0].get("id"), Some(&json!("test-id"))); - assert_eq!( - assistant_content[0].get("input"), - Some(&json!({ "some": "arg" })) - ); - assert_eq!( - assistant_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should wrap non-object (invalid) tool call input in an object" - // - // This lives inside the `describe('assistant message')` cache_control block - // in the TS source. The same case is also covered in - // `anthropic_convert_test.rs`; reproduced here for parity with the TS suite. - #[test] - fn should_wrap_non_object_invalid_tool_call_input_in_an_object() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg( - Role::Assistant, - vec![ContentPart::tool_call( - "test-id".to_string(), - "test-tool".to_string(), - json!("invalid"), - )], - ), - ]); - let result = convert_full(p, true); - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content[0].get("type"), Some(&json!("tool_use"))); - assert_eq!( - assistant_content[0].get("input"), - Some(&json!({ "rawInvalidInput": "invalid" })) - ); - } - - // TS: "should set cache_control on last assistant message part with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl`. - #[test] - fn should_set_cache_control_on_last_assistant_message_part_with_message_cache_control() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg_with_opts( - Role::Assistant, - vec![ContentPart::text("part1"), ContentPart::text("part2")], - cache_control_opts(json!({ "type": "ephemeral" })), - ), - ]); - let result = convert_full(p, true); - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content.len(), 2); - // First part: no cache_control - assert!(assistant_content[0].get("cache_control").is_none()); - // Last part: cache_control from message-level providerOptions - assert_eq!( - assistant_content[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('tool message') -// =========================================================================== - -mod tool_message { - use super::*; - - // TS: "should set cache_control on tool result message part with part cache control" - // - // Part-level `providerOptions` on a `tool-result` content part. - #[test] - fn should_set_cache_control_on_tool_result_message_part_with_part_cache_control() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::ToolResult { - tool_call_id: "test".to_string(), - result: json!({ "test": "test" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), - }], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("tool_result"))); - assert_eq!(content[0].get("tool_use_id"), Some(&json!("test"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on tool result with output cache control" - // - // The `output` of a tool-result carries `providerOptions` (here a `text` - // output with `providerOptions.anthropic.cacheControl`). - #[test] - fn should_set_cache_control_on_tool_result_with_output_cache_control() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::tool_result( - "test".to_string(), - json!({ - "type": "text", - "value": "test", - "providerOptions": cache_control_opts(json!({ "type": "ephemeral" })), - }), - )], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("tool_result"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on tool result with content output cache control" - // - // The `output` is a `content` output whose first content part carries - // `providerOptions.anthropic.cacheControl`. - #[test] - fn should_set_cache_control_on_tool_result_with_content_output_cache_control() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::tool_result( - "test".to_string(), - json!({ - "type": "content", - "value": [{ - "type": "text", - "text": "test", - "providerOptions": cache_control_opts(json!({ "type": "ephemeral" })), - }], - }), - )], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("tool_result"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on last tool result message part with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl`. - #[test] - fn should_set_cache_control_on_last_tool_result_message_part_with_message_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::Tool, - vec![ - ContentPart::tool_result("part1".to_string(), json!({ "test": "part1" })), - ContentPart::tool_result("part2".to_string(), json!({ "test": "part2" })), - ], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content.len(), 2); - // First part: no cache_control - assert!(content[0].get("cache_control").is_none()); - // Last part: cache_control from message-level providerOptions - assert_eq!( - content[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('cache control validation') -// =========================================================================== - -mod cache_control_validation { - use super::*; - - // TS: "should reject cache_control on thinking blocks" - // - // When a reasoning part (with a signature → thinking block) has - // `providerOptions.anthropic.cacheControl`, the CacheControlValidator emits - // a warning and does NOT include cache_control on the thinking block. - #[test] - fn should_reject_cache_control_on_thinking_blocks() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ContentPart::Reasoning { - text: "thinking content".to_string(), - signature: Some("test-sig".to_string()), - provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), - }], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("thinking"))); - assert_eq!(content[0].get("thinking"), Some(&json!("thinking content"))); - assert_eq!(content[0].get("signature"), Some(&json!("test-sig"))); - // cache_control should NOT be present on thinking blocks - assert!(content[0].get("cache_control").is_none()); - - // A warning should be emitted about the rejected cache_control. - let has_warning = result.warnings.iter().any(|w| match w { - Warning::Unsupported { - feature, details, .. - } => { - feature == "cache_control on non-cacheable context" - && details - .as_deref() - .is_some_and(|d| d.contains("thinking block")) - } - _ => false, - }); - assert!( - has_warning, - "expected a cache_control rejection warning, got: {:?}", - result.warnings - ); - } - - // TS: "should reject cache_control on redacted thinking blocks" - // - // A reasoning part with `providerOptions.anthropic.redactedData` (and no - // signature) becomes a `redacted_thinking` block; cache_control is rejected. - #[test] - fn should_reject_cache_control_on_redacted_thinking_blocks() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ContentPart::Reasoning { - text: "redacted".to_string(), - signature: None, - provider_options: Some( - serde_json::from_value(json!({ - "anthropic": { - "redactedData": "abc123", - "cacheControl": { "type": "ephemeral" }, - } - })) - .unwrap(), - ), - }], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("redacted_thinking"))); - assert_eq!(content[0].get("data"), Some(&json!("abc123"))); - // cache_control should NOT be present on redacted thinking blocks - assert!(content[0].get("cache_control").is_none()); - - let has_warning = result.warnings.iter().any(|w| match w { - Warning::Unsupported { - feature, details, .. - } => { - feature == "cache_control on non-cacheable context" - && details - .as_deref() - .is_some_and(|d| d.contains("redacted thinking block")) - } - _ => false, - }); - assert!( - has_warning, - "expected a cache_control rejection warning, got: {:?}", - result.warnings - ); - } -} - -// =========================================================================== -// describe('cache control') > "should limit cache breakpoints to 4" -// =========================================================================== - -mod breakpoint_limit { - use super::*; - - // TS: "should limit cache breakpoints to 4" - // - // The CacheControlValidator tracks the number of cache_control breakpoints - // across the entire prompt and rejects any beyond the 4th. The first 4 - // get cache_control; the 5th is rejected with a warning. - #[test] - fn should_limit_cache_breakpoints_to_4() { - // Build a prompt with 5 cache_control breakpoints: - // 1. system 1 (cache_control) - // 2. system 2 (cache_control) - // 3. user 1 (cache_control) - // 4. assistant 1 (cache_control) - // 5. user 2 (cache_control — should be rejected) - let p = prompt(vec![ - msg( - Role::System, - vec![text_part_with_cache_control( - "system 1", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::System, - vec![text_part_with_cache_control( - "system 2", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::User, - vec![text_part_with_cache_control( - "user 1", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::Assistant, - vec![text_part_with_cache_control( - "assistant 1", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::User, - vec![text_part_with_cache_control( - "user 2 (should be rejected)", - json!({ "type": "ephemeral" }), - )], - ), - ]); - let result = convert_full(p, true); - - // First 4 should have cache_control - let system = result.system.as_ref().expect("expected system blocks"); - assert_eq!(system.len(), 2); - assert_eq!( - system[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - assert_eq!( - system[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - - let msg0_content = result - .messages - .first() - .expect("expected message 0") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!( - msg0_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - - let msg1_content = result - .messages - .get(1) - .expect("expected message 1") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!( - msg1_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - - // 5th should be rejected (no cache_control) - let msg2_content = result - .messages - .get(2) - .expect("expected message 2") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert!( - msg2_content[0].get("cache_control").is_none(), - "5th breakpoint should be rejected" - ); - - // Should have warning about exceeding limit - let has_limit_warning = result.warnings.iter().any(|w| match w { - Warning::Unsupported { - feature, details, .. - } => { - feature == "cacheControl breakpoint limit" - && details - .as_deref() - .is_some_and(|d| d.contains("Maximum 4 cache breakpoints exceeded")) - } - _ => false, - }); - assert!( - has_limit_warning, - "expected a breakpoint limit warning, got: {:?}", - result.warnings - ); - } -} - -// =========================================================================== -// Integration tests from anthropic-language-model.test.ts -// -// The TS integration tests exercise the full `doGenerate` path with a mock -// server. The cache_control feature itself lives in -// `convert_prompt_to_anthropic_full` (the request-body builder), so these are -// exercised here at the conversion level — verifying the request shape that -// `doGenerate` would send. -// =========================================================================== - -mod integration { - use super::*; - - // TS: "should support cache control" (L2298) - // - // A user message with message-level `providerOptions.anthropic.cacheControl` - // produces a request whose text block carries `cache_control`. - #[test] - fn should_support_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::User, - vec![ContentPart::text("Hello")], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("text"))); - assert_eq!(content[0].get("text"), Some(&json!("Hello"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should support cache control and return extra fields in provider metadata" (L2457) - // - // Same as above but with `ttl: '1h'`; the request block carries the full - // `cache_control` object including `ttl`. - #[test] - fn should_support_cache_control_and_return_extra_fields_in_provider_metadata() { - let p = prompt(vec![msg_with_opts( - Role::User, - vec![ContentPart::text("Hello")], - cache_control_opts(json!({ "type": "ephemeral", "ttl": "1h" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral", "ttl": "1h" })) - ); - } -} diff --git a/aimux-providers/tests/anthropic_convert_full_test.rs b/aimux-providers/tests/anthropic_convert_full_test.rs index fc463d68a..a02fb47b0 100644 --- a/aimux-providers/tests/anthropic_convert_full_test.rs +++ b/aimux-providers/tests/anthropic_convert_full_test.rs @@ -15,7 +15,6 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; -use aimux_core::shared::provider_namespace; use aimux_core::types::Warning; use aimux_providers::anthropic::convert::convert_prompt_to_anthropic_full; use serde_json::{Value, json}; @@ -314,66 +313,6 @@ fn should_omit_reasoning_parts_without_signature_when_send_reasoning_is_false() // Extended-thinking input echo (build_request_body_with_warnings wiring) // --------------------------------------------------------------------------- -#[test] -fn thinking_enabled_echoes_reasoning_parts_as_thinking_blocks() { - use aimux_core::options::CallOptions; - use aimux_providers::anthropic::convert::build_request_body_with_warnings; - - let mut options = CallOptions::new(vec![msg( - Role::Assistant, - vec![ - reasoning_part("Let me weigh the options.", Some("sig-echo-1")), - text_part("Here is the answer."), - ], - )]); - options.provider_options = Some(provider_namespace( - "anthropic", - json!({ "thinking": { "type": "enabled", "budgetTokens": 1024 } }), - )); - let result = - build_request_body_with_warnings("claude-sonnet-4-20250514", &options, false).unwrap(); - let blocks = result.body["messages"][0]["content"].as_array().unwrap(); - let thinking = blocks - .iter() - .find(|b| b["type"] == "thinking") - .expect("reasoning part must be echoed as a thinking block"); - assert_eq!(thinking["signature"], "sig-echo-1"); - assert_eq!(thinking["thinking"], "Let me weigh the options."); - assert!( - !result - .warnings - .iter() - .any(|w| matches!(w, Warning::Other { message } if message.contains("disabled"))), - "no 'disabled' warning when thinking is enabled: {:?}", - result.warnings - ); -} - -#[test] -fn thinking_disabled_omits_reasoning_parts() { - use aimux_core::options::CallOptions; - use aimux_providers::anthropic::convert::build_request_body_with_warnings; - - let mut options = CallOptions::new(vec![msg( - Role::Assistant, - vec![ - reasoning_part("Let me weigh the options.", Some("sig-echo-1")), - text_part("Here is the answer."), - ], - )]); - options.provider_options = Some(provider_namespace( - "anthropic", - json!({ "thinking": { "type": "disabled" } }), - )); - let result = - build_request_body_with_warnings("claude-sonnet-4-20250514", &options, false).unwrap(); - let blocks = result.body["messages"][0]["content"].as_array().unwrap(); - assert!( - !blocks.iter().any(|b| b["type"] == "thinking"), - "thinking block must be omitted when thinking is disabled" - ); -} - // --------------------------------------------------------------------------- // URL images // --------------------------------------------------------------------------- diff --git a/aimux-providers/tests/anthropic_convert_test.rs b/aimux-providers/tests/anthropic_convert_test.rs index 0b835c6cb..cbe7b8113 100644 --- a/aimux-providers/tests/anthropic_convert_test.rs +++ b/aimux-providers/tests/anthropic_convert_test.rs @@ -17,7 +17,6 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::tool::{FunctionTool, ToolChoice}; use aimux_core::types::Warning; use aimux_providers::anthropic::convert::convert_prompt_to_anthropic; @@ -54,10 +53,6 @@ fn ftool(name: &str, desc: &str, schema: Value) -> FunctionTool { FunctionTool::new(name.to_string(), schema).with_description(desc.to_string()) } -fn anthropic_opts(value: Value) -> Option { - Some(provider_namespace("anthropic", value)) -} - /// Decode a base64 string into raw bytes (mirrors the TS `data: { type: 'data', /// data: '' }` input form, which the Rust `ContentPart::Image` holds as /// raw bytes). @@ -361,28 +356,6 @@ mod prepare_tools_tests { assert!(result.betas.is_empty()); } - #[test] - fn should_correctly_prepare_function_tools() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.provider_options = anthropic_opts(json!({ "eagerInputStreaming": true })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "eager_input_streaming": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - } - #[test] fn should_correctly_preserve_tool_input_examples() { let mut tool = ftool( @@ -523,48 +496,6 @@ mod prepare_tools_tests { // ── deferLoading for function tools ── - #[test] - fn should_include_defer_loading_when_set_to_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.provider_options = anthropic_opts(json!({ "deferLoading": true })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "defer_loading": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert_eq!(result.betas.len(), 1); - } - - #[test] - fn should_include_defer_loading_when_set_to_false() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.provider_options = anthropic_opts(json!({ "deferLoading": false })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "defer_loading": false, - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - #[test] fn should_not_include_defer_loading_when_not_specified() { let tool = ftool( @@ -580,30 +511,6 @@ mod prepare_tools_tests { // ── allowedCallers for function tools ── - #[test] - fn should_include_allowed_callers_and_advanced_tool_use_beta_when_set() { - let mut tool = ftool( - "query_database", - "Query a database", - json!({ "type": "object", "properties": { "sql": { "type": "string" } } }), - ); - tool.provider_options = - anthropic_opts(json!({ "allowedCallers": ["code_execution_20250825"] })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - - let expected = json!({ - "name": "query_database", - "description": "Query a database", - "input_schema": { "type": "object", "properties": { "sql": { "type": "string" } } }, - "allowed_callers": ["code_execution_20250825"], - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - #[test] fn should_not_include_allowed_callers_when_not_specified() { let tool = ftool( @@ -617,53 +524,6 @@ mod prepare_tools_tests { assert!(!tool_def.contains_key("allowed_callers")); } - #[test] - fn should_include_both_defer_loading_and_allowed_callers_when_both_set() { - let mut tool = ftool( - "query_database", - "Query a database", - json!({ "type": "object", "properties": { "sql": { "type": "string" } } }), - ); - tool.provider_options = anthropic_opts(json!({ - "deferLoading": true, - "allowedCallers": ["code_execution_20250825"], - })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let tool_def = result.tools.unwrap()[0].as_object().unwrap().clone(); - assert_eq!(tool_def.get("defer_loading"), Some(&json!(true))); - assert_eq!( - tool_def.get("allowed_callers"), - Some(&json!(["code_execution_20250825"])) - ); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - } - - #[test] - fn should_include_allowed_callers_with_code_execution_20260120() { - let mut tool = ftool( - "query_database", - "Query a database", - json!({ "type": "object", "properties": { "sql": { "type": "string" } } }), - ); - tool.provider_options = - anthropic_opts(json!({ "allowedCallers": ["code_execution_20260120"] })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - - let expected = json!({ - "name": "query_database", - "description": "Query a database", - "input_schema": { "type": "object", "properties": { "sql": { "type": "string" } } }, - "allowed_callers": ["code_execution_20260120"], - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - // ── tool choice ── #[test] diff --git a/aimux-providers/tests/anthropic_provider_tools_test.rs b/aimux-providers/tests/anthropic_provider_tools_test.rs index 5df2c81aa..d9c32eaac 100644 --- a/aimux-providers/tests/anthropic_provider_tools_test.rs +++ b/aimux-providers/tests/anthropic_provider_tools_test.rs @@ -52,8 +52,7 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; +use aimux_core::tool::{ProviderTool, Tool}; use aimux_providers::anthropic::AnthropicConfig; use aimux_providers::anthropic::model::AnthropicModel; @@ -152,11 +151,6 @@ async fn get_request_headers(server: &MockServer) -> Vec<(String, String)> { .collect() } -/// Helper to build `provider_options` from a JSON value. -fn anthropic_opts(value: Value) -> Option { - Some(provider_namespace("anthropic", value)) -} - /// Build a provider-defined tool: `Tool::Provider(ProviderTool { id, name, args })`. fn provider_tool(id: &str, name: &str, args: Value) -> Tool { Tool::Provider(ProviderTool { @@ -166,25 +160,6 @@ fn provider_tool(id: &str, name: &str, args: Value) -> Tool { }) } -/// Build a `calculator` function tool. When `eager` is true, sets the -/// Anthropic `eagerInputStreaming` provider option (mirrors the TS test that -/// mixes a client-side tool with a provider-defined tool). -fn calculator_tool(description: &str, eager: bool) -> Tool { - let provider_options = if eager { - anthropic_opts(json!({ "eagerInputStreaming": true })) - } else { - None - }; - Tool::Function(FunctionTool { - name: "calculator".to_string(), - description: Some(description.to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict: None, - provider_options, - input_examples: None, - }) -} - // ════════════════════════════════════════════════════════════════════════════�? // web search tool // (anthropic-language-model.test.ts L3191-3747) @@ -741,53 +716,6 @@ mod web_search_tool { "the trailing text must still be surfaced" ); } - - /// TS: "should work alongside regular client-side tools" (L3695) - #[tokio::test] - async fn should_work_alongside_regular_client_side_tools() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "I can search and calculate." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [ - // { type: 'function', name: 'calculator', description: 'Calculate math', - // inputSchema: { type: 'object', properties: {} }, - // providerOptions: { anthropic: { eagerInputStreaming: true } } }, - // { type: 'provider', id: 'anthropic.web_search_20250305', - // name: 'web_search', args: { maxUses: 1 } }, - // ] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![ - calculator_tool("Calculate math", true), - provider_tool( - "anthropic.web_search_20250305", - "web_search", - json!({ "maxUses": 1 }), - ), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 2); - // tools[0] is the function tool 'calculator' - assert_eq!(body["tools"][0]["name"], "calculator"); - assert_eq!(body["tools"][0]["eager_input_streaming"], true); - // tools[1] is the web_search provider tool - assert_eq!(body["tools"][1]["type"], "web_search_20250305"); - assert_eq!(body["tools"][1]["max_uses"], 1); - } } // ════════════════════════════════════════════════════════════════════════════�? @@ -912,169 +840,12 @@ mod web_fetch_tool { mod tool_search_tool { use super::*; - /// Helper: a function tool with deferLoading provider option. - #[allow(dead_code)] - fn deferred_function_tool(name: &str, desc: &str) -> FunctionTool { - FunctionTool { - name: name.to_string(), - description: Some(desc.to_string()), - input_schema: json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - }), - strict: None, - provider_options: anthropic_opts(json!({ "deferLoading": true })), - input_examples: None, - } - } - - /// TS: "should send request body with tool search tool and deferred tools" (L3971, regex) - #[tokio::test] - async fn should_send_request_body_with_tool_search_regex_and_deferred_tools() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-tool-search-regex.1' - mock_json(&server, 200, text_response("Weather data")).await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Find out weather data in SF")], - ..Default::default() - }]; - let mut options = default_options(prompt); - options.tools = Some(vec![ - provider_tool( - "anthropic.tool_search_regex_20251119", - "tool_search", - json!({}), - ), - Tool::Function(deferred_function_tool("get_temp_data", "For a location")), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["max_tokens"], 64000); - assert_eq!(body["model"], "claude-sonnet-4-5"); - assert_eq!(body["tools"].as_array().unwrap().len(), 2); - assert_eq!( - body["tools"][0], - json!({ - "name": "tool_search_tool_regex", - "type": "tool_search_tool_regex_20251119", - }) - ); - assert_eq!(body["tools"][1]["defer_loading"], true); - assert_eq!(body["tools"][1]["name"], "get_temp_data"); - } - - /// TS: "should include advanced-tool-use beta header" (L4017, regex) - #[tokio::test] - async fn should_include_beta_header_regex() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![ - provider_tool( - "anthropic.tool_search_regex_20251119", - "tool_search", - json!({}), - ), - Tool::Function(deferred_function_tool("get_temp_data", "For a location")), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - // claude-sonnet-4-5 supports structured outputs, so a function tool in - // the request triggers the structured-outputs beta. - assert_eq!(beta, Some("structured-outputs-2025-11-13")); - } - /// TS: "should include tool search tool call and result in content" (L4028, regex) #[tokio::test] async fn should_include_tool_search_regex_tool_call_and_result_in_content() { // Snapshot test �?requires fixture + response parsing for server tools. } - /// TS: "should send request body with tool search bm25 tool" (L4077) - #[tokio::test] - async fn should_send_request_body_with_tool_search_bm25() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-tool-search-bm25.1' - mock_json(&server, 200, text_response("Weather data")).await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in San Francisco?")], - ..Default::default() - }]; - let mut options = default_options(prompt); - options.tools = Some(vec![ - provider_tool( - "anthropic.tool_search_bm25_20251119", - "tool_search", - json!({}), - ), - Tool::Function(deferred_function_tool( - "get_weather", - "Get the current weather", - )), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["max_tokens"], 64000); - assert_eq!(body["model"], "claude-sonnet-4-5"); - assert_eq!(body["tools"].as_array().unwrap().len(), 2); - assert_eq!( - body["tools"][0], - json!({ - "name": "tool_search_tool_bm25", - "type": "tool_search_tool_bm25_20251119", - }) - ); - assert_eq!(body["tools"][1]["defer_loading"], true); - assert_eq!(body["tools"][1]["name"], "get_weather"); - } - - /// TS: "should include advanced-tool-use beta header" (L4123, bm25) - // TODO: requires CallOptions.tools to support provider tools - #[tokio::test] - async fn should_include_beta_header_bm25() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![ - provider_tool( - "anthropic.tool_search_bm25_20251119", - "tool_search", - json!({}), - ), - Tool::Function(deferred_function_tool( - "get_weather", - "Get the current weather", - )), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("structured-outputs-2025-11-13")); - } - /// TS: "should include tool search tool call and result in content" (L4134, bm25) #[tokio::test] #[ignore = "snapshot test �?requires fixture + response parsing for server tools"] @@ -1320,57 +1091,6 @@ mod advisor_tool { // ════════════════════════════════════════════════════════════════════════════�? mod mcp_servers { - use super::*; - - /// TS: "should send request body with include and tool" (L4411) - /// - /// This test passes `providerOptions.anthropic.mcpServers` through - /// `CallOptions.provider_options`, so it compiles. It will fail on - /// assertions until `build_request_body` reads `mcpServers`. - #[tokio::test] - async fn should_send_request_body_with_mcp_servers() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-mcp.1'; we use a minimal text response. - mock_json(&server, 200, text_response("MCP response")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "mcpServers": [ - { - "type": "url", - "name": "echo", - "url": "https://echo.mcp.inevitable.fyi/mcp", - }, - ], - })); - - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // TS expects mcp_servers in the request body. - assert_eq!( - body["mcp_servers"], - json!([ - { - "name": "echo", - "type": "url", - "url": "https://echo.mcp.inevitable.fyi/mcp", - }, - ]) - ); - assert_eq!(body["max_tokens"], 4096); - assert_eq!(body["model"], "claude-3-haiku-20240307"); - - // TS expects anthropic-beta: "mcp-client-2025-04-04" - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("mcp-client-2025-04-04")); - } /// TS: "should include mcp tool call and result in content" (L4464) #[tokio::test] @@ -1418,39 +1138,6 @@ mod code_execution_file_uploads { // TS expects a container_upload part in the message content. // TS expects headers: "files-api-2025-04-14,code-execution-2025-08-25" } - - /// TS: "should send container id for a follow-up code execution turn" (L4606) - // TODO: requires CallOptions.tools to support provider tools + container in providerOptions - #[tokio::test] - async fn should_send_container_id_for_follow_up() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { "id": "container_12345" }, - })); - options.tools = Some(vec![provider_tool( - "anthropic.code_execution_20250825", - "code_execution", - json!({}), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // TS expects container: "container_12345" in the request body. - assert_eq!(body["container"], "container_12345"); - assert_eq!(body["max_tokens"], 4096); - assert_eq!( - body["tools"][0], - json!({ - "name": "code_execution", - "type": "code_execution_20250825", - }) - ); - } } // ════════════════════════════════════════════════════════════════════════════�? @@ -1459,213 +1146,11 @@ mod code_execution_file_uploads { // ════════════════════════════════════════════════════════════════════════════�? mod agent_skills { - use super::*; - - /// TS: "should send request body with skills in container" (L4666) - // TODO: requires CallOptions.tools to support provider tools + container.skills - #[tokio::test] - async fn should_send_request_body_with_skills_in_container() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-code-execution-20250825.pptx-skill' - mock_json(&server, 200, text_response("Skill result")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "id": "test-container-id", - "skills": [ - { - "type": "anthropic", - "skillId": "pptx", - "version": "latest", - }, - { - "type": "custom", - "providerReference": { - "anthropic": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - }, - "version": "1.0", - }, - ], - }, - })); - // TODO: options.tools = Some(vec![provider_tool code_execution_20250825]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body["container"], - json!({ - "id": "test-container-id", - "skills": [ - { - "skill_id": "pptx", - "type": "anthropic", - "version": "latest", - }, - { - "skill_id": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - "type": "custom", - "version": "1.0", - }, - ], - }) - ); - assert_eq!(body["max_tokens"], 4096); - // TS expects result.warnings to be empty. - } - - /// TS: "should add a warning when the code execution tool is not present" (L4746) - // TODO: requires container.skills processing in build_request_body - #[tokio::test] - #[ignore = "requires container.skills processing in build_request_body"] - async fn should_add_warning_when_code_execution_tool_not_present() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "id": "test-container-id", - "skills": [ - { - "type": "anthropic", - "skillId": "pptx", - "version": "latest", - }, - { - "type": "custom", - "providerReference": { - "anthropic": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - }, - "version": "1.0", - }, - ], - }, - })); - // No tools �?code execution tool is not present. - let result = model.do_generate(&options).await.unwrap(); - - // TS expects warnings: [{ message: "code execution tool is required when using skills", type: "other" }] - assert!(result.warnings.iter().any( - |w| matches!(w, aimux_core::types::Warning::Other { message, .. } - if message.contains("code execution tool is required when using skills")) - )); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // Container should still be in the body even without the code execution tool. - assert!(body["container"]["skills"].is_array()); - } - - /// TS: "should include beta headers when skills are configured" (L4819) - // TODO: requires CallOptions.tools to support provider tools + skills beta headers - #[tokio::test] - async fn should_include_beta_headers_when_skills_configured() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "skills": [ - { - "type": "anthropic", - "skillId": "pptx", - "version": "latest", - }, - ], - }, - })); - // TODO: options.tools = Some(vec![provider_tool code_execution_20250825]); - let _result = model.do_generate(&options).await.unwrap(); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - // TS expects: "code-execution-2025-08-25,skills-2025-10-02,files-api-2025-04-14" - assert!(beta.is_some_and(|b| b.contains("skills-2025-10-02") - && b.contains("code-execution-2025-08-25") - && b.contains("files-api-2025-04-14"))); - } /// TS: "should expose container information as provider metadata" (L4859) #[tokio::test] #[ignore = "snapshot test �?requires providerMetadata for container info"] async fn should_expose_container_info_as_provider_metadata() {} - - /// TS: "should resolve custom skill provider references at the Anthropic boundary" (L4892) - // TODO: requires CallOptions.tools to support provider tools + skill resolution - #[tokio::test] - async fn should_resolve_custom_skill_provider_references() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "skills": [ - { - "type": "custom", - "providerReference": { - "anthropic": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - }, - }, - ], - }, - })); - // TODO: options.tools = Some(vec![provider_tool code_execution_20250825]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body["container"], - json!({ - "skills": [ - { - "skill_id": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - "type": "custom", - }, - ], - }) - ); - } - - /// TS: "should throw when a custom skill provider reference does not include anthropic" (L4956) - // TODO: requires skill provider reference validation - #[tokio::test] - #[ignore = "requires skill provider reference validation (NoSuchProviderReferenceError)"] - async fn should_throw_when_custom_skill_reference_missing_anthropic() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "skills": [ - { - "type": "custom", - "providerReference": { - "openai": "skill_abc", - }, - }, - ], - }, - })); - - // TS expects this to throw NoSuchProviderReferenceError. - let result = model.do_generate(&options).await; - assert!(result.is_err()); - } } // ════════════════════════════════════════════════════════════════════════════�? @@ -2006,66 +1491,4 @@ mod code_execution_20250522 { // 2. text // TODO: assert once GenerateContent has the needed variants. } - - /// TS: "should work alongside regular client-side tools" (L5435) - // TODO: requires CallOptions.tools to support mixed function + provider tools - #[tokio::test] - async fn should_work_alongside_regular_client_side_tools() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "I can execute code and calculate." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [ - // { type: 'function', name: 'calculator', description: 'Calculate math expressions', - // inputSchema: { type: 'object', properties: {} } }, - // { type: 'provider', id: 'anthropic.code_execution_20250522', - // name: 'code_execution', args: {} }, - // ] - let mut options = default_options(factorial_prompt()); - options.tools = Some(vec![ - Tool::Function( - FunctionTool::new( - "calculator".to_string(), - json!({ "type": "object", "properties": {} }), - ) - .with_description("Calculate math expressions".to_string()), - ), - provider_tool( - "anthropic.code_execution_20250522", - "code_execution", - json!({}), - ), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 2); - // tools[0] is the function tool 'calculator' - assert_eq!(body["tools"][0]["name"], "calculator"); - assert_eq!( - body["tools"][0]["description"], - "Calculate math expressions" - ); - // tools[1] is the code_execution provider tool - assert_eq!(body["tools"][1]["type"], "code_execution_20250522"); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("code-execution-2025-05-22")); - } } diff --git a/aimux-providers/tests/aws_polly_test.rs b/aimux-providers/tests/aws_polly_test.rs index 1aa13be7f..5541ea346 100644 --- a/aimux-providers/tests/aws_polly_test.rs +++ b/aimux-providers/tests/aws_polly_test.rs @@ -7,7 +7,7 @@ //! //! No network access, no real credentials. -use serde_json::{Value, json}; +use serde_json::Value; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -261,39 +261,6 @@ async fn language_maps_to_language_code() { assert_eq!(body["LanguageCode"], "es-US"); } -/// Provider options (`aws_polly` key) forward Polly-specific fields. -#[tokio::test] -async fn provider_options_forward_polly_fields() { - let server = MockServer::start().await; - mock_audio_response(&server, "mp3").await; - - let provider = AwsPollyProvider::new(test_config(&server)); - let model = provider.speech("aws_polly/standard"); - - let mut options = speech_options("Hello"); - let provider_options = aimux_core::shared::provider_namespace( - "aws_polly", - json!({ - "engine": "neural", - "sampleRate": "22050", - "textType": "ssml", - "lexiconNames": ["lex1"], - "speechMarkTypes": ["sentence"] - }), - ); - options.provider_options = Some(provider_options); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["Engine"], "neural", "engine override should apply"); - assert_eq!(body["SampleRate"], "22050"); - assert_eq!(body["TextType"], "ssml"); - assert_eq!(body["LexiconNames"], json!(["lex1"])); - assert_eq!(body["SpeechMarkTypes"], json!(["sentence"])); -} - /// The response carries timestamp, model id and headers. #[tokio::test] async fn response_carries_metadata() { diff --git a/aimux-providers/tests/bedrock_convert_test.rs b/aimux-providers/tests/bedrock_convert_test.rs index a5a536629..039ef89d6 100644 --- a/aimux-providers/tests/bedrock_convert_test.rs +++ b/aimux-providers/tests/bedrock_convert_test.rs @@ -33,7 +33,7 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::message::Role; use aimux_core::options::ToolChoice; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; +use aimux_core::shared::SharedProviderOptions; use aimux_core::tool::FunctionTool; use aimux_providers::bedrock::convert::{ @@ -83,21 +83,6 @@ fn file_base64( } } -fn text_with_cache(text: &str, cache_type: &str, ttl: Option<&str>) -> ContentPart { - let mut cp = serde_json::Map::new(); - cp.insert("type".to_string(), json!(cache_type)); - if let Some(t) = ttl { - cp.insert("ttl".to_string(), json!(t)); - } - ContentPart::Text { - text: text.to_string(), - provider_options: Some(provider_namespace( - "bedrock", - json!({ "cachePoint": Value::Object(cp) }), - )), - } -} - fn reasoning(text: &str, signature: Option<&str>) -> ContentPart { ContentPart::Reasoning { text: text.to_string(), @@ -252,29 +237,6 @@ fn user_consistent_document_names() { // SKIPPED (TS: "should throw for file parts with provider references"): // FileReference is not converted (no Result to surface the throw). -/// TS: "should add cache point to user content part when specified" -#[test] -fn user_content_part_cache_point() { - let (system, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - ContentPart::text("Hello"), - text_with_cache("cached", "default", Some("5m")), - ContentPart::text("World"), - ])]); - assert!(system.is_empty()); - assert_eq!( - Value::Array(messages), - json!([{ - "role": "user", - "content": [ - { "text": "Hello" }, - { "text": "cached" }, - { "cachePoint": { "type": "default", "ttl": "5m" } }, - { "text": "World" }, - ] - }]) - ); -} - // ── assistant messages ────────────────────────────────────────────────────── /// TS: "should remove trailing whitespace from last assistant message when there is no further user message" @@ -344,28 +306,6 @@ fn assistant_combine_sequential() { // SKIPPED (TS: assistant message cache point, 5m, 1h — 3 cases): message-level // providerOptions are not modelled on LanguageModelPromptMessage. -/// TS: "should add cache point to assistant content part when specified" -#[test] -fn assistant_content_part_cache_point() { - let (_, messages) = convert_prompt_to_bedrock(&vec![assistant(vec![ - ContentPart::text("Hello"), - text_with_cache("cached", "default", Some("1h")), - ContentPart::text("World"), - ])]); - assert_eq!( - Value::Array(messages), - json!([{ - "role": "assistant", - "content": [ - { "text": "Hello" }, - { "text": "cached" }, - { "cachePoint": { "type": "default", "ttl": "1h" } }, - { "text": "World" }, - ] - }]) - ); -} - /// TS: "should properly convert reasoning content type" #[test] fn assistant_reasoning_with_signature() { @@ -744,56 +684,6 @@ fn tool_result_json_output() { // ── citations ─────────────────────────────────────────────────────────────── -/// TS: "should handle citations enabled for PDF" -#[test] -fn citations_enabled_for_pdf() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "AAECAw==", - "application/pdf", - None, - Some(provider_namespace( - "bedrock", - json!({ "citations": { "enabled": true } }), - )), - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ - "document": { - "format": "pdf", - "name": "document-1", - "source": { "bytes": "AAECAw==" }, - "citations": { "enabled": true } - } - }) - ); -} - -/// TS: "should handle citations disabled for PDF" -#[test] -fn citations_disabled_for_pdf() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "AAECAw==", - "application/pdf", - None, - Some(provider_namespace( - "bedrock", - json!({ "citations": { "enabled": false } }), - )), - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ - "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" } } - }) - ); - assert!( - messages[0]["content"][0]["document"] - .get("citations") - .is_none() - ); -} - /// TS: "should handle no citations specified for PDF (default)" #[test] fn citations_default_for_pdf() { @@ -816,38 +706,6 @@ fn citations_default_for_pdf() { ); } -/// TS: "should handle multiple PDFs with different citation settings" -#[test] -fn citations_multiple_pdfs() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - file_base64( - "AAECAw==", - "application/pdf", - None, - Some(provider_namespace( - "bedrock", - json!({ "citations": { "enabled": true } }), - )), - ), - file_base64( - "BAUGBw==", - "application/pdf", - None, - Some(provider_namespace( - "bedrock", - json!({ "citations": { "enabled": false } }), - )), - ), - ])]); - assert_eq!( - messages[0]["content"], - json!([ - { "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" }, "citations": { "enabled": true } } }, - { "document": { "format": "pdf", "name": "document-2", "source": { "bytes": "BAUGBw==" } } }, - ]) - ); -} - // ── additional file format tests ──────────────────────────────────────────── // SKIPPED (TS: "should throw an error for unsupported file mime type in user diff --git a/aimux-providers/tests/bedrock_embedding_test.rs b/aimux-providers/tests/bedrock_embedding_test.rs index dd663c511..ba1a1f5bb 100644 --- a/aimux-providers/tests/bedrock_embedding_test.rs +++ b/aimux-providers/tests/bedrock_embedding_test.rs @@ -251,43 +251,6 @@ async fn should_support_cross_region_cohere() { assert_eq!(result.usage.unwrap().tokens, 6); } -/// TS: "should pass outputDimension for Cohere v4 embedding models" -#[tokio::test] -async fn should_pass_output_dimension_cohere_v4() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/model/cohere.embed-v4:0/invoke")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("x-amzn-bedrock-input-token-count", "6") - .set_body_json(json!({ - "embeddings": { "float": [mock_embeddings()[0]] } - })), - ) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("cohere.embed-v4:0"); - - let provider_options = - aimux_core::shared::provider_namespace("bedrock", json!({"outputDimension": 256})); - let options = EmbeddingCallOptions { - values: vec![TEST_VALUES[0].to_string()], - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["output_dimension"], 256); -} - /// TS: "should send SINGLE_EMBEDDING payload for Nova embeddings" #[tokio::test] async fn should_support_nova_embeddings() { @@ -332,41 +295,3 @@ async fn should_support_nova_embeddings() { TEST_VALUES[0] ); } - -/// TS: "should pass embeddingDimension for Nova embeddings" -#[tokio::test] -async fn should_pass_nova_embedding_dimension() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path( - "/model/amazon.nova-2-multimodal-embeddings-v1:0/invoke", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "embeddings": [ - { "embeddingType": "TEXT", "embedding": mock_embeddings()[0] } - ], - "inputTokenCount": 8 - }))) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("amazon.nova-2-multimodal-embeddings-v1:0"); - - let provider_options = - aimux_core::shared::provider_namespace("bedrock", json!({"embeddingDimension": 256})); - let options = EmbeddingCallOptions { - values: vec![TEST_VALUES[0].to_string()], - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["singleEmbeddingParams"]["embeddingDimension"], 256); -} diff --git a/aimux-providers/tests/bedrock_reasoning_test.rs b/aimux-providers/tests/bedrock_reasoning_test.rs index e5a6f8266..f9ee76cfd 100644 --- a/aimux-providers/tests/bedrock_reasoning_test.rs +++ b/aimux-providers/tests/bedrock_reasoning_test.rs @@ -36,7 +36,6 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReasonUnified, ProviderMetadata}; @@ -89,11 +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 { - Some(provider_namespace("bedrock", value)) -} - /// 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"); @@ -855,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 @@ -992,48 +892,6 @@ fn convert_reasoning_content_type() { ); } -/// TS: "should properly convert redacted-reasoning content type". -/// -/// SKIPPED: `ContentPart::Reasoning` has no `redactedData` field and -/// `convert_prompt_to_bedrock` has no `redactedReasoning` branch, so redacted -/// reasoning cannot be replayed on the Bedrock side. The test body documents -/// the intended behaviour (a `reasoningContent.redactedReasoning { data }` -/// block) for when the data model gains support. -#[test] -#[ignore = "redacted reasoning is not modelled on ContentPart::Reasoning / convert"] -fn convert_reasoning_redacted_content_type() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ContentPart::Reasoning { - text: String::new(), - signature: None, - provider_options: Some(provider_namespace( - "bedrock", - json!({ "redactedData": "Redacted reasoning information" }), - )), - }]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "redactedReasoning": { "data": "Redacted reasoning information" } - } - } - ] - } - ]) - ); -} - /// TS: "should omit assistant message reasoning parts signed by a foreign /// provider". /// diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs index 5debaf66e..15943ffb0 100644 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ b/aimux-providers/tests/bedrock_remaining_test.rs @@ -770,40 +770,6 @@ async fn temperature_not_clamped_in_range() { // ── 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 po = aimux_core::shared::provider_namespace( - "bedrock", - 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 diff --git a/aimux-providers/tests/bedrock_reranking_test.rs b/aimux-providers/tests/bedrock_reranking_test.rs deleted file mode 100644 index 27728418f..000000000 --- a/aimux-providers/tests/bedrock_reranking_test.rs +++ /dev/null @@ -1,389 +0,0 @@ -//! Rust translations of the Amazon Bedrock Reranking model tests. -//! -//! Source: `reference/ai/packages/amazon-bedrock/src/reranking/amazon-bedrock-reranking-model.test.ts` -//! (13 test cases across "json documents" and "text documents" groups). - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::reranking_model::{RerankingCallOptions, RerankingDocuments, RerankingModel}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_providers::{BedrockProvider, BedrockProviderConfig}; - -// -- helpers ----------------------------------------------------------------- - -fn rerank_response_body() -> Value { - json!({ - "results": [ - { "index": 0, "relevanceScore": 0.5110583305358887 }, - { "index": 5, "relevanceScore": 0.30241215229034424 } - ] - }) -} - -fn provider(server: &MockServer) -> BedrockProvider { - let config = BedrockProviderConfig::with_bearer_token("test-auth", "us-west-2") - .with_base_url(server.uri()); - BedrockProvider::new(config) -} - -fn bedrock_provider_options() -> SharedProviderOptions { - provider_namespace( - "bedrock", - json!({ - "nextToken": "test-token", - "additionalModelRequestFields": { "test": "test-value" } - }), - ) -} - -fn text_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { - RerankingCallOptions { - documents: RerankingDocuments::Text { - values: vec![ - "sunny day at the beach".to_string(), - "rainy day in the city".to_string(), - ], - }, - query: query.to_string(), - top_n: Some(top_n), - abort_signal: None, - provider_options: Some(bedrock_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -fn json_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { - RerankingCallOptions { - documents: RerankingDocuments::Object { - values: vec![ - json!({ "example": "sunny day at the beach" }), - json!({ "example": "rainy day in the city" }), - ], - }, - query: query.to_string(), - top_n: Some(top_n), - abort_signal: None, - provider_options: Some(bedrock_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -async fn mount_rerank_mock(server: &MockServer) { - Mock::given(method("POST")) - .and(path("/rerank")) - .respond_with(ResponseTemplate::new(200).set_body_json(rerank_response_body())) - .mount(server) - .await; -} - -// -- json documents tests ---------------------------------------------------- - -#[tokio::test] -async fn json_docs_should_send_request_with_stringified_json_documents() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["nextToken"], "test-token"); - assert_eq!(body["queries"][0]["textQuery"]["text"], "rainy day"); - assert_eq!(body["queries"][0]["type"], "TEXT"); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["modelConfiguration"]["modelArn"], - "arn:aws:bedrock:us-west-2::foundation-model/cohere.rerank-v3-5:0" - ); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["modelConfiguration"]["additionalModelRequestFields"] - ["test"], - "test-value" - ); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["numberOfResults"], - 2 - ); - assert_eq!( - body["rerankingConfiguration"]["type"], - "BEDROCK_RERANKING_MODEL" - ); - // JSON document sources - assert_eq!(body["sources"][0]["type"], "INLINE"); - assert_eq!(body["sources"][0]["inlineDocumentSource"]["type"], "JSON"); - assert_eq!( - body["sources"][0]["inlineDocumentSource"]["jsonDocument"]["example"], - "sunny day at the beach" - ); - assert_eq!( - body["sources"][1]["inlineDocumentSource"]["jsonDocument"]["example"], - "rainy day in the city" - ); -} - -#[tokio::test] -async fn json_docs_should_send_bedrock_reranking_configuration_wire_key() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // The Bedrock API requires this exact member name. - assert!( - body["rerankingConfiguration"] - .get("bedrockRerankingConfiguration") - .is_some(), - "rerankingConfiguration.bedrockRerankingConfiguration must be present" - ); -} - -#[tokio::test] -async fn json_docs_should_send_request_with_correct_headers() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("content-type").and_then(|v| v.to_str().ok()), - Some("application/json") - ); - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-auth") - ); -} - -#[tokio::test] -async fn json_docs_should_return_result_without_warnings() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.warnings.is_none()); -} - -#[tokio::test] -async fn json_docs_should_return_result_with_correct_ranking() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert_eq!(result.ranking.len(), 2); - assert_eq!(result.ranking[0].index, 0); - assert!((result.ranking[0].relevance_score - 0.5110583305358887).abs() < 1e-8); - assert_eq!(result.ranking[1].index, 5); - assert!((result.ranking[1].relevance_score - 0.30241215229034424).abs() < 1e-8); -} - -#[tokio::test] -async fn json_docs_should_not_return_provider_metadata() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); -} - -#[tokio::test] -async fn json_docs_should_return_result_with_correct_response() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let response = result.response.expect("response"); - let body = response.body.expect("body"); - assert_eq!(body["results"][0]["index"], 0); - assert_eq!(body["results"][0]["relevanceScore"], 0.5110583305358887); - assert_eq!(body["results"][1]["index"], 5); - assert_eq!(body["results"][1]["relevanceScore"], 0.30241215229034424); -} - -// -- text documents tests ---------------------------------------------------- - -#[tokio::test] -async fn text_docs_should_send_request_with_text_documents() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["nextToken"], "test-token"); - assert_eq!(body["queries"][0]["textQuery"]["text"], "rainy day"); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["modelConfiguration"]["modelArn"], - "arn:aws:bedrock:us-west-2::foundation-model/cohere.rerank-v3-5:0" - ); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["numberOfResults"], - 2 - ); - // Text document sources - assert_eq!(body["sources"][0]["type"], "INLINE"); - assert_eq!(body["sources"][0]["inlineDocumentSource"]["type"], "TEXT"); - assert_eq!( - body["sources"][0]["inlineDocumentSource"]["textDocument"]["text"], - "sunny day at the beach" - ); - assert_eq!( - body["sources"][1]["inlineDocumentSource"]["textDocument"]["text"], - "rainy day in the city" - ); -} - -#[tokio::test] -async fn text_docs_should_send_request_with_correct_headers() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("content-type").and_then(|v| v.to_str().ok()), - Some("application/json") - ); - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-auth") - ); -} - -#[tokio::test] -async fn text_docs_should_return_result_without_warnings() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.warnings.is_none()); -} - -#[tokio::test] -async fn text_docs_should_return_result_with_correct_ranking() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert_eq!(result.ranking.len(), 2); - assert_eq!(result.ranking[0].index, 0); - assert!((result.ranking[0].relevance_score - 0.5110583305358887).abs() < 1e-8); -} - -#[tokio::test] -async fn text_docs_should_not_return_provider_metadata() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); -} - -#[tokio::test] -async fn text_docs_should_return_result_with_correct_response() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let response = result.response.expect("response"); - let body = response.body.expect("body"); - assert_eq!(body["results"][0]["index"], 0); - assert_eq!(body["results"][0]["relevanceScore"], 0.5110583305358887); -} diff --git a/aimux-providers/tests/cartesia_transcription_test.rs b/aimux-providers/tests/cartesia_transcription_test.rs index b68ae4691..e13cf79a1 100644 --- a/aimux-providers/tests/cartesia_transcription_test.rs +++ b/aimux-providers/tests/cartesia_transcription_test.rs @@ -207,52 +207,3 @@ async fn should_use_real_date() { assert!(result.response.timestamp.is_some()); assert_eq!(result.response.model_id, Some("best".to_string())); } - -#[tokio::test] -async fn should_warn_about_streaming_options() { - let server = MockServer::start().await; - mock_response(&server, &fixture_response(), &[]).await; - - let config = CartesiaConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CartesiaProvider::new(config); - let model = provider.transcription("best"); - - let mut opts = options(mock_audio(), "audio/wav"); - let po = - aimux_core::shared::provider_namespace("cartesia", json!({"streaming": {"delay": "low"}})); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "providerOptions.cartesia.streaming"); - } - w => panic!("expected Unsupported warning, got: {w:?}"), - } -} - -#[tokio::test] -async fn should_pass_language_and_timestamp_granularities() { - let server = MockServer::start().await; - mock_response(&server, &fixture_response(), &[]).await; - - let config = CartesiaConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CartesiaProvider::new(config); - let model = provider.transcription("best"); - - let mut opts = options(mock_audio(), "audio/wav"); - let po = aimux_core::shared::provider_namespace( - "cartesia", - json!({"language": "en", "timestampGranularities": ["word"]}), - ); - opts.provider_options = Some(po); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let form = parse_multipart(&requests[0].body); - assert_eq!(form.get("language").unwrap(), "en"); - assert_eq!(form.get("timestamp_granularities[]").unwrap(), "word"); -} diff --git a/aimux-providers/tests/cohere_embedding_test.rs b/aimux-providers/tests/cohere_embedding_test.rs index bdbd04471..393bdc5c5 100644 --- a/aimux-providers/tests/cohere_embedding_test.rs +++ b/aimux-providers/tests/cohere_embedding_test.rs @@ -169,71 +169,6 @@ async fn should_pass_model_and_values() { ); } -/// TS: "should pass the input_type setting" -#[tokio::test] -async fn should_pass_input_type_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embed")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.embedding_model("embed-english-v3.0"); - - let provider_options = - aimux_core::shared::provider_namespace("cohere", json!({"inputType": "search_document"})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["input_type"], "search_document"); -} - -/// TS: "should pass the output_dimension setting" -#[tokio::test] -async fn should_pass_output_dimension_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embed")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.embedding_model("embed-v4.0"); - - let provider_options = - aimux_core::shared::provider_namespace("cohere", json!({"outputDimension": 256})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["output_dimension"], 256); - assert_eq!(body["model"], "embed-v4.0"); -} - /// TS: "should pass headers" #[tokio::test] async fn should_pass_headers() { diff --git a/aimux-providers/tests/cohere_remaining_test.rs b/aimux-providers/tests/cohere_remaining_test.rs index a894c7d04..944cbd631 100644 --- a/aimux-providers/tests/cohere_remaining_test.rs +++ b/aimux-providers/tests/cohere_remaining_test.rs @@ -303,38 +303,6 @@ fn convert_image_file_to_data_uri() { assert!(result.documents.is_empty()); } -/// TS: "should pass through detail provider option as image_url.detail" -#[test] -fn convert_image_file_with_detail_provider_option() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: Some(aimux_core::shared::provider_namespace( - "cohere", - json!({ "detail": "high" }), - )), - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - // The Rust convert does not parse `cohere.detail` from provider_options - // on File parts — this is a documented data-model gap. We verify the image - // URL is still produced correctly. - let msg = &result.messages[0]; - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content[0]["type"], json!("image_url")); - assert!( - content[0]["image_url"]["url"] - .as_str() - .unwrap() - .starts_with("data:image/png;base64,") - ); -} - /// TS: "should omit detail when no provider option is set" #[test] fn convert_image_file_omits_detail_when_no_option() { diff --git a/aimux-providers/tests/cohere_reranking_test.rs b/aimux-providers/tests/cohere_reranking_test.rs deleted file mode 100644 index 28cafe299..000000000 --- a/aimux-providers/tests/cohere_reranking_test.rs +++ /dev/null @@ -1,346 +0,0 @@ -//! Rust translations of the Cohere Reranking model tests. -//! -//! Source: `reference/ai/packages/cohere/src/reranking/cohere-reranking-model.test.ts` -//! (12 test cases across "json documents" and "text documents" groups). - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::reranking_model::{RerankingCallOptions, RerankingDocuments, RerankingModel}; -use aimux_providers::{CohereConfig, CohereProvider}; - -// -- helpers ----------------------------------------------------------------- - -fn rerank_response_body() -> Value { - json!({ - "id": "b44fe75b-e3d3-489a-b61e-1a1aede3ef72", - "meta": { - "api_version": { "version": "2" }, - "billed_units": { "search_units": 1 } - }, - "results": [ - { "index": 1, "relevance_score": 0.10183054 }, - { "index": 0, "relevance_score": 0.03762639 } - ] - }) -} - -fn provider(server: &MockServer) -> CohereProvider { - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - CohereProvider::new(config) -} - -fn cohere_provider_options() -> aimux_core::shared::SharedProviderOptions { - aimux_core::shared::provider_namespace( - "cohere", - json!({ "maxTokensPerDoc": 1000, "priority": 1 }), - ) -} - -fn text_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { - RerankingCallOptions { - documents: RerankingDocuments::Text { - values: vec![ - "sunny day at the beach".to_string(), - "rainy day in the city".to_string(), - ], - }, - query: query.to_string(), - top_n: Some(top_n), - abort_signal: None, - provider_options: Some(cohere_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -fn json_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { - RerankingCallOptions { - documents: RerankingDocuments::Object { - values: vec![ - json!({ "example": "sunny day at the beach" }), - json!({ "example": "rainy day in the city" }), - ], - }, - query: query.to_string(), - top_n: Some(top_n), - abort_signal: None, - provider_options: Some(cohere_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -async fn mount_rerank_mock(server: &MockServer) { - Mock::given(method("POST")) - .and(path("/rerank")) - .respond_with(ResponseTemplate::new(200).set_body_json(rerank_response_body())) - .mount(server) - .await; -} - -// -- json documents tests ---------------------------------------------------- - -#[tokio::test] -async fn json_docs_should_send_request_with_stringified_json_documents() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body["documents"][0], - json!("{\"example\":\"sunny day at the beach\"}") - ); - assert_eq!( - body["documents"][1], - json!("{\"example\":\"rainy day in the city\"}") - ); - assert_eq!(body["max_tokens_per_doc"], 1000); - assert_eq!(body["model"], "rerank-english-v3.0"); - assert_eq!(body["priority"], 1); - assert_eq!(body["query"], "rainy day"); - assert_eq!(body["top_n"], 2); -} - -#[tokio::test] -async fn json_docs_should_send_request_with_correct_headers() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - assert_eq!( - headers.get("content-type").and_then(|v| v.to_str().ok()), - Some("application/json") - ); -} - -#[tokio::test] -async fn json_docs_should_return_result_with_warnings() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let warnings = result.warnings.expect("warnings"); - assert_eq!(warnings.len(), 1); - match &warnings[0] { - aimux_core::types::Warning::Compatibility { feature, details } => { - assert_eq!(feature, "object documents"); - assert_eq!( - details.as_deref(), - Some("Object documents are converted to strings.") - ); - } - other => panic!("expected Compatibility warning, got {other:?}"), - } -} - -#[tokio::test] -async fn json_docs_should_return_result_with_correct_ranking() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert_eq!(result.ranking.len(), 2); - assert_eq!(result.ranking[0].index, 1); - assert!((result.ranking[0].relevance_score - 0.10183054).abs() < 1e-8); - assert_eq!(result.ranking[1].index, 0); - assert!((result.ranking[1].relevance_score - 0.03762639).abs() < 1e-8); -} - -#[tokio::test] -async fn json_docs_should_not_return_provider_metadata() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); -} - -#[tokio::test] -async fn json_docs_should_return_result_with_correct_response() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let response = result.response.expect("response"); - assert_eq!( - response.id.as_deref(), - Some("b44fe75b-e3d3-489a-b61e-1a1aede3ef72") - ); - let body = response.body.expect("body"); - assert_eq!(body["results"][0]["index"], 1); - assert_eq!(body["results"][0]["relevance_score"], 0.10183054); -} - -// -- text documents tests ---------------------------------------------------- - -#[tokio::test] -async fn text_docs_should_send_request_with_text_documents() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["documents"][0], "sunny day at the beach"); - assert_eq!(body["documents"][1], "rainy day in the city"); - assert_eq!(body["model"], "rerank-english-v3.0"); - assert_eq!(body["query"], "rainy day"); - assert_eq!(body["top_n"], 2); -} - -#[tokio::test] -async fn text_docs_should_send_request_with_correct_headers() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - assert_eq!( - headers.get("content-type").and_then(|v| v.to_str().ok()), - Some("application/json") - ); -} - -#[tokio::test] -async fn text_docs_should_return_result_without_warnings() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let warnings = result.warnings.expect("warnings"); - assert!(warnings.is_empty()); -} - -#[tokio::test] -async fn text_docs_should_return_result_with_correct_ranking() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert_eq!(result.ranking.len(), 2); - assert_eq!(result.ranking[0].index, 1); - assert!((result.ranking[0].relevance_score - 0.10183054).abs() < 1e-8); -} - -#[tokio::test] -async fn text_docs_should_not_return_provider_metadata() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); -} - -#[tokio::test] -async fn text_docs_should_return_result_with_correct_response() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let response = result.response.expect("response"); - assert_eq!( - response.id.as_deref(), - Some("b44fe75b-e3d3-489a-b61e-1a1aede3ef72") - ); -} diff --git a/aimux-providers/tests/convert_golden_test.rs b/aimux-providers/tests/convert_golden_test.rs deleted file mode 100644 index 0a3ae341e..000000000 --- a/aimux-providers/tests/convert_golden_test.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Golden equivalence tests for the three request-body converters that were -//! split during M11 (#76). -//! -//! The splits were pure reorganization; these tests pin each converter's FULL -//! request body + warning list for a representative option matrix so any -//! future refactor (or a regression like the Anthropic default-budget drop) -//! is caught by an exact comparison, not by a partial assertion. - -use serde_json::json; - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::tool::{FunctionTool, Tool}; - -use aimux_providers::anthropic::convert::build_request_body_with_warnings as anthropic_build; -use aimux_providers::openai::convert::build_request_body_with_warnings as openai_build; -use aimux_providers::openai::responses::convert::build_responses_request_body; - -fn user_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn weather_tool() -> FunctionTool { - FunctionTool { - name: "weather".to_string(), - description: Some("current weather".to_string()), - input_schema: json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false - }), - strict: None, - provider_options: None, - input_examples: None, - } -} - -fn json_schema_format() -> ResponseFormat { - ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "answer": { "type": "string" } }, - "required": ["answer"] - })), - name: Some("qa".to_string()), - description: Some("answer extraction".to_string()), - } -} - -// ── OpenAI Chat Completions ───────────────────────────────────────────────── - -#[test] -fn openai_chat_golden() { - let options = CallOptions { - prompt: user_prompt(), - max_output_tokens: Some(2048), - temperature: Some(0.7), - reasoning: Some(aimux_core::types::ReasoningEffort::High), - tools: Some(vec![Tool::Function(weather_tool())]), - tool_choice: ToolChoice::Auto, - response_format: Some(json_schema_format()), - body_overrides: Some(json!({ "user": "override-me" })), - ..CallOptions::default() - }; - - let result = openai_build( - "o3-mini", - &options, - false, - "openai", - &aimux_providers::openai::OpenAICompatProfile::full(), - ) - .expect("openai chat build"); - - assert_eq!( - result.body, - json!({ - "model": "o3-mini", - "messages": [ { "role": "user", "content": "Hello" } ], - "max_completion_tokens": 2048, - "response_format": { - "type": "json_schema", - "json_schema": { - "schema": { - "type": "object", - "properties": { "answer": { "type": "string" } }, - "required": ["answer"] - }, - "name": "qa", - "description": "answer extraction", - "strict": true - } - }, - "reasoning_effort": "high", - "tools": [ - { - "type": "function", - "function": { - "name": "weather", - "description": "current weather", - "parameters": { - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false - } - } - } - ], - "tool_choice": "auto", - "user": "override-me" - }), - "openai chat body diverged: {}", - result.body - ); - // `temperature` is stripped for reasoning models with a warning; the - // body override `user` lands after built-in fields. - assert_eq!(result.body.get("temperature"), None); - assert_eq!( - serde_json::to_value(&result.warnings).unwrap(), - json!([{ "Unsupported": { "feature": "temperature", - "details": "temperature is not supported for reasoning models" } }]) - ); -} - -// ── OpenAI Responses API ──────────────────────────────────────────────────── - -#[test] -fn responses_golden() { - let provider = aimux_core::shared::provider_namespace( - "openai", - json!({ - "reasoningEffort": "high", - "textVerbosity": "low", - "metadata": { "k": "v" } - }), - ); - let options = CallOptions { - prompt: user_prompt(), - seed: Some(42), - top_k: Some(0.2), - response_format: Some(json_schema_format()), - provider_options: Some(provider), - ..CallOptions::default() - }; - - let result = build_responses_request_body("o3-mini", &options, false); - - assert_eq!( - result.body, - json!({ - "model": "o3-mini", - "input": [ - { "role": "user", "content": [ { "type": "input_text", "text": "Hello" } ] } - ], - "text": { - "format": { - "type": "json_schema", - "strict": true, - "name": "qa", - "description": "answer extraction", - "schema": { - "type": "object", - "properties": { "answer": { "type": "string" } }, - "required": ["answer"] - } - }, - "verbosity": "low" - }, - "metadata": { "k": "v" }, - "reasoning": { "effort": "high", "summary": "detailed" } - }), - "responses body diverged: {}", - result.body - ); - - // top_k / seed are unsupported in the Responses API → compatibility - // warnings, and the model is a reasoning model so it exposes `reasoning`. - assert_eq!( - serde_json::to_value(&result.warnings).unwrap(), - json!([ - { "Unsupported": { "feature": "topK", "details": null } }, - { "Unsupported": { "feature": "seed", "details": null } } - ]) - ); -} - -// ── Anthropic ─────────────────────────────────────────────────────────────── - -#[test] -fn anthropic_golden() { - let provider = aimux_core::shared::provider_namespace( - "anthropic", - json!({ - "thinking": { "type": "enabled", "budgetTokens": 4096 } - }), - ); - let options = CallOptions { - prompt: user_prompt(), - max_output_tokens: Some(1000), - temperature: Some(0.7), - top_k: Some(0.5), - stop_sequences: Some(vec!["END".to_string()]), - provider_options: Some(provider), - ..CallOptions::default() - }; - - let result = anthropic_build("claude-sonnet-4-5", &options, false).expect("anthropic build"); - - assert_eq!( - result.body, - json!({ - "model": "claude-sonnet-4-5", - "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ] } ], - "max_tokens": 1000 + 4096, - "stream": false, - "thinking": { "type": "enabled", "budget_tokens": 4096 }, - "stop_sequences": ["END"] - }), - "anthropic body diverged: {}", - result.body - ); - - // Thinking enabled strips temperature/topK with warnings. - assert_eq!(result.body.get("temperature"), None); - assert_eq!(result.body.get("top_k"), None); - assert_eq!( - serde_json::to_value(&result.warnings).unwrap(), - json!([ - { "Unsupported": { "feature": "temperature", - "details": "temperature is not supported when thinking is enabled" } }, - { "Unsupported": { "feature": "topK", - "details": "topK is not supported when thinking is enabled" } } - ]) - ); -} diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index e9c35c5cd..fabc52f63 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -801,10 +801,13 @@ async fn finding_2_anthropic_web_search_result_mapped_and_sources_emitted() { ] { assert_eq!( by_url.get(url).copied(), - Some(&aimux_core::shared::provider_namespace( - "anthropic", - json!({ "pageAge": expected_page_age }) - )), + Some( + &aimux_core::shared::provider_namespace( + "anthropic", + json!({ "pageAge": expected_page_age }) + ) + .unwrap() + ), "source {url}: providerMetadata must hold exactly anthropic.pageAge" ); } diff --git a/aimux-providers/tests/deepgram_transcription_test.rs b/aimux-providers/tests/deepgram_transcription_test.rs index 11e3fa406..4d09cb343 100644 --- a/aimux-providers/tests/deepgram_transcription_test.rs +++ b/aimux-providers/tests/deepgram_transcription_test.rs @@ -140,43 +140,6 @@ async fn should_extract_text() { assert_eq!(result.text, "galileo was an american robotic space program"); } -#[tokio::test] -async fn should_pass_detect_language_query_param() { - let server = MockServer::start().await; - mock_response(&server, &fixture_response()).await; - - let config = DeepgramConfig::new("test-api-key").with_base_url(server.uri()); - let provider = DeepgramProvider::new(config); - let model = provider.transcription("nova-3"); - - let mut opts = options(mock_audio(), "audio/wav"); - let po = aimux_core::shared::provider_namespace("deepgram", json!({"detectLanguage": true})); - opts.provider_options = Some(po); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert!(requests[0].url.as_str().contains("detect_language=true")); -} - -#[tokio::test] -async fn should_return_detected_language() { - let server = MockServer::start().await; - mock_response(&server, &fixture_response()).await; - - let config = DeepgramConfig::new("test-api-key").with_base_url(server.uri()); - let provider = DeepgramProvider::new(config); - let model = provider.transcription("nova-3"); - - let mut opts = options(mock_audio(), "audio/wav"); - let po = aimux_core::shared::provider_namespace("deepgram", json!({"detectLanguage": true})); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.language, Some("en".to_string())); -} - #[tokio::test] async fn should_include_response_data() { let server = MockServer::start().await; @@ -224,36 +187,6 @@ async fn should_use_real_date() { assert_eq!(result.response.model_id, Some("nova-3".to_string())); } -#[tokio::test] -async fn should_return_language_from_inline_response() { - let server = MockServer::start().await; - mock_response( - &server, - &json!({ - "metadata": {"duration": 1.0}, - "results": { - "channels": [{ - "detected_language": "sv", - "alternatives": [{"transcript": "hej", "words": []}] - }] - } - }), - ) - .await; - - let config = DeepgramConfig::new("test-api-key").with_base_url(server.uri()); - let provider = DeepgramProvider::new(config); - let model = provider.transcription("nova-3"); - - let mut opts = options(mock_audio(), "audio/wav"); - let po = aimux_core::shared::provider_namespace("deepgram", json!({"detectLanguage": true})); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.language, Some("sv".to_string())); -} - #[tokio::test] async fn should_return_none_language_when_not_detected() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/deepseek_chat_test.rs b/aimux-providers/tests/deepseek_chat_test.rs index 166fd70b8..57800b67d 100644 --- a/aimux-providers/tests/deepseek_chat_test.rs +++ b/aimux-providers/tests/deepseek_chat_test.rs @@ -26,9 +26,6 @@ //! overridden with the mock server's root URI (no `/v1` suffix), so the //! resulting request path is `/chat/completions`. -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::tool::RawToolCall; - use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; @@ -38,7 +35,7 @@ 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, ResponseFormat, Tool}; +use aimux_core::options::{CallOptions, Tool}; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; @@ -80,11 +77,6 @@ fn make_provider(server: &MockServer, model_id: &str) -> Box .expect("deepseek provider should build") } -/// Wrap a value as `providerOptions.deepseek.`. -fn deepseek_opts(value: Value) -> Option { - Some(provider_namespace("deepseek", value)) -} - /// Mount a JSON chat-completion response on `/chat/completions`. async fn mock_json(server: &MockServer, body: Value) { Mock::given(method("POST")) @@ -146,24 +138,6 @@ fn text_deltas(parts: &[StreamPart]) -> Vec { .collect() } -/// A weather function tool matching the TS test's `inputSchema`. -fn weather_tool() -> FunctionTool { - FunctionTool { - name: "weather".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - } -} - // ───────────────────────────────────────────────────────────────────────────── // Local fixtures // ───────────────────────────────────────────────────────────────────────────── @@ -195,66 +169,6 @@ fn text_completion_body() -> Value { }) } -/// A non-streaming tool-call completion body. Abbreviated from the -/// `deepseek-tool-call` fixture (reasoning_content omitted — reasoning is tested -/// in `deepseek_reasoning_test.rs`). -fn tool_call_completion_body() -> Value { - json!({ - "id": "7a630f5b-b7e6-4878-82f8-d77db164d42b", - "object": "chat.completion", - "created": 1764665845, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "", - "tool_calls": [{ - "index": 0, - "id": "call_00_9V0vrf86Pc9aelHCJMZqnJBo", - "type": "function", - "function": { - "name": "weather", - "arguments": "{\"location\": \"San Francisco\"}" - } - }] - }, - "logprobs": null, - "finish_reason": "tool_calls" - }], - "usage": { - "prompt_tokens": 339, - "completion_tokens": 92, - "total_tokens": 431 - } - }) -} - -/// A non-streaming JSON completion body. Abbreviated from the `deepseek-json` -/// fixture (reasoning_content omitted). -fn json_completion_body() -> Value { - json!({ - "id": "f03bc170-b375-4561-9685-35182c8152c5", - "object": "chat.completion", - "created": 1764681341, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "{\n \"location\": \"San Francisco\",\n \"condition\": \"cloudy\",\n \"temperature\": 7\n}" - }, - "logprobs": null, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 495, - "completion_tokens": 144, - "total_tokens": 639 - } - }) -} - // ════════════════════════════════════════════════════════════════════════════ // doGenerate — text // @@ -340,89 +254,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 -/// `GenerateContent::ToolCall` parts with parsed JSON input. -#[tokio::test] -async fn should_extract_tool_call_content() { - 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()]); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - - // The tool-call fixture has empty content and one tool call. - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call_00_9V0vrf86Pc9aelHCJMZqnJBo"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location": "San Francisco"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - // ════════════════════════════════════════════════════════════════════════════ // doGenerate — JSON response format // @@ -434,125 +265,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. -#[tokio::test] -async fn should_extract_json_response_text_content() { - 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"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert!( - text.contains("San Francisco"), - "expected JSON content with 'San Francisco', got: {text}" - ), - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); -} - // ════════════════════════════════════════════════════════════════════════════ // doStream — text // @@ -683,87 +395,6 @@ async fn should_stream_text() { // (reasoning streaming is tested in `deepseek_reasoning_test.rs`). // ════════════════════════════════════════════════════════════════════════════ -/// TS: doStream › tool call › "should stream tool call" (line ~780). -/// -/// Verifies that tool call deltas are streamed as ToolInputStart / -/// ToolInputDelta / ToolInputEnd / ToolCall parts. -#[tokio::test] -async fn should_stream_tool_call() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"weather","arguments":""}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"location\": \"San Francisco\"}"}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"x","object":"chat.completion.chunk","created":1,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server, "deepseek-reasoner"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![weather_tool().into()]); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model - .do_stream(&options) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Should have ToolInputStart, ToolInputDelta, ToolInputEnd, ToolCall. - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputStart { .. })), - "expected ToolInputStart" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputDelta { .. })), - "expected ToolInputDelta" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputEnd { .. })), - "expected ToolInputEnd" - ); - - let tool_calls: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id, tool_name, input)), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "call_1"); - assert_eq!(tool_calls[0].1, "weather"); - assert_eq!(tool_calls[0].2, r#"{"location": "San Francisco"}"#); - - // The last part should be Finish with ToolCalls. - match parts.last() { - Some(StreamPart::Finish { finish_reason, .. }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - other => panic!("expected Finish as last part, got {other:?}"), - } -} - // ════════════════════════════════════════════════════════════════════════════ // Message conversion — convert-to-deepseek-chat-messages.test.ts // diff --git a/aimux-providers/tests/deepseek_reasoning_test.rs b/aimux-providers/tests/deepseek_reasoning_test.rs index f8296eef1..7b2e96350 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 aimux_core::shared::{SharedProviderOptions, provider_namespace}; - use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; @@ -95,14 +93,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 { - Some(provider_namespace("deepseek", value)) -} - /// Mount a JSON chat-completion response on `/chat/completions`. async fn mock_json(server: &MockServer, body: Value) { Mock::given(method("POST")) @@ -400,28 +390,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] @@ -563,115 +531,6 @@ async fn should_passthrough_reasoning_minimal_without_normalization() { ); } -/// 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. diff --git a/aimux-providers/tests/elevenlabs_realtime_stream_test.rs b/aimux-providers/tests/elevenlabs_realtime_stream_test.rs index a7614c9f9..e3963235a 100644 --- a/aimux-providers/tests/elevenlabs_realtime_stream_test.rs +++ b/aimux-providers/tests/elevenlabs_realtime_stream_test.rs @@ -263,74 +263,6 @@ async fn stream_realtime_happy_path() { } } -/// providerOptions: languageCode lands on the query; include_timestamps -/// switches the committed event to the words-carrying shape, which fills -/// segments (spacing entries dropped) and the finish duration. -#[tokio::test] -async fn stream_language_and_timestamps() { - let (base_url, captured) = start(|ws| { - Box::pin(async move { - ws.send(Message::Text( - r#"{"message_type":"committed_transcript_with_timestamps","text":"Hello world","language_code":"fr","words":[ - {"text":"Hello","start":0.0,"end":0.5,"type":"word"}, - {"text":" ","start":0.5,"end":0.5,"type":"spacing"}, - {"text":"world","start":0.5,"end":1.0,"type":"word"} - ]}"# - .replace('\n', ""), - )) - .await - .unwrap(); - let _ = ws.next().await; // close - }) - }) - .await; - - let model = realtime_model(&base_url, "scribe_v2_realtime"); - let mut options = stream_options(vec![AudioChunk::Binary(vec![1])], None); - let provider_options = aimux_core::shared::provider_namespace( - "elevenlabs", - serde_json::json!({ "languageCode": "fr", "includeTimestamps": true }), - ); - options.provider_options = Some(provider_options); - let result = model.do_stream(options).await.unwrap(); - let parts = collect(result).await; - - let uri = captured.uri(); - assert!(uri.contains("language_code=fr"), "in {uri}"); - assert!(uri.contains("include_timestamps=true"), "in {uri}"); - - match &parts[parts.len() - 2] { - Ok(TranscriptionStreamPart::TranscriptFinal { - text, - start_second, - end_second, - .. - }) => { - assert_eq!(text, "Hello world"); - assert_eq!(*start_second, Some(0.0)); - assert_eq!(*end_second, Some(1.0)); - } - other => panic!("expected final with timestamps, got {other:?}"), - } - match parts.last().unwrap() { - Ok(TranscriptionStreamPart::Finish { - text, - segments, - language, - duration_in_seconds, - .. - }) => { - assert_eq!(text, "Hello world"); - assert_eq!(language.as_deref(), Some("fr")); - assert_eq!(*duration_in_seconds, Some(1.0)); - assert_eq!(segments.len(), 2, "spacing entry dropped: {segments:?}"); - assert_eq!(segments[0].text, "Hello"); - assert_eq!(segments[1].text, "world"); - } - other => panic!("expected finish, got {other:?}"), - } -} - /// An empty audio stream commits via a single empty chunk. #[tokio::test] async fn stream_empty_audio_commits_empty_chunk() { diff --git a/aimux-providers/tests/google_embedding_test.rs b/aimux-providers/tests/google_embedding_test.rs index 51bcaeac2..a14328afc 100644 --- a/aimux-providers/tests/google_embedding_test.rs +++ b/aimux-providers/tests/google_embedding_test.rs @@ -11,7 +11,6 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; -use aimux_core::shared::provider_namespace; use aimux_providers::{GoogleConfig, GoogleProvider}; const TEST_VALUES: &[&str] = &["sunny day at the beach", "rainy day in the city"]; @@ -137,68 +136,6 @@ async fn should_pass_model_and_values() { ); } -/// TS: "should pass the outputDimensionality setting" -#[tokio::test] -async fn should_pass_output_dimensionality() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-embedding-001:batchEmbedContents")) - .respond_with(ResponseTemplate::new(200).set_body_json(batch_response_body())) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.embedding_model("gemini-embedding-001"); - - let provider_options = provider_namespace("google", json!({"outputDimensionality": 64})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["requests"][0]["outputDimensionality"], 64); -} - -/// TS: "should pass the taskType setting" -#[tokio::test] -async fn should_pass_task_type() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-embedding-001:batchEmbedContents")) - .respond_with(ResponseTemplate::new(200).set_body_json(batch_response_body())) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.embedding_model("gemini-embedding-001"); - - let provider_options = provider_namespace("google", json!({"taskType": "SEMANTIC_SIMILARITY"})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["requests"][0]["taskType"], "SEMANTIC_SIMILARITY"); -} - /// TS: "should pass headers" #[tokio::test] async fn should_pass_headers() { diff --git a/aimux-providers/tests/google_files_test.rs b/aimux-providers/tests/google_files_test.rs index bd85807c5..938fe1df6 100644 --- a/aimux-providers/tests/google_files_test.rs +++ b/aimux-providers/tests/google_files_test.rs @@ -14,7 +14,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData}; use aimux_core::retry::RetryConfig; -use aimux_core::shared::{FileBytes, provider_namespace}; +use aimux_core::shared::FileBytes; use aimux_providers::{GoogleConfig, GoogleProvider}; // -- helpers ----------------------------------------------------------------- @@ -151,31 +151,6 @@ async fn should_send_correct_headers_for_resumable_upload_initiation() { ); } -#[tokio::test] -async fn should_include_display_name_in_initiation_body_when_provided() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = provider.files(); - - let po = provider_namespace("google", json!({ "displayName": "my-document" })); - let opts = UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Binary(vec![1]), - }, - media_type: "text/plain".to_string(), - filename: None, - provider_options: Some(po), - abort_signal: None, - }; - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body, json!({ "file": { "display_name": "my-document" } })); -} - #[tokio::test] async fn should_omit_display_name_from_body_when_not_provided() { let server = MockServer::start().await; @@ -476,33 +451,6 @@ async fn should_throw_when_upload_request_fails() { // -- provider options tests -------------------------------------------------- -#[tokio::test] -async fn should_accept_valid_provider_options() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = provider.files(); - - let po = provider_namespace( - "google", - json!({ "displayName": "test", "pollIntervalMs": 5000, "pollTimeoutMs": 60000 }), - ); - let opts = UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Binary(vec![1]), - }, - media_type: "text/plain".to_string(), - filename: None, - provider_options: Some(po), - abort_signal: None, - }; - - let result = files.upload_file(&opts).await.unwrap(); - - assert!(result.provider_reference.contains_key("google")); -} - #[tokio::test] async fn should_work_without_provider_options() { let server = MockServer::start().await; @@ -516,30 +464,6 @@ async fn should_work_without_provider_options() { assert!(result.provider_reference.contains_key("google")); } -#[tokio::test] -async fn should_pass_through_unknown_properties() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = provider.files(); - - let po = provider_namespace("google", json!({ "customField": "custom-value" })); - let opts = UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Binary(vec![1]), - }, - media_type: "text/plain".to_string(), - filename: None, - provider_options: Some(po), - abort_signal: None, - }; - - let result = files.upload_file(&opts).await.unwrap(); - - assert!(result.provider_reference.contains_key("google")); -} - // -- response metadata tests ------------------------------------------------- #[tokio::test] diff --git a/aimux-providers/tests/google_image_test.rs b/aimux-providers/tests/google_image_test.rs index 259fe1a93..6292f6011 100644 --- a/aimux-providers/tests/google_image_test.rs +++ b/aimux-providers/tests/google_image_test.rs @@ -13,7 +13,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, }; -use aimux_core::shared::{AspectRatio, Size, provider_namespace}; +use aimux_core::shared::{AspectRatio, Size}; use aimux_core::types::Warning; use aimux_providers::{GoogleConfig, GoogleImageSettings, GoogleProvider}; @@ -213,33 +213,6 @@ async fn sends_aspect_ratio_in_the_request() { assert_eq!(body["parameters"]["aspectRatio"], "16:9"); } -/// TS: "should combine aspectRatio and provider options" -#[tokio::test] -async fn should_combine_aspect_ratio_and_provider_options() { - let server = MockServer::start().await; - mock_imagen_response(&server, imagen_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("imagen-3.0-generate-002"); - - let mut opts = options("test prompt"); - opts.n = 1; - opts.aspect_ratio = Some(AspectRatio::new(1, 1)); - opts.provider_options.extend(provider_namespace( - "google", - json!({ "personGeneration": "dont_allow" }), - )); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["parameters"]["aspectRatio"], "1:1"); - assert_eq!(body["parameters"]["personGeneration"], "dont_allow"); - assert_eq!(body["parameters"]["sampleCount"], 1); -} - /// TS: "should return warnings for unsupported settings" #[tokio::test] async fn should_return_warnings_for_unsupported_settings() { @@ -350,70 +323,6 @@ async fn should_use_real_date_when_no_custom_date_provider_is_specified() { ); } -/// TS: "should only pass valid provider options" -#[tokio::test] -async fn should_only_pass_valid_provider_options() { - let server = MockServer::start().await; - mock_imagen_response(&server, imagen_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("imagen-3.0-generate-002"); - - let mut opts = options(PROMPT); - opts.n = 2; - opts.aspect_ratio = Some(AspectRatio::new(16, 9)); - opts.provider_options.extend(provider_namespace( - "google", - json!({ - "addWatermark": false, - "personGeneration": "allow_all", - "foo": "bar", - "negativePrompt": "negative prompt" - }), - )); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // Only personGeneration should be passed (other options are not in the schema) - assert_eq!(body["parameters"]["personGeneration"], "allow_all"); - assert_eq!(body["parameters"]["aspectRatio"], "16:9"); - assert_eq!(body["parameters"]["sampleCount"], 2); - // Invalid options should not be present - assert!(body["parameters"].get("addWatermark").is_none()); - assert!(body["parameters"].get("foo").is_none()); - assert!(body["parameters"].get("negativePrompt").is_none()); -} - -/// TS: "should emit an unsupported warning and not leak into parameters" (googleSearch on Imagen) -#[tokio::test] -async fn should_emit_warning_for_google_search_on_imagen() { - let server = MockServer::start().await; - mock_imagen_response(&server, imagen_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("imagen-3.0-generate-002"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.provider_options - .extend(provider_namespace("google", json!({ "googleSearch": {} }))); - - let result = model.do_generate(&opts).await.unwrap(); - - assert!(result.warnings.iter().any(|w| { - matches!(w, Warning::Unsupported { feature, details } if feature == "googleSearch" - && details.as_deref() == Some("Google Search grounding is only supported on Gemini image models.")) - })); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert!(body["parameters"].get("googleSearch").is_none()); -} - /// TS: "should throw error when files are provided" #[tokio::test] async fn should_throw_error_when_files_are_provided() { @@ -685,55 +594,6 @@ async fn gemini_should_not_send_tools_when_no_google_search() { assert!(body.get("tools").is_none()); } -/// TS: "should forward providerOptions.google.googleSearch as the google_search tool" -#[tokio::test] -async fn gemini_should_forward_google_search_as_tool() { - let server = MockServer::start().await; - mock_gemini_response(&server, gemini_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("gemini-2.5-flash-image"); - - let mut opts = options("A beautiful sunset"); - opts.n = 1; - opts.provider_options.extend(provider_namespace( - "google", - json!({ "googleSearch": { "searchTypes": { "imageSearch": {} } } }), - )); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body["tools"], - json!([{ "googleSearch": { "searchTypes": { "imageSearch": {} } } }]) - ); -} - -/// TS: "should not leak googleSearch into providerOptions passthrough" -#[tokio::test] -async fn gemini_should_not_leak_google_search_into_passthrough() { - let server = MockServer::start().await; - mock_gemini_response(&server, gemini_response_body()).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("gemini-2.5-flash-image"); - - let mut opts = options("A beautiful sunset"); - opts.n = 1; - opts.provider_options - .extend(provider_namespace("google", json!({ "googleSearch": {} }))); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert!(body["generationConfig"].get("googleSearch").is_none()); -} - /// TS: "should include input images in request for editing" #[tokio::test] async fn gemini_should_include_input_images_for_editing() { @@ -799,58 +659,3 @@ async fn gemini_should_throw_error_when_mask_is_provided() { let err = result.unwrap_err().to_string(); assert!(err.contains("do not support mask-based image editing")); } - -/// TS: "should forward groundingMetadata from the language-model response into providerMetadata.google" -#[tokio::test] -async fn gemini_should_forward_grounding_metadata() { - let server = MockServer::start().await; - let grounding_metadata = json!({ - "webSearchQueries": ["who performs at the 2026 super bowl halftime show"], - "groundingChunks": [ - { "web": { "uri": "https://example.com/source", "title": "Example" } } - ] - }); - mock_gemini_response( - &server, - json!({ - "candidates": [{ - "content": { - "parts": [{ - "inlineData": { - "mimeType": "image/png", - "data": "base64-generated-image" - } - }], - "role": "model" - }, - "finishReason": "STOP", - "groundingMetadata": grounding_metadata - }], - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 100, - "totalTokenCount": 110 - } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.image("gemini-2.5-flash-image"); - - let mut opts = options("A beautiful sunset"); - opts.n = 1; - opts.provider_options - .extend(provider_namespace("google", json!({ "googleSearch": {} }))); - - let result = model.do_generate(&opts).await.unwrap(); - - let meta = result.provider_metadata.unwrap(); - let google = meta.get("google").unwrap(); - assert_eq!(google.get("groundingMetadata"), Some(&grounding_metadata)); - // images should still be present - let images = google.get("images").unwrap().as_array().unwrap(); - assert_eq!(images.len(), 1); - assert_eq!(images[0], json!({})); -} diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs index 9ff55e675..23e85c4c2 100644 --- a/aimux-providers/tests/google_model_test.rs +++ b/aimux-providers/tests/google_model_test.rs @@ -21,7 +21,6 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; @@ -1911,77 +1910,6 @@ mod request_body { assert!(part["functionCall"].get("thoughtSignature").is_none()); } - #[test] - fn assistant_server_tool_round_trips_with_shared_google_vertex_wire_shape() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Search the web")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::ToolCall { - tool_call_id: "logical-call-id".to_string(), - tool_name: "server:GOOGLE_SEARCH_WEB".to_string(), - input: json!(r#"{"query":"Singapore weather"}"#), - provider_executed: Some(true), - thought_signature: None, - provider_options: Some(provider_namespace( - "google", - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "call-signature" - }), - )), - }, - ContentPart::ToolResult { - tool_call_id: "logical-call-id".to_string(), - tool_name: Some("server:GOOGLE_SEARCH_WEB".to_string()), - result: json!({ "results": [{ "title": "Sunny" }] }), - is_error: None, - preliminary: None, - dynamic: None, - provider_options: Some(provider_namespace( - "google", - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "result-signature" - }), - )), - }, - ], - ..Default::default() - }, - ]; - - let body = build_request_body("gemini-3-pro-preview", &default_options(prompt)); - assert_eq!( - body["contents"][1]["parts"], - json!([ - { - "toolCall": { - "toolType": "GOOGLE_SEARCH_WEB", - "args": { "query": "Singapore weather" }, - "id": "server-call-1" - }, - "thoughtSignature": "call-signature" - }, - { - "toolResponse": { - "toolType": "GOOGLE_SEARCH_WEB", - "response": { "results": [{ "title": "Sunny" }] }, - "id": "server-call-1" - }, - "thoughtSignature": "result-signature" - } - ]) - ); - } - // ── tool result → functionResponse part in a user message ───────────────── #[test] diff --git a/aimux-providers/tests/google_pse_test.rs b/aimux-providers/tests/google_pse_test.rs index e693da8a3..efeadf59f 100644 --- a/aimux-providers/tests/google_pse_test.rs +++ b/aimux-providers/tests/google_pse_test.rs @@ -13,7 +13,6 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::Provider; use aimux_core::error::AiMuxError; use aimux_core::search_model::{SearchCallOptions, SearchModel}; -use aimux_core::shared::provider_namespace; use aimux_providers::{GooglePseConfig, GooglePseProvider}; const API_KEY: &str = "test-google-key"; @@ -155,32 +154,6 @@ async fn do_search_forwards_max_results_as_num() { // -- cx from provider_options ------------------------------------------------ -#[tokio::test] -async fn cx_resolved_from_provider_options_when_config_has_none() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/customsearch/v1")) - .and(query_param("cx", "options-cx")) - .respond_with(ResponseTemplate::new(200).set_body_json(search_body())) - .mount(&server) - .await; - - // Config without cx. - let config = - GooglePseConfig::new(API_KEY).with_base_url(format!("{}/customsearch/v1", server.uri())); - let provider = GooglePseProvider::new(config); - let model = provider.search_model(); - - let mut options = opts("rust language"); - let po = provider_namespace("google_pse", json!({ "cx": "options-cx" })); - options.provider_options = Some(po); - model.do_search(&options).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let query = requests[0].url.query().unwrap_or(""); - assert!(query.contains("cx=options-cx"), "query: {query}"); -} - #[tokio::test] async fn missing_cx_returns_invalid_argument_error() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 326409b88..d9547cf35 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -30,7 +30,6 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, ReasoningOutput}; -use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -1189,26 +1188,6 @@ mod do_generate { assert_eq!(body["reasoning_effort"], "none"); } - /// TS: "should prefer providerOptions reasoningEffort over top-level reasoning" - #[tokio::test] - async fn provider_option_reasoning_effort_preferred() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let provider_opts = provider_namespace("groq", json!({"reasoningEffort": "high"})); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - provider_options: Some(provider_opts), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["reasoning_effort"], "high"); - } - /// TS: "should extract usage" #[tokio::test] async fn extracts_usage() { @@ -1347,72 +1326,6 @@ mod do_generate { assert_eq!(result.finish_reason.raw.as_deref(), Some("eos")); } - /// TS: "should pass provider options" (reasoningFormat, user, parallelToolCalls) - #[tokio::test] - async fn pass_provider_options() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let provider_opts = provider_namespace( - "groq", - json!({ - "reasoningFormat": "hidden", - "user": "test-user-id", - "parallelToolCalls": false - }), - ); - let options = CallOptions { - provider_options: Some(provider_opts), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["reasoning_format"], "hidden"); - assert_eq!(body["user"], "test-user-id"); - assert_eq!(body["parallel_tool_calls"], false); - } - - /// TS: "should pass serviceTier provider option" - #[tokio::test] - async fn pass_service_tier_flex() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let provider_opts = provider_namespace("groq", json!({"serviceTier": "flex"})); - let options = CallOptions { - provider_options: Some(provider_opts), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["service_tier"], "flex"); - } - - /// TS: "should pass performance serviceTier provider option" - #[tokio::test] - async fn pass_service_tier_performance() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let provider_opts = provider_namespace("groq", json!({"serviceTier": "performance"})); - let options = CallOptions { - provider_options: Some(provider_opts), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["service_tier"], "performance"); - } - /// TS: "should pass tools and toolChoice" #[tokio::test] async fn pass_tools_and_tool_choice() { @@ -1477,71 +1390,6 @@ mod do_generate { assert_eq!(body["response_format"]["json_schema"]["strict"], true); } - /// TS: "should pass response format as json_object when structuredOutputs - /// explicitly disabled" - #[tokio::test] - async fn response_format_json_object_when_disabled() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let provider_opts = provider_namespace("groq", json!({"structuredOutputs": false})); - let options = CallOptions { - provider_options: Some(provider_opts), - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - })), - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["response_format"]["type"], "json_object"); - - // Should have a warning about structuredOutputs - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => feature == "responseFormat", - _ => false, - })); - } - - /// TS: "should send strict: false when strictJsonSchema is explicitly disabled" - #[tokio::test] - async fn strict_json_schema_false() { - let server = MockServer::start().await; - mock_json(&server, groq_text_body()).await; - - let model = make_provider(&server); - - let provider_opts = provider_namespace("groq", json!({"strictJsonSchema": false})); - let options = CallOptions { - provider_options: Some(provider_opts), - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - })), - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - 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() { diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 190613acf..419717385 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -31,7 +31,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, ReasoningOutput, Source}; +use aimux_core::result::{GenerateContent, Source}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; @@ -1365,218 +1365,6 @@ async fn should_handle_structured_output_with_custom_name_and_description() { // Reasoning // ════════════════════════════════════════════════════════════════════════════ -/// TS: reasoning › "should handle reasoning content in responses" -#[tokio::test] -async fn should_handle_reasoning_content_in_responses() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_reasoning", - "model": "deepseek-ai/DeepSeek-R1", - "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": { "input_tokens": 10, "output_tokens": 50, "total_tokens": 60 }, - "output": [ - { - "id": "reasoning_1", - "type": "reasoning", - "content": [ - { "type": "reasoning_text", "text": "Let me think about this problem step by step..." } - ] - }, - { - "id": "msg_after_reasoning", - "type": "message", - "role": "assistant", - "status": "completed", - "content": [ - { "type": "output_text", "text": "The answer is 42." } - ] - } - ], - "output_text": null - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-R1"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { - text, - provider_metadata, - }) => { - assert_eq!(text, "Let me think about this problem step by step..."); - assert_eq!( - provider_metadata.as_ref(), - Some(&aimux_core::shared::provider_namespace( - "huggingface", - json!({ "itemId": "reasoning_1" }) - )) - ); - } - other => panic!("expected Reasoning at [0], got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => assert_eq!(text, "The answer is 42."), - other => panic!("expected Text at [1], got {other:?}"), - } -} - -/// TS: reasoning › "should stream reasoning content" -#[tokio::test] -async fn should_stream_reasoning_content() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - sse_event( - r#"{"type":"response.created","response":{"id":"resp_reasoning_stream","object":"response","created_at":1741269019,"status":"in_progress","model":"deepseek-ai/DeepSeek-R1"}}"#, - ), - sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"reasoning_stream","type":"reasoning"},"sequence_number":1}"#, - ), - sse_event( - r#"{"type":"response.reasoning_text.delta","item_id":"reasoning_stream","output_index":0,"content_index":0,"delta":"Thinking about","sequence_number":2}"#, - ), - sse_event( - r#"{"type":"response.reasoning_text.delta","item_id":"reasoning_stream","output_index":0,"content_index":0,"delta":" the problem...","sequence_number":3}"#, - ), - sse_event( - r#"{"type":"response.reasoning_text.done","item_id":"reasoning_stream","output_index":0,"content_index":0,"text":"Thinking about the problem...","sequence_number":4}"#, - ), - sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"reasoning_stream","type":"reasoning","content":[{"type":"reasoning_text","text":"Thinking about the problem..."}]},"sequence_number":5}"#, - ), - sse_event( - r#"{"type":"response.output_item.added","output_index":1,"item":{"id":"msg_stream","type":"message","role":"assistant"},"sequence_number":6}"#, - ), - sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_stream","output_index":1,"content_index":0,"delta":"The solution is","sequence_number":7}"#, - ), - sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_stream","output_index":1,"content_index":0,"delta":" simple.","sequence_number":8}"#, - ), - sse_event( - r#"{"type":"response.output_item.done","output_index":1,"item":{"id":"msg_stream","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"The solution is simple."}]},"sequence_number":9}"#, - ), - sse_event( - r#"{"type":"response.completed","response":{"id":"resp_reasoning_stream","status":"completed","usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}},"sequence_number":10}"#, - ), - ]); - mock_sse(&server, chunks).await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-R1"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let parts = collect_stream(result).await; - - // Expected: stream-start, response-metadata, reasoning-start, - // reasoning-delta×2, reasoning-end, text-start, text-delta×2, text-end, - // finish - assert_eq!(parts.len(), 11); - - assert!(matches!(&parts[0], StreamPart::StreamStart { .. })); - - match &parts[1] { - 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")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - - match &parts[2] { - StreamPart::ReasoningStart { - id, - provider_metadata, - } => { - assert_eq!(id, "reasoning_stream"); - assert_eq!( - provider_metadata.as_ref(), - Some(&aimux_core::shared::provider_namespace( - "huggingface", - json!({ "itemId": "reasoning_stream" }) - )) - ); - } - other => panic!("expected ReasoningStart, got {other:?}"), - } - match &parts[3] { - StreamPart::ReasoningDelta { id, delta, .. } => { - assert_eq!(id, "reasoning_stream"); - assert_eq!(delta, "Thinking about"); - } - other => panic!("expected ReasoningDelta, got {other:?}"), - } - match &parts[4] { - StreamPart::ReasoningDelta { id, delta, .. } => { - assert_eq!(id, "reasoning_stream"); - assert_eq!(delta, " the problem..."); - } - other => panic!("expected ReasoningDelta, got {other:?}"), - } - match &parts[5] { - StreamPart::ReasoningEnd { id, .. } => assert_eq!(id, "reasoning_stream"), - other => panic!("expected ReasoningEnd, got {other:?}"), - } - match &parts[6] { - StreamPart::TextStart { id, .. } => assert_eq!(id, "msg_stream"), - other => panic!("expected TextStart, got {other:?}"), - } - match &parts[7] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "msg_stream"); - assert_eq!(delta, "The solution is"); - } - other => panic!("expected TextDelta, got {other:?}"), - } - match &parts[8] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "msg_stream"); - assert_eq!(delta, " simple."); - } - other => panic!("expected TextDelta, got {other:?}"), - } - match &parts[9] { - StreamPart::TextEnd { id, .. } => assert_eq!(id, "msg_stream"), - other => panic!("expected TextEnd, got {other:?}"), - } - match &parts[10] { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(10)); - assert_eq!(usage.output_tokens.total, Some(20)); - } - other => panic!("expected Finish, got {other:?}"), - } -} - // ════════════════════════════════════════════════════════════════════════════ // Provider options // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/jina_ai_test.rs b/aimux-providers/tests/jina_ai_test.rs index 4fdce3cd5..bccf0b61a 100644 --- a/aimux-providers/tests/jina_ai_test.rs +++ b/aimux-providers/tests/jina_ai_test.rs @@ -89,10 +89,6 @@ fn json_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { } } -fn jina_provider_options(return_documents: bool) -> aimux_core::shared::SharedProviderOptions { - aimux_core::shared::provider_namespace("jina", json!({ "returnDocuments": return_documents })) -} - async fn mount_rerank_mock(server: &MockServer) { Mock::given(method("POST")) .and(path("/v1/rerank")) @@ -214,23 +210,6 @@ async fn do_rerank_sends_correct_request_body() { assert!(body.get("return_documents").is_none()); } -#[tokio::test] -async fn do_rerank_forwards_return_documents_provider_option() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model(MODEL); - - let mut opts = text_docs_opts("rainy day", 2); - opts.provider_options = Some(jina_provider_options(false)); - model.do_rerank(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["return_documents"], false); -} - #[tokio::test] async fn object_documents_are_stringified_with_warning() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/linkup_test.rs b/aimux-providers/tests/linkup_test.rs index 891e4114f..73f25281b 100644 --- a/aimux-providers/tests/linkup_test.rs +++ b/aimux-providers/tests/linkup_test.rs @@ -60,16 +60,6 @@ fn opts(query: &str) -> SearchCallOptions { SearchCallOptions::new(query) } -fn linkup_provider_options( - depth: &str, - output_type: &str, -) -> aimux_core::shared::SharedProviderOptions { - aimux_core::shared::provider_namespace( - "linkup", - json!({ "depth": depth, "outputType": output_type }), - ) -} - async fn mount_search_mock(server: &MockServer) { Mock::given(method("POST")) .and(path("/v1/search")) @@ -195,24 +185,6 @@ async fn do_search_sends_correct_request_body() { assert_eq!(body["excludeDomains"], json!(["example.com"])); } -#[tokio::test] -async fn do_search_forwards_depth_and_output_type_provider_options() { - let server = MockServer::start().await; - mount_search_mock(&server).await; - - let provider = provider(&server); - let model = provider.search_model(); - - let mut options = opts("rust language"); - options.provider_options = Some(linkup_provider_options("deep", "sourcedAnswer")); - model.do_search(&options).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["depth"], "deep"); - assert_eq!(body["outputType"], "sourcedAnswer"); -} - // -- error mapping ----------------------------------------------------------- #[tokio::test] diff --git a/aimux-providers/tests/open_responses_test.rs b/aimux-providers/tests/open_responses_test.rs index 576f3a911..6f2b56881 100644 --- a/aimux-providers/tests/open_responses_test.rs +++ b/aimux-providers/tests/open_responses_test.rs @@ -1333,82 +1333,6 @@ mod do_generate_tests { // -- ProviderOptions reasoning tests -- - fn lmstudio_opts(value: Value) -> Option { - Some(aimux_core::shared::provider_namespace("lmstudio", value)) - } - - #[tokio::test] - async fn provider_options_reasoning_summary_detailed() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - provider_options: lmstudio_opts(json!({"reasoningSummary": "detailed"})), - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!(body["reasoning"], json!({"summary": "detailed"})); - } - - #[tokio::test] - async fn provider_options_combines_effort_with_summary() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - provider_options: lmstudio_opts(json!({"reasoningSummary": "auto"})), - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!( - body["reasoning"], - json!({"effort": "high", "summary": "auto"}) - ); - } - - #[tokio::test] - async fn provider_options_reasoning_summary_concise() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - provider_options: lmstudio_opts(json!({"reasoningSummary": "concise"})), - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!(body["reasoning"], json!({"summary": "concise"})); - } - - #[tokio::test] - async fn provider_options_no_reasoning_fields() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - provider_options: lmstudio_opts(json!({})), - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert!(body.get("reasoning").is_none()); - } - // -- Tool call parsing tests -- #[tokio::test] diff --git a/aimux-providers/tests/openai_convert_extended_test.rs b/aimux-providers/tests/openai_convert_extended_test.rs index 761f991f4..31aa31128 100644 --- a/aimux-providers/tests/openai_convert_extended_test.rs +++ b/aimux-providers/tests/openai_convert_extended_test.rs @@ -11,7 +11,6 @@ use aimux_core::content::ContentPart; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ToolChoice}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::types::ReasoningEffort; use aimux_providers::openai::OpenAICompatProfile; use aimux_providers::openai::convert::{ @@ -71,9 +70,6 @@ fn default_opts(p: LanguageModelPrompt) -> CallOptions { recording_context: None, } } -fn po(map: Value) -> Option { - Some(provider_namespace("openai", map)) -} // �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T // convert-to-openai-chat-messages extended tests (25 cases) @@ -99,77 +95,6 @@ mod convert_extended { assert!(r.is_empty()); } - #[test] - fn adds_bpt_to_system_message() { - let p: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::Text { - text: "You are a helpful assistant.".into(), - provider_options: po(json!({ "promptCacheBreakpoint": { "mode": "explicit" } })), - }], - ..Default::default() - }]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - Value::Array(r), - json!([{ "role": "system", "content": [{ "type": "text", "text": "You are a helpful assistant.", "prompt_cache_breakpoint": { "mode": "explicit" } }] }]) - ); - } - - #[test] - fn adds_bpt_to_user_content() { - let bpt = json!({ "mode": "explicit" }); - let o = po(json!({ "promptCacheBreakpoint": bpt })); - let p: LanguageModelPrompt = vec![up(vec![ - ContentPart::Text { - text: "Hello".into(), - provider_options: o.clone(), - }, - ContentPart::FileUrl { - url: "https://example.com/image.png".into(), - media_type: "image/png".into(), - provider_options: o.clone(), - }, - ContentPart::FileBase64 { - data: "AAECAw==".into(), - media_type: "audio/wav".into(), - filename: None, - provider_options: o.clone(), - }, - ContentPart::FileReference { - media_type: "application/pdf".into(), - reference: json!({ "openai": "file-pdf-123" }), - filename: None, - provider_options: o, - }, - ])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - Value::Array(r), - json!([{ "role": "user", "content": [ - { "type": "text", "text": "Hello", "prompt_cache_breakpoint": bpt }, - { "type": "image_url", "image_url": { "url": "https://example.com/image.png" }, "prompt_cache_breakpoint": bpt }, - { "type": "input_audio", "input_audio": { "data": "AAECAw==", "format": "wav" }, "prompt_cache_breakpoint": bpt }, - { "type": "file", "file": { "file_id": "file-pdf-123" }, "prompt_cache_breakpoint": bpt } - ]}]) - ); - } - - #[test] - fn adds_image_detail() { - let p = vec![up(vec![ContentPart::FileBase64 { - data: "AAECAw==".into(), - media_type: "image/png".into(), - filename: None, - provider_options: po(json!({ "imageDetail": "low" })), - }])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - Value::Array(r), - json!([{ "role": "user", "content": [{ "type": "image_url", "image_url": { "url": "data:image/png;base64,AAECAw==", "detail": "low" } }] }]) - ); - } - #[test] fn audio_wav() { let r = convert_prompt_to_openai_messages(&vec![up(vec![fb64("AAECAw==", "audio/wav")])]); @@ -336,23 +261,6 @@ mod convert_extended { json!(format!("data:image/png;base64,{b64}")) ); } - - #[test] - fn adds_bpt_to_assistant_text() { - let p: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::Text { - text: "Cached assistant content".into(), - provider_options: po(json!({ "promptCacheBreakpoint": { "mode": "explicit" } })), - }], - ..Default::default() - }]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - Value::Array(r), - json!([{ "role": "assistant", "content": [{ "type": "text", "text": "Cached assistant content", "prompt_cache_breakpoint": { "mode": "explicit" } }] }]) - ); - } } // �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T @@ -389,21 +297,6 @@ mod prepare_tools_unsupported { mod request_body_extended { use super::*; - /// TS: "should pass settings" (logitBias, parallelToolCalls, user) - #[test] - fn passes_settings() { - let opts = CallOptions { - provider_options: po( - json!({ "logitBias": { "50256": -100 }, "parallelToolCalls": false, "user": "test-user-id" }), - ), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - 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'" #[test] fn no_reasoning_effort_for_provider_default() { @@ -429,62 +322,6 @@ mod request_body_extended { assert_eq!(body["reasoning_effort"], json!("medium")); } - /// TS: "should prefer providerOptions reasoningEffort over top-level reasoning" - #[test] - fn prefer_provider_reasoning_effort() { - let opts = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - provider_options: po(json!({ "reasoningEffort": "high" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["reasoning_effort"], json!("high")); - } - - /// TS: "should pass reasoningEffort setting from provider metadata" - #[test] - fn reasoning_effort_from_provider() { - let opts = CallOptions { - provider_options: po(json!({ "reasoningEffort": "low" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["reasoning_effort"], json!("low")); - } - - /// TS: "should pass reasoningEffort xhigh setting" - #[test] - fn reasoning_effort_xhigh() { - let opts = CallOptions { - provider_options: po(json!({ "reasoningEffort": "xhigh" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-5.1-codex-max", &opts, false).unwrap(); - assert_eq!(body["reasoning_effort"], json!("xhigh")); - } - - /// TS: "should pass reasoningEffort max setting" - #[test] - fn reasoning_effort_max() { - let opts = CallOptions { - provider_options: po(json!({ "reasoningEffort": "max" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-5.6", &opts, false).unwrap(); - assert_eq!(body["reasoning_effort"], json!("max")); - } - - /// TS: "should pass textVerbosity setting from provider options" - #[test] - fn text_verbosity() { - let opts = CallOptions { - provider_options: po(json!({ "textVerbosity": "low" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["verbosity"], json!("low")); - } - /// TS: reasoning models ??"should clear out temperature, top_p, etc." #[test] fn reasoning_clears_temperature_etc() { @@ -569,49 +406,6 @@ mod request_body_extended { assert_eq!(result.warnings.len(), 1); } - /// TS: "should allow forcing reasoning behavior for unrecognized model IDs via providerOptions" - #[test] - fn force_reasoning_via_provider_options() { - let opts = CallOptions { - temperature: Some(0.5), - top_p: Some(0.7), - provider_options: po(json!({ "forceReasoning": true })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "stealth-reasoning-model", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("temperature").is_none() || result.body["temperature"].is_null()); - assert!(result.body.get("top_p").is_none() || result.body["top_p"].is_null()); - assert_eq!(result.warnings.len(), 2); - } - - /// TS: "should default systemMessageMode to developer when forcing reasoning" - #[test] - fn developer_messages_when_forcing_reasoning() { - let p: LanguageModelPrompt = vec![ - sys("You are a helpful assistant."), - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let opts = CallOptions { - prompt: p, - provider_options: po(json!({ "forceReasoning": true })), - ..default_opts(vec![]) - }; - let body = build_request_body("stealth-reasoning-model", &opts, false).unwrap(); - assert_eq!(body["messages"][0]["role"], json!("developer")); - assert_eq!(body["messages"][1]["content"], json!("Hello")); - } - /// TS: "should use developer messages for o1" #[test] fn developer_messages_for_o1() { @@ -631,26 +425,6 @@ mod request_body_extended { assert_eq!(body["messages"][0]["role"], json!("developer")); } - /// TS: "should allow overriding systemMessageMode via providerOptions" - #[test] - fn override_system_message_mode() { - let p: LanguageModelPrompt = vec![ - sys("You are a helpful assistant."), - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let opts = CallOptions { - prompt: p, - provider_options: po(json!({ "systemMessageMode": "developer" })), - ..default_opts(vec![]) - }; - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["messages"][0]["role"], json!("developer")); - } - /// TS: "should use default systemMessageMode when not overridden" #[test] fn default_system_message_mode_for_gpt4o() { @@ -670,107 +444,6 @@ mod request_body_extended { assert_eq!(body["messages"][0]["role"], json!("system")); } - /// TS: "should send max_completion_tokens extension setting" - #[test] - fn max_completion_tokens_extension() { - let opts = CallOptions { - provider_options: po(json!({ "maxCompletionTokens": 255 })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["max_completion_tokens"], json!(255)); - } - - /// TS: "should send prediction extension setting" - #[test] - fn prediction_extension() { - let opts = CallOptions { - provider_options: po( - json!({ "prediction": { "type": "content", "content": "Hello, World!" } }), - ), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!( - body["prediction"], - json!({ "type": "content", "content": "Hello, World!" }) - ); - } - - /// TS: "should send store extension setting" - #[test] - fn store_extension() { - let opts = CallOptions { - provider_options: po(json!({ "store": true })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!(body["store"], json!(true)); - } - - /// TS: "should send metadata extension values" - #[test] - fn metadata_extension() { - let opts = CallOptions { - provider_options: po(json!({ "metadata": { "custom": "value" } })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!(body["metadata"], json!({ "custom": "value" })); - } - - /// TS: "should send promptCacheKey extension value" - #[test] - fn prompt_cache_key() { - let opts = CallOptions { - provider_options: po(json!({ "promptCacheKey": "test-cache-key-123" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!(body["prompt_cache_key"], json!("test-cache-key-123")); - } - - /// TS: "should send promptCacheRetention extension value" - #[test] - fn prompt_cache_retention() { - let opts = CallOptions { - provider_options: po(json!({ "promptCacheRetention": "24h" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!(body["prompt_cache_retention"], json!("24h")); - } - - /// TS: "should send promptCacheOptions extension value" - #[test] - fn prompt_cache_options() { - let opts = CallOptions { - provider_options: po( - json!({ "promptCacheOptions": { "mode": "explicit", "ttl": "30m" } }), - ), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-5.6", &opts, false).unwrap(); - assert_eq!( - body["prompt_cache_options"], - json!({ "mode": "explicit", "ttl": "30m" }) - ); - } - - /// TS: "should send safetyIdentifier extension value" - #[test] - fn safety_identifier() { - let opts = CallOptions { - provider_options: po(json!({ "safetyIdentifier": "test-safety-identifier-123" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!( - body["safety_identifier"], - json!("test-safety-identifier-123") - ); - } - /// TS: "should remove temperature setting for gpt-4o-search-preview and add warning" #[test] fn search_preview_removes_temperature() { @@ -830,124 +503,4 @@ mod request_body_extended { assert!(result.body.get("temperature").is_none() || result.body["temperature"].is_null()); assert_eq!(result.warnings.len(), 1); } - - /// TS: "should send serviceTier flex processing setting" - #[test] - fn service_tier_flex() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["service_tier"], json!("flex")); - } - - /// TS: "should show warning when using flex processing with unsupported model" - #[test] - fn flex_warning_unsupported() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-4o-mini", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("service_tier").is_none() || result.body["service_tier"].is_null()); - assert_eq!(result.warnings.len(), 1); - assert!( - matches!(&result.warnings[0], aimux_core::types::Warning::Unsupported { feature, .. } if feature == "serviceTier") - ); - } - - /// TS: "should allow flex processing with o4-mini model without warnings" - #[test] - fn flex_o4mini_no_warning() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "o4-mini", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert_eq!(result.body["service_tier"], json!("flex")); - assert!(result.warnings.is_empty()); - } - - /// TS: "should send serviceTier priority processing setting" - #[test] - fn service_tier_priority() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-4o-mini", &opts, false).unwrap(); - assert_eq!(body["service_tier"], json!("priority")); - } - - /// TS: "should show warning when using priority processing with unsupported model" - #[test] - fn priority_warning_unsupported() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-3.5-turbo", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("service_tier").is_none() || result.body["service_tier"].is_null()); - assert_eq!(result.warnings.len(), 1); - } - - /// TS: "should allow priority processing with gpt-4o model without warnings" - #[test] - fn priority_gpt4o_no_warning() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-4o", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert_eq!(result.body["service_tier"], json!("priority")); - assert!(result.warnings.is_empty()); - } - - /// TS: "should allow priority processing with o3 model without warnings" - #[test] - fn priority_o4mini_no_warning() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "o4-mini", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert_eq!(result.body["service_tier"], json!("priority")); - assert!(result.warnings.is_empty()); - } } diff --git a/aimux-providers/tests/openai_convert_test.rs b/aimux-providers/tests/openai_convert_test.rs index 3f8955677..1804f8c7e 100644 --- a/aimux-providers/tests/openai_convert_test.rs +++ b/aimux-providers/tests/openai_convert_test.rs @@ -527,24 +527,6 @@ mod build_request_body_tests { ); } - /// RFC-0016 M3: `logprobs` / `topLogprobs` provider options reach the - /// request body — previously they were silently dropped by the - /// provider_options whitelist (the only "quietly no-op" option). - #[test] - fn passes_logprobs_and_top_logprobs() { - let provider_options = aimux_core::shared::provider_namespace( - "openai", - json!({ "logprobs": true, "topLogprobs": 3 }), - ); - let options = CallOptions { - provider_options: Some(provider_options), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); - assert_eq!(body["logprobs"], json!(true)); - assert_eq!(body["top_logprobs"], json!(3)); - } - /// TS: "should pass tools and toolChoice" #[test] fn passes_tools_and_tool_choice() { diff --git a/aimux-providers/tests/openai_embedding_test.rs b/aimux-providers/tests/openai_embedding_test.rs index 4000dbb49..84324fedf 100644 --- a/aimux-providers/tests/openai_embedding_test.rs +++ b/aimux-providers/tests/openai_embedding_test.rs @@ -14,7 +14,6 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; -use aimux_core::shared::provider_namespace; use aimux_providers::{OpenAIConfig, OpenAIProvider}; // ── helpers ───────────────────────────────────────────────────────────────── @@ -228,45 +227,6 @@ async fn should_pass_model_and_values() { assert!(body.get("user").is_none()); } -/// TS: "should pass the dimensions setting" -#[tokio::test] -async fn should_pass_dimensions_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.embedding_model("text-embedding-3-large"); - - let provider_options = provider_namespace("openai", json!({"dimensions": 64})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - - assert_eq!(body["dimensions"], 64); - assert_eq!(body["model"], "text-embedding-3-large"); - assert_eq!( - body["input"], - json!(["sunny day at the beach", "rainy day in the city"]) - ); - assert_eq!(body["encoding_format"], "float"); -} - /// TS: "should pass headers" — verifies that Authorization, OpenAI-Organization, /// OpenAI-Project, config-level headers, and request-level headers are all sent. #[tokio::test] diff --git a/aimux-providers/tests/openai_files_test.rs b/aimux-providers/tests/openai_files_test.rs deleted file mode 100644 index f25a29653..000000000 --- a/aimux-providers/tests/openai_files_test.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! Rust translations of the OpenAI Files provider tests. -//! -//! Source: `reference/ai/packages/openai/src/files/openai-files.test.ts` -//! (8 test cases). -//! -//! Each test uses `wiremock` to stub the OpenAI `/v1/files` endpoint, -//! uploads a file via `OpenAIFiles::upload_file`, and asserts on the -//! request or the returned result. - -use std::collections::HashMap; - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData}; -use aimux_core::shared::{FileBytes, SharedProviderOptions, provider_namespace}; -use aimux_providers::{OpenAIConfig, OpenAIProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The standard successful file response body used by most tests. -fn file_response_body(id: &str) -> Value { - json!({ - "id": id, - "object": "file", - "bytes": 1024, - "created_at": 1700000000, - "filename": "test.csv", - "purpose": "assistants", - "status": "processed", - "expires_at": null - }) -} - -/// Build an `OpenAIProvider` pointing at the mock server. -fn provider(server: &MockServer) -> OpenAIProvider { - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - OpenAIProvider::new(config) -} - -/// Build `UploadFileCallOptions` with binary data and the given provider options. -fn upload_options(provider_options: Option) -> UploadFileCallOptions { - UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Binary(vec![1, 2, 3]), - }, - media_type: "application/octet-stream".to_string(), - filename: None, - provider_options, - abort_signal: None, - } -} - -// ── tests ─────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn should_send_multipart_request_with_purpose() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - let mut opts = upload_options(None); - let po = provider_namespace("openai", json!({ "purpose": "assistants" })); - opts.provider_options = Some(po); - - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - let body_str = String::from_utf8_lossy(&requests[0].body); - // The purpose field should be present in the multipart body. - assert!( - body_str.contains("assistants"), - "multipart body should contain 'assistants': {body_str}" - ); - // The file field should be present. - assert!( - body_str.contains("name=\"file\""), - "multipart body should contain a 'file' part: {body_str}" - ); -} - -#[tokio::test] -async fn should_return_provider_reference_with_openai_key() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-xyz789"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - let po = provider_namespace("openai", json!({ "purpose": "assistants" })); - let opts = upload_options(Some(po)); - - let result = files.upload_file(&opts).await.unwrap(); - - assert_eq!( - result.provider_reference.get("openai"), - Some(&"file-xyz789".to_string()) - ); -} - -#[tokio::test] -async fn should_return_provider_metadata_from_response() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - let po = provider_namespace("openai", json!({ "purpose": "assistants" })); - let opts = upload_options(Some(po)); - - let result = files.upload_file(&opts).await.unwrap(); - - let metadata = result.provider_metadata.expect("metadata"); - let openai = metadata.get("openai").expect("openai metadata"); - assert_eq!(openai["filename"], "test.csv"); - assert_eq!(openai["purpose"], "assistants"); - assert_eq!(openai["bytes"], 1024); - assert_eq!(openai["createdAt"], 1700000000); - assert_eq!(openai["status"], "processed"); -} - -#[tokio::test] -async fn should_default_purpose_to_assistants() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - // No provider options — purpose should default to "assistants". - let opts = upload_options(None); - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("assistants"), - "default purpose should be 'assistants': {body_str}" - ); -} - -#[tokio::test] -async fn should_pass_expires_after_when_provided() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - let po = provider_namespace( - "openai", - json!({ "purpose": "assistants", "expiresAfter": 3600 }), - ); - let opts = upload_options(Some(po)); - - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("3600"), - "expires_after should be '3600' in multipart body: {body_str}" - ); -} - -#[tokio::test] -async fn should_pass_auth_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let mut extra_headers = HashMap::new(); - extra_headers.insert("Custom-Header".to_string(), "custom-value".to_string()); - - let config = OpenAIConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_org_id("test-org") - .with_project("test-project") - .with_headers(extra_headers); - let provider = OpenAIProvider::new(config); - let files = provider.files(); - - let po = provider_namespace("openai", json!({ "purpose": "assistants" })); - let opts = upload_options(Some(po)); - - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - assert_eq!( - headers - .get("openai-organization") - .and_then(|v| v.to_str().ok()), - Some("test-org") - ); - assert_eq!( - headers.get("openai-project").and_then(|v| v.to_str().ok()), - Some("test-project") - ); - assert_eq!( - headers.get("custom-header").and_then(|v| v.to_str().ok()), - Some("custom-value") - ); -} - -#[tokio::test] -async fn should_handle_base64_string_data() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - // "hello world" encoded as base64. - let opts = UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Base64("aGVsbG8gd29ybGQ=".to_string()), - }, - media_type: "application/octet-stream".to_string(), - filename: None, - provider_options: None, - abort_signal: None, - }; - - let result = files.upload_file(&opts).await.unwrap(); - - assert_eq!( - result.provider_reference.get("openai"), - Some(&"file-abc123".to_string()) - ); -} - -#[tokio::test] -async fn should_set_specification_version_and_provider() { - let provider = OpenAIProvider::new(OpenAIConfig::new("test-api-key")); - let files = provider.files(); - - assert_eq!(files.specification_version(), "v4"); - assert_eq!(files.provider(), "openai.files"); -} - -/// A transient 503 followed by 200 must succeed: `upload_file` retries the -/// upload exchange using the provider's configured retry settings, the same -/// way `list_models` already does. -#[tokio::test] -async fn transient_failure_is_retried_and_succeeds() { - let server = MockServer::start().await; - let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let responder_attempts = std::sync::Arc::clone(&attempts); - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(move |_: &wiremock::Request| { - if responder_attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { - ResponseTemplate::new(503) - .insert_header("retry-after-ms", "0") - .set_body_json(json!({"error": {"message": "try again"}})) - } else { - ResponseTemplate::new(200).set_body_json(file_response_body("file-retried")) - } - }) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(aimux_core::retry::RetryConfig { - max_retries: 1, - ..Default::default() - }); - let provider = OpenAIProvider::new(config); - let files = provider.files(); - - let result = files.upload_file(&upload_options(None)).await.unwrap(); - - assert_eq!( - result.provider_reference.get("openai"), - Some(&"file-retried".to_string()) - ); - assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); -} diff --git a/aimux-providers/tests/openai_image_test.rs b/aimux-providers/tests/openai_image_test.rs index 7a8c23d84..c12a5a7af 100644 --- a/aimux-providers/tests/openai_image_test.rs +++ b/aimux-providers/tests/openai_image_test.rs @@ -11,8 +11,6 @@ //! verify that a timestamp is present and that `model_id` matches, rather //! than asserting an exact timestamp value. -use std::collections::HashMap; - use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -20,7 +18,6 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::image_model::{ ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, }; -use aimux_core::shared::provider_namespace; use aimux_core::shared::{AspectRatio, Size}; use aimux_providers::{OpenAIConfig, OpenAIProvider}; @@ -129,136 +126,6 @@ fn base64_file(media_type: &str, b64: &str) -> ImageFile { // doGenerate — generation // ════════════════════════════════════════════════════════════════════════════ -/// TS: "should pass the model and the settings" -#[tokio::test] -async fn should_pass_the_model_and_the_settings() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("dall-e-3"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.provider_options = provider_namespace("openai", json!({ "style": "vivid" })); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "dall-e-3"); - assert_eq!(body["prompt"], PROMPT); - assert_eq!(body["n"], 1); - assert_eq!(body["size"], "1024x1024"); - assert_eq!(body["style"], "vivid"); - assert_eq!(body["response_format"], "b64_json"); -} - -/// TS: "should map provider options to snake_case for /images/generations" -#[tokio::test] -async fn should_map_provider_options_to_snake_case_for_generations() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("gpt-image-1"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.provider_options = provider_namespace( - "openai", - json!({ - "quality": "high", - "background": "transparent", - "moderation": "low", - "outputFormat": "webp", - "outputCompression": 80, - "user": "user-123" - }), - ); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "gpt-image-1"); - assert_eq!(body["prompt"], PROMPT); - assert_eq!(body["n"], 1); - assert_eq!(body["size"], "1024x1024"); - assert_eq!(body["quality"], "high"); - assert_eq!(body["background"], "transparent"); - assert_eq!(body["moderation"], "low"); - assert_eq!(body["output_format"], "webp"); - assert_eq!(body["output_compression"], 80); - assert_eq!(body["user"], "user-123"); - // gpt-image-1 should NOT have response_format - assert!(body.get("response_format").is_none()); -} - -/// TS: "should pass headers" -#[tokio::test] -async fn should_pass_headers() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let mut provider_headers = HashMap::new(); - provider_headers.insert( - "Custom-Provider-Header".to_string(), - "provider-header-value".to_string(), - ); - - let config = OpenAIConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_org_id("test-organization") - .with_project("test-project") - .with_headers(provider_headers); - let provider = OpenAIProvider::new(config); - let model = provider.image("dall-e-3"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.provider_options = provider_namespace("openai", json!({ "style": "vivid" })); - let mut req_headers = HashMap::new(); - req_headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - opts.headers = Some(req_headers); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let h = &requests[0].headers; - assert_eq!( - h.get("authorization").unwrap().to_str().unwrap(), - "Bearer test-api-key" - ); - assert_eq!( - h.get("openai-organization").unwrap().to_str().unwrap(), - "test-organization" - ); - assert_eq!( - h.get("openai-project").unwrap().to_str().unwrap(), - "test-project" - ); - assert_eq!( - h.get("custom-provider-header").unwrap().to_str().unwrap(), - "provider-header-value" - ); - assert_eq!( - h.get("custom-request-header").unwrap().to_str().unwrap(), - "request-header-value" - ); -} - /// TS: "should extract the generated images" #[tokio::test] async fn should_extract_the_generated_images() { @@ -591,40 +458,6 @@ async fn should_include_response_format_for_dall_e_3() { assert_eq!(body["response_format"], "b64_json"); } -/// TS: "should return image meta data" -#[tokio::test] -async fn should_return_image_meta_data() { - let server = MockServer::start().await; - mock_generations_response(&server, image_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("dall-e-3"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.provider_options = provider_namespace("openai", json!({ "style": "vivid" })); - - let result = model.do_generate(&opts).await.unwrap(); - - let meta = result.provider_metadata.unwrap(); - let openai = meta.get("openai").unwrap(); - let images = openai.get("images").unwrap().as_array().unwrap(); - assert_eq!(images.len(), 2); - // First image has revisedPrompt - assert_eq!( - images[0].get("revisedPrompt"), - Some(&json!( - "A small and adorable baby sea otter. This little creature is covered in a thick and fluffy brown fur, its tiny paws are slightly visible. The otter has bright, curious eyes and it's floating on its back on a calm sea, surrounded by floating seaweed." - )) - ); - assert_eq!(images[0].get("created"), Some(&json!(1770935200))); - // Second image has no revisedPrompt - assert!(images[1].get("revisedPrompt").is_none()); - assert_eq!(images[1].get("created"), Some(&json!(1770935200))); -} - /// TS: "should map OpenAI usage to usage" #[tokio::test] async fn should_map_openai_usage_to_usage() { @@ -860,100 +693,6 @@ async fn should_send_multiple_images_as_form_data_array() { ); } -/// TS: "should pass provider options in form data" -#[tokio::test] -async fn should_pass_provider_options_in_form_data() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("gpt-image-1"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - opts.provider_options = provider_namespace( - "openai", - json!({ - "quality": "high", - "background": "transparent" - }), - ); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("name=\"quality\""), - "should contain quality field: {body_str}" - ); - assert!( - body_str.contains("high"), - "should contain quality value: {body_str}" - ); - assert!( - body_str.contains("name=\"background\""), - "should contain background field: {body_str}" - ); - assert!( - body_str.contains("transparent"), - "should contain background value: {body_str}" - ); -} - -/// TS: "should map provider options to snake_case for /images/edits" -#[tokio::test] -async fn should_map_provider_options_to_snake_case_for_edits() { - let server = MockServer::start().await; - mock_edits_response(&server, image_edit_response_body()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.image("gpt-image-1"); - - let mut opts = options(PROMPT); - opts.n = 1; - opts.size = Some(Size::new(1024, 1024)); - opts.files = Some(vec![binary_file("image/png", &[137, 80, 78, 71])]); - opts.provider_options = provider_namespace( - "openai", - json!({ - "inputFidelity": "high", - "outputFormat": "webp", - "outputCompression": 80, - "user": "user-123" - }), - ); - - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("name=\"input_fidelity\""), - "should contain input_fidelity: {body_str}" - ); - assert!( - body_str.contains("name=\"output_format\""), - "should contain output_format: {body_str}" - ); - assert!( - body_str.contains("name=\"output_compression\""), - "should contain output_compression: {body_str}" - ); - assert!( - body_str.contains("name=\"user\""), - "should contain user: {body_str}" - ); - assert!( - body_str.contains("user-123"), - "should contain user value: {body_str}" - ); -} - /// TS: "should extract the edited images from response" #[tokio::test] async fn should_extract_the_edited_images_from_response() { diff --git a/aimux-providers/tests/openai_model_extended_test.rs b/aimux-providers/tests/openai_model_extended_test.rs index adaee7465..8efa65c9a 100644 --- a/aimux-providers/tests/openai_model_extended_test.rs +++ b/aimux-providers/tests/openai_model_extended_test.rs @@ -14,7 +14,6 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::{CallOptions, ToolChoice}; use aimux_core::result::{GenerateContent, Source}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -54,9 +53,7 @@ fn default_opts(p: LanguageModelPrompt) -> CallOptions { recording_context: None, } } -fn po(map: Value) -> Option { - Some(provider_namespace("openai", map)) -} + fn sse_event(json_str: &str) -> String { format!("data: {json_str}\n\n") } @@ -317,33 +314,6 @@ mod do_generate_extended { ); } - /// 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() { @@ -390,52 +360,6 @@ mod do_generate_extended { 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() { @@ -524,126 +448,6 @@ mod do_generate_extended { 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() { @@ -811,125 +615,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_responses_test.rs b/aimux-providers/tests/openai_responses_test.rs index 527aa2dbc..99e4ea754 100644 --- a/aimux-providers/tests/openai_responses_test.rs +++ b/aimux-providers/tests/openai_responses_test.rs @@ -30,7 +30,6 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::{CallOptions, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; -use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; @@ -179,49 +178,6 @@ mod do_generate_request { // -- should send model id, settings, and input -- - /// TS: "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_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 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 options = CallOptions { - temperature: Some(0.5), - top_p: Some(0.3), - provider_options: Some(provider_namespace("openai", json!({ "maxToolCalls": 10 }))), - ..CallOptions::new(prompt) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "gpt-4o"); - assert_eq!(body["input"][0]["role"], "system"); - assert_eq!(body["input"][0]["content"], "You are a helpful assistant."); - assert_eq!(body["input"][1]["role"], "user"); - assert_eq!(body["input"][1]["content"][0]["type"], "input_text"); - assert_eq!(body["input"][1]["content"][0]["text"], "Hello"); - assert_eq!(body["temperature"], 0.5); - assert_eq!(body["top_p"], 0.3); - assert_eq!(body["max_tool_calls"], 10); - } - // -- should send response format json schema -- /// TS: "should send response format json schema" @@ -333,272 +289,24 @@ mod do_generate_request { // -- should send store = false for reasoning model -- - /// TS: "should send store = false provider option and opt into - /// reasoning.encrypted_content for reasoning models" - #[tokio::test] - async fn should_send_store_false_reasoning_model() { - 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-5-mini"); - - let options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "store": false }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "gpt-5-mini"); - assert_eq!(body["store"], false); - assert_eq!(body["include"][0], "reasoning.encrypted_content"); - } - // -- should send store = false for non-reasoning model -- - /// TS: "should send store = false provider option and not opt into - /// reasoning.encrypted_content for non-reasoning models" - #[tokio::test] - async fn should_send_store_false_non_reasoning_model() { - 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 options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "store": false }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["store"], false); - assert!(body.get("include").is_none()); - } - // -- should send store = true -- - /// TS: "should send store = true provider option without - /// reasoning.encrypted_content" - #[tokio::test] - async fn should_send_store_true() { - 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 options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "store": true }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["store"], true); - assert!(body.get("include").is_none()); - } - // -- should send previous response id -- - /// TS: "should send previous response id provider option" - #[tokio::test] - async fn should_send_previous_response_id() { - 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 options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "previousResponseId": "resp_123" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["previous_response_id"], "resp_123"); - } - // -- should warn when both conversation and previousResponseId -- - /// TS: "should warn when both conversation and previousResponseId are - /// provided" - #[tokio::test] - async fn should_warn_when_conversation_and_previous_response_id() { - 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 options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "conversation": "conv_123", "previousResponseId": "resp_123" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["conversation"], "conv_123"); - assert_eq!(body["previous_response_id"], "resp_123"); - - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "conversation"); - } - other => panic!("expected Unsupported warning, got {other:?}"), - } - } - // -- should send reasoningEffort and reasoningSummary -- - /// TS: "should send reasoningEffort and reasoningSummary provider options" - #[tokio::test] - async fn should_send_reasoning_effort_and_summary() { - 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("o3-mini"); - - let options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "reasoningEffort": "low", "reasoningSummary": "auto" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "o3-mini"); - assert_eq!(body["reasoning"]["effort"], "low"); - assert_eq!(body["reasoning"]["summary"], "auto"); - } - // -- should send reasoning with detailed summary when only effort is set -- - /// TS: "should send xhigh reasoningEffort for codex-max model" (verifies - /// that `summary` defaults to "detailed" when effort is non-"none") - #[tokio::test] - async fn should_default_reasoning_summary_to_detailed() { - 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-5.1-codex-max"); - - let options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "reasoningEffort": "xhigh" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["reasoning"]["effort"], "xhigh"); - assert_eq!(body["reasoning"]["summary"], "detailed"); - } - // -- should send parallelToolCalls -- - /// TS: "should send parallelToolCalls provider option" - #[tokio::test] - async fn should_send_parallel_tool_calls() { - 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 options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "parallelToolCalls": false }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["parallel_tool_calls"], false); - } - // -- should send user -- - /// TS: "should send user provider option" - #[tokio::test] - async fn should_send_user_option() { - 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 options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "user": "user_123" }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["user"], "user_123"); - } - // -- should send conversation -- - /// TS: "should send conversation provider option" - #[tokio::test] - async fn should_send_conversation() { - 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 options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "conversation": "conv_123" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["conversation"], "conv_123"); - } - // -- should send function tools -- /// TS: tools are prepared into the Responses `tools` array. @@ -1384,62 +1092,6 @@ mod do_stream { // -- should stream text with store=true (reasoning-end at summary_part.done) -- - /// Verifies that when `store=true`, reasoning summary parts are concluded - /// immediately at `reasoning_summary_part.done` (not deferred to - /// `output_item.done`). - #[tokio::test] - async fn should_conclude_reasoning_at_summary_done_when_store_true() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning"}}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"thinking"}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.done","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"thinking"}]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31","incomplete_details":null,"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":20}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "store": true }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_stream(&options).await.expect("should succeed"); - let parts = collect_stream(result).await; - - // With store=true, ReasoningEnd should be emitted at - // reasoning_summary_part.done (before output_item.done), so no - // duplicate ReasoningEnd at output_item.done. - let reasoning_ends: Vec<&StreamPart> = parts - .iter() - .filter(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - .collect(); - assert_eq!( - reasoning_ends.len(), - 1, - "should have exactly one ReasoningEnd with store=true" - ); - } - // -- should emit provider metadata with responseId in finish -- /// TS: finish event should carry providerMetadata.openai.responseId. diff --git a/aimux-providers/tests/openai_transcription_test.rs b/aimux-providers/tests/openai_transcription_test.rs index 83e90cc9c..f533f960e 100644 --- a/aimux-providers/tests/openai_transcription_test.rs +++ b/aimux-providers/tests/openai_transcription_test.rs @@ -21,7 +21,6 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::error::AiMuxError; -use aimux_core::shared::provider_namespace; use aimux_core::transcription_model::{AudioInput, TranscriptionCallOptions, TranscriptionModel}; use aimux_providers::{OpenAIConfig, OpenAIProvider}; @@ -319,81 +318,6 @@ async fn should_use_real_date() { assert_eq!(result.response.model_id, Some("whisper-1".to_string())); } -/// TS: "should pass response_format when `providerOptions.openai.timestampGranularities` is set" -#[tokio::test] -async fn should_pass_response_format_with_timestamp_granularities_word() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("whisper-1"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let po = provider_namespace("openai", json!({"timestampGranularities": ["word"]})); - options.provider_options = Some(po); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let form = parse_multipart_form(&requests[0].body); - assert_eq!(form.get("model").unwrap(), "whisper-1"); - assert_eq!(form.get("response_format").unwrap(), "verbose_json"); - assert_eq!(form.get("temperature").unwrap(), "0"); - assert_eq!(form.get("timestamp_granularities[]").unwrap(), "word"); -} - -/// TS: "should not set pass response_format to "verbose_json" when model is "gpt-4o-transcribe"" -#[tokio::test] -async fn should_use_json_for_gpt4o_transcribe() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("gpt-4o-transcribe"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let po = provider_namespace("openai", json!({"timestampGranularities": ["word"]})); - options.provider_options = Some(po); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let form = parse_multipart_form(&requests[0].body); - assert_eq!(form.get("model").unwrap(), "gpt-4o-transcribe"); - assert_eq!(form.get("response_format").unwrap(), "json"); - assert_eq!(form.get("temperature").unwrap(), "0"); - assert_eq!(form.get("timestamp_granularities[]").unwrap(), "word"); -} - -/// TS: "should pass timestamp_granularities when specified" -#[tokio::test] -async fn should_pass_timestamp_granularities_segment() { - let server = MockServer::start().await; - mock_json_response(&server, &fixture_response()).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("whisper-1"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let po = provider_namespace("openai", json!({"timestampGranularities": ["segment"]})); - options.provider_options = Some(po); - - model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let form = parse_multipart_form(&requests[0].body); - assert_eq!(form.get("model").unwrap(), "whisper-1"); - assert_eq!(form.get("response_format").unwrap(), "verbose_json"); - assert_eq!(form.get("temperature").unwrap(), "0"); - assert_eq!(form.get("timestamp_granularities[]").unwrap(), "segment"); -} - /// TS: "should work when no words, language, or duration are returned" #[tokio::test] async fn should_work_without_optional_fields() { @@ -428,97 +352,6 @@ async fn should_work_without_optional_fields() { assert_eq!(headers.get("content-type").unwrap(), "application/json"); } -/// TS: "should parse segments when provided in response" -#[tokio::test] -async fn should_parse_segments() { - let server = MockServer::start().await; - mock_json_response( - &server, - &json!({ - "task": "transcribe", - "text": "Hello world. How are you?", - "segments": [ - { - "id": 0, "seek": 0, "start": 0.0, "end": 2.5, - "text": "Hello world.", "tokens": [1234, 5678], - "temperature": 0.0, "avg_logprob": -0.5, - "compression_ratio": 1.2, "no_speech_prob": 0.1 - }, - { - "id": 1, "seek": 250, "start": 2.5, "end": 5.0, - "text": " How are you?", "tokens": [9012, 3456], - "temperature": 0.0, "avg_logprob": -0.6, - "compression_ratio": 1.1, "no_speech_prob": 0.05 - } - ], - "language": "english", - "duration": 5.0, - "_request_id": "req_1234" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("whisper-1"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let po = provider_namespace("openai", json!({"timestampGranularities": ["segment"]})); - options.provider_options = Some(po); - - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.segments.len(), 2); - assert_eq!(result.segments[0].text, "Hello world."); - assert_eq!(result.segments[0].start_second, 0.0); - assert_eq!(result.segments[0].end_second, 2.5); - assert_eq!(result.segments[1].text, " How are you?"); - assert_eq!(result.segments[1].start_second, 2.5); - assert_eq!(result.segments[1].end_second, 5.0); - assert_eq!(result.text, "Hello world. How are you?"); - assert_eq!(result.duration_in_seconds, Some(5.0)); - assert_eq!(result.language, Some("en".to_string())); -} - -/// TS: "should fallback to words when segments are not available" -#[tokio::test] -async fn should_fallback_to_words() { - let server = MockServer::start().await; - mock_json_response( - &server, - &json!({ - "task": "transcribe", - "text": "Hello world", - "words": [ - {"word": "Hello", "start": 0.0, "end": 1.0}, - {"word": "world", "start": 1.0, "end": 2.0} - ], - "language": "english", - "duration": 2.0, - "_request_id": "req_1234" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("whisper-1"); - - let mut options = transcription_options(mock_audio_data(), "audio/wav"); - let po = provider_namespace("openai", json!({"timestampGranularities": ["word"]})); - options.provider_options = Some(po); - - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.segments.len(), 2); - assert_eq!(result.segments[0].text, "Hello"); - assert_eq!(result.segments[0].start_second, 0.0); - assert_eq!(result.segments[0].end_second, 1.0); - assert_eq!(result.segments[1].text, "world"); - assert_eq!(result.segments[1].start_second, 1.0); - assert_eq!(result.segments[1].end_second, 2.0); -} - /// TS: "should handle empty segments array" #[tokio::test] async fn should_handle_empty_segments() { diff --git a/aimux-providers/tests/recraft_test.rs b/aimux-providers/tests/recraft_test.rs index 24f510534..3a7df642c 100644 --- a/aimux-providers/tests/recraft_test.rs +++ b/aimux-providers/tests/recraft_test.rs @@ -128,34 +128,6 @@ async fn should_pass_model_prompt_n_and_default_response_format() { assert_eq!(body["response_format"], "b64_json"); } -#[tokio::test] -async fn should_forward_recraft_extension_fields() { - let server = MockServer::start().await; - mock_generations_response(&server, b64_response_body()).await; - - let model = make_model(&server); - let mut opts = options(PROMPT); - opts.provider_options = aimux_core::shared::provider_namespace( - "recraft", - json!({ - "style": "digital_illustration", - "styleId": "50c0b14e-3e4f-4a18-9d8e-2b1f0a1c2d3e", - "negativePrompt": "blurry, low quality", - "randomSeed": 12345, - "responseFormat": "b64_json" - }), - ); - model.do_generate(&opts).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["style"], "digital_illustration"); - assert_eq!(body["style_id"], "50c0b14e-3e4f-4a18-9d8e-2b1f0a1c2d3e"); - assert_eq!(body["negative_prompt"], "blurry, low quality"); - assert_eq!(body["random_seed"], 12345); - assert_eq!(body["response_format"], "b64_json"); -} - #[tokio::test] async fn should_map_options_seed_to_random_seed() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/replicate_image_test.rs b/aimux-providers/tests/replicate_image_test.rs index e4b9f64a5..4ee5d239d 100644 --- a/aimux-providers/tests/replicate_image_test.rs +++ b/aimux-providers/tests/replicate_image_test.rs @@ -112,27 +112,6 @@ async fn should_pass_auth_headers() { ); } -#[tokio::test] -async fn should_use_custom_wait_time() { - let server = MockServer::start().await; - mock_replicate(&server, json!(format!("{}/image.png", server.uri()))).await; - let config = ReplicateConfig::new("test-token").with_base_url(server.uri()); - let model = ReplicateProvider::new(config).image("black-forest-labs/flux-schnell"); - let mut opts = options(PROMPT); - opts.n = 1; - opts.provider_options - .extend(aimux_core::shared::provider_namespace( - "replicate", - json!({ "maxWaitTimeInSeconds": 30 }), - )); - model.do_generate(&opts).await.unwrap(); - let reqs = server.received_requests().await.unwrap(); - assert_eq!( - reqs[0].headers.get("prefer").unwrap().to_str().unwrap(), - "wait=30" - ); -} - #[tokio::test] async fn should_handle_array_output() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/vertex_embedding_test.rs b/aimux-providers/tests/vertex_embedding_test.rs deleted file mode 100644 index 3c4b37450..000000000 --- a/aimux-providers/tests/vertex_embedding_test.rs +++ /dev/null @@ -1,327 +0,0 @@ -//! Rust translations of the AI SDK Google Vertex embedding model tests. -//! -//! Source: `reference/ai/packages/google-vertex/src/google-vertex-embedding-model.test.ts` -//! (460 lines, 12 cases). - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_providers::{VertexAuth, VertexProvider, VertexProviderConfig}; - -const TEST_VALUES: &[&str] = &["test text one", "test text two"]; - -fn mock_provider_options() -> SharedProviderOptions { - provider_namespace( - "google", - json!({ - "outputDimensionality": 768, - "taskType": "SEMANTIC_SIMILARITY", - "title": "test title", - "autoTruncate": false - }), - ) -} - -/// The fixture response body from `__fixtures__/google-vertex-embedding.json`. -fn predict_response_body() -> Value { - json!({ - "predictions": [ - { - "embeddings": { - "values": [-0.017999587580561638, -0.006893285550177097, -0.036766719073057175, -0.017558680847287178, -0.019938766956329346], - "statistics": { "token_count": 6 } - } - }, - { - "embeddings": { - "values": [-0.06007182598114014, 0.004907649010419846, -0.00690646655857563, -0.007314121350646019, -0.048464205116033554], - "statistics": { "token_count": 5 } - } - } - ] - }) -} - -fn embed_content_response_body() -> Value { - json!({ - "embedding": { "values": [0.1, 0.2, 0.3] }, - "usageMetadata": { "promptTokenCount": 4 } - }) -} - -fn test_values() -> Vec { - TEST_VALUES - .iter() - .map(std::string::ToString::to_string) - .collect() -} - -fn default_options(values: Vec) -> EmbeddingCallOptions { - EmbeddingCallOptions { - values, - abort_signal: None, - provider_options: Some(mock_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -fn test_provider(base_url: String) -> VertexProvider { - let config = VertexProviderConfig { - base_url, - project: Some("test-project".to_string()), - location: Some("us-central1".to_string()), - auth: VertexAuth::BearerToken("test-token".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }; - VertexProvider::new(config) -} - -/// TS: "should extract embeddings" -// f32 test fixtures mirror provider output verbatim -#[allow(clippy::excessive_precision)] -#[tokio::test] -async fn should_extract_embeddings() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - assert_eq!(result.embeddings.len(), 2); - assert_eq!( - result.embeddings[0], - vec![ - -0.017999587580561638, - -0.006893285550177097, - -0.036766719073057175, - -0.017558680847287178, - -0.019938766956329346 - ] - ); - assert_eq!( - result.embeddings[1], - vec![ - -0.06007182598114014, - 0.004907649010419846, - -0.00690646655857563, - -0.007314121350646019, - -0.048464205116033554 - ] - ); -} - -/// TS: "should extract usage" -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - let usage = result.usage.expect("usage should be present"); - // token_count: 6 + 5 = 11 - assert_eq!(usage.tokens, 11); -} - -/// TS: "should pass the model parameters correctly" -#[tokio::test] -async fn should_pass_model_parameters() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let _ = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["instances"].as_array().unwrap().len(), 2); - assert_eq!(body["instances"][0]["content"], "test text one"); - assert_eq!(body["instances"][0]["task_type"], "SEMANTIC_SIMILARITY"); - assert_eq!(body["instances"][0]["title"], "test title"); - assert_eq!(body["parameters"]["autoTruncate"], false); - assert_eq!(body["parameters"]["outputDimensionality"], 768); -} - -/// TS: "should accept googleVertex as provider options key" -#[tokio::test] -async fn should_accept_google_vertex_key() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let provider_options = provider_namespace( - "googleVertex", - json!({ - "outputDimensionality": 768, - "taskType": "SEMANTIC_SIMILARITY", - "title": "test title", - "autoTruncate": false - }), - ); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["instances"][0]["task_type"], "SEMANTIC_SIMILARITY"); - assert_eq!(body["parameters"]["outputDimensionality"], 768); -} - -/// TS: "should pass the taskType setting in instances" -#[tokio::test] -async fn should_pass_task_type_only() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let provider_options = provider_namespace("google", json!({"taskType": "SEMANTIC_SIMILARITY"})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["instances"][0]["task_type"], "SEMANTIC_SIMILARITY"); - // parameters should be empty object when no outputDimensionality/autoTruncate - assert_eq!(body["parameters"].as_object().unwrap().len(), 0); -} - -/// TS: "should use embedContent for gemini-embedding-2" -#[tokio::test] -async fn should_use_embed_content_for_gemini_2() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-embedding-2:embedContent")) - .respond_with(ResponseTemplate::new(200).set_body_json(embed_content_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("gemini-embedding-2"); - - let result = model - .do_embed(&default_options(vec![TEST_VALUES[0].to_string()])) - .await - .expect("should succeed"); - assert_eq!(result.embeddings, vec![vec![0.1, 0.2, 0.3]]); - let usage = result.usage.expect("usage present"); - assert_eq!(usage.tokens, 4); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["content"]["parts"][0]["text"], "test text one"); - assert_eq!(body["embedContentConfig"]["autoTruncate"], false); - assert_eq!(body["embedContentConfig"]["outputDimensionality"], 768); - assert_eq!( - body["embedContentConfig"]["taskType"], - "SEMANTIC_SIMILARITY" - ); - assert_eq!(body["embedContentConfig"]["title"], "test title"); -} - -/// TS: "should limit gemini-embedding-2 to one value per call" -#[test] -fn gemini_embedding_2_max_per_call() { - let config = VertexProviderConfig { - base_url: "https://example.com".to_string(), - project: Some("test-project".to_string()), - location: Some("us-central1".to_string()), - auth: VertexAuth::BearerToken("test".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }; - let provider = VertexProvider::new(config); - let model = provider.embedding_model("gemini-embedding-2"); - assert_eq!(model.max_embeddings_per_call(), Some(1)); -} - -/// TS: "should expose response headers" -#[tokio::test] -async fn should_expose_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(predict_response_body()), - ) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - let headers = result - .response - .as_ref() - .and_then(|r| r.headers.as_ref()) - .expect("headers present"); - assert_eq!( - headers.get("test-header").map(std::string::String::as_str), - Some("test-value") - ); -} diff --git a/aimux-providers/tests/vertex_transcription_test.rs b/aimux-providers/tests/vertex_transcription_test.rs index f1738464c..15accfcb0 100644 --- a/aimux-providers/tests/vertex_transcription_test.rs +++ b/aimux-providers/tests/vertex_transcription_test.rs @@ -186,38 +186,3 @@ async fn should_handle_no_results() { assert_eq!(result.language, None); assert_eq!(result.duration_in_seconds, None); } - -#[tokio::test] -async fn should_use_provider_options_for_region_and_language() { - let server = MockServer::start().await; - // Different region → different path - let path_str = "/v2/projects/test-project/locations/us/recognizers/_:recognize"; - Mock::given(method("POST")) - .and(path(path_str)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/json") - .set_body_json(default_body()), - ) - .mount(&server) - .await; - - let config = VertexProviderConfig::new("test-token", "test-project", "us-central1") - .with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.transcription("chirp_2").unwrap(); - - let mut opts = options(mock_audio(), "audio/wav"); - let po = aimux_core::shared::provider_namespace( - "googleVertex", - json!({"region": "us", "languageCodes": ["en", "es"]}), - ); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.unwrap(); - - assert_eq!(result.text, "hello world"); - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["config"]["languageCodes"], json!(["en", "es"])); -} diff --git a/aimux-providers/tests/voyage_embedding_test.rs b/aimux-providers/tests/voyage_embedding_test.rs index 7588a4ac9..fede560e0 100644 --- a/aimux-providers/tests/voyage_embedding_test.rs +++ b/aimux-providers/tests/voyage_embedding_test.rs @@ -173,70 +173,6 @@ async fn should_pass_model_and_values() { assert!(body.get("input_type").is_none()); } -/// TS: "should pass the input_type setting" -#[tokio::test] -async fn should_pass_input_type_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = VoyageConfig::new("test-api-key").with_base_url(server.uri()); - let provider = VoyageProvider::new(config); - let model = provider.embedding_model("voyage-3.5"); - - let provider_options = - aimux_core::shared::provider_namespace("voyage", json!({"inputType": "document"})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["input_type"], "document"); -} - -/// TS: "should pass the output_dimension setting" -#[tokio::test] -async fn should_pass_output_dimension_setting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/embeddings")) - .respond_with(ResponseTemplate::new(200).set_body_json(embedding_response_body())) - .mount(&server) - .await; - - let config = VoyageConfig::new("test-api-key").with_base_url(server.uri()); - let provider = VoyageProvider::new(config); - let model = provider.embedding_model("voyage-3.5"); - - let provider_options = - aimux_core::shared::provider_namespace("voyage", json!({"outputDimension": 256})); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["output_dimension"], 256); -} - /// TS: "should pass headers" #[tokio::test] async fn should_pass_headers() { diff --git a/aimux-providers/tests/xai_responses_test.rs b/aimux-providers/tests/xai_responses_test.rs index c482a561a..43d38cf40 100644 --- a/aimux-providers/tests/xai_responses_test.rs +++ b/aimux-providers/tests/xai_responses_test.rs @@ -10,7 +10,6 @@ //! or SSE response, creates an `XaiResponsesModel` via `XAIProvider`, calls //! `do_generate` / `do_stream`, and asserts on the result. -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::tool::{RawToolCall, ToolResult}; use futures::StreamExt; @@ -75,10 +74,6 @@ fn make_provider(server: &MockServer) -> XAIProvider { XAIProvider::new(config) } -fn xai_provider_options(opts: Value) -> Option { - Some(provider_namespace("xai", opts)) -} - /// A standard Responses API JSON body returning a text message. fn text_response_body() -> Value { json!({ @@ -170,39 +165,6 @@ mod do_generate { assert_eq!(result.usage.output_tokens.reasoning, Some(123)); } - /// TS: should expose cost_in_usd_ticks in providerMetadata - #[tokio::test] - async fn cost_in_usd_ticks() { - 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, "cost_in_usd_ticks": 113500 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!( - result.provider_metadata, - Some(provider_namespace( - "xai", - json!({ "costInUsdTicks": 113500 }) - )) - ); - } - /// TS: should not include providerMetadata when cost_in_usd_ticks is missing #[tokio::test] async fn no_cost_no_metadata() { @@ -316,121 +278,6 @@ mod do_generate { mod reasoning { use super::*; - /// TS: should extract reasoning with encrypted content when store=false - #[tokio::test] - async fn reasoning_with_encrypted_content() { - 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": [ - { - "type": "reasoning", - "id": "rs_456", - "status": "completed", - "summary": [{ "type": "summary_text", "text": "First, analyze the question carefully." }], - "encrypted_content": "abc123encryptedcontent" - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "The answer is 42.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 20, "output_tokens_details": { "reasoning_tokens": 15 } } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { - text, - provider_metadata, - }) => { - assert_eq!(text, "First, analyze the question carefully."); - assert_eq!( - provider_metadata, - &Some(provider_namespace( - "xai", - json!({ "itemId": "rs_456", "reasoningEncryptedContent": "abc123encryptedcontent" }) - )) - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => assert_eq!(text, "The answer is 42."), - other => panic!("expected Text, got {other:?}"), - } - } - - /// TS: should handle reasoning without encrypted content - #[tokio::test] - async fn reasoning_without_encrypted_content() { - 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": [ - { - "type": "reasoning", - "id": "rs_456", - "status": "completed", - "summary": [{ "type": "summary_text", "text": "Thinking through the problem." }] - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "Solution found.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 15 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { - text, - provider_metadata, - }) => { - assert_eq!(text, "Thinking through the problem."); - assert_eq!( - provider_metadata, - &Some(provider_namespace("xai", json!({ "itemId": "rs_456" }))) - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - } - /// TS: should extract reasoning from content when summary is empty #[tokio::test] async fn reasoning_from_content() { @@ -477,63 +324,6 @@ mod reasoning { other => panic!("expected Reasoning, got {other:?}"), } } - - /// TS: should extract reasoning with encrypted content but empty summary text - #[tokio::test] - async fn reasoning_empty_summary_with_encrypted() { - 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": [ - { - "type": "reasoning", - "id": "rs_789", - "status": "completed", - "summary": [], - "encrypted_content": "encrypted_zdr_content_xyz" - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "Here is my response.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 20, "output_tokens_details": { "reasoning_tokens": 15 } } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { - text, - provider_metadata, - }) => { - assert_eq!(text, ""); - assert_eq!( - provider_metadata, - &Some(provider_namespace( - "xai", - json!({ "itemId": "rs_789", "reasoningEncryptedContent": "encrypted_zdr_content_xyz" }) - )) - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - } } // ════════════════════════════════════════════════════════════════════════════ @@ -596,233 +386,6 @@ mod settings { ); } - /// 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() { @@ -873,32 +436,6 @@ mod settings { 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() { @@ -1925,58 +1462,6 @@ mod do_stream { assert_eq!(reasoning_deltas, vec!["First", ", analyze the question."]); } - /// TS: should include encrypted content in reasoning-end providerMetadata - #[tokio::test] - async fn reasoning_end_encrypted_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "reasoning", "id": "rs_456", "status": "in_progress", "summary": [] }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_summary_part.added", "item_id": "rs_456", "output_index": 0, "summary_index": 0, "part": { "type": "summary_text", "text": "" } }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_summary_text.delta", "item_id": "rs_456", "output_index": 0, "summary_index": 0, "delta": "Analyzing..." }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_summary_text.done", "item_id": "rs_456", "output_index": 0, "summary_index": 0, "text": "Analyzing..." }).to_string()), - &sse_event(&json!({ "type": "response.output_item.done", "item": { "type": "reasoning", "id": "rs_456", "status": "completed", "summary": [{ "type": "summary_text", "text": "Analyzing..." }], "encrypted_content": "encrypted_data_abc123" }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "message", "id": "msg_789", "role": "assistant", "status": "in_progress", "content": [] }, "output_index": 1 }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_789", "output_index": 1, "content_index": 0, "delta": "Result." }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 20 } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let reasoning_end = parts - .iter() - .find(|p| matches!(p, StreamPart::ReasoningEnd { .. })); - assert!(reasoning_end.is_some()); - if let Some(StreamPart::ReasoningEnd { - id, - provider_metadata, - }) = reasoning_end - { - assert_eq!(id, "reasoning-rs_456"); - assert_eq!( - provider_metadata, - &Some(provider_namespace( - "xai", - json!({ "itemId": "rs_456", "reasoningEncryptedContent": "encrypted_data_abc123" }) - )) - ); - } - } - /// TS: should stream web_search tool calls #[tokio::test] async fn stream_web_search_tool() { @@ -2136,58 +1621,6 @@ mod do_stream { } } - /// TS: should expose cost_in_usd_ticks in finish providerMetadata - #[tokio::test] - async fn stream_cost_in_usd_ticks() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "Hello" }).to_string()), - &sse_event(&json!({ - "type": "response.completed", - "response": { - "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", - "status": "completed", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5, "cost_in_usd_ticks": 113500 } - } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { - provider_metadata, .. - }) = finish - { - assert_eq!( - provider_metadata, - &Some(provider_namespace( - "xai", - json!({ "costInUsdTicks": 113500 }) - )) - ); - } else { - panic!("no finish part found"); - } - } - /// TS: should stream citations as sources #[tokio::test] async fn stream_citations() { @@ -2983,32 +2416,6 @@ mod convert_input { assert!(input.is_empty()); } - /// An explicit false value must override stale legacy provider metadata. - #[test] - fn explicit_client_execution_overrides_legacy_provider_metadata() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall { - tool_call_id: "call_123".to_string(), - tool_name: "weather".to_string(), - input: json!({ "city": "Singapore" }), - provider_executed: Some(false), - thought_signature: None, - provider_options: Some(provider_namespace( - "xai", - json!({ "providerExecuted": true }), - )), - }], - ..Default::default() - }]; - - let (input, warnings) = convert_to_xai_responses_input(&prompt).unwrap(); - assert!(warnings.is_empty()); - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "function_call"); - assert_eq!(input[0]["call_id"], "call_123"); - } - /// TS: should convert tool results #[test] fn tool_result() { diff --git a/aimux-providers/tests/xai_test.rs b/aimux-providers/tests/xai_test.rs index 27acc8d5d..75b248941 100644 --- a/aimux-providers/tests/xai_test.rs +++ b/aimux-providers/tests/xai_test.rs @@ -13,7 +13,6 @@ //! or SSE response, creates an `XaiModel` via `XAIProvider`, calls //! `do_generate` / `do_stream`, and asserts on the result. -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; use aimux_core::tool::RawToolCall; use std::collections::HashMap; @@ -28,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, ReasoningOutput, Source}; +use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -145,33 +144,12 @@ fn text_deltas(parts: &[StreamPart]) -> Vec { .collect() } -/// Extract reasoning deltas from a list of stream parts. -fn reasoning_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - /// Build a provider pointed at the mock server. fn make_provider(server: &MockServer) -> XAIProvider { let config = XAIConfig::new("test-api-key").with_base_url(server.uri()); XAIProvider::new(config) } -/// Provider options with a single xai key. -fn xai_options(key: &str, value: Value) -> Option { - Some(provider_namespace("xai", json!({ key: value }))) -} - -/// Build provider options from a JSON object. -fn xai_provider_options(opts: Value) -> Option { - Some(provider_namespace("xai", opts)) -} - // ════════════════════════════════════════════════════════════════════════════ // supportsReasoningEffort — direct function tests // (supports-reasoning-effort.test.ts) @@ -1246,41 +1224,6 @@ mod convert_messages { 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(provider_namespace( - "xai", - json!({"imageDetail": "low"}), - )), - }, - ], - ..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[1]["image_url"]["detail"], "low"); - } - /// TS: should not set detail when imageDetail is not set #[tokio::test] async fn no_image_detail() { @@ -1562,159 +1505,6 @@ mod do_generate { ); } - /// 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); - } - - /// TS: should pass search parameters - #[tokio::test] - async fn pass_search_parameters() { - 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!({ - "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 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); - } - - /// TS: should pass search parameters with sources array - #[tokio::test] - async fn pass_search_parameters_with_sources() { - 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!({ - "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 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"); - } - /// TS: should support json schema response format without warnings #[tokio::test] async fn json_schema_response_format() { @@ -1787,63 +1577,6 @@ mod do_generate { assert_eq!(result.usage.output_tokens.reasoning, Some(0)); } - /// TS: should extract citations as sources - #[tokio::test] - async fn extract_citations() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "citations-test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Here are the latest developments in AI.", "tool_calls": null }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "citations": [ - "https://example.com/article1", - "https://example.com/article2" - ] - }))) - .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 } - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let sources: Vec<_> = result - .content - .iter() - .filter_map(|c| match c { - GenerateContent::Source(Source { - url, source_type, .. - }) => Some((url.clone(), source_type.clone())), - _ => None, - }) - .collect(); - assert_eq!(sources.len(), 2); - assert_eq!( - sources[0].0.as_deref(), - Some("https://example.com/article1") - ); - assert_eq!(sources[0].1, "url"); - assert_eq!( - sources[1].0.as_deref(), - Some("https://example.com/article2") - ); - } - /// TS: should avoid duplication when there is a trailing assistant message #[tokio::test] async fn avoid_duplication_trailing_assistant() { @@ -2091,54 +1824,6 @@ mod do_stream { assert_eq!(usage.input_tokens.total, Some(0)); assert_eq!(usage.output_tokens.total, Some(0)); } - - /// TS: should stream citations as sources - #[tokio::test] - async fn stream_citations() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"c8e45f92","model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"c8e45f92","model":"grok-3","choices":[{"index":0,"delta":{"content":"Latest AI news"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"c8e45f92","model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":30,"total_tokens":34},"citations":["https://example.com/source1","https://example.com/source2"]}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .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 } - })), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - let sources: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::Source(Source { url, .. }) => url.clone(), - _ => None, - }) - .collect(); - assert_eq!(sources.len(), 2); - assert_eq!(sources[0], "https://example.com/source1"); - assert_eq!(sources[1], "https://example.com/source2"); - } } // ════════════════════════════════════════════════════════════════════════════ @@ -2149,50 +1834,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() { @@ -2281,29 +1922,6 @@ mod reasoning { 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() { @@ -2332,192 +1950,6 @@ mod reasoning { }); 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() { - 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-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("low")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - // Should have text content and reasoning content - let has_text = result - .content - .iter() - .any(|c| matches!(c, GenerateContent::Text { text, .. } if text == "Hello")); - assert!(has_text, "should have text 'Hello'"); - - let reasoning = result.content.iter().find_map(|c| match c { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => Some(text.clone()), - _ => None, - }); - assert!(reasoning.is_some(), "should have reasoning content"); - assert!(reasoning.unwrap().starts_with("First, the user said")); - } - - /// TS: should extract reasoning tokens from usage - #[tokio::test] - async fn extract_reasoning_tokens_from_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-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("high")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - 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 handle reasoning streaming - #[tokio::test] - async fn reasoning_streaming() { - let server = MockServer::start().await; - let chunks = [ - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"First","role":"assistant"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" said"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"Hello"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ]; - let body = sse_body( - &chunks - .iter() - .map(|c| sse_event(c)) - .collect::>() - .iter() - .map(std::string::String::as_str) - .collect::>(), - ); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(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!("low")), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - let deltas = reasoning_deltas(&parts); - assert_eq!(deltas, vec!["First", ",", " the", " user", " said"]); - - // Should also have text delta - let text = text_deltas(&parts); - assert_eq!(text, vec!["Hello"]); - } - - /// TS: should deduplicate repetitive reasoning deltas - #[tokio::test] - async fn dedup_reasoning_deltas() { - let server = MockServer::start().await; - let chunks = [ - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Thinking... "},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Thinking... "},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Thinking... "},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Actually calculating now..."},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"content":"The answer is 42."},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":20,"total_tokens":35,"completion_tokens_details":{"reasoning_tokens":10}}}"#, - ]; - let body = sse_body( - &chunks - .iter() - .map(|c| sse_event(c)) - .collect::>() - .iter() - .map(std::string::String::as_str) - .collect::>(), - ); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(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!("low")), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - // "Thinking... " should only appear once (dedup) - let deltas = reasoning_deltas(&parts); - assert_eq!(deltas, vec!["Thinking... ", "Actually calculating now..."]); - - // Should have text delta - let text = text_deltas(&parts); - assert_eq!(text, vec!["The answer is 42."]); - - // Should have reasoning tokens in finish usage - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { usage, .. } => Some(usage.clone()), - _ => None, - }); - let usage = finish.expect("should have Finish"); - assert_eq!(usage.output_tokens.reasoning, Some(10)); - assert_eq!(usage.output_tokens.text, Some(20)); - } } // ════════════════════════════════════════════════════════════════════════════ From c24e364fe99205aa239641b37e4191e27de92d43 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Tue, 6 Oct 2026 16:04:32 +0800 Subject: [PATCH 5/8] fix(providers): read amazonBedrock before bedrock; validate vendor options as upstream schemas do; restore ported tests What: Bedrock reads the amazonBedrock namespace first and falls back to bedrock. Vendor option parsing follows each upstream schema: nullish fields accept null, optional ones do not, wrong types are errors, unknown keys are dropped before the request body (OpenAI, Groq, DeepSeek, Anthropic, xAI, and partly Cohere, Google, Mistral). Restores the ported tracker metadata cases, Google files option cases and the Bedrock message-conversion tests deleted earlier. Why: review found options passed through unvalidated where upstream rejects or strips them, and ported tests deleted instead of adapted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- MIGRATION-NS.md | 196 +++ .../tests/streaming_tool_call_tracker_test.rs | 71 +- aimux-providers/src/anthropic/convert.rs | 233 +++- aimux-providers/src/bedrock/convert.rs | 2 +- aimux-providers/src/cohere/convert.rs | 4 +- aimux-providers/src/google/convert.rs | 7 +- aimux-providers/src/mistral/convert.rs | 10 + aimux-providers/src/openai/convert.rs | 254 +++- aimux-providers/src/xai/responses/convert.rs | 56 +- aimux-providers/tests/bedrock_convert_test.rs | 1060 +++++++++++++++++ aimux-providers/tests/google_files_test.rs | 54 +- 11 files changed, 1920 insertions(+), 27 deletions(-) create mode 100644 MIGRATION-NS.md create mode 100644 aimux-providers/tests/bedrock_convert_test.rs diff --git a/MIGRATION-NS.md b/MIGRATION-NS.md new file mode 100644 index 000000000..631c4c433 --- /dev/null +++ b/MIGRATION-NS.md @@ -0,0 +1,196 @@ +# Namespace types migration recipe (provider options / provider metadata) + +Do not commit this file. + +## What changed + +Both are now `HashMap` (namespace -> JSON object), the AI SDK `Record`: + +```rust +// aimux_core::shared (also re-exported from aimux_core::prelude) +pub type JsonObject = serde_json::Map; +pub type SharedProviderOptions = HashMap; +pub type SharedProviderMetadata = HashMap; +pub fn provider_namespace(namespace: &str, object: serde_json::Value) -> SharedProviderMetadata; +// aimux_core::types +pub type ProviderMetadata = SharedProviderMetadata; // was serde_json::Value +``` + +Every `provider_options` field (content parts, messages, CallOptions, GenerateTextOptions, FunctionTool, every +`*CallOptions`) is `Option` (the media/embedding call options that were already non-Option stay +non-Option). Every `provider_metadata` field (GenerateContent, StreamPart, ToolCall/ToolResult/RawToolCall, Source, +ReasoningOutput, GenerateResult, ...) is `Option`. Not changed: `recording::ProviderRecord.provider_options` +(provider construction options such as org/project, flat, not namespaced). + +Imports: + +```rust +use aimux_core::shared::{provider_namespace, JsonObject, SharedProviderMetadata, SharedProviderOptions}; +use aimux_core::types::ProviderMetadata; +``` + +Wire JSON is unchanged: `{"openai": {"k": v}}`. A non-object namespace value (`{"openai": 1}`, `[]`, `"x"`, `null`) or a +non-object top level is now a deserialization error. + +## Patterns + +### 1. Build metadata / options, one namespace (the common case) + +```rust +// before +provider_metadata: Some(json!({ "xai": { "itemId": item_id } })), +// after +provider_metadata: Some(provider_namespace("xai", json!({ "itemId": item_id }))), +``` + +Same for `provider_options: Some(...)` (identical type). The helper is `#[must_use]`; a non-object second argument yields +an empty namespace, so pass an object literal, or build a `JsonObject` yourself (pattern 3). + +Functions that returned `Value` metadata now return `ProviderMetadata`: + +```rust +// before +fn server_tool_metadata(...) -> Value { ...; json!({ "google": payload }) } +// after +fn server_tool_metadata(...) -> ProviderMetadata { ...; provider_namespace("google", payload) } +``` + +### 2. Build metadata with several namespaces + +```rust +// before +Some(json!({ + "googleVertex": { "thoughtSignature": "a" }, + "vertex": { "thoughtSignature": "b" }, +})) +// after (tests / fixtures): deserialize the literal, shape is checked by the type +Some(serde_json::from_value(json!({ + "googleVertex": { "thoughtSignature": "a" }, + "vertex": { "thoughtSignature": "b" }, +})).unwrap()) +// after (non-test code): extend +let mut m = provider_namespace("bedrock", json!({ "signature": sig })); +m.extend(provider_namespace("amazonBedrock", json!({ "signature": sig }))); +Some(m) +``` + +### 3. Build from a `Map` you filled imperatively + +The `Value::Object(..)` wrapper goes away. + +```rust +// before +let mut metadata = HashMap::new(); +let mut prodia_meta = Map::new(); +prodia_meta.insert("images".into(), json!([job_result])); +metadata.insert("prodia".into(), Value::Object(prodia_meta)); +// after +metadata.insert("prodia".into(), prodia_meta); // prodia_meta: JsonObject (= Map) + +// before +std::iter::once(("google".to_string(), Value::Object(metadata))).collect() +// after +HashMap::from([("google".to_string(), metadata)]) // or provider_namespace("google", Value::Object(metadata)) +``` + +`Map::new()` is the same type as `JsonObject::new()`; `use aimux_core::shared::JsonObject` if you want the alias. + +### 4. Merge into existing metadata + +```rust +// before (Value) +meta["xai"]["reasoningEncryptedContent"] = json!(ec); +if let Some(o) = meta.as_object_mut() { o.insert(...) } +// after +meta.entry("xai".into()).or_default().insert("reasoningEncryptedContent".into(), json!(ec)); +// merge a whole map one level deep +for (ns, obj) in other { meta.entry(ns).or_default().extend(obj); } +``` + +Building the inner object first and wrapping once is also fine: + +```rust +let mut inner = json!({ "itemId": part_id }); // before: json! then index-assign +inner["reasoningEncryptedContent"] = json!(ec); // still works on a Value +provider_namespace("xai", inner) // wrap at the end +``` + +### 5. Read an option from a namespace + +`options.provider_options.get("x")` now returns `Option<&JsonObject>`, not `Option<&Value>`. Method chains that call +`.get("k")`, `.and_then(|o| o.get("k"))`, `.as_str()` on the *inner values* are unchanged. + +```rust +// before and after (compiles unchanged) +let v = opts.provider_options.get("openai").and_then(|o| o.get("user")).and_then(|v| v.as_str()); +// per-part options (Option before, Option now) +let cc = provider_options.as_ref().and_then(|o| o.get("anthropic")).and_then(|a| a.get("cacheControl")); +``` + +What breaks: + +```rust +// before: object-ness check on the namespace value +let o = provider_opts.as_object()?; // E0599: no method as_object on &Map +// after: it already is the object +let o = provider_opts; +// before: .and_then(|v| v.as_object()) after: delete the and_then (the value is already &JsonObject) +// before: opts["k"] (Value index, Null on miss) after: Map index PANICS on a missing key; use opts.get("k") +// before: Value::is_object / is_null on the namespace after: gone (always an object) +// need a Value again (e.g. to feed serde_json::from_value or json!): Value::Object(obj.clone()) +``` + +Looping a string-literal slice: `for key in &["bedrock", "amazonBedrock"] { po.get(key) }` fails with +`String: Borrow<&str>` (E0277); iterate by value (`for key in ["bedrock", "amazonBedrock"]`) or write `po.get(*key)`. + +### 6. Pass through + +Fields and function parameters that merely carry a value need no change except the declared type: + +```rust +// before +fn f(meta: Option) -> StreamPart { StreamPart::TextEnd { id, provider_metadata: meta } } +// after +fn f(meta: Option) -> StreamPart { ... } +``` + +Local variables typed `Option` / `HashMap` that end up in these fields: retype them. +Intermediate scratch values that are not themselves stored (e.g. `StreamingToolCallTracker::with_extract_metadata` +closures, which still return `Option`) stay `Value`; `with_build_provider_metadata` still takes `Option<&Value>` +and returns `Option`, so build with `provider_namespace("google", json!({...}))`. + +### 7. Tests comparing against `json!` + +```rust +// before +assert_eq!(part.provider_metadata, Some(json!({ "anthropic": { "signature": "s" } }))); +// after, option 1: compare typed +assert_eq!(part.provider_metadata, Some(provider_namespace("anthropic", json!({ "signature": "s" })))); +// after, option 2: compare the serialized shape (keeps the json! literal as-is) +assert_eq!(serde_json::to_value(&part.provider_metadata).unwrap(), json!({ "anthropic": { "signature": "s" } })); +// indexing keeps working: HashMap -> m["anthropic"]["signature"] == json!("s") +// but a whole namespace is a Map, not a Value: Value::Object(m["anthropic"].clone()) == json!({ ... }) +``` + +### 8. `Option` per-part / per-message options + +`ContentPart::*.provider_options`, `LanguageModelPromptMessage.provider_options` were `Option`; now +`Option`. Constructing sites become `Some(provider_namespace(..))` (pattern 1); `None` unchanged. +A flat (non-namespaced) object such as `json!({ "organization": "o" })` is no longer representable there; if a test +or code passed one, it was relying on the loose shape: wrap it in a namespace or drop it. + +### 9. `Value` results holding provider metadata + +`GenerateTextResult.provider_metadata`, `GenerateObjectResult.provider_metadata`, `ReasoningOutput.provider_metadata` and +the stream `Finish` part are `Option`. `ResponseMessageBuilder` is `pub(crate)` (core only). + +## Contract-test fixtures / tests that fail because they pin a non-object namespace value + +Scanned every `*.json` / `*.jsonl` under contract-tests, aimux-core, aimux-provider-utils, aimux-providers/tests +(fixtures and cassettes), bindings and tools for `provider_options|provider_metadata|providerOptions|providerMetadata` +keys whose value is not `null` or an object of objects: none found. In particular +`contract-tests/fixtures/wire-format.json` (15 occurrences) already uses the namespace -> object shape, and +`aimux-core/tests/contract_test.rs` passes. Tests inside `aimux-providers/tests/*.rs` that build these values with +`json!` do not compile yet and are handled per vendor directory with the recipe above; any that pin a non-object +namespace value (for example `json!({ "openai": 1 })` or a flat `{ "organization": ... }`) must be reported with +file:line rather than kept loose. diff --git a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs index 7b4646272..d10c9dde7 100644 --- a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs +++ b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use aimux_core::shared::provider_namespace; use aimux_core::stream_part::StreamPart; use aimux_core::tool::RawToolCall; use aimux_core::types::ProviderMetadata; @@ -14,7 +15,7 @@ use aimux_provider_utils::{ StreamingToolCallDelta, StreamingToolCallFunction, StreamingToolCallTracker, TrackerError, TypeValidation, }; -use serde_json::Value; +use serde_json::{Value, json}; /// The TS tracker's four events, projected out of [`StreamPart`] so the /// assertions below read like the upstream suite. @@ -1052,6 +1053,74 @@ mod flush { mod metadata { use super::*; + fn google_tracker() -> StreamingToolCallTracker { + StreamingToolCallTracker::new() + .with_extract_metadata(|delta| { + delta.extra["extra_content"]["google"]["thought_signature"] + .as_str() + .map(|sig| json!({ "thoughtSignature": sig })) + }) + .with_build_provider_metadata(|metadata| { + metadata.and_then(|m| m.get("thoughtSignature")).map(|sig| { + provider_namespace("google", json!({ "thoughtSignature": sig })).unwrap() + }) + }) + } + + #[test] + fn extracts_and_includes_provider_metadata_in_tool_call_parts() { + let mut h = Harness::with(google_tracker()); + + h.delta( + start(0, "call_1", "fn", "{}") + .extra(json!({ "extra_content": { "google": { "thought_signature": "sig123" } } })), + ) + .unwrap(); + h.flush(); + + let tool_call = h.parts.iter().find(|p| matches!(p, Part::ToolCall { .. })); + assert_eq!( + tool_call, + Some(&Part::ToolCall { + tool_call_id: "call_1".into(), + tool_name: "fn".into(), + input: "{}".into(), + provider_metadata: Some( + provider_namespace("google", json!({ "thoughtSignature": "sig123" })).unwrap() + ), + }) + ); + } + + #[test] + fn includes_provider_metadata_for_unfinished_tool_calls_finalized_in_flush() { + let mut h = Harness::with( + StreamingToolCallTracker::new() + .with_extract_metadata(|_| Some(json!({ "custom": { "key": "value" } }))) + .with_build_provider_metadata(|metadata| { + metadata.map(|m| provider_namespace("provider", m.clone()).unwrap()) + }), + ); + + h.delta(start(0, "call_1", "fn", "{\"incomplete")).unwrap(); + h.clear(); + + h.flush(); + + assert_eq!( + h.parts.last(), + Some(&Part::ToolCall { + tool_call_id: "call_1".into(), + tool_name: "fn".into(), + input: "{\"incomplete".into(), + provider_metadata: Some( + provider_namespace("provider", json!({ "custom": { "key": "value" } })) + .unwrap() + ), + }) + ); + } + #[test] fn omits_provider_metadata_when_the_builder_returns_none() { let mut h = Harness::with( diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index 069a03819..4698795c5 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -1592,6 +1592,223 @@ pub struct RequestBodyResult { pub betas: BTreeSet, } +fn parse_anthropic_option_object(shape: &str, value: &Value) -> Result { + let object = value.as_object().ok_or(())?; + let parsed = crate::openai::convert::parse_option_fields(object, "anthropic", |key, value| { + let kind = match (shape, key) { + ("options", "sendReasoning" | "disableParallelToolUse" | "toolStreaming") => "bool", + ("options", "structuredOutputMode") => "outputFormat|jsonTool|auto", + ("options", "thinking") => "thinking", + ("options", "cacheControl") => "cache", + ("options", "metadata") => "metadata", + ("options", "mcpServers") => "[]mcp", + ("options", "container") => "container", + ("options", "effort") => "low|medium|high|xhigh|max", + ("options", "taskBudget") => "taskBudget", + ("options", "speed") | ("fallback", "speed") => "fast|standard", + ("options", "serviceTier") => "auto|standard_only", + ("options", "inferenceGeo") => "us|global", + ("options", "fallbacks") => "fallbacks", + ("options", "anthropicBeta") => "[]string", + ("options", "safeguards") => "[]safeguard", + ("options", "compaction") => "compaction", + ("options", "contextManagement") => "context", + ("thinking", "type") => "adaptive|enabled|disabled|between_tools", + ("thinking", "display") + if object.get("type").and_then(Value::as_str) == Some("adaptive") => + { + "omitted|summarized|updates" + } + ("thinking", "budgetTokens") + if object.get("type").and_then(Value::as_str) == Some("enabled") => + { + "number" + } + ("thinking", "blockBinding") + if object + .get("type") + .is_none_or(|t| t.as_str() == Some("adaptive")) => + { + "binding" + } + ("binding", "prefixMismatchBehavior") => "error|drop_block", + ("cache", "type") => "ephemeral", + ("cache", "ttl") => "5m|1h", + ("metadata", "userId") => "string", + ("mcp", "type") => "url", + ("mcp", "name" | "url") => "string", + ("mcp", "authorizationToken") => "?string", + ("mcp", "toolConfiguration") => "?toolConfiguration", + ("toolConfiguration", "enabled") => "?bool", + ("toolConfiguration", "allowedTools") => "?[]string", + ("container", "id") | ("skill", "version") => "string", + ("container", "skills") => "[]skill", + ("skill", "type") => "anthropic|custom", + ("skill", "skillId") + if object.get("type").and_then(Value::as_str) == Some("anthropic") => + { + "string" + } + ("skill", "providerReference") + if object.get("type").and_then(Value::as_str) == Some("custom") => + { + "stringRecord" + } + ("taskBudget", "type") => "tokens", + ("taskBudget", "total") => "total", + ("taskBudget", "remaining") => "remaining", + ("fallback", "model") => "string", + ("fallback", "max_tokens") => "integer", + ("fallback", "thinking" | "output_config") | ("safeguard", "classifierContext") => { + "record" + } + ("safeguard", "type") => "dangerous_tool_use", + ("compaction", "type") => "summarize", + ("compaction", "instructions") => "string", + ("edit", "instructions") + if object.get("type").and_then(Value::as_str) == Some("compact_20260112") => + { + "string" + } + ("context", "edits") => "[]edit", + ("edit", "type") => "clear_tool_uses_20250919|clear_thinking_20251015|compact_20260112", + ("edit", "trigger") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "trigger" + } + ("edit", "trigger") + if object.get("type").and_then(Value::as_str) == Some("compact_20260112") => + { + "inputTokens" + } + ("edit", "clearAtLeast") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "inputTokens" + } + ("edit", "keep") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "toolUses" + } + ("edit", "keep") + if object.get("type").and_then(Value::as_str) + == Some("clear_thinking_20251015") => + { + "keepThinking" + } + ("edit", "clearToolInputs") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "bool" + } + ("edit", "excludeTools") + if object.get("type").and_then(Value::as_str) + == Some("clear_tool_uses_20250919") => + { + "[]string" + } + ("edit", "pauseAfterCompaction") + if object.get("type").and_then(Value::as_str) == Some("compact_20260112") => + { + "bool" + } + ("trigger", "type") => "input_tokens|tool_uses", + ("inputTokens", "type") => "input_tokens", + ("toolUses", "type") => "tool_uses", + ("thinkingTurns", "type") => "thinking_turns", + ("trigger" | "inputTokens" | "toolUses" | "thinkingTurns", "value") => "number", + _ => return None, + }; + Some(parse_anthropic_option_value(kind, value)) + }) + .map_err(|_| ())?; + let required: &[&str] = match shape { + "thinking" if !object.contains_key("type") => &["blockBinding"], + "thinking" | "cache" | "compaction" | "safeguard" | "edit" => &["type"], + "binding" => &["prefixMismatchBehavior"], + "mcp" => &["type", "name", "url"], + "skill" if object.get("type").and_then(Value::as_str) == Some("custom") => { + &["type", "providerReference"] + } + "skill" => &["type", "skillId"], + "taskBudget" => &["type", "total"], + "fallback" => &["model"], + "context" => &["edits"], + "trigger" | "inputTokens" | "toolUses" | "thinkingTurns" => &["type", "value"], + _ => &[], + }; + if required.iter().any(|key| !parsed.contains_key(*key)) { + return Err(()); + } + Ok(Value::Object(parsed)) +} + +fn parse_anthropic_option_value(kind: &str, value: &Value) -> Result { + if let Some(kind) = kind.strip_prefix('?') { + return if value.is_null() { + Ok(Value::Null) + } else { + parse_anthropic_option_value(kind, value) + }; + } + if let Some(kind) = kind.strip_prefix("[]") { + return value + .as_array() + .ok_or(())? + .iter() + .map(|v| parse_anthropic_option_value(kind, v)) + .collect::, _>>() + .map(Value::Array); + } + let valid = match kind { + "bool" => value.is_boolean(), + "string" => value.is_string(), + "number" => value.is_number(), + "integer" => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_991.0), + "total" => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && (20000.0..=9_007_199_254_740_991.0).contains(&n)), + "remaining" => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && (0.0..=9_007_199_254_740_991.0).contains(&n)), + "record" => value.is_object(), + "stringRecord" => value + .as_object() + .is_some_and(|o| o.values().all(Value::is_string)), + "fallbacks" => { + return if value.as_str() == Some("default") { + Ok(value.clone()) + } else { + parse_anthropic_option_value("[]fallback", value) + }; + } + "keepThinking" => { + return if value.as_str() == Some("all") { + Ok(value.clone()) + } else { + parse_anthropic_option_object("thinkingTurns", value) + }; + } + "thinking" | "binding" | "cache" | "metadata" | "mcp" | "toolConfiguration" + | "container" | "skill" | "taskBudget" | "fallback" | "safeguard" | "compaction" + | "context" | "edit" | "trigger" | "inputTokens" | "toolUses" | "thinkingTurns" => { + return parse_anthropic_option_object(kind, value); + } + _ => value + .as_str() + .is_some_and(|s| kind.split('|').any(|allowed| allowed == s)), + }; + if valid { Ok(value.clone()) } else { Err(()) } +} + /// Read a value from `provider_options["anthropic"][key]`. fn anthropic_option(options: &Option, key: &str) -> Option { options @@ -2003,7 +2220,10 @@ fn append_anthropic_mcp_servers( if let Some(at) = server.get("authorizationToken") { o.insert("authorization_token".to_string(), at.clone()); } - if let Some(tc) = server.get("toolConfiguration") { + if let Some(tc) = server + .get("toolConfiguration") + .filter(|value| !value.is_null()) + { let mut tc_obj = Map::new(); if let Some(at) = tc.get("allowedTools") { tc_obj.insert("allowed_tools".to_string(), at.clone()); @@ -2121,6 +2341,17 @@ pub fn build_request_body_with_warnings( options: &CallOptions, stream: bool, ) -> Result { + let mut parsed_options = options.clone(); + if let Some(namespaces) = parsed_options.provider_options.as_mut() + && let Some(raw) = namespaces.get("anthropic") + { + let parsed = parse_anthropic_option_object("options", &Value::Object(raw.clone())) + .map_err(|()| { + AiMuxError::InvalidArgument("invalid anthropic provider options".to_string()) + })?; + namespaces.insert("anthropic".to_string(), parsed.as_object().unwrap().clone()); + } + let options = &parsed_options; let mut warnings: Vec = Vec::new(); let mut betas: BTreeSet = BTreeSet::new(); let caps = get_model_capabilities(model_id); diff --git a/aimux-providers/src/bedrock/convert.rs b/aimux-providers/src/bedrock/convert.rs index a54b82151..9590540c3 100644 --- a/aimux-providers/src/bedrock/convert.rs +++ b/aimux-providers/src/bedrock/convert.rs @@ -529,7 +529,7 @@ pub fn build_request_body(model_id: &str, options: &CallOptions) -> Value { let bedrock_opts = options .provider_options .as_ref() - .and_then(|po| po.get("bedrock")); + .and_then(|po| po.get("amazonBedrock").or_else(|| po.get("bedrock"))); let reasoning_config = bedrock_opts.and_then(|bo| bo.get("reasoningConfig")); let user_amrf = bedrock_opts.and_then(|bo| bo.get("additionalModelRequestFields")); diff --git a/aimux-providers/src/cohere/convert.rs b/aimux-providers/src/cohere/convert.rs index a8997cf32..ebe3172dd 100644 --- a/aimux-providers/src/cohere/convert.rs +++ b/aimux-providers/src/cohere/convert.rs @@ -432,9 +432,9 @@ pub fn resolve_cohere_thinking( let mut obj = json!({ "type": t_type }); if let Some(budget) = thinking .get("tokenBudget") - .and_then(serde_json::Value::as_u64) + .filter(|value| value.is_number()) { - obj["token_budget"] = json!(budget); + obj["token_budget"] = budget.clone(); } return Some(obj); } diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 616472093..50dd725c6 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -1100,7 +1100,12 @@ fn build_request_body_with_warnings_for_namespace( && let ResponseFormat::Json { schema, .. } = rf { generation_config.insert("responseMimeType".to_string(), json!("application/json")); - if let Some(s) = schema { + if let Some(s) = schema + && read_provider_options(options.provider_options.as_ref(), namespace) + .and_then(|options| options.get("structuredOutputs")) + .and_then(Value::as_bool) + .unwrap_or(true) + { let openapi = convert_json_schema_to_openapi_schema(s, true); if !openapi.is_null() { generation_config.insert("responseSchema".to_string(), openapi); diff --git a/aimux-providers/src/mistral/convert.rs b/aimux-providers/src/mistral/convert.rs index 38e805acf..3ea6b9f1b 100644 --- a/aimux-providers/src/mistral/convert.rs +++ b/aimux-providers/src/mistral/convert.rs @@ -296,6 +296,16 @@ pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) - body["stream"] = json!(true); } + if let Some(safe_prompt) = options + .provider_options + .as_ref() + .and_then(|namespaces| namespaces.get("mistral")) + .and_then(|options| options.get("safePrompt")) + .and_then(Value::as_bool) + { + body["safe_prompt"] = json!(safe_prompt); + } + if let Some(max_tokens) = options.max_output_tokens { body["max_tokens"] = json!(max_tokens); } diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index e3ed970ef..a37375e85 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -5,7 +5,7 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::shared::SharedProviderOptions; +use aimux_core::shared::{JsonObject, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde::Serialize; @@ -900,6 +900,200 @@ pub struct RequestBodyResult { pub warnings: Vec, } +/// Parse declared namespace fields, discarding unknown keys like a default z.object. +/// +/// # Errors +/// Returns a parse error when a declared field fails its schema. +pub(crate) fn parse_option_fields( + options: &JsonObject, + provider: &str, + parse: impl Fn(&str, &Value) -> Option>, +) -> Result { + let mut parsed = JsonObject::new(); + for (key, value) in options { + if let Some(result) = parse(key, value) { + parsed.insert( + key.clone(), + result.map_err(|()| { + AiMuxError::InvalidArgument(format!("invalid {provider} provider options")) + })?, + ); + } + } + Ok(parsed) +} + +fn parse_chat_provider_options( + options: &Option, + provider: &str, +) -> Result, AiMuxError> { + if !matches!(provider, "openai" | "groq" | "deepseek") { + return Ok(options.clone()); + } + let Some(namespace) = options.as_ref().and_then(|options| options.get(provider)) else { + return Ok(None); + }; + let parsed = parse_option_fields(namespace, provider, |key, value| { + let one_of = |values: &[&str]| value.as_str().is_some_and(|s| values.contains(&s)); + let valid = match (provider, key) { + ("openai", "logitBias") => { + return Some(value.as_object().ok_or(()).and_then(|object| { + let mut parsed = JsonObject::new(); + for (key, value) in object { + if !value.is_number() { + return Err(()); + } + let key = key.trim_matches(|c| { + matches!(c, + '\u{0009}'..='\u{000d}' | '\u{0020}' | '\u{00a0}' | '\u{1680}' + | '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' + | '\u{205f}' | '\u{3000}' | '\u{feff}' + ) + }); + let number = if key.is_empty() { + 0.0 + } else if let Some((digits, radix)) = key + .strip_prefix("0x") + .or_else(|| key.strip_prefix("0X")) + .map(|s| (s, 16)) + .or_else(|| { + key.strip_prefix("0o") + .or_else(|| key.strip_prefix("0O")) + .map(|s| (s, 8)) + }) + .or_else(|| { + key.strip_prefix("0b") + .or_else(|| key.strip_prefix("0B")) + .map(|s| (s, 2)) + }) + { + if digits.is_empty() { + return Err(()); + } + let bits_per_digit = match radix { + 16 => 4, + 8 => 3, + _ => 1, + }; + let mut significant_bits = 0usize; + let mut mantissa = 0u64; + let mut guard = false; + let mut sticky = false; + for digit in digits.chars() { + let digit = digit.to_digit(radix).ok_or(())?; + for shift in (0..bits_per_digit).rev() { + let bit = (digit >> shift) & 1; + if significant_bits == 0 && bit == 0 { + continue; + } + if significant_bits < 53 { + mantissa = (mantissa << 1) | u64::from(bit); + } else if significant_bits == 53 { + guard = bit != 0; + } else { + sticky |= bit != 0; + } + significant_bits += 1; + } + } + if guard && (sticky || mantissa & 1 != 0) { + mantissa += 1; + } + let exponent = significant_bits.saturating_sub(53); + if exponent > 1023 { + f64::INFINITY + } else { + mantissa as f64 * 2.0f64.powi(exponent as i32) + } + } else { + key.parse::().map_err(|_| ())? + }; + if !number.is_finite() { + return Err(()); + } + let key = if number == 0.0 { + "0".to_string() + } else if number.abs() >= 1e21 || number.abs() < 1e-6 { + let scientific = format!("{number:e}"); + let (mantissa, exponent) = scientific.split_once('e').ok_or(())?; + let exponent = exponent.parse::().map_err(|_| ())?; + format!("{mantissa}e{exponent:+}") + } else { + number.to_string() + }; + parsed.insert(key, value.clone()); + } + Ok(Value::Object(parsed)) + })); + } + ("openai", "logprobs") => value.is_boolean() || value.is_number(), + ("openai", "maxCompletionTokens") => value.is_number(), + ("openai", "metadata") => value.as_object().is_some_and(|object| { + object.iter().all(|(key, value)| { + key.encode_utf16().count() <= 64 + && value + .as_str() + .is_some_and(|s| s.encode_utf16().count() <= 512) + }) + }), + ("openai", "prediction") => value.is_object(), + ("openai", "reasoningEffort") => { + one_of(&["none", "minimal", "low", "medium", "high", "xhigh", "max"]) + } + ("openai", "serviceTier") => { + one_of(&["auto", "flex", "priority", "fast", "ultrafast", "default"]) + } + ("openai", "textVerbosity") => one_of(&["low", "medium", "high"]), + ("openai", "promptCacheRetention") => one_of(&["in_memory", "24h"]), + ("openai", "systemMessageMode") => one_of(&["system", "developer", "remove"]), + ("openai", "user" | "promptCacheKey" | "safetyIdentifier") | ("groq", "user") => { + value.is_string() + } + ("openai", "parallelToolCalls" | "store" | "strictJsonSchema" | "forceReasoning") + | ("groq", "parallelToolCalls" | "structuredOutputs" | "strictJsonSchema") + | ("deepseek", "logprobs" | "strictJsonSchema") => value.is_boolean(), + ("groq", "reasoningFormat") => one_of(&["parsed", "raw", "hidden"]), + ("groq", "reasoningEffort") => one_of(&["none", "default", "low", "medium", "high"]), + ("groq", "serviceTier") => one_of(&["on_demand", "performance", "flex", "auto"]), + ("deepseek", "reasoningEffort") => one_of(&["low", "medium", "high", "xhigh", "max"]), + ("deepseek", "topLogprobs") => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && (0.0..=20.0).contains(&n)), + ("deepseek", "userId") => value.as_str().is_some_and(|s| { + !s.is_empty() + && s.len() <= 512 + && s.bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-')) + }), + ("openai", "promptCacheOptions") | ("deepseek", "thinking") => { + return Some(value.as_object().ok_or(()).and_then(|object| { + parse_option_fields(object, provider, |key, value| { + let valid = match (provider, key) { + ("openai", "mode") => value + .as_str() + .is_some_and(|s| matches!(s, "implicit" | "explicit")), + ("openai", "ttl") => value.as_str() == Some("30m"), + ("deepseek", "type") => value + .as_str() + .is_some_and(|s| matches!(s, "adaptive" | "enabled" | "disabled")), + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + }) + .map(Value::Object) + .map_err(|_| ()) + })); + } + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + })?; + Ok(Some(std::collections::HashMap::from([( + provider.to_string(), + parsed, + )]))) +} + /// Get a value from provider_options.openai.. fn openai_option(options: &Option, key: &str) -> Option { options @@ -939,21 +1133,18 @@ pub fn build_request_body( /// Conversion errors propagate to the caller (fail-fast, issue H2): the old /// behaviour of silently returning `body: null` sent empty requests upstream /// and made conversion failures invisible. -/// Look up a key from the provider-specific options (groq → "groq" then -/// "openai"; otherwise "openai"). +/// Look up a key from the selected vendor namespace. fn provider_option( provider_opts: &Option, provider: &str, key: &str, ) -> Option { - if provider == "groq" - && let Some(v) = provider_opts + if matches!(provider, "groq" | "deepseek") { + return provider_opts .as_ref() - .and_then(|m| m.get("groq")) + .and_then(|m| m.get(provider)) .and_then(|o| o.get(key)) - .cloned() - { - return Some(v); + .cloned(); } openai_option(provider_opts, key) } @@ -1025,8 +1216,7 @@ fn apply_max_tokens( profile: &OpenAICompatProfile, is_reasoning_model: bool, ) { - let max_completion_tokens_opt = provider_option(provider_opts, provider, "maxCompletionTokens") - .and_then(|v| v.as_u64().map(|n| n as u32)); + let max_completion_tokens_opt = provider_option(provider_opts, provider, "maxCompletionTokens"); let use_mct_key = match profile.max_tokens_key { Some("max_tokens") => false, @@ -1198,7 +1388,12 @@ fn apply_response_format( .unwrap_or(true), ) } else { - (true, true) + ( + true, + provider_option(provider_opts, provider, "strictJsonSchema") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + ) }; if schema.is_some() && structured_outputs { @@ -1255,12 +1450,32 @@ fn apply_provider_option_passthrough( set("promptCacheRetention", "prompt_cache_retention"); set("promptCacheOptions", "prompt_cache_options"); set("safetyIdentifier", "safety_identifier"); - // M3 (RFC-0016): logprobs request support. Previously `logprobs` / - // `topLogprobs` were silently dropped by the provider_options whitelist — - // the only option that "quietly did nothing". Pass-through as-is (OpenAI - // expects `logprobs: bool` and `top_logprobs: int`). - set("logprobs", "logprobs"); - set("topLogprobs", "top_logprobs"); + if provider == "deepseek" { + set("userId", "user_id"); + } + if provider != "openai" { + set("topLogprobs", "top_logprobs"); + } + if let Some(logprobs) = provider_option(provider_opts, provider, "logprobs") { + if provider == "openai" { + if logprobs == json!(true) || logprobs.is_number() { + body["logprobs"] = json!(true); + body["top_logprobs"] = if logprobs.is_number() { + logprobs + } else { + json!(0) + }; + } + } else if provider == "deepseek" { + if logprobs == json!(true) || body.get("top_logprobs").is_some() { + body["logprobs"] = json!(true); + } + } else { + body["logprobs"] = logprobs; + } + } else if provider == "deepseek" && body.get("top_logprobs").is_some() { + body["logprobs"] = json!(true); + } // Groq: reasoning_format provider option if provider == "groq" && let Some(val) = provider_option(provider_opts, provider, "reasoningFormat") @@ -1404,7 +1619,8 @@ pub fn build_request_body_with_warnings( ) -> Result { let mut warnings: Vec = Vec::new(); let caps = get_model_capabilities(model_id); - let provider_opts = &options.provider_options; + let parsed_provider_opts = parse_chat_provider_options(&options.provider_options, provider)?; + let provider_opts = &parsed_provider_opts; let resolved_reasoning_effort = resolve_reasoning_effort(provider_opts, provider, &options.reasoning); diff --git a/aimux-providers/src/xai/responses/convert.rs b/aimux-providers/src/xai/responses/convert.rs index 76432d8ab..6efaf41a2 100644 --- a/aimux-providers/src/xai/responses/convert.rs +++ b/aimux-providers/src/xai/responses/convert.rs @@ -585,7 +585,61 @@ pub fn build_responses_request_body( stream: bool, ) -> Result { let mut warnings: Vec = Vec::new(); - let xai_opts = &options.provider_options; + let parsed_options = options + .provider_options + .as_ref() + .map(|namespaces| { + let mut namespaces = namespaces.clone(); + if let Some(raw) = namespaces.get("xai") { + let parsed = + crate::openai::convert::parse_option_fields(raw, "xai", |key, value| { + let valid = match key { + "reasoningEffort" => value.as_str().is_some_and(|s| { + matches!(s, "none" | "low" | "medium" | "high" | "xhigh") + }), + "reasoningSummary" => value + .as_str() + .is_some_and(|s| matches!(s, "auto" | "concise" | "detailed")), + "logprobs" | "parallelToolCalls" | "store" => value.is_boolean(), + "topLogprobs" => value + .as_f64() + .is_some_and(|n| n.fract() == 0.0 && (0.0..=8.0).contains(&n)), + "minP" => value.as_f64().is_some_and(|n| (0.0..=1.0).contains(&n)), + "maxTurns" => value.as_f64().is_some_and(|n| { + n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_991.0 + }), + "promptCacheKey" | "safetyIdentifier" | "previousResponseId" + | "user" => value.is_string(), + "serviceTier" => value + .as_str() + .is_some_and(|s| matches!(s, "default" | "priority")), + "include" => { + value.is_null() + || value.as_array().is_some_and(|items| { + items.iter().all(|item| { + item.as_str().is_some_and(|s| { + matches!( + s, + "file_search_call.results" + | "web_search_call.action.sources" + | "code_interpreter_call.outputs" + | "reasoning.encrypted_content" + | "no_inline_citations" + ) + }) + }) + }) + } + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + })?; + namespaces.insert("xai".to_string(), parsed); + } + Ok::<_, AiMuxError>(namespaces) + }) + .transpose()?; + let xai_opts = &parsed_options; if options.stop_sequences.is_some() { warnings.push(Warning::Unsupported { diff --git a/aimux-providers/tests/bedrock_convert_test.rs b/aimux-providers/tests/bedrock_convert_test.rs new file mode 100644 index 000000000..858863108 --- /dev/null +++ b/aimux-providers/tests/bedrock_convert_test.rs @@ -0,0 +1,1060 @@ +//! Pure-function tests for the Amazon Bedrock provider's conversion layer. +//! +//! Translated from the TS test files: +//! - `convert-to-amazon-bedrock-chat-messages.test.ts` (64 cases) +//! - `amazon-bedrock-prepare-tools.test.ts` (23 cases incl. `it.each`) +//! - `convert-amazon-bedrock-usage.test.ts` (9 cases) +//! +//! Cases the Rust data model cannot express are skipped with an inline +//! comment. The main categories of skips: +//! - **Message-level `providerOptions`**: `LanguageModelPromptMessage` has no +//! `provider_options` field, so message-level cache points (system / user / +//! assistant message `bedrock.cachePoint`) cannot be expressed. Part-level +//! cache points (on `ContentPart::Text` `provider_options`) ARE covered. +//! - **System-after-non-system throw / unsupported-mime throws**: the Rust +//! `convert_prompt_to_bedrock` returns `(system, messages)` (no `Result`), +//! so it cannot surface `UnsupportedFunctionalityError`. +//! - **S3 URLs / provider references**: `FileUrl` / `FileReference` are not +//! converted by the Rust Bedrock path. +//! - **Top-level-only mediaType auto-detection from bytes**: the Rust path +//! does not sniff magic bytes. +//! - **Mistral tool-call-id normalization (`isMistral`)**: the Rust +//! `convert_prompt_to_bedrock` has no `isMistral` parameter. The +//! non-Mistral (passthrough) cases ARE covered. +//! - **Redacted reasoning / foreign-provider reasoning**: `ContentPart::Reasoning` +//! carries only `signature` (no `redactedData`, no provider distinction). +//! - **Provider-defined tools (web_search, anthropic provider tools)** and +//! `additionalTools`/`betas`: the Rust `FunctionTool` has no `type`/`id`. +//! - **`raw` echo on `Usage`**: the Rust `Usage` type has no `raw` field. + +use serde_json::{Value, json}; + +use aimux_core::shared::{SharedProviderOptions, provider_namespace}; + +use aimux_core::content::ContentPart; +use aimux_core::language_model_message::LanguageModelPromptMessage; +use aimux_core::message::Role; + +use aimux_providers::bedrock::convert::{BedrockUsage, convert_prompt_to_bedrock, convert_usage}; + +// ── prompt builders ───────────────────────────────────────────────────────── + +fn msg(role: Role, content: Vec) -> LanguageModelPromptMessage { + LanguageModelPromptMessage { + role, + content, + provider_options: None, + } +} + +fn user(content: Vec) -> LanguageModelPromptMessage { + msg(Role::User, content) +} + +fn assistant(content: Vec) -> LanguageModelPromptMessage { + msg(Role::Assistant, content) +} + +fn system_msg(text: &str) -> LanguageModelPromptMessage { + msg(Role::System, vec![ContentPart::text(text)]) +} + +fn tool_msg(content: Vec) -> LanguageModelPromptMessage { + msg(Role::Tool, content) +} + +/// A `file` part whose inline data is an already-base64 string — mirrors the +/// TS `file` part with `data: { type: 'data', data: '' }` (the Rust +/// `ContentPart::FileBase64` holds the base64 string verbatim). +fn file_base64( + data: &str, + media_type: &str, + filename: Option<&str>, + provider_options: Option, +) -> ContentPart { + ContentPart::FileBase64 { + data: data.to_string(), + media_type: media_type.to_string(), + filename: filename.map(std::string::ToString::to_string), + provider_options, + } +} + +fn text_with_cache(text: &str, cache_type: &str, ttl: Option<&str>) -> ContentPart { + let mut cp = serde_json::Map::new(); + cp.insert("type".to_string(), json!(cache_type)); + if let Some(t) = ttl { + cp.insert("ttl".to_string(), json!(t)); + } + ContentPart::Text { + text: text.to_string(), + provider_options: Some( + provider_namespace("bedrock", json!({ "cachePoint": Value::Object(cp) })).unwrap(), + ), + } +} + +fn reasoning(text: &str, signature: Option<&str>) -> ContentPart { + ContentPart::Reasoning { + text: text.to_string(), + signature: signature.map(std::string::ToString::to_string), + provider_options: None, + } +} + +fn tool_call(id: &str, name: &str, input: Value) -> ContentPart { + ContentPart::tool_call(id.to_string(), name.to_string(), input) +} + +fn tool_result(id: &str, output: Value) -> ContentPart { + ContentPart::tool_result(id.to_string(), output) +} + +// ════════════════════════════════════════════════════════════════════════════ +// convert-to-amazon-bedrock-chat-messages +// ════════════════════════════════════════════════════════════════════════════ + +// ── system messages ───────────────────────────────────────────────────────── + +/// TS: "should combine multiple leading system messages into a single system message" +#[test] +fn system_combine_multiple_leading() { + let (system, _) = convert_prompt_to_bedrock(&vec![system_msg("Hello"), system_msg("World")]); + assert_eq!( + Value::Array(system), + json!([{ "text": "Hello" }, { "text": "World" }]) + ); +} + +// SKIPPED (TS: "should throw an error if a system message is provided after a +// non-system message"): convert_prompt_to_bedrock returns (system, messages) +// with no Result, so it cannot surface UnsupportedFunctionalityError. + +// SKIPPED (TS: system message cache point, 5m, 1h — 3 cases): message-level +// providerOptions are not modelled on LanguageModelPromptMessage. + +/// TS: "should extract the system message" +#[test] +fn system_extract_single() { + let (system, _) = convert_prompt_to_bedrock(&vec![system_msg("Hello")]); + assert_eq!(Value::Array(system), json!([{ "text": "Hello" }])); +} + +// ── user messages ─────────────────────────────────────────────────────────── + +/// TS: "should convert messages with image parts" +#[test] +fn user_convert_image_parts() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ + ContentPart::text("Hello"), + file_base64("AAECAw==", "image/png", None, None), + ])]); + assert_eq!( + Value::Array(messages), + json!([{ + "role": "user", + "content": [ + { "text": "Hello" }, + { "image": { "format": "png", "source": { "bytes": "AAECAw==" } } }, + ] + }]) + ); +} + +// SKIPPED (TS: "should convert image parts with S3 URLs"): FileUrl is not +// converted by the Rust Bedrock path. + +/// TS: "should convert messages with document parts" +#[test] +fn user_convert_document_parts() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ + ContentPart::text("Hello"), + file_base64("AAECAw==", "application/pdf", None, None), + ])]); + assert_eq!( + messages[0]["content"], + json!([ + { "text": "Hello" }, + { "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" } } }, + ]) + ); +} + +/// TS: "should strip file extension when filename is provided" +#[test] +fn user_strip_file_extension() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ + ContentPart::text("Hello"), + file_base64( + "AAECAw==", + "application/pdf", + Some("custom-filename.pdf"), + None, + ), + ])]); + assert_eq!( + messages[0]["content"][1], + json!({ "document": { "format": "pdf", "name": "custom-filename", "source": { "bytes": "AAECAw==" } } }) + ); +} + +/// TS: "should preserve filename without extension when provided" +#[test] +fn user_preserve_filename_without_extension() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( + "AAECAw==", + "application/pdf", + Some("custom-filename"), + None, + )])]); + assert_eq!( + messages[0]["content"][0], + json!({ "document": { "format": "pdf", "name": "custom-filename", "source": { "bytes": "AAECAw==" } } }) + ); +} + +/// TS: "should use consistent document names for prompt cache effectiveness" +#[test] +fn user_consistent_document_names() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ + file_base64("AAECAw==", "application/pdf", None, None), + file_base64("BAUGBw==", "application/pdf", None, None), + ]), + assistant(vec![ContentPart::text("OK")]), + user(vec![file_base64("AAECAw==", "application/pdf", None, None)]), + ]); + assert_eq!( + Value::Array(messages), + json!([ + { "role": "user", "content": [ + { "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" } } }, + { "document": { "format": "pdf", "name": "document-2", "source": { "bytes": "BAUGBw==" } } }, + ]}, + { "role": "assistant", "content": [{ "text": "OK" }] }, + { "role": "user", "content": [ + { "document": { "format": "pdf", "name": "document-3", "source": { "bytes": "AAECAw==" } } }, + ]}, + ]) + ); +} + +// SKIPPED (TS: user message cache point, 5m, 1h — 3 cases): message-level +// providerOptions are not modelled on LanguageModelPromptMessage. + +// SKIPPED (TS: "should throw for file parts with provider references"): +// FileReference is not converted (no Result to surface the throw). + +/// TS: "should add cache point to user content part when specified" +#[test] +fn user_content_part_cache_point() { + let (system, messages) = convert_prompt_to_bedrock(&vec![user(vec![ + ContentPart::text("Hello"), + text_with_cache("cached", "default", Some("5m")), + ContentPart::text("World"), + ])]); + assert!(system.is_empty()); + assert_eq!( + Value::Array(messages), + json!([{ + "role": "user", + "content": [ + { "text": "Hello" }, + { "text": "cached" }, + { "cachePoint": { "type": "default", "ttl": "5m" } }, + { "text": "World" }, + ] + }]) + ); +} + +// ── assistant messages ────────────────────────────────────────────────────── + +/// TS: "should remove trailing whitespace from last assistant message when there is no further user message" +#[test] +fn assistant_trim_trailing_whitespace_last() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("user content")]), + assistant(vec![ContentPart::text("assistant content ")]), + ]); + assert_eq!( + Value::Array(messages), + json!([ + { "role": "user", "content": [{ "text": "user content" }] }, + { "role": "assistant", "content": [{ "text": "assistant content" }] }, + ]) + ); +} + +/// TS: "should remove trailing whitespace from last assistant message with multi-part content when there is no further user message" +#[test] +fn assistant_trim_trailing_whitespace_multi_part() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("user content")]), + assistant(vec![ + ContentPart::text("assistant "), + ContentPart::text("content "), + ]), + ]); + assert_eq!( + messages[1]["content"], + json!([{ "text": "assistant " }, { "text": "content" }]) + ); +} + +/// TS: "should keep trailing whitespace from assistant message when there is a further user message" +#[test] +fn assistant_keep_trailing_whitespace_with_further_user() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("user content")]), + assistant(vec![ContentPart::text("assistant content ")]), + user(vec![ContentPart::text("user content 2")]), + ]); + assert_eq!( + messages[1]["content"], + json!([{ "text": "assistant content " }]) + ); +} + +/// TS: "should combine multiple sequential assistant messages into a single message" +#[test] +fn assistant_combine_sequential() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("Hi!")]), + assistant(vec![ContentPart::text("Hello")]), + assistant(vec![ContentPart::text("World")]), + assistant(vec![ContentPart::text("!")]), + ]); + assert_eq!( + Value::Array(messages), + json!([ + { "role": "user", "content": [{ "text": "Hi!" }] }, + { "role": "assistant", "content": [{ "text": "Hello" }, { "text": "World" }, { "text": "!" }] }, + ]) + ); +} + +// SKIPPED (TS: assistant message cache point, 5m, 1h — 3 cases): message-level +// providerOptions are not modelled on LanguageModelPromptMessage. + +/// TS: "should add cache point to assistant content part when specified" +#[test] +fn assistant_content_part_cache_point() { + let (_, messages) = convert_prompt_to_bedrock(&vec![assistant(vec![ + ContentPart::text("Hello"), + text_with_cache("cached", "default", Some("1h")), + ContentPart::text("World"), + ])]); + assert_eq!( + Value::Array(messages), + json!([{ + "role": "assistant", + "content": [ + { "text": "Hello" }, + { "text": "cached" }, + { "cachePoint": { "type": "default", "ttl": "1h" } }, + { "text": "World" }, + ] + }]) + ); +} + +/// TS: "should properly convert reasoning content type" +#[test] +fn assistant_reasoning_with_signature() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("Explain your reasoning")]), + assistant(vec![reasoning( + "This is my step-by-step reasoning process", + Some("test-signature"), + )]), + ]); + assert_eq!( + messages[1]["content"], + json!([{ + "reasoningContent": { + "reasoningText": { + "text": "This is my step-by-step reasoning process", + "signature": "test-signature" + } + } + }]) + ); +} + +// SKIPPED (TS: "should properly convert redacted-reasoning content type"): +// ContentPart::Reasoning has no redactedData field. + +// SKIPPED (TS: "should omit assistant message reasoning parts signed by a +// foreign provider"): ContentPart::Reasoning.signature carries no provider +// distinction, so foreign-provider (anthropic) signatures cannot be detected. + +/// TS: "should preserve assistant message reasoning parts with amazonBedrock providerOptions" +#[test] +fn assistant_preserve_amazon_bedrock_reasoning() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("Explain your reasoning")]), + assistant(vec![ + reasoning( + "Bedrock-signed reasoning round-tripped to Bedrock", + Some("bedrock-signature"), + ), + ContentPart::text("final answer"), + ]), + ]); + assert_eq!( + messages[1]["content"], + json!([ + { "reasoningContent": { "reasoningText": { + "text": "Bedrock-signed reasoning round-tripped to Bedrock", + "signature": "bedrock-signature" + }}}, + { "text": "final answer" }, + ]) + ); +} + +/// TS: "should not trim reasoning text when a signature is present" +#[test] +fn assistant_no_trim_reasoning_with_signature() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("Explain your reasoning")]), + assistant(vec![reasoning( + "This is my reasoning with trailing space ", + Some("test-signature"), + )]), + ]); + assert_eq!( + messages[1]["content"][0]["reasoningContent"]["reasoningText"]["text"], + json!("This is my reasoning with trailing space ") + ); +} + +/// TS: "should omit reasoning content without signature" +#[test] +fn assistant_omit_reasoning_without_signature() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("Explain your reasoning")]), + assistant(vec![ + reasoning("This is my reasoning with trailing space ", None), + ContentPart::text("final answer"), + ]), + ]); + assert_eq!(messages[1]["content"], json!([{ "text": "final answer" }])); +} + +/// TS: "should omit multiple reasoning parts without signatures" +#[test] +fn assistant_omit_multiple_reasoning_without_signatures() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("Explain your reasoning")]), + assistant(vec![ + reasoning("First reasoning with trailing space ", None), + reasoning("Second reasoning with trailing space ", None), + ContentPart::text("final answer"), + ]), + ]); + assert_eq!(messages[1]["content"], json!([{ "text": "final answer" }])); +} + +/// TS: "should omit unsigned reasoning while preserving tool calls in multi-turn tool use" +#[test] +fn assistant_omit_unsigned_reasoning_preserving_tool_calls() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("What is the weather?")]), + assistant(vec![ + reasoning("I should call the weather tool.", None), + tool_call("call-1", "getWeather", json!({ "city": "SF" })), + ]), + tool_msg(vec![tool_result( + "call-1", + json!({ "type": "text", "value": "Sunny, 72F" }), + )]), + ]); + assert_eq!( + Value::Array(messages), + json!([ + { "role": "user", "content": [{ "text": "What is the weather?" }] }, + { "role": "assistant", "content": [{ + "toolUse": { "input": { "city": "SF" }, "name": "getWeather", "toolUseId": "call-1" } + }]}, + { "role": "user", "content": [{ + "toolResult": { "toolUseId": "call-1", "content": [{ "text": "Sunny, 72F" }] } + }]}, + ]) + ); +} + +/// TS: "should preserve reasoning text with signature in multi-turn tool use" +#[test] +fn assistant_preserve_reasoning_multi_turn() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("What is the weather?")]), + assistant(vec![ + reasoning("Let me check the weather API.\n", Some("sig-abc123")), + tool_call("call-1", "getWeather", json!({ "city": "SF" })), + ]), + tool_msg(vec![tool_result( + "call-1", + json!({ "type": "text", "value": "Sunny, 72F" }), + )]), + assistant(vec![ + reasoning("The weather is sunny and warm.\n", Some("sig-def456")), + ContentPart::text("It is sunny and 72F in SF."), + ]), + ]); + assert_eq!( + Value::Array(messages), + json!([ + { "role": "user", "content": [{ "text": "What is the weather?" }] }, + { "role": "assistant", "content": [ + { "reasoningContent": { "reasoningText": { + "text": "Let me check the weather API.\n", "signature": "sig-abc123" + }}}, + { "toolUse": { "input": { "city": "SF" }, "name": "getWeather", "toolUseId": "call-1" } }, + ]}, + { "role": "user", "content": [{ + "toolResult": { "toolUseId": "call-1", "content": [{ "text": "Sunny, 72F" }] } + }]}, + { "role": "assistant", "content": [ + { "reasoningContent": { "reasoningText": { + "text": "The weather is sunny and warm.\n", "signature": "sig-def456" + }}}, + { "text": "It is sunny and 72F in SF." }, + ]}, + ]) + ); +} + +/// TS: "should handle a mix of text and reasoning content types" +#[test] +fn assistant_mix_text_and_reasoning() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("Explain your reasoning")]), + assistant(vec![ + ContentPart::text("My answer is 42."), + reasoning( + "I calculated this by analyzing the meaning of life", + Some("reasoning-process"), + ), + ]), + ]); + assert_eq!( + messages[1]["content"], + json!([ + { "text": "My answer is 42." }, + { "reasoningContent": { "reasoningText": { + "text": "I calculated this by analyzing the meaning of life", + "signature": "reasoning-process" + }}}, + ]) + ); +} + +/// TS: "should filter out empty text blocks in assistant messages" +#[test] +fn assistant_filter_empty_text_blocks() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("Hello")]), + assistant(vec![ + ContentPart::text("\n\n"), + tool_call("call-123", "test", json!({})), + ContentPart::text(" "), + ContentPart::text("actual content"), + ]), + ]); + assert_eq!( + messages[1]["content"], + json!([ + { "toolUse": { "toolUseId": "call-123", "name": "test", "input": {} } }, + { "text": "actual content" }, + ]) + ); +} + +/// TS: "should wrap non-object (invalid) tool call input in an object" +#[test] +fn assistant_wrap_non_object_tool_input() { + let (_, messages) = convert_prompt_to_bedrock(&vec![assistant(vec![tool_call( + "call-1", + "cityAttractions", + Value::String("{ \"city\": \"San Francisco\", }".to_string()), + )])]); + assert_eq!( + messages[0]["content"], + json!([{ + "toolUse": { + "toolUseId": "call-1", + "name": "cityAttractions", + "input": { "rawInvalidInput": "{ \"city\": \"San Francisco\", }" } + } + }]) + ); +} + +/// TS: "should strip invalid characters from tool call names" +#[test] +fn assistant_strip_invalid_tool_name_chars() { + let (_, messages) = convert_prompt_to_bedrock(&vec![assistant(vec![ + tool_call("call-1", "$READFILE", json!({})), + tool_call( + "call-2", + "exchange_delivered_order_items<|channel|>", + json!({}), + ), + tool_call("call-3", "$", json!({})), + ])]); + assert_eq!( + messages[0]["content"], + json!([ + { "toolUse": { "toolUseId": "call-1", "name": "READFILE", "input": {} } }, + { "toolUse": { "toolUseId": "call-2", "name": "exchange_delivered_order_itemschannel", "input": {} } }, + { "toolUse": { "toolUseId": "call-3", "name": "_", "input": {} } }, + ]) + ); +} + +/// TS: "should preserve empty text blocks when reasoning blocks are present" +#[test] +fn assistant_preserve_empty_text_with_reasoning() { + let (_, messages) = convert_prompt_to_bedrock(&vec![ + user(vec![ContentPart::text("Hello")]), + assistant(vec![ + reasoning("thinking...", Some("sig-1")), + ContentPart::text(""), + reasoning("more thinking...", Some("sig-2")), + ContentPart::text("response text"), + tool_call("call-123", "test", json!({})), + ]), + ]); + assert_eq!( + messages[1]["content"], + json!([ + { "reasoningContent": { "reasoningText": { "text": "thinking...", "signature": "sig-1" } } }, + { "text": "" }, + { "reasoningContent": { "reasoningText": { "text": "more thinking...", "signature": "sig-2" } } }, + { "text": "response text" }, + { "toolUse": { "toolUseId": "call-123", "name": "test", "input": {} } }, + ]) + ); +} + +// ── tool messages ─────────────────────────────────────────────────────────── + +/// TS: "should convert tool result with content array containing text" +#[test] +fn tool_result_content_text() { + let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( + "call-123", + json!({ "type": "content", "value": [{ "type": "text", "text": "The result is 42" }] }), + )])]); + assert_eq!( + messages[0], + json!({ + "role": "user", + "content": [{ + "toolResult": { + "toolUseId": "call-123", + "content": [{ "text": "The result is 42" }] + } + }] + }) + ); +} + +/// TS: "should convert tool result with content array containing image" +#[test] +fn tool_result_content_image() { + let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( + "call-123", + json!({ "type": "content", "value": [{ + "type": "file", + "data": { "type": "data", "data": "base64data" }, + "mediaType": "image/jpeg" + }] }), + )])]); + assert_eq!( + messages[0]["content"][0], + json!({ + "toolResult": { + "toolUseId": "call-123", + "content": [{ "image": { "format": "jpeg", "source": { "bytes": "base64data" } } }] + } + }) + ); +} + +// SKIPPED (TS: "should convert tool result images with S3 URLs"): FileUrl is +// not converted by the Rust Bedrock path. + +/// TS: "should convert tool result with content array containing PDF" +#[test] +fn tool_result_content_pdf() { + let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( + "call-123", + json!({ "type": "content", "value": [{ + "type": "file", + "data": { "type": "data", "data": "base64data" }, + "mediaType": "application/pdf", + "filename": "tool-result.pdf" + }] }), + )])]); + assert_eq!( + messages[0]["content"][0], + json!({ + "toolResult": { + "toolUseId": "call-123", + "content": [{ + "document": { "format": "pdf", "name": "tool-result", "source": { "bytes": "base64data" } } + }] + } + }) + ); +} + +// SKIPPED (TS: "should throw error for unsupported image format in tool result +// content" and "should throw error for unsupported mime type in tool result +// file content"): convert_prompt_to_bedrock returns no Result; unknown mimes +// fall back to a default format instead of throwing. + +/// TS: "should fallback to stringified result when content is undefined" (json output) +#[test] +fn tool_result_json_output() { + let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( + "call-123", + json!({ "type": "json", "value": { "value": 42 } }), + )])]); + assert_eq!( + messages[0]["content"][0], + json!({ + "toolResult": { + "toolUseId": "call-123", + "content": [{ "text": "{\"value\":42}" }] + } + }) + ); +} + +// ── citations ─────────────────────────────────────────────────────────────── + +/// TS: "should handle citations enabled for PDF" +#[test] +fn citations_enabled_for_pdf() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( + "AAECAw==", + "application/pdf", + None, + Some(provider_namespace("bedrock", json!({ "citations": { "enabled": true } })).unwrap()), + )])]); + assert_eq!( + messages[0]["content"][0], + json!({ + "document": { + "format": "pdf", + "name": "document-1", + "source": { "bytes": "AAECAw==" }, + "citations": { "enabled": true } + } + }) + ); +} + +/// TS: "should handle citations disabled for PDF" +#[test] +fn citations_disabled_for_pdf() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( + "AAECAw==", + "application/pdf", + None, + Some(provider_namespace("bedrock", json!({ "citations": { "enabled": false } })).unwrap()), + )])]); + assert_eq!( + messages[0]["content"][0], + json!({ + "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" } } + }) + ); + assert!( + messages[0]["content"][0]["document"] + .get("citations") + .is_none() + ); +} + +/// TS: "should handle no citations specified for PDF (default)" +#[test] +fn citations_default_for_pdf() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( + "AAECAw==", + "application/pdf", + None, + None, + )])]); + assert_eq!( + messages[0]["content"][0], + json!({ + "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" } } + }) + ); + assert!( + messages[0]["content"][0]["document"] + .get("citations") + .is_none() + ); +} + +/// TS: "should handle multiple PDFs with different citation settings" +#[test] +fn citations_multiple_pdfs() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ + file_base64( + "AAECAw==", + "application/pdf", + None, + Some( + provider_namespace("bedrock", json!({ "citations": { "enabled": true } })).unwrap(), + ), + ), + file_base64( + "BAUGBw==", + "application/pdf", + None, + Some( + provider_namespace("bedrock", json!({ "citations": { "enabled": false } })) + .unwrap(), + ), + ), + ])]); + assert_eq!( + messages[0]["content"], + json!([ + { "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" }, "citations": { "enabled": true } } }, + { "document": { "format": "pdf", "name": "document-2", "source": { "bytes": "BAUGBw==" } } }, + ]) + ); +} + +// ── additional file format tests ──────────────────────────────────────────── + +// SKIPPED (TS: "should throw an error for unsupported file mime type in user +// message content"): unknown mimes fall back to a default format (no throw). + +/// TS: "should handle xlsx files correctly" +#[test] +fn file_format_xlsx() { + let (system, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( + "base64data", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + None, + None, + )])]); + assert!(system.is_empty()); + assert_eq!( + messages[0]["content"][0], + json!({ "document": { "format": "xlsx", "name": "document-1", "source": { "bytes": "base64data" } } }) + ); +} + +/// TS: "should handle docx files correctly" +#[test] +fn file_format_docx() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( + "base64data", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + None, + None, + )])]); + assert_eq!( + messages[0]["content"][0], + json!({ "document": { "format": "docx", "name": "document-1", "source": { "bytes": "base64data" } } }) + ); +} + +// ── Mistral tool call ID normalization ────────────────────────────────────── + +// SKIPPED (TS: "should normalize tool call IDs in tool results when isMistral +// is true" and "...in tool calls when isMistral is true"): the Rust +// convert_prompt_to_bedrock has no isMistral parameter. + +/// TS: "should not normalize tool call IDs when isMistral is false" +#[test] +fn mistral_no_normalize_when_false() { + let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; + let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( + original_id, + json!({ "type": "text", "value": "The result is 42" }), + )])]); + assert_eq!( + messages[0]["content"][0]["toolResult"]["toolUseId"], + json!(original_id) + ); +} + +/// TS: "should default to not normalizing when isMistral is not provided" +#[test] +fn mistral_default_no_normalize() { + let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; + let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( + original_id, + json!({ "type": "text", "value": "The result is 42" }), + )])]); + assert_eq!( + messages[0]["content"][0]["toolResult"]["toolUseId"], + json!(original_id) + ); +} + +// ── top-level-only mediaType resolution ───────────────────────────────────── + +/// TS: "should pass through a full image mediaType unchanged" +#[test] +fn media_type_pass_through_full_image() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( + "iVBORw0KGgo=", + "image/png", + None, + None, + )])]); + assert_eq!( + Value::Array(messages), + json!([{ + "role": "user", + "content": [{ "image": { "format": "png", "source": { "bytes": "iVBORw0KGgo=" } } }] + }]) + ); +} + +// SKIPPED (TS: "should detect subtype from inline bytes when mediaType is +// top-level-only (image)" and "...(application/pdf)"): the Rust path does not +// sniff magic bytes to resolve a top-level-only mediaType. + +/// TS: "should route to document slot for non-image top-level type via getTopLevelMediaType" +#[test] +fn media_type_route_to_document_text_plain() { + let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( + "base64data", + "text/plain", + None, + None, + )])]); + assert_eq!( + messages[0]["content"][0], + json!({ "document": { "format": "txt", "name": "document-1", "source": { "bytes": "base64data" } } }) + ); +} + +// SKIPPED (TS: "should throw UnsupportedFunctionalityError for URL data (File +// URL)", "...for unsupported full image mediaType", and "...when top-level-only +// bytes cannot be detected"): FileUrl is not converted; unknown image mimes +// fall back to "png" instead of throwing; no magic-byte detection. + +// ════════════════════════════════════════════════════════════════════════════ +// convert-amazon-bedrock-usage +// ════════════════════════════════════════════════════════════════════════════ +// +// The TS `convertAmazonBedrockUsage` echoes the input as `result.raw`. The Rust +// `Usage` type has no `raw` field, so only the `inputTokens`/`outputTokens` +// breakdown is asserted; the two `raw`-only cases ("should include totalTokens +// in raw when provided" and "should preserve raw usage data") are SKIPPED. + +fn bedrock_usage( + input: u32, + output: u32, + cache_read: Option, + cache_write: Option, +) -> BedrockUsage { + BedrockUsage { + input_tokens: Some(input), + output_tokens: Some(output), + total_tokens: None, + cache_read_input_tokens: cache_read, + cache_write_input_tokens: cache_write, + } +} + +/// TS: "should convert basic usage without cache tokens" +#[test] +fn usage_basic_without_cache() { + let u = convert_usage(Some(&bedrock_usage(100, 50, None, None))); + assert_eq!(u.input_tokens.total, Some(100)); + assert_eq!(u.input_tokens.no_cache, Some(100)); + assert_eq!(u.input_tokens.cache_read, Some(0)); + assert_eq!(u.input_tokens.cache_write, Some(0)); + assert_eq!(u.output_tokens.total, Some(50)); + assert_eq!(u.output_tokens.text, Some(50)); +} + +/// TS: "should convert usage with cache read tokens" +#[test] +fn usage_with_cache_read() { + let u = convert_usage(Some(&bedrock_usage(100, 50, Some(80), None))); + assert_eq!(u.input_tokens.total, Some(180)); + assert_eq!(u.input_tokens.no_cache, Some(100)); + assert_eq!(u.input_tokens.cache_read, Some(80)); + assert_eq!(u.input_tokens.cache_write, Some(0)); + assert_eq!(u.output_tokens.total, Some(50)); + assert_eq!(u.output_tokens.text, Some(50)); +} + +/// TS: "should convert usage with cache write tokens" +#[test] +fn usage_with_cache_write() { + let u = convert_usage(Some(&bedrock_usage(100, 50, None, Some(60)))); + assert_eq!(u.input_tokens.total, Some(160)); + assert_eq!(u.input_tokens.no_cache, Some(100)); + assert_eq!(u.input_tokens.cache_read, Some(0)); + assert_eq!(u.input_tokens.cache_write, Some(60)); + assert_eq!(u.output_tokens.total, Some(50)); + assert_eq!(u.output_tokens.text, Some(50)); +} + +/// TS: "should convert usage with both cache read and write tokens" +#[test] +fn usage_with_both_cache() { + let u = convert_usage(Some(&bedrock_usage(100, 50, Some(80), Some(60)))); + assert_eq!(u.input_tokens.total, Some(240)); + assert_eq!(u.input_tokens.no_cache, Some(100)); + assert_eq!(u.input_tokens.cache_read, Some(80)); + assert_eq!(u.input_tokens.cache_write, Some(60)); + assert_eq!(u.output_tokens.total, Some(50)); + assert_eq!(u.output_tokens.text, Some(50)); +} + +/// TS: "should handle null cache tokens" +#[test] +fn usage_null_cache_tokens() { + let u = convert_usage(Some(&bedrock_usage(100, 50, None, None))); + assert_eq!(u.input_tokens.total, Some(100)); + assert_eq!(u.input_tokens.no_cache, Some(100)); + assert_eq!(u.input_tokens.cache_read, Some(0)); + assert_eq!(u.input_tokens.cache_write, Some(0)); + assert_eq!(u.output_tokens.total, Some(50)); + assert_eq!(u.output_tokens.text, Some(50)); +} + +/// TS: "should handle null usage" +#[test] +fn usage_null() { + let u = convert_usage(None); + assert_eq!(u.input_tokens.total, None); + assert_eq!(u.input_tokens.no_cache, None); + assert_eq!(u.input_tokens.cache_read, None); + assert_eq!(u.input_tokens.cache_write, None); + assert_eq!(u.output_tokens.total, None); + assert_eq!(u.output_tokens.text, None); +} + +/// TS: "should handle undefined usage" +#[test] +fn usage_undefined() { + let u = convert_usage(None); + assert_eq!(u.input_tokens.total, None); + assert_eq!(u.input_tokens.no_cache, None); + assert_eq!(u.input_tokens.cache_read, None); + assert_eq!(u.input_tokens.cache_write, None); + assert_eq!(u.output_tokens.total, None); + assert_eq!(u.output_tokens.text, None); +} + +// SKIPPED (TS: "should include totalTokens in raw when provided" and "should +// preserve raw usage data"): the Rust `Usage` type has no `raw` echo field. diff --git a/aimux-providers/tests/google_files_test.rs b/aimux-providers/tests/google_files_test.rs index 938fe1df6..f8cf2234e 100644 --- a/aimux-providers/tests/google_files_test.rs +++ b/aimux-providers/tests/google_files_test.rs @@ -14,7 +14,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData}; use aimux_core::retry::RetryConfig; -use aimux_core::shared::FileBytes; +use aimux_core::shared::{FileBytes, provider_namespace}; use aimux_providers::{GoogleConfig, GoogleProvider}; // -- helpers ----------------------------------------------------------------- @@ -451,6 +451,34 @@ async fn should_throw_when_upload_request_fails() { // -- provider options tests -------------------------------------------------- +#[tokio::test] +async fn should_accept_valid_provider_options() { + let server = MockServer::start().await; + mount_success_mocks(&server).await; + + let provider = provider(&server); + let files = provider.files(); + + let po = provider_namespace( + "google", + json!({ "displayName": "test", "pollIntervalMs": 5000, "pollTimeoutMs": 60000 }), + ) + .unwrap(); + let opts = UploadFileCallOptions { + data: UploadFileData::Data { + data: FileBytes::Binary(vec![1]), + }, + media_type: "text/plain".to_string(), + filename: None, + provider_options: Some(po), + abort_signal: None, + }; + + let result = files.upload_file(&opts).await.unwrap(); + + assert!(result.provider_reference.contains_key("google")); +} + #[tokio::test] async fn should_work_without_provider_options() { let server = MockServer::start().await; @@ -464,6 +492,30 @@ async fn should_work_without_provider_options() { assert!(result.provider_reference.contains_key("google")); } +#[tokio::test] +async fn should_pass_through_unknown_properties() { + let server = MockServer::start().await; + mount_success_mocks(&server).await; + + let provider = provider(&server); + let files = provider.files(); + + let po = provider_namespace("google", json!({ "customField": "custom-value" })).unwrap(); + let opts = UploadFileCallOptions { + data: UploadFileData::Data { + data: FileBytes::Binary(vec![1]), + }, + media_type: "text/plain".to_string(), + filename: None, + provider_options: Some(po), + abort_signal: None, + }; + + let result = files.upload_file(&opts).await.unwrap(); + + assert!(result.provider_reference.contains_key("google")); +} + // -- response metadata tests ------------------------------------------------- #[tokio::test] From 3ccf7dd19c4e76ac8d41ce09e83d38e09b61d41f Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Tue, 6 Oct 2026 16:47:34 +0800 Subject: [PATCH 6/8] fix(providers): surface option parse errors for Cohere, Google and Mistral; stop unknown keys leaking What: the Cohere, Google and Mistral option readers return errors for null where upstream's schema rejects it (thinking, structuredOutputs, safePrompt) and Cohere accepts a numeric token budget; the converters return Result and the model, Vertex and Azure paths propagate it. OpenAI Responses nested fields and the Google and Vertex image options no longer pass unknown keys into the request body. Why: completes the option-validation alignment started in the previous commit; upstream strips unknown keys and fails on these nulls. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-providers/src/cohere/convert.rs | 52 ++++++++---- aimux-providers/src/cohere/model.rs | 4 +- aimux-providers/src/google/convert.rs | 82 +++++++++++++++---- aimux-providers/src/google/image.rs | 17 ++-- aimux-providers/src/google/model.rs | 4 +- aimux-providers/src/mistral/convert.rs | 44 +++++++--- aimux-providers/src/mistral/model.rs | 4 +- aimux-providers/src/openai/convert.rs | 5 ++ .../src/openai/responses/convert.rs | 9 +- aimux-providers/src/vertex/image.rs | 26 ++---- aimux-providers/src/vertex/model.rs | 4 +- .../tests/mistral_remaining_test.rs | 12 +-- 12 files changed, 183 insertions(+), 80 deletions(-) diff --git a/aimux-providers/src/cohere/convert.rs b/aimux-providers/src/cohere/convert.rs index ebe3172dd..c10ffb8b9 100644 --- a/aimux-providers/src/cohere/convert.rs +++ b/aimux-providers/src/cohere/convert.rs @@ -4,6 +4,7 @@ //! `cohere-prepare-tools.ts`, and `map-cohere-finish-reason.ts`. use aimux_core::content::ContentPart; +use aimux_core::error::AiMuxError; use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; @@ -309,12 +310,14 @@ pub struct RequestBodyResult { /// Mirrors the TS `getArgs` in `cohere-chat-language-model.ts`: assembles the /// model id, messages, documents, sampling settings, response format, tools, /// and the `thinking` config resolved from `reasoning` / provider options. -#[must_use] +/// +/// # Errors +/// Returns a parse error for invalid Cohere provider options. pub fn build_request_body( model_id: &str, options: &CallOptions, stream: bool, -) -> RequestBodyResult { +) -> Result { let converted = convert_prompt_to_cohere(&options.prompt); let mut warnings: Vec = Vec::new(); @@ -380,11 +383,11 @@ pub fn build_request_body( } // Reasoning / thinking. - if let Some(thinking) = resolve_cohere_thinking(options.reasoning, &options.provider_options) { + if let Some(thinking) = resolve_cohere_thinking(options.reasoning, &options.provider_options)? { body["thinking"] = thinking; } - RequestBodyResult { body, warnings } + Ok(RequestBodyResult { body, warnings }) } // ── Reasoning / thinking ──────────────────────────────────────────────────── @@ -414,44 +417,61 @@ fn reasoning_budget_percentage(reasoning: ReasoningEffort) -> Option { /// - `reasoning: None` (not specified) → no `thinking` field. /// - `reasoning: "none"` → `{ type: "disabled" }`. /// - other reasoning levels → `{ type: "enabled", token_budget: }`. -#[must_use] +/// +/// # Errors +/// Returns a parse error for invalid Cohere thinking options. pub fn resolve_cohere_thinking( reasoning: Option, provider_options: &Option, -) -> Option { +) -> Result, AiMuxError> { // Provider options take precedence. if let Some(po) = provider_options && let Some(cohere) = po.get("cohere") && let Some(thinking) = cohere.get("thinking") { + let thinking = thinking.as_object().ok_or_else(|| { + AiMuxError::InvalidArgument("invalid cohere provider options".to_string()) + })?; + let thinking = + crate::openai::convert::parse_option_fields(thinking, "cohere", |key, value| { + let valid = match key { + "type" => value + .as_str() + .is_some_and(|value| matches!(value, "enabled" | "disabled")), + "tokenBudget" => value.is_number(), + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + })?; let t_type = thinking .get("type") .and_then(|v| v.as_str()) .unwrap_or("enabled") .to_string(); let mut obj = json!({ "type": t_type }); - if let Some(budget) = thinking - .get("tokenBudget") - .filter(|value| value.is_number()) - { + if let Some(budget) = thinking.get("tokenBudget") { obj["token_budget"] = budget.clone(); } - return Some(obj); + return Ok(Some(obj)); } - let reasoning = reasoning?; + let Some(reasoning) = reasoning else { + return Ok(None); + }; if reasoning == ReasoningEffort::ProviderDefault { - return None; + return Ok(None); } if reasoning == ReasoningEffort::None { - return Some(json!({ "type": "disabled" })); + return Ok(Some(json!({ "type": "disabled" }))); } - let pct = reasoning_budget_percentage(reasoning)?; + let Some(pct) = reasoning_budget_percentage(reasoning) else { + return Ok(None); + }; let max_tokens = DEFAULT_REASONING_MAX_TOKENS; let raw = (max_tokens as f64 * pct).round() as u32; let budget = max_tokens.min(1024.max(raw)); - Some(json!({ "type": "enabled", "token_budget": budget })) + Ok(Some(json!({ "type": "enabled", "token_budget": budget }))) } /// Parse Cohere finish reason string into `FinishReason`. diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index bc6583a7e..59438d9c8 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -122,7 +122,7 @@ impl LanguageModel for CohereModel { } async fn do_generate(&self, options: &CallOptions) -> Result { - let body_result = build_request_body(&self.model_id, options, false); + let body_result = build_request_body(&self.model_id, options, false)?; let body = body_result.body.clone(); let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( @@ -266,7 +266,7 @@ impl LanguageModel for CohereModel { } async fn do_stream(&self, options: &CallOptions) -> Result { - let body_result = build_request_body(&self.model_id, options, true); + let body_result = build_request_body(&self.model_id, options, true)?; let body = body_result.body.clone(); let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.endpoint(); diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 0d327eae6..9c871ebe0 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -19,6 +19,7 @@ //! provider-executed tool parts. use aimux_core::content::ContentPart; +use aimux_core::error::AiMuxError; use aimux_core::language_model_message::LanguageModelPrompt; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; @@ -1014,6 +1015,33 @@ fn is_empty_object_schema(obj: &Map) -> bool { && !obj.contains_key("additionalProperties") } +// Image wrappers delegate these fields through the upstream language schema. +#[must_use] +pub(crate) fn image_generation_option(key: &str, value: &Value) -> Value { + let fields: &[&str] = match key { + "thinkingConfig" => &["thinkingBudget", "includeThoughts", "thinkingLevel"], + "imageConfig" => &[ + "aspectRatio", + "imageSize", + "personGeneration", + "prominentPeople", + "imageOutputOptions", + ], + "imageOutputOptions" => &["mimeType", "compressionQuality"], + _ => return value.clone(), + }; + let Some(object) = value.as_object() else { + return value.clone(); + }; + Value::Object( + object + .iter() + .filter(|(key, _)| fields.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), image_generation_option(key, value))) + .collect(), + ) +} + // ── build_request_body ─────────────────────────────────────────────────────── /// Build the Gemini `generateContent` request body from `CallOptions`. @@ -1025,31 +1053,40 @@ fn is_empty_object_schema(obj: &Map) -> bool { /// /// This is the request-body-only entry point; warnings about unsupported tools /// are discarded. Use [`build_request_body_with_warnings`] to surface them. -#[must_use] -pub fn build_request_body(model_id: &str, options: &CallOptions) -> Value { - build_request_body_with_warnings(model_id, options).0 +/// +/// # Errors +/// Returns an error when `structuredOutputs` is not a boolean. +pub fn build_request_body(model_id: &str, options: &CallOptions) -> Result { + build_request_body_with_warnings(model_id, options).map(|(body, _)| body) } /// Build a Vertex Gemini request body using Vertex's provider-metadata /// namespaces when replaying response parts. -#[must_use] -pub(crate) fn build_vertex_request_body(model_id: &str, options: &CallOptions) -> Value { +/// +/// # Errors +/// Returns an error when `structuredOutputs` is not a boolean. +pub(crate) fn build_vertex_request_body( + model_id: &str, + options: &CallOptions, +) -> Result { build_request_body_with_warnings_for_namespace( model_id, options, ProviderMetadataNamespace::Vertex, ) - .0 + .map(|(body, _)| body) } /// Build the Gemini `generateContent` request body **and** collect the tool /// warnings (e.g. unsupported provider-defined tools, mixed function+provider /// tools on pre-Gemini-3 models). -#[must_use] +/// +/// # Errors +/// Returns an error when `structuredOutputs` is not a boolean. pub fn build_request_body_with_warnings( model_id: &str, options: &CallOptions, -) -> (Value, Vec) { +) -> Result<(Value, Vec), AiMuxError> { build_request_body_with_warnings_for_namespace( model_id, options, @@ -1061,7 +1098,27 @@ fn build_request_body_with_warnings_for_namespace( model_id: &str, options: &CallOptions, namespace: ProviderMetadataNamespace, -) -> (Value, Vec) { +) -> Result<(Value, Vec), AiMuxError> { + let names: &[&str] = match namespace { + ProviderMetadataNamespace::Google => &["google"], + ProviderMetadataNamespace::Vertex => &["googleVertex", "vertex", "google"], + }; + let provider_options = options.provider_options.as_ref().and_then(|options| { + names + .iter() + .find_map(|name| options.get(*name).map(|value| (*name, value))) + }); + let structured_outputs = + match provider_options.and_then(|(_, options)| options.get("structuredOutputs")) { + None => true, + Some(Value::Bool(value)) => *value, + Some(_) => { + return Err(AiMuxError::InvalidArgument(format!( + "invalid {} provider options", + provider_options.unwrap().0 + ))); + } + }; let GooglePrompt { system_instruction, contents, @@ -1101,10 +1158,7 @@ fn build_request_body_with_warnings_for_namespace( { generation_config.insert("responseMimeType".to_string(), json!("application/json")); if let Some(s) = schema - && read_provider_options(options.provider_options.as_ref(), namespace) - .and_then(|options| options.get("structuredOutputs")) - .and_then(Value::as_bool) - .unwrap_or(true) + && structured_outputs { let openapi = convert_json_schema_to_openapi_schema(s, true); if !openapi.is_null() { @@ -1138,7 +1192,7 @@ fn build_request_body_with_warnings_for_namespace( // the TS SDK. (Some callers include it; the API ignores extra fields.) let _ = model_id; - (Value::Object(body), prepared.warnings) + Ok((Value::Object(body), prepared.warnings)) } // ── finish reason ──────────────────────────────────────────────────────────── diff --git a/aimux-providers/src/google/image.rs b/aimux-providers/src/google/image.rs index 05f203392..098864a03 100644 --- a/aimux-providers/src/google/image.rs +++ b/aimux-providers/src/google/image.rs @@ -283,13 +283,20 @@ impl GoogleImageModel { generation_config.insert("seed".to_string(), json!(seed)); } - // Passthrough provider options (excluding googleSearch) + // Only declared generation-config options survive the upstream language schema. if let Some(google) = options.provider_options.get("google") { - for (key, value) in google { - if key == "googleSearch" || key == "personGeneration" || key == "aspectRatio" { - continue; + for key in [ + "audioTimestamp", + "thinkingConfig", + "mediaResolution", + "imageConfig", + ] { + if let Some(value) = google.get(key) { + generation_config.insert( + key.to_string(), + crate::google::convert::image_generation_option(key, value), + ); } - generation_config.insert(key.clone(), value.clone()); } } diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index 1d77bdbbb..1d976fcf2 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -143,7 +143,7 @@ impl LanguageModel for GoogleModel { )); } 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 (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new( @@ -241,7 +241,7 @@ impl LanguageModel for GoogleModel { )); } 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 (body, tool_warnings) = build_request_body_with_warnings(&self.model_id, options)?; let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.stream_endpoint(); let resp = aimux_provider_utils::post_json_to_api( diff --git a/aimux-providers/src/mistral/convert.rs b/aimux-providers/src/mistral/convert.rs index 3ea6b9f1b..0f6c3a5eb 100644 --- a/aimux-providers/src/mistral/convert.rs +++ b/aimux-providers/src/mistral/convert.rs @@ -4,6 +4,7 @@ //! `mistral-prepare-tools.ts`, and `map-mistral-finish-reason.ts`. use aimux_core::content::ContentPart; +use aimux_core::error::AiMuxError; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; @@ -283,8 +284,35 @@ fn convert_part_to_mistral(part: &ContentPart) -> Value { // ── Request body ──────────────────────────────────────────────────────────── /// Convert `CallOptions` to a Mistral request body. -#[must_use] -pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) -> Value { +/// +/// # Errors +/// Returns a parse error for invalid Mistral provider options. +pub fn build_request_body( + model_id: &str, + options: &CallOptions, + stream: bool, +) -> Result { + let provider_options = options + .provider_options + .as_ref() + .and_then(|namespaces| namespaces.get("mistral")) + .map(|namespace| { + crate::openai::convert::parse_option_fields(namespace, "mistral", |key, value| { + let valid = match key { + "safePrompt" | "structuredOutputs" | "strictJsonSchema" + | "parallelToolCalls" => value.is_boolean(), + "documentImageLimit" | "documentPageLimit" => value.is_number(), + "promptCacheKey" => value.is_string(), + "reasoningEffort" => value + .as_str() + .is_some_and(|value| matches!(value, "high" | "none")), + _ => return None, + }; + Some(if valid { Ok(value.clone()) } else { Err(()) }) + }) + }) + .transpose()? + .unwrap_or_default(); let messages = convert_prompt_to_mistral_messages(&options.prompt); let mut body = json!({ @@ -296,14 +324,8 @@ pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) - body["stream"] = json!(true); } - if let Some(safe_prompt) = options - .provider_options - .as_ref() - .and_then(|namespaces| namespaces.get("mistral")) - .and_then(|options| options.get("safePrompt")) - .and_then(Value::as_bool) - { - body["safe_prompt"] = json!(safe_prompt); + if let Some(safe_prompt) = provider_options.get("safePrompt") { + body["safe_prompt"] = safe_prompt.clone(); } if let Some(max_tokens) = options.max_output_tokens { @@ -377,7 +399,7 @@ pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) - } } - body + Ok(body) } /// Parse Mistral finish reason string into `FinishReason`. diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 598a2e5a1..74e713e32 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -225,7 +225,7 @@ impl LanguageModel for MistralModel { } async fn do_generate(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options, false); + let body = build_request_body(&self.model_id, options, false)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new( @@ -324,7 +324,7 @@ impl LanguageModel for MistralModel { } async fn do_stream(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options, true); + let body = build_request_body(&self.model_id, options, true)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new( diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index a37375e85..363a93f10 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -927,6 +927,11 @@ fn parse_chat_provider_options( options: &Option, provider: &str, ) -> Result, AiMuxError> { + let provider = if provider == "azure" { + "openai" + } else { + provider + }; if !matches!(provider, "openai" | "groq" | "deepseek") { return Ok(options.clone()); } diff --git a/aimux-providers/src/openai/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs index 7f56479ae..bc5768aba 100644 --- a/aimux-providers/src/openai/responses/convert.rs +++ b/aimux-providers/src/openai/responses/convert.rs @@ -770,10 +770,17 @@ fn apply_responses_provider_options( set("user", "user"); set("instructions", "instructions"); set("promptCacheKey", "prompt_cache_key"); - set("promptCacheOptions", "prompt_cache_options"); set("promptCacheRetention", "prompt_cache_retention"); set("safetyIdentifier", "safety_identifier"); set("truncation", "truncation"); + if let Some(Value::Object(options)) = openai_option(provider_opts, "promptCacheOptions") { + body["prompt_cache_options"] = Value::Object( + options + .into_iter() + .filter(|(key, _)| matches!(key.as_str(), "mode" | "ttl")) + .collect(), + ); + } } /// `service_tier` with model-capability validation. diff --git a/aimux-providers/src/vertex/image.rs b/aimux-providers/src/vertex/image.rs index 51e1394b7..f4dc4a1b6 100644 --- a/aimux-providers/src/vertex/image.rs +++ b/aimux-providers/src/vertex/image.rs @@ -119,16 +119,6 @@ impl VertexImageModel { .and_then(|v| v.as_str()); let mask_dilation = edit_opts.and_then(|e| e.get("maskDilation")); - // Build other options (excluding "edit") - let mut other_options = Map::new(); - if let Some(obj) = gv_opts { - for (k, v) in obj { - if k != "edit" { - other_options.insert(k.clone(), v.clone()); - } - } - } - let is_edit_mode = options.files.as_ref().is_some_and(|f| !f.is_empty()); let mut parameters = Map::new(); @@ -178,10 +168,6 @@ impl VertexImageModel { } } - for (k, v) in &other_options { - parameters.insert(k.clone(), v.clone()); - } - let body = if is_edit_mode { json!({ "instances": [{ "prompt": options.prompt, "referenceImages": reference_images }], @@ -315,17 +301,19 @@ impl VertexImageModel { gc.insert("seed".into(), json!(seed)); } - // Passthrough provider options + // Only declared generation-config options survive the upstream language schema. let gv_opts = options .provider_options .get("googleVertex") .or_else(|| options.provider_options.get("vertex")); if let Some(obj) = gv_opts { - for (k, v) in obj { - if matches!(k.as_str(), "responseModalities" | "imageConfig") { - continue; + for key in ["audioTimestamp", "thinkingConfig", "mediaResolution"] { + if let Some(value) = obj.get(key) { + gc.insert( + key.to_string(), + crate::google::convert::image_generation_option(key, value), + ); } - gc.insert(k.clone(), v.clone()); } } diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index febea02ce..c187b8d91 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -181,7 +181,7 @@ impl LanguageModel for VertexModel { )); } let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); - let body = build_vertex_request_body(&self.model_id, options); + let body = build_vertex_request_body(&self.model_id, options)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest { @@ -277,7 +277,7 @@ impl LanguageModel for VertexModel { )); } let code_execution_tool_name = code_execution_tool_name(options.tools.as_deref()); - let body = build_vertex_request_body(&self.model_id, options); + let body = build_vertex_request_body(&self.model_id, options)?; let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.stream_endpoint(); let resp = aimux_provider_utils::post_json_to_api( diff --git a/aimux-providers/tests/mistral_remaining_test.rs b/aimux-providers/tests/mistral_remaining_test.rs index 8f9c4fbf7..b5a66188d 100644 --- a/aimux-providers/tests/mistral_remaining_test.rs +++ b/aimux-providers/tests/mistral_remaining_test.rs @@ -715,14 +715,14 @@ fn parse_finish_reason_unknown() { #[test] fn build_request_body_stream_flag() { let options = default_options(test_prompt()); - let body = build_request_body("mistral-small-latest", &options, true); + let body = build_request_body("mistral-small-latest", &options, true).unwrap(); assert_eq!(body["stream"], json!(true)); } #[test] fn build_request_body_no_stream_flag() { let options = default_options(test_prompt()); - let body = build_request_body("mistral-small-latest", &options, false); + let body = build_request_body("mistral-small-latest", &options, false).unwrap(); assert!(body.get("stream").is_none()); } @@ -736,7 +736,7 @@ fn build_request_body_tool_with_description() { tools: Some(vec![Tool::Function(tool)]), ..default_options(Vec::new()) }; - let body = build_request_body("mistral-small-latest", &options, false); + let body = build_request_body("mistral-small-latest", &options, false).unwrap(); assert_eq!(body["tools"][0]["function"]["name"], json!("weather")); assert_eq!( body["tools"][0]["function"]["description"], @@ -754,7 +754,7 @@ fn build_request_body_tool_with_strict() { tools: Some(vec![Tool::Function(tool)]), ..default_options(Vec::new()) }; - let body = build_request_body("mistral-small-latest", &options, false); + let body = build_request_body("mistral-small-latest", &options, false).unwrap(); assert_eq!(body["tools"][0]["function"]["strict"], json!(true)); } @@ -771,7 +771,7 @@ fn build_request_body_json_schema_with_name_and_description() { }), ..default_options(Vec::new()) }; - let body = build_request_body("mistral-small-latest", &options, false); + let body = build_request_body("mistral-small-latest", &options, false).unwrap(); assert_eq!(body["response_format"]["type"], json!("json_schema")); assert_eq!(body["response_format"]["json_schema"]["schema"], schema); assert_eq!( @@ -800,7 +800,7 @@ fn build_request_body_json_object_when_no_schema() { }), ..default_options(Vec::new()) }; - let body = build_request_body("mistral-small-latest", &options, false); + let body = build_request_body("mistral-small-latest", &options, false).unwrap(); assert_eq!(body["response_format"], json!({ "type": "json_object" })); } From fb2ba7caadb13a6be3969f7cbe4991c12cb497db Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Wed, 7 Oct 2026 20:44:15 +0800 Subject: [PATCH 7/8] fix(jev): retain response metadata as a typed provider object --- aimux-providers/src/jev.rs | 7 ++++++- aimux-providers/tests/jev_test.rs | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/aimux-providers/src/jev.rs b/aimux-providers/src/jev.rs index 51429d14e..8c5e3162e 100644 --- a/aimux-providers/src/jev.rs +++ b/aimux-providers/src/jev.rs @@ -283,7 +283,12 @@ fn convert_response( probability_source: source, usage, latency_ms: None, - provider_metadata: Some(HashMap::from([("jev".into(), raw.clone())])), + provider_metadata: Some(HashMap::from([( + "jev".into(), + raw.as_object().cloned().ok_or_else(|| { + AiMuxError::InvalidResponseData("Jev response must be an object".into()) + })?, + )])), response: Some(DecisionResponse { headers: Some(headers), body: Some(raw), diff --git a/aimux-providers/tests/jev_test.rs b/aimux-providers/tests/jev_test.rs index 5f8feb1df..cebae4ffd 100644 --- a/aimux-providers/tests/jev_test.rs +++ b/aimux-providers/tests/jev_test.rs @@ -206,7 +206,10 @@ async fn official_contract_round_trip_preserves_types_and_raw_metadata() { result.response.as_ref().unwrap().body.as_ref().unwrap(), &fixture() ); - assert_eq!(result.provider_metadata.as_ref().unwrap()["jev"], fixture()); + assert_eq!( + Value::Object(result.provider_metadata.as_ref().unwrap()["jev"].clone()), + fixture() + ); assert_eq!( result.response.as_ref().unwrap().headers.as_ref().unwrap()["x-request-id"], "test-request" From c22dcfada642dde44595202f3e2452b2201ab955 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Wed, 7 Oct 2026 20:51:24 +0800 Subject: [PATCH 8/8] chore(node): regenerate decision provider options and metadata types --- bindings/node/src/types/DecisionCallOptions.ts | 2 +- bindings/node/src/types/DecisionResult.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/node/src/types/DecisionCallOptions.ts b/bindings/node/src/types/DecisionCallOptions.ts index 6a99b0311..6bbb66603 100644 --- a/bindings/node/src/types/DecisionCallOptions.ts +++ b/bindings/node/src/types/DecisionCallOptions.ts @@ -3,4 +3,4 @@ import type { DecisionQuestion } from "./DecisionQuestion"; import type { TimeoutConfiguration } from "./TimeoutConfiguration"; import type { JsonValue } from "./serde_json/JsonValue"; -export type DecisionCallOptions = { state: JsonValue, questions: Array, max_retries?: number | null, timeout?: TimeoutConfiguration | null, provider_options?: { [key in string]: JsonValue } | null, headers?: { [key in string]: string } | null, }; +export type DecisionCallOptions = { state: JsonValue, questions: Array, max_retries?: number | null, timeout?: TimeoutConfiguration | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, headers?: { [key in string]: string } | null, }; diff --git a/bindings/node/src/types/DecisionResult.ts b/bindings/node/src/types/DecisionResult.ts index 11c20a92e..f69ee1607 100644 --- a/bindings/node/src/types/DecisionResult.ts +++ b/bindings/node/src/types/DecisionResult.ts @@ -6,4 +6,4 @@ import type { DecisionRounding } from "./DecisionRounding"; import type { Usage } from "./Usage"; import type { JsonValue } from "./serde_json/JsonValue"; -export type DecisionResult = { rounding: DecisionRounding, answers: { [key in string]: DecisionAnswer }, provider: string, model: string, model_version: string | null, probability_source: DecisionProbabilitySource, usage: Usage | null, latency_ms: number | null, provider_metadata: { [key in string]: JsonValue } | null, response: DecisionResponse | null, }; +export type DecisionResult = { rounding: DecisionRounding, answers: { [key in string]: DecisionAnswer }, provider: string, model: string, model_version: string | null, probability_source: DecisionProbabilitySource, usage: Usage | null, latency_ms: number | null, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: DecisionResponse | null, };