diff --git a/CHANGELOG.md b/CHANGELOG.md index cf029da3c..e994e26a9 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/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-core/src/content.rs b/aimux-core/src/content.rs index 44eb1e478..25681a97c 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,14 +18,14 @@ 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, }, /// Provider-specific custom content. Custom { kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A file generated during reasoning. @@ -31,7 +33,7 @@ pub enum ContentPart { data: crate::shared::GeneratedFileData, media_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] - provider_options: Option, + provider_options: Option, }, /// A request to approve a tool call on a later turn. @@ -53,7 +55,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). @@ -63,7 +65,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. @@ -78,7 +80,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. @@ -88,7 +90,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). @@ -102,7 +104,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. @@ -119,7 +121,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. @@ -136,7 +138,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. @@ -167,7 +169,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 97aec81af..70fd5ad89 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -26,9 +26,10 @@ use crate::result::{ GenerateContent, GenerateResult, GeneratedFile, ReasoningFileOutput, ReasoningPart, Source, StreamResult, StreamTextResultAggregated, TextContent, ToolApprovalRequestOutput, }; +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 @@ -77,7 +78,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. @@ -198,7 +199,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)] @@ -230,7 +231,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, @@ -340,7 +341,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 89e8ec6b2..ab198e193 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 928af2b6a..77e8e64f2 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -122,8 +122,9 @@ pub mod prelude { init_session_store, list_sessions, session_calls, }; pub use crate::shared::{ - AspectRatio, FileBytes, FileData, GeneratedFileData, SharedHeaders, SharedProviderMetadata, - SharedProviderOptions, SharedProviderReference, Size, + AspectRatio, FileBytes, FileData, GeneratedFileData, 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 d7a4010ac..e9a6a302a 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, FunctionToolInputExample, 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 d2f82fa96..f56ff9d9f 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -1195,39 +1195,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( - [("openai".into(), serde_json::json!({ "foo": 1 }))] - .into_iter() - .collect(), - ); - 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(), - ); - 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(), - ); - 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/response_messages.rs b/aimux-core/src/response_messages.rs index 93fd7deea..c3239b6e3 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -15,7 +15,7 @@ use crate::result::{ GenerateContent, GeneratedFile, ReasoningFileOutput, ReasoningOutput, ReasoningPart, Source, TextContent, ToolApprovalRequestOutput, }; -use crate::shared::{FileBytes, GeneratedFileData}; +use crate::shared::{FileBytes, GeneratedFileData, SharedProviderMetadata}; /// 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. @@ -32,10 +32,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) } @@ -59,11 +61,16 @@ impl ResponseMessageBuilder { Self::default() } - pub fn text_start(&mut self, id: String, metadata: Option) { + pub fn text_start(&mut self, id: String, metadata: Option) { self.text_delta(id, "", metadata); } - pub fn text_delta(&mut self, id: String, delta: &str, metadata: Option) { + pub fn text_delta( + &mut self, + id: String, + delta: &str, + metadata: Option, + ) { let index = *self.text_indexes.entry(id).or_insert_with(|| { let index = self.content.len(); self.content.push(GenerateContent::Text { @@ -84,16 +91,21 @@ impl ResponseMessageBuilder { } } - pub fn text_end(&mut self, id: String, metadata: Option) { + pub fn text_end(&mut self, id: String, metadata: Option) { self.text_delta(id.clone(), "", metadata); self.text_indexes.remove(&id); } - pub fn reasoning_start(&mut self, id: String, metadata: Option) { + pub fn reasoning_start(&mut self, id: String, metadata: Option) { self.reasoning_delta(id, "", metadata); } - pub fn reasoning_delta(&mut self, id: String, delta: &str, metadata: Option) { + pub fn reasoning_delta( + &mut self, + id: String, + delta: &str, + metadata: Option, + ) { let index = *self.reasoning_indexes.entry(id).or_insert_with(|| { let index = self.content.len(); self.content @@ -111,12 +123,12 @@ impl ResponseMessageBuilder { } } - pub fn reasoning_end(&mut self, id: String, metadata: Option) { + pub fn reasoning_end(&mut self, id: String, metadata: Option) { self.reasoning_delta(id.clone(), "", metadata); self.reasoning_indexes.remove(&id); } - pub fn text(&mut self, text: &str, metadata: Option<&Value>) { + pub fn text(&mut self, text: &str, metadata: Option<&SharedProviderMetadata>) { self.content.push(GenerateContent::Text { text: text.to_owned(), provider_metadata: metadata.cloned(), @@ -128,7 +140,7 @@ impl ResponseMessageBuilder { .push(GenerateContent::Reasoning(reasoning.clone())); } - pub fn custom(&mut self, kind: String, provider_metadata: Option) { + pub fn custom(&mut self, kind: String, provider_metadata: Option) { self.content.push(GenerateContent::Custom { kind, provider_metadata, diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index 8dd03a299..aad084f05 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -194,7 +194,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 7a6e71637..c1ca198f6 100644 --- a/aimux-core/src/shared.rs +++ b/aimux-core/src/shared.rs @@ -33,12 +33,38 @@ 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" } }`. +/// +/// 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" + ))); + }; + Ok(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 421c26500..2c95fb05c 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::{Map, 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 7d6a94583..0f9e54730 100644 --- a/aimux-core/src/types.rs +++ b/aimux-core/src/types.rs @@ -166,5 +166,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..459086e03 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) } @@ -790,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(SharedProviderMetadata::from([("fal".to_string(), 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!( - merged["fal"], - 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-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index 5f90d829a..503972392 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -2489,7 +2489,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-provider-utils/tests/streaming_tool_call_tracker_test.rs b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs index 263b6c26a..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,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, }, } @@ -1059,9 +1061,9 @@ mod metadata { .map(|sig| json!({ "thoughtSignature": sig })) }) .with_build_provider_metadata(|metadata| { - metadata - .and_then(|m| m.get("thoughtSignature")) - .map(|sig| json!({ "google": { "thoughtSignature": sig } })) + metadata.and_then(|m| m.get("thoughtSignature")).map(|sig| { + provider_namespace("google", json!({ "thoughtSignature": sig })).unwrap() + }) }) } @@ -1083,7 +1085,9 @@ 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" })).unwrap() + ), }) ); } @@ -1094,7 +1098,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()).unwrap()) }), ); @@ -1109,7 +1113,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" } })) + .unwrap() + ), }) ); } 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 cd035d80c..4698795c5 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, ) @@ -745,7 +754,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, @@ -762,18 +771,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 } @@ -1228,7 +1243,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, @@ -1260,7 +1275,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, @@ -1268,7 +1287,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, @@ -1569,8 +1592,225 @@ 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 { +fn anthropic_option(options: &Option, key: &str) -> Option { options .as_ref() .and_then(|m| m.get("anthropic")) @@ -1980,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()); @@ -2098,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); @@ -2223,44 +2477,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 mut provider = std::collections::HashMap::new(); - provider.insert("anthropic".to_string(), 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/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 7b950c223..8d8dee1dc 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, 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,10 @@ 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 })) + .expect("provider metadata must be an object"), + ) } fn is_tool_search_provider_name(name: &str) -> bool { @@ -447,11 +453,15 @@ pub(crate) fn stream_parts_for_result_block( id: generate_source_id(), url: str_field(result, "url").unwrap_or_default(), title: str_field(result, "title"), - provider_metadata: Some(json!({ - "anthropic": { - "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 @@ -519,9 +529,11 @@ 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 }), + ) + .expect("provider metadata must be an object") }), })] } @@ -593,9 +605,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 })) + .expect("provider metadata must be an object"), + ), })); } // Provider-executed (server-side) tool calls are surfaced as tool @@ -626,18 +639,23 @@ pub(crate) fn parse_anthropic_content( input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - 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 }), + ) + .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(json!({ - "anthropic": { "redactedData": data } - })), + provider_metadata: Some( + provider_namespace("anthropic", json!({ "redactedData": data })) + .expect("provider metadata must be an object"), + ), })); } // ── Server-tool result blocks → GenerateContent::ToolResult ── @@ -671,12 +689,16 @@ pub(crate) fn parse_anthropic_content( id: generate_source_id(), url: str_field(result, "url").unwrap_or_default(), title: str_field(result, "title"), - provider_metadata: Some(json!({ - "anthropic": { - "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"), + ), })); } } @@ -797,9 +819,11 @@ 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 }), + ) + .expect("provider metadata must be an object") }), })); } @@ -899,7 +923,7 @@ enum BlockState { dynamic: Option, provider_tool_name: Option, provider_tool_input_type: Option, - provider_metadata: Option, + provider_metadata: Option, first_delta: bool, }, Thinking { @@ -1097,12 +1121,10 @@ pub(crate) async fn anthropic_stream_core( input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - 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, + })).expect("provider metadata must be an object")), })); } // Redacted thinking — emit as ReasoningStart. @@ -1110,9 +1132,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 })).expect("provider metadata must be an object")), }); blocks.insert( index, @@ -1277,7 +1297,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 })).expect("provider metadata must be an object") }), }); } 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 5486d3fee..9590540c3 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")) @@ -528,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/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index ef31433df..a5c77492f 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; @@ -596,11 +599,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 { @@ -626,12 +628,15 @@ 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 })) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("bedrock", json!({ "signature": s })) + .expect("provider metadata must be an object"), + ); + metadata }) } @@ -670,10 +675,13 @@ 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 d34a5fe8c..c10ffb8b9 100644 --- a/aimux-providers/src/cohere/convert.rs +++ b/aimux-providers/src/cohere/convert.rs @@ -4,9 +4,11 @@ //! `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}; +use aimux_core::shared::SharedProviderOptions; use aimux_core::tool::Tool; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde_json::{Value, json}; @@ -308,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(); @@ -379,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 ──────────────────────────────────────────────────── @@ -413,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 { + provider_options: &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") - .and_then(serde_json::Value::as_u64) - { - obj["token_budget"] = json!(budget); + 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 a047a970e..59438d9c8 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}; @@ -121,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( @@ -215,7 +216,7 @@ impl LanguageModel for CohereModel { media_type: "text/plain".to_string(), title, filename: None, - provider_metadata: Some(json!({ "cohere": cohere_meta })), + provider_metadata: Some(HashMap::from([("cohere".to_string(), cohere_meta)])), })); } } @@ -265,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(); @@ -572,7 +573,7 @@ impl LanguageModel for CohereModel { } else { final_usage }, - provider_metadata: Some(serde_json::json!({ "cohere": {} })), + provider_metadata: Some(provider_namespace("cohere", json!({})).expect("provider metadata must be an object")), }); }; 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..a8dcf356d 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 a70ba1d82..9c871ebe0 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -19,10 +19,12 @@ //! 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}; 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 +71,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 @@ -1013,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`. @@ -1024,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, @@ -1060,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, @@ -1099,7 +1157,9 @@ 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 + && structured_outputs + { let openapi = convert_json_schema_to_openapi_schema(s, true); if !openapi.is_null() { generation_config.insert("responseSchema".to_string(), openapi); @@ -1132,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 ──────────────────────────────────────────────────────────── @@ -1335,116 +1395,3 @@ pub fn extract_sources( sources } - -#[cfg(test)] -mod tests { - use super::*; - use aimux_core::language_model_message::LanguageModelPromptMessage; - - fn assistant_prompt(content: Vec) -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::Assistant, - content, - provider_options: None, - }] - } - - fn code_metadata(namespace: &str, id: &str) -> Value { - json!({ - (namespace): { - "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), - 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, - provider_options: Some(json!({ - "googleVertex": { "thoughtSignature": "google-vertex" }, - "vertex": { "thoughtSignature": "vertex" }, - "google": { "thoughtSignature": "google" }, - })), - }; - 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, - provider_options: Some(json!({ - "google": { "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/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..098864a03 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; @@ -283,17 +283,20 @@ impl GoogleImageModel { generation_config.insert("seed".to_string(), json!(seed)); } - // Passthrough provider options (excluding googleSearch) - if let Some(google) = options - .provider_options - .get("google") - .and_then(|v| v.as_object()) - { - for (key, value) in google { - if key == "googleSearch" || key == "personGeneration" || key == "aspectRatio" { - continue; + // Only declared generation-config options survive the upstream language schema. + if let Some(google) = options.provider_options.get("google") { + 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()); } } @@ -386,7 +389,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 +430,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 +496,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 c275a7120..1d976fcf2 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, GeneratedFileData}; +use aimux_core::shared::{FileBytes, GeneratedFileData, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{ FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, @@ -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( @@ -186,16 +186,20 @@ 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": { - "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, @@ -237,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( @@ -368,10 +372,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 })).expect("provider metadata must be an object")); // text part if let Some(text) = part.get("text").and_then(|v| v.as_str()) { @@ -549,12 +553,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}"), @@ -578,12 +581,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}"), @@ -667,16 +669,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, + })).expect("provider metadata must be an object")); yield Ok(StreamPart::Finish { finish_reason: if stream_errored { @@ -711,7 +711,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, @@ -719,7 +719,7 @@ fn server_tool_metadata( if let Some(signature) = thought_signature { payload["thoughtSignature"] = json!(signature); } - json!({ "google": payload }) + provider_namespace("google", payload).expect("provider metadata must be an object") } /// Extract `GenerateContent` items from a non-streaming candidate. @@ -750,10 +750,13 @@ 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 })) + .expect("provider metadata must be an object") + }); // Branch order matches upstream (google-language-model.ts:420-534): // executableCode → codeExecutionResult → text → functionCall @@ -879,12 +882,11 @@ fn extract_content_from_candidate( .to_string(); last_server_tool_call_id = Some(id.clone()); let input = 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()), + ); content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), @@ -906,12 +908,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 c45a70e79..b75da8726 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::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage, Warning}; @@ -221,9 +222,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 })) + .expect("provider metadata must be an object"), + ), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), response: Some(aimux_core::result::GenerateResponse { body: response_body, @@ -343,9 +345,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 })).expect("provider metadata must be an object")), }); } } @@ -377,9 +377,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 })).expect("provider metadata must be an object")), }); } _ => {} @@ -548,9 +546,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 })).expect("provider metadata must be an object")), }); }; @@ -1095,9 +1091,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 })) + .expect("provider metadata must be an object"), + ), }); // Process annotations → source parts. @@ -1127,9 +1124,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 })) + .expect("provider metadata must be an object"), + ), })); } } 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/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/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/convert.rs b/aimux-providers/src/mistral/convert.rs index 38e805acf..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,6 +324,10 @@ pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) - body["stream"] = json!(true); } + if let Some(safe_prompt) = provider_options.get("safePrompt") { + body["safe_prompt"] = safe_prompt.clone(); + } + if let Some(max_tokens) = options.max_output_tokens { body["max_tokens"] = json!(max_tokens); } @@ -367,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 820be5dc3..74e713e32 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}; @@ -224,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( @@ -323,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( @@ -602,7 +603,7 @@ impl LanguageModel for MistralModel { } else { final_usage }, - provider_metadata: Some(serde_json::json!({ "mistral": {} })), + provider_metadata: Some(provider_namespace("mistral", serde_json::json!({})).expect("provider metadata must be an object")), }); }; diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index 648ad7e85..363a93f10 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::{JsonObject, 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); @@ -900,15 +900,210 @@ 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> { + let provider = if provider == "azure" { + "openai" + } else { + provider + }; + 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 { - 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,21 +1138,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_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) } @@ -966,7 +1158,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 { @@ -986,7 +1178,7 @@ fn resolve_reasoning_effort( } fn resolve_is_reasoning_model( - provider_opts: &Option>, + provider_opts: &Option, provider: &str, caps: &ModelCapabilities, ) -> bool { @@ -996,7 +1188,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, @@ -1024,13 +1216,12 @@ 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, ) { - 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, @@ -1160,7 +1351,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, @@ -1202,7 +1393,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 { @@ -1240,7 +1436,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| { @@ -1259,12 +1455,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") @@ -1277,7 +1493,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, @@ -1408,7 +1624,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/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 fedc18605..231500473 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}; @@ -378,7 +379,8 @@ 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).expect("provider metadata must be an object")); Ok(GenerateResult { content, @@ -759,7 +761,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).expect("provider metadata must be an object"); // 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 df59ae273..bc5768aba 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")) @@ -364,7 +363,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")) @@ -374,7 +373,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")) @@ -384,7 +383,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")) @@ -394,7 +395,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")) @@ -531,7 +532,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) { @@ -580,7 +581,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(); @@ -596,7 +597,7 @@ fn warn_conversation_conflict( } fn resolve_responses_system_message_mode( - provider_opts: &Option>, + provider_opts: &Option, is_reasoning_model: bool, caps: &ModelCapabilities, ) -> SystemMessageMode { @@ -618,7 +619,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, @@ -674,7 +675,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 { @@ -723,7 +724,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> = @@ -754,7 +755,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) { @@ -769,16 +770,23 @@ 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. fn apply_responses_service_tier( body: &mut Value, - provider_opts: &Option>, + provider_opts: &Option, caps: &ModelCapabilities, warnings: &mut Vec, ) { @@ -811,7 +819,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 493ef382f..e593b11ad 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), - } - })), + }), + ).expect("provider metadata must be an object")), }); } } @@ -184,11 +188,15 @@ pub fn build_responses_generate_result( input, provider_executed: None, dynamic: None, - provider_metadata: Some(json!({ - (provider_key.clone()): { - "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") => { @@ -212,22 +220,27 @@ pub fn build_responses_generate_result( input, provider_executed: None, dynamic: None, - provider_metadata: Some(json!({ - (provider_key.clone()): { - "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") => { 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), - } - })); + }), + ).expect("provider metadata must be an object")); if parts.is_empty() { content.push(GenerateContent::Reasoning(ReasoningOutput { text: String::new(), @@ -250,10 +263,16 @@ pub fn build_responses_generate_result( Some("compaction") => { content.push(GenerateContent::Custom { kind: "openai.compaction".to_string(), - provider_metadata: Some(json!({ provider_key.clone(): { - "type": "compaction", "itemId": part.get("id"), - "encryptedContent": part.get("encrypted_content"), - }})), + provider_metadata: Some( + provider_namespace( + &provider_key, + json!({ + "type": "compaction", "itemId": part.get("id"), + "encryptedContent": part.get("encrypted_content"), + }), + ) + .expect("provider metadata must be an object"), + ), }); } Some("mcp_approval_request") => { @@ -312,7 +331,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(json!({ 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") @@ -377,12 +397,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).expect("provider metadata must be an object") } fn annotation_source(annotation: &Value, provider_key: &str) -> Result { @@ -448,7 +468,7 @@ fn annotation_source(annotation: &Value, provider_key: &str) -> Result { yield Ok(StreamPart::Custom { kind: "openai.compaction".to_string(), - provider_metadata: Some(json!({ provider_key.clone(): { + provider_metadata: Some(provider_namespace(&provider_key, json!({ "type": "compaction", "itemId": item.get("id"), "encryptedContent": item.get("encrypted_content"), - }})), + })).expect("provider metadata must be an object")), }); } @@ -1162,7 +1182,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).expect("provider metadata must be an object")); 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 294f8e764..83e904e7f 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}; @@ -397,20 +400,18 @@ impl LanguageModel for VertexAnthropicModel { input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - 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, + })).expect("provider metadata must be an object")), })); } 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 + })).expect("provider metadata must be an object")), }); blocks.insert(index, BlockState::Thinking { started: true }); } @@ -629,7 +630,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..f4dc4a1b6 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; @@ -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.and_then(|v| v.as_object()) { - 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 }], @@ -235,9 +221,11 @@ 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()) + .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), @@ -313,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.and_then(|v| v.as_object()) { - for (k, v) in obj { - if matches!(k.as_str(), "responseModalities" | "imageConfig") { - continue; + if let Some(obj) = gv_opts { + 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()); } } @@ -382,9 +372,11 @@ 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()) + .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 1a45f2fc7..c187b8d91 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -19,9 +19,11 @@ use aimux_core::result::{ GenerateContent, GenerateResult, GeneratedFile, GeneratedFileData, ReasoningOutput, Source, StreamResult, }; -use aimux_core::shared::FileBytes; +use aimux_core::shared::{FileBytes, 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}; @@ -179,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 { @@ -275,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( @@ -751,18 +753,20 @@ 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()) + .expect("provider metadata must be an object"); + metadata.extend( + provider_namespace("vertex", payload).expect("provider metadata must be an object"), + ); + 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 a6204b331..e43528dc9 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 6e7d2df8a..6efaf41a2 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() @@ -587,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/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index 2ad997eb6..2f33c978b 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}; @@ -301,7 +302,10 @@ 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) + .expect("provider metadata must be an object"), + ), })); } } @@ -323,9 +327,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 })) + .expect("provider metadata must be an object"), + ) } else { None }; @@ -445,7 +450,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 })).expect("provider metadata must be an object")), }); } continue; @@ -459,7 +464,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 })).expect("provider metadata must be an object")), }); continue; } @@ -478,13 +483,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 })).expect("provider metadata must be an object")), }); } 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 })).expect("provider metadata must be an object")), }); continue; } @@ -667,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 })).expect("provider metadata must be an object")), }); } @@ -677,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).expect("provider metadata must be an object")), }); active_reasoning.remove(part_id); } @@ -900,7 +905,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 })).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 a93d7070d..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.insert( - "amazonBedrock".into(), - 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 f9f8993c6..000000000 --- a/aimux-providers/tests/anthropic_cache_control_test.rs +++ /dev/null @@ -1,720 +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::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: Value) -> 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) -> Value { - json!({ "anthropic": { "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, - 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(json!({ - "anthropic": { - "redactedData": "abc123", - "cacheControl": { "type": "ephemeral" }, - } - })), - }], - )]); - 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 8fa080e20..a02fb47b0 100644 --- a/aimux-providers/tests/anthropic_convert_full_test.rs +++ b/aimux-providers/tests/anthropic_convert_full_test.rs @@ -313,74 +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( - [( - "anthropic".to_string(), - json!({ "thinking": { "type": "enabled", "budgetTokens": 1024 } }), - )] - .into_iter() - .collect(), - ); - 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( - [( - "anthropic".to_string(), - json!({ "thinking": { "type": "disabled" } }), - )] - .into_iter() - .collect(), - ); - 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 // --------------------------------------------------------------------------- @@ -743,7 +675,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 deleted file mode 100644 index 58092a649..000000000 --- a/aimux-providers/tests/anthropic_convert_test.rs +++ /dev/null @@ -1,997 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust port of the Anthropic provider pure-function tests. -//! -//! Translated from the Vercel AI SDK TypeScript tests: -//! - `packages/anthropic/src/convert-anthropic-usage.test.ts` (15 cases) -//! - `packages/anthropic/src/anthropic-prepare-tools.test.ts` (function-tool + -//! tool-choice subset) -//! - `packages/anthropic/src/convert-to-anthropic-prompt.test.ts` (subset -//! supported by the current Rust data model) -//! -//! Tests that exercise features absent from the Rust `aimux-core` data model -//! (reasoning parts, cache control, URL images, provider-defined tools, -//! 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::tool::{FunctionTool, ToolChoice}; -use aimux_core::types::Warning; -use aimux_providers::anthropic::convert::convert_prompt_to_anthropic; -use aimux_providers::anthropic::prepare_tools::prepare_tools; -use aimux_providers::anthropic::usage::{ - AnthropicInputTokens, AnthropicOutputTokens, convert_anthropic_usage, -}; -use base64::Engine; -use serde_json::{Value, json}; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn msg(role: Role, parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: None, - } -} - -fn text_part(t: &str) -> ContentPart { - ContentPart::text(t.to_string()) -} - -fn prompt(msgs: Vec) -> Vec { - msgs -} - -/// Build a function tool with the common defaults (no strict, no provider -/// options, no input examples). -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) -} - -/// 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). -fn decode_b64(s: &str) -> Vec { - base64::engine::general_purpose::STANDARD - .decode(s) - .expect("valid base64") -} - -// ═════════════════════════════════════════════════════════════════════════════ -// convertAnthropicUsage (15 cases) -// ═════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod convert_anthropic_usage_tests { - use super::*; - - #[test] - fn should_use_usage_as_raw_when_raw_usage_is_not_provided() { - let usage = json!({ "input_tokens": 10, "output_tokens": 20 }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.raw, usage); - } - - #[test] - fn should_use_raw_usage_as_raw_when_provided() { - let usage = json!({ "input_tokens": 10, "output_tokens": 20 }); - let raw_usage = json!({ - "input_tokens": 10, - "output_tokens": 20, - "service_tier": "standard", - "inference_geo": "not_available", - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 0, - }, - }); - let result = convert_anthropic_usage(&usage, Some(&raw_usage)); - assert_eq!(result.raw, raw_usage); - } - - #[test] - fn should_compute_token_totals_correctly_with_cache_tokens() { - let usage = json!({ - "input_tokens": 10, - "output_tokens": 20, - "cache_creation_input_tokens": 5, - "cache_read_input_tokens": 3, - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!( - result.input_tokens, - AnthropicInputTokens { - total: 18, - no_cache: 10, - cache_read: 3, - cache_write: 5 - } - ); - assert_eq!( - result.output_tokens, - AnthropicOutputTokens { - total: 20, - text: None, - reasoning: None - } - ); - } - - #[test] - fn should_handle_null_cache_tokens() { - let usage = json!({ - "input_tokens": 100, - "output_tokens": 50, - "cache_creation_input_tokens": null, - "cache_read_input_tokens": null, - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.input_tokens.cache_read, 0); - assert_eq!(result.input_tokens.cache_write, 0); - } - - #[test] - fn should_sum_across_all_iterations_when_iterations_array_is_present() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - // Total should be sum of iterations, not top-level values. - assert_eq!(result.input_tokens.total, 203000); // 180000 + 23000 - assert_eq!(result.input_tokens.no_cache, 203000); - assert_eq!(result.output_tokens.total, 4500); // 3500 + 1000 - assert_eq!(result.raw, usage); - } - - #[test] - fn should_handle_single_iteration_message_only_no_compaction_triggered() { - let usage = json!({ - "input_tokens": 5000, - "output_tokens": 500, - "iterations": [ - { "type": "message", "input_tokens": 5000, "output_tokens": 500 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 5000); - assert_eq!(result.output_tokens.total, 500); - } - - #[test] - fn should_handle_multiple_compaction_iterations_long_running_task() { - let usage = json!({ - "input_tokens": 10000, - "output_tokens": 500, - "iterations": [ - { "type": "compaction", "input_tokens": 200000, "output_tokens": 4000 }, - { "type": "message", "input_tokens": 50000, "output_tokens": 2000 }, - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 30000, "output_tokens": 1500 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - // Total = 200000 + 50000 + 180000 + 30000 = 460000 input - assert_eq!(result.input_tokens.total, 460000); - // Total = 4000 + 2000 + 3500 + 1500 = 11000 output - assert_eq!(result.output_tokens.total, 11000); - } - - #[test] - fn should_combine_iterations_with_cache_tokens() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "cache_creation_input_tokens": 1000, - "cache_read_input_tokens": 500, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - // noCache = sum of iterations only - assert_eq!(result.input_tokens.no_cache, 203000); // 180000 + 23000 - assert_eq!(result.input_tokens.cache_write, 1000); - assert_eq!(result.input_tokens.cache_read, 500); - assert_eq!(result.input_tokens.total, 204500); // 203000 + 1000 + 500 - assert_eq!(result.output_tokens.total, 4500); // 3500 + 1000 - } - - #[test] - fn should_use_raw_usage_as_raw_even_when_iterations_are_present() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let raw_usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "service_tier": "standard", - }); - let result = convert_anthropic_usage(&usage, Some(&raw_usage)); - assert_eq!(result.raw, raw_usage); - assert_eq!(result.input_tokens.total, 203000); - } - - #[test] - fn should_use_top_level_values_when_iterations_is_null() { - let usage = json!({ - "input_tokens": 100, - "output_tokens": 50, - "iterations": null, - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.output_tokens.total, 50); - } - - #[test] - fn should_use_top_level_values_when_iterations_is_undefined() { - let usage = json!({ "input_tokens": 100, "output_tokens": 50 }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.output_tokens.total, 50); - } - - #[test] - fn should_use_top_level_values_when_iterations_array_is_empty() { - let usage = json!({ - "input_tokens": 100, - "output_tokens": 50, - "iterations": [], - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.output_tokens.total, 50); - } - - #[test] - fn should_handle_zero_tokens_in_iterations() { - let usage = json!({ - "input_tokens": 0, - "output_tokens": 0, - "iterations": [ - { "type": "compaction", "input_tokens": 0, "output_tokens": 0 }, - { "type": "message", "input_tokens": 0, "output_tokens": 0 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 0); - assert_eq!(result.output_tokens.total, 0); - } - - #[test] - fn should_match_documentation_example_exactly() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - let expected_total_input = 180000 + 23000; // 203000 - let expected_total_output = 3500 + 1000; // 4500 - assert_eq!(result.input_tokens.total, expected_total_input); - assert_eq!(result.output_tokens.total, expected_total_output); - // The top-level values (45000, 1234) are NOT the billed amounts when - // iterations is present. - assert_ne!( - result.input_tokens.total, - usage["input_tokens"].as_u64().unwrap() - ); - assert_ne!( - result.output_tokens.total, - usage["output_tokens"].as_u64().unwrap() - ); - } - - #[test] - fn should_handle_re_applying_previous_compaction_block_no_new_compaction() { - let usage = json!({ - "input_tokens": 15000, - "output_tokens": 800, - }); - let result = convert_anthropic_usage(&usage, None); - // Top-level values are accurate when no new compaction triggered. - assert_eq!(result.input_tokens.total, 15000); - assert_eq!(result.output_tokens.total, 800); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// prepareTools (function-tool + tool-choice subset) -// ═════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod prepare_tools_tests { - use super::*; - - fn assert_strict_warning(warnings: &[Warning], tool_name: &str, strict_val: bool) { - assert_eq!(warnings.len(), 1, "expected exactly one warning"); - match &warnings[0] { - Warning::Unsupported { feature, details } => { - assert_eq!(feature, "strict"); - let expected = format!( - "Tool '{tool_name}' has strict: {strict_val}, but strict mode is not supported by this provider. The strict property will be ignored." - ); - assert_eq!(details.as_ref().unwrap(), &expected); - } - other => panic!("expected Unsupported warning, got {other:?}"), - } - } - - #[test] - fn should_return_undefined_tools_and_tool_choice_when_tools_are_null() { - let result = prepare_tools(None, None, false, true, true, false); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - assert!(result.betas.is_empty()); - } - - #[test] - fn should_return_undefined_tools_and_tool_choice_when_tools_are_empty() { - let tools: Vec = vec![]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - 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()); - } - - // ── strict mode for function tools ── - - #[test] - fn strict_included_and_beta_when_supports_structured_output_and_strict_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(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": {} }, - "strict": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert_eq!(result.betas.len(), 1); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn beta_but_not_strict_when_strict_undefined_and_supports_structured_output() { - let tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - 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": {} }, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn no_strict_emit_warning_no_beta_when_both_supports_flags_false() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(true); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, false, false, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.is_empty()); - assert_strict_warning(&result.tool_warnings, "testFunction", true); - } - - #[test] - fn strict_but_no_beta_when_supports_structured_output_false_but_strict_tools_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(true); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, false, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "strict": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.is_empty()); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn beta_when_strict_false_and_supports_structured_output_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(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": {} }, - "strict": false, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.tool_warnings.is_empty()); - } - - // ── 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( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - 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!(!tool_def.contains_key("defer_loading")); - } - - // ── 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( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - 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!(!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] - fn should_handle_tool_choice_auto() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::Auto; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!(result.tool_choice.unwrap(), json!({ "type": "auto" })); - } - - #[test] - fn should_handle_tool_choice_required() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::Required; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!(result.tool_choice.unwrap(), json!({ "type": "any" })); - } - - #[test] - fn should_handle_tool_choice_none() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::None; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - } - - #[test] - fn should_handle_tool_choice_tool() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!( - result.tool_choice.unwrap(), - json!({ "type": "tool", "name": "testFunction" }) - ); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// convertToAnthropicPrompt (subset supported by the Rust data model) -// ═════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod convert_prompt_tests { - use super::*; - - #[test] - fn should_convert_a_single_system_message_into_an_anthropic_system_message() { - let p = prompt(vec![msg( - Role::System, - vec![text_part("This is a system message")], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert_eq!( - system, - Some(vec![ - json!({ "type": "text", "text": "This is a system message" }) - ]) - ); - assert!(messages.is_empty()); - } - - #[test] - fn should_convert_multiple_system_messages_into_an_anthropic_system_message() { - let p = prompt(vec![ - msg(Role::System, vec![text_part("This is a system message")]), - msg( - Role::System, - vec![text_part("This is another system message")], - ), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert_eq!( - system, - Some(vec![ - json!({ "type": "text", "text": "This is a system message" }), - json!({ "type": "text", "text": "This is another system message" }), - ]) - ); - assert!(messages.is_empty()); - } - - #[test] - fn should_add_image_parts_for_uint8array_images() { - // TS input uses `data: { type: 'data', data: 'AAECAw==' }`; the Rust - // `ContentPart::Image` holds the decoded raw bytes and re-encodes them. - let p = prompt(vec![msg( - Role::User, - vec![ContentPart::image( - decode_b64("AAECAw=="), - "image/png".to_string(), - )], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": "AAECAw==", - }, - }], - })] - ); - } - - #[test] - fn should_convert_a_single_tool_result_into_an_anthropic_user_message() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "json", "value": { "test": "This is a tool message" } }), - )], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}", - }], - })] - ); - } - - #[test] - fn should_convert_multiple_tool_results_into_an_anthropic_user_message() { - let p = prompt(vec![msg( - Role::Tool, - vec![ - ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "json", "value": { "test": "This is a tool message" } }), - ), - ContentPart::tool_result( - "tool-call-2".to_string(), - json!({ "type": "json", "value": { "something": "else" } }), - ), - ], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}", - }, - { - "type": "tool_result", - "tool_use_id": "tool-call-2", - "content": "{\"something\":\"else\"}", - }, - ], - })] - ); - } - - #[test] - fn should_combine_user_and_tool_messages() { - let p = prompt(vec![ - msg( - Role::Tool, - vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "json", "value": { "test": "This is a tool message" } }), - )], - ), - msg(Role::User, vec![text_part("This is a user message")]), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}", - }, - { "type": "text", "text": "This is a user message" }, - ], - })] - ); - } - - #[test] - fn should_combine_multiple_sequential_assistant_messages_into_a_single_message() { - let p = prompt(vec![ - msg(Role::User, vec![text_part("Hi!")]), - msg(Role::Assistant, vec![text_part("Hello")]), - msg(Role::Assistant, vec![text_part("World")]), - msg(Role::Assistant, vec![text_part("!")]), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![ - json!({ "role": "user", "content": [{ "type": "text", "text": "Hi!" }] }), - json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "Hello" }, - { "type": "text", "text": "World" }, - { "type": "text", "text": "!" }, - ], - }), - ] - ); - } - - #[test] - fn should_wrap_non_object_invalid_tool_call_input_in_an_object() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ContentPart::tool_call( - "call-1".to_string(), - "cityAttractions".to_string(), - // malformed JSON the model produced, kept as a raw string - json!("{ \"city\": \"San Francisco\", }"), - )], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "assistant", - "content": [{ - "type": "tool_use", - "name": "cityAttractions", - "id": "call-1", - "input": { "rawInvalidInput": "{ \"city\": \"San Francisco\", }" }, - }], - })] - ); - } - - #[test] - fn should_convert_user_assistant_tool_assistant_user_message_sequence_with_multiple_tool_calls() - { - let p = prompt(vec![ - msg( - Role::User, - vec![text_part("weather for berlin, london and paris")], - ), - msg( - Role::Assistant, - vec![ - text_part( - "I will use the weather tool to get the weather for berlin, london and paris", - ), - ContentPart::tool_call( - "weather-call-1".to_string(), - "weather".to_string(), - json!({ "location": "berlin" }), - ), - ContentPart::tool_call( - "weather-call-2".to_string(), - "weather".to_string(), - json!({ "location": "london" }), - ), - ContentPart::tool_call( - "weather-call-3".to_string(), - "weather".to_string(), - json!({ "location": "paris" }), - ), - ], - ), - msg( - Role::Tool, - vec![ - ContentPart::tool_result( - "weather-call-1".to_string(), - json!({ "type": "json", "value": { "weather": "sunny" } }), - ), - ContentPart::tool_result( - "weather-call-2".to_string(), - json!({ "type": "json", "value": { "weather": "cloudy" } }), - ), - ContentPart::tool_result( - "weather-call-3".to_string(), - json!({ "type": "json", "value": { "weather": "rainy" } }), - ), - ], - ), - msg( - Role::Assistant, - vec![text_part( - "The weather for berlin is sunny, the weather for london is cloudy, and the weather for paris is rainy", - )], - ), - msg(Role::User, vec![text_part("and for new york?")]), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![ - json!({ - "role": "user", - "content": [{ "type": "text", "text": "weather for berlin, london and paris" }], - }), - json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "I will use the weather tool to get the weather for berlin, london and paris" }, - { "type": "tool_use", "id": "weather-call-1", "name": "weather", "input": { "location": "berlin" } }, - { "type": "tool_use", "id": "weather-call-2", "name": "weather", "input": { "location": "london" } }, - { "type": "tool_use", "id": "weather-call-3", "name": "weather", "input": { "location": "paris" } }, - ], - }), - json!({ - "role": "user", - "content": [ - { "type": "tool_result", "tool_use_id": "weather-call-1", "content": "{\"weather\":\"sunny\"}" }, - { "type": "tool_result", "tool_use_id": "weather-call-2", "content": "{\"weather\":\"cloudy\"}" }, - { "type": "tool_result", "tool_use_id": "weather-call-3", "content": "{\"weather\":\"rainy\"}" }, - ], - }), - json!({ - "role": "assistant", - "content": [{ "type": "text", "text": "The weather for berlin is sunny, the weather for london is cloudy, and the weather for paris is rainy" }], - }), - json!({ - "role": "user", - "content": [{ "type": "text", "text": "and for new york?" }], - }), - ] - ); - } -} 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/aws_polly_test.rs b/aimux-providers/tests/aws_polly_test.rs index 34c553f1c..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,40 +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 mut provider_options = std::collections::HashMap::new(); - provider_options.insert( - "aws_polly".to_string(), - 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/azure_responses_test.rs b/aimux-providers/tests/azure_responses_test.rs index 1a20c158b..69eb7971c 100644 --- a/aimux-providers/tests/azure_responses_test.rs +++ b/aimux-providers/tests/azure_responses_test.rs @@ -563,7 +563,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 092f5680a..f236d552c 100644 --- a/aimux-providers/tests/bedrock_convert_test.rs +++ b/aimux-providers/tests/bedrock_convert_test.rs @@ -29,6 +29,8 @@ 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; @@ -72,7 +74,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 +92,9 @@ 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) })).unwrap(), + ), } } @@ -744,7 +748,7 @@ fn citations_enabled_for_pdf() { "AAECAw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": true } } })), + Some(provider_namespace("bedrock", json!({ "citations": { "enabled": true } })).unwrap()), )])]); assert_eq!( messages[0]["content"][0], @@ -766,7 +770,7 @@ fn citations_disabled_for_pdf() { "AAECAw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": false } } })), + Some(provider_namespace("bedrock", json!({ "citations": { "enabled": false } })).unwrap()), )])]); assert_eq!( messages[0]["content"][0], @@ -811,13 +815,18 @@ fn citations_multiple_pdfs() { "AAECAw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": true } } })), + Some( + provider_namespace("bedrock", json!({ "citations": { "enabled": true } })).unwrap(), + ), ), file_base64( "BAUGBw==", "application/pdf", None, - Some(json!({ "bedrock": { "citations": { "enabled": false } } })), + Some( + provider_namespace("bedrock", json!({ "citations": { "enabled": false } })) + .unwrap(), + ), ), ])]); assert_eq!( diff --git a/aimux-providers/tests/bedrock_embedding_test.rs b/aimux-providers/tests/bedrock_embedding_test.rs index 3ccce5bd9..ba1a1f5bb 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}; @@ -253,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 mut provider_options = HashMap::new(); - provider_options.insert("bedrock".to_string(), 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() { @@ -334,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 mut provider_options = HashMap::new(); - provider_options.insert("bedrock".to_string(), 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 2fd3bf07e..f728a846f 100644 --- a/aimux-providers/tests/bedrock_reasoning_test.rs +++ b/aimux-providers/tests/bedrock_reasoning_test.rs @@ -37,7 +37,7 @@ use aimux_core::message::Role; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; 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; @@ -142,7 +142,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, @@ -892,47 +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( - json!({ "bedrock": { "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_reranking_test.rs b/aimux-providers/tests/bedrock_reranking_test.rs deleted file mode 100644 index 8ce074a20..000000000 --- a/aimux-providers/tests/bedrock_reranking_test.rs +++ /dev/null @@ -1,392 +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 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_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() -> HashMap { - let mut po = HashMap::new(); - po.insert( - "bedrock".to_string(), - json!({ - "nextToken": "test-token", - "additionalModelRequestFields": { "test": "test-value" } - }), - ); - po -} - -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_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..e13cf79a1 100644 --- a/aimux-providers/tests/cartesia_transcription_test.rs +++ b/aimux-providers/tests/cartesia_transcription_test.rs @@ -207,56 +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 mut po = HashMap::new(); - po.insert( - "cartesia".to_string(), - 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 mut po = HashMap::new(); - po.insert( - "cartesia".to_string(), - 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 67427c7c2..393bdc5c5 100644 --- a/aimux-providers/tests/cohere_embedding_test.rs +++ b/aimux-providers/tests/cohere_embedding_test.rs @@ -169,74 +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 mut provider_options = HashMap::new(); - provider_options.insert( - "cohere".to_string(), - 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 mut provider_options = HashMap::new(); - provider_options.insert("cohere".to_string(), 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_reranking_test.rs b/aimux-providers/tests/cohere_reranking_test.rs deleted file mode 100644 index 66c31f0c7..000000000 --- a/aimux-providers/tests/cohere_reranking_test.rs +++ /dev/null @@ -1,350 +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 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_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() -> HashMap { - let mut po = HashMap::new(); - po.insert( - "cohere".to_string(), - json!({ "maxTokensPerDoc": 1000, "priority": 1 }), - ); - po -} - -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 f6a4f9eef..000000000 --- a/aimux-providers/tests/convert_golden_test.rs +++ /dev/null @@ -1,153 +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}; - -use aimux_providers::anthropic::convert::build_request_body_with_warnings as anthropic_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 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 ───────────────────────────────────────────────── - -// ── OpenAI Responses API ──────────────────────────────────────────────────── - -#[test] -fn responses_golden() { - let mut provider = std::collections::HashMap::new(); - provider.insert( - "openai".to_string(), - 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 mut provider = std::collections::HashMap::new(); - provider.insert( - "anthropic".to_string(), - 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 bbb928673..e9533443a 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -40,6 +40,7 @@ use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, ReasoningOutput, Source, StreamResult}; 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; @@ -164,7 +165,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 { @@ -184,7 +185,7 @@ type ToolCallView<'a> = ( Value, Option, Option, - Option<&'a Value>, + Option<&'a ProviderMetadata>, ); fn tool_calls(content: &[GenerateContent]) -> Vec> { @@ -225,7 +226,7 @@ type ToolResultView<'a> = ( &'a Value, Option, Option, - Option<&'a Value>, + Option<&'a ProviderMetadata>, ); fn tool_results(content: &[GenerateContent]) -> Vec> { @@ -259,7 +260,7 @@ type SourceView<'a> = ( &'a str, Option<&'a str>, Option<&'a str>, - Option<&'a Value>, + Option<&'a ProviderMetadata>, ); fn sources(content: &[GenerateContent]) -> Vec> { @@ -627,7 +628,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(); @@ -643,7 +644,13 @@ 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 }) + ) + .unwrap() + ), "source {url}: providerMetadata must hold exactly anthropic.pageAge" ); } @@ -886,7 +893,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" }) ); @@ -902,7 +909,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..4d09cb343 100644 --- a/aimux-providers/tests/deepgram_transcription_test.rs +++ b/aimux-providers/tests/deepgram_transcription_test.rs @@ -140,45 +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 mut po = HashMap::new(); - po.insert("deepgram".to_string(), 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 mut po = HashMap::new(); - po.insert("deepgram".to_string(), 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; @@ -226,37 +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 mut po = HashMap::new(); - po.insert("deepgram".to_string(), 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 bffa49285..5b16ace99 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::tool::RawToolCall; -use std::collections::HashMap; - 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,13 +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> { - let mut m = HashMap::new(); - m.insert("deepseek".to_string(), value); - Some(m) -} - /// Mount a JSON chat-completion response on `/chat/completions`. async fn mock_json(server: &MockServer, body: Value) { Mock::given(method("POST")) @@ -148,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 // ───────────────────────────────────────────────────────────────────────────── @@ -197,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 // @@ -300,41 +212,6 @@ async fn should_extract_text_content() { // Translated from `deepseek-chat-language-model.test.ts` doGenerate › tool call. // ════════════════════════════════════════════════════════════════════════════ -/// 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 // @@ -346,38 +223,6 @@ async fn should_extract_tool_call_content() { // ("Return JSON." or "Return JSON that conforms to the following schema: ..."). // ════════════════════════════════════════════════════════════════════════════ -/// 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 // @@ -461,87 +306,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 deleted file mode 100644 index 4d5c3f671..000000000 --- a/aimux-providers/tests/deepseek_reasoning_test.rs +++ /dev/null @@ -1,622 +0,0 @@ -//! DeepSeek `reasoning_content` tests, translated from the Vercel AI SDK -//! TypeScript suite. -//! -//! Translation sources: -//! - `packages/deepseek/src/chat/deepseek-chat-language-model.test.ts` -//! - `describe('doGenerate') › describe('reasoning')` — request body with -//! `thinking` + response extraction from the reasoning fixture. -//! - `describe('doGenerate') › describe('top-level reasoning')` — mapping of -//! top-level `reasoning` and `providerOptions.deepseek` to the -//! `thinking` / `reasoning_effort` request fields. -//! - `describe('doStream') › describe('reasoning')` — streaming the -//! reasoning fixture. -//! - `packages/openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts` -//! - DeepSeek is a thin OpenAI-compatible wrapper, so the -//! `reasoning_content` response-extraction tests (doGenerate + doStream) -//! apply directly. These cover the core behaviour gap: the shared OpenAI -//! response parser currently ignores `reasoning_content` (serde skips the -//! unknown field), so reasoning text is not surfaced as a -//! `GenerateContent::Reasoning` part / `StreamPart::Reasoning*` event. -//! -//! HTTP is mocked with `wiremock` (a real loopback server), replacing the TS -//! `createTestServer`. Each test starts its own `MockServer` so parallel -//! `#[tokio::test]` runs do not collide. -//! -//! # TDD status -//! -//! DeepSeek delegates to the shared [`OpenAIModel`], whose response parser does -//! not read `reasoning_content` / `reasoning` and whose request builder does -//! not emit a `thinking` field or do the DeepSeek-specific `reasoning` → -//! `reasoning_effort` remapping (xhigh→max, minimal→low with compatibility -//! warnings). Provider options keyed under `deepseek` are also not consulted -//! (the shared builder only reads the `openai` key). -//! -//! Tests that assert these unimplemented behaviours are written as TDD red -//! lights: they compile cleanly against the existing types -//! (`GenerateContent::Reasoning`, `StreamPart::Reasoning*`, -//! `Warning::Compatibility`) but fail at runtime until the feature lands. -//! No `#[ignore]` is used — every test compiles against types that exist today. -//! -//! # Mock URL note -//! -//! The real DeepSeek base URL is `https://api.deepseek.com/v1`, so production -//! requests hit `/v1/chat/completions`. In these tests `with_base_url` is -//! overridden with the mock server's root URI (no `/v1` suffix), so the -//! resulting request path is `/chat/completions` — matching the convention in -//! `openai_compatible_test.rs`. - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -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; -use aimux_core::result::{GenerateContent, ReasoningOutput}; -use aimux_core::stream_part::StreamPart; - -use aimux_providers::{ProviderOptions, provider}; - -// ───────────────────────────────────────────────────────────────────────────── -// Shared helpers (mirrors openai_compatible_test.rs / anthropic_model_test.rs) -// ───────────────────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// `CallOptions` with only `prompt` set (everything else default/None). -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build a DeepSeek provider whose base URL points at the mock server. -fn make_provider(server: &MockServer) -> Box { - provider( - "deepseek", - Some("test-api-key".to_string()), - "deepseek-reasoner", - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("deepseek provider should build") -} - -/// Mount a JSON chat-completion response on `/chat/completions`. -async fn mock_json(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mount an SSE stream response on `/chat/completions`. -async fn mock_sse(server: &MockServer, body: String) { - 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; -} - -/// Build a single SSE `data: \n\n` event string. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Concatenate SSE events and append the `[DONE]` sentinel. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect every `StreamPart` from a `StreamResult` into a `Vec`. -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .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() -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — reasoning_content response extraction -// -// Translated from -// `openai-compatible-chat-language-model.test.ts` doGenerate reasoning tests -// and `deepseek-chat-language-model.test.ts` doGenerate › reasoning › "should -// extract text content". -// -// TS behaviour: `reasoning = message.reasoning_content ?? message.reasoning` -// (JS nullish coalescing — an empty string is NOT nullish, so `""` wins over -// `reasoning`); a non-empty reasoning string is pushed as a `{ type: -// "reasoning", text }` content part after the text part. -// -// Rust status: the shared OpenAI `MessageResponse` has no `reasoning_content` -// / `reasoning` field, so serde silently drops them — no `Reasoning` content -// part is ever produced. The extraction tests below are TDD red lights. -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should prefer reasoning_content over reasoning field when both are -/// provided" (openai-compatible test, line ~320). -/// -/// RED LIGHT: Rust ignores `reasoning_content`, so only the `Text` part is -/// produced — the `Reasoning` assertion fails. -#[tokio::test] -async fn should_prefer_reasoning_content_over_reasoning_field_when_both_provided() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "chatcmpl-1", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Hello, World!", - "reasoning_content": "This is from reasoning_content", - "reasoning": "This is from reasoning field" - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.content.len(), - 2, - "expected [Text, Reasoning] — reasoning_content should be extracted" - ); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { - assert_eq!(text, "This is from reasoning_content") - } - other => panic!("expected Reasoning from reasoning_content, got {other:?}"), - } -} - -/// TS: "should extract reasoning from reasoning field when reasoning_content -/// is not provided" (openai-compatible test, line ~300). -/// -/// NOTE: the TS `prepareJsonResponse` helper defaults `reasoning_content` to -/// `""` (empty string). Because JS `??` does not treat `""` as nullish, -/// `"" ?? reasoning` resolves to `""`, whose `.length` is 0, so NO reasoning -/// part is emitted — the TS snapshot is text-only. This test replicates that -/// exact edge case. It currently passes in Rust because reasoning is ignored -/// entirely; after `reasoning_content` extraction is implemented (mirroring -/// the `??` + length-check) it must still pass. -#[tokio::test] -async fn should_extract_reasoning_from_reasoning_field_when_reasoning_content_not_provided() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "chatcmpl-1", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Hello, World!", - "reasoning_content": "", - "reasoning": "This is the reasoning from the reasoning field" - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // Empty-string reasoning_content suppresses the reasoning-field fallback - // (TS `??` edge case), so only the text part is expected. - assert_eq!( - result.content.len(), - 1, - "empty reasoning_content should not yield a reasoning part" - ); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text only, got {other:?}"), - } -} - -/// TS: deepseek doGenerate › reasoning › "should extract text content" -/// (deepseek-chat-language-model.test.ts, line ~118), using the -/// `deepseek-reasoning` fixture. -/// -/// The fixture carries both `content` and a long `reasoning_content`. The TS -/// snapshot surfaces the reasoning as a `{ type: "reasoning" }` part. The -/// `reasoning_content` text below is abbreviated from the fixture for -/// readability; the assertion still verifies extraction. -/// -/// RED LIGHT: Rust ignores `reasoning_content`, so only `Text` is produced. -#[tokio::test] -async fn should_extract_reasoning_content_and_text_from_deepseek_reasoning_fixture() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "945bb10c-9bf3-47ff-a2a2-43bbe9705c72", - "object": "chat.completion", - "created": 1764660903, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "The word \"strawberry\" contains three instances of the letter \"r\": one after the \"t\" and two before the \"y\".", - "reasoning_content": "We are asked: \"How many 'r's are in the word 'strawberry'?\" We need to count the occurrences of the letter 'r'." - }, - "logprobs": null, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 18, - "completion_tokens": 345, - "total_tokens": 363, - "completion_tokens_details": { "reasoning_tokens": 315 } - }, - "system_fingerprint": "fp_eaab8d114b_prod0820_fp8_kvcache" - }), - ) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.content.len(), - 2, - "expected [Text, Reasoning] from the reasoning fixture" - ); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert!( - text.contains("strawberry"), - "expected the fixture text, got: {text}" - ), - other => panic!("expected Text, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => assert!( - text.contains("How many"), - "expected the fixture reasoning_content, got: {text}" - ), - other => panic!("expected Reasoning, got {other:?}"), - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — request body: thinking & reasoning_effort -// -// Translated from `deepseek-chat-language-model.test.ts` doGenerate › -// "reasoning" ("should send correct request body") and "top-level reasoning" -// describe blocks (lines ~82–311). -// -// TS behaviour: DeepSeek maps top-level `reasoning` and -// `providerOptions.deepseek` to a `thinking` field (`{ type: "enabled" | -// "disabled" | "adaptive" }`) and a `reasoning_effort` field, with -// xhigh→max / minimal→low remapping that emits `Compatibility` warnings. -// -// Rust status (stage2-001, RFC-0017 阶段 2): the DeepSeek 特化已整体退役—— -// 共享 builder 直传 `options.reasoning` 为 `reasoning_effort`(7 档,无归一化), -// 不注入 `thinking`,不产生归一化 warning;`providerOptions.deepseek.*` 被忽略。 -// thinking 注入 / effort 重映射由用户 bodyOverrides 定义。以下测试断言退役后的 -// 透传语义(原断言特化的测试已按新语义改写)。 -// ════════════════════════════════════════════════════════════════════════════ - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — reasoning_content streaming -// -// Translated from `openai-compatible-chat-language-model.test.ts` doStream -// reasoning tests (lines ~1856–2149) and `deepseek-chat-language-model.test.ts` -// doStream › reasoning › "should stream reasoning" (line ~764). -// -// TS behaviour: `reasoningContent = delta.reasoning_content ?? delta.reasoning`; -// when truthy it emits `reasoning-start` / `reasoning-delta` / `reasoning-end` -// before the text deltas. -// -// Rust status: the shared `Delta` type has no `reasoning_content` / `reasoning` -// field, so serde drops them — no `StreamPart::Reasoning*` is ever emitted. -// These tests are TDD red lights. -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should stream reasoning content before text deltas" (line ~1856). -/// -/// RED LIGHT: `reasoning_content` deltas are dropped, so no -/// `ReasoningStart` / `ReasoningDelta` / `ReasoningEnd` parts are emitted. -#[tokio::test] -async fn should_stream_reasoning_content_before_text_deltas() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"Let me think"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"","reasoning_content":" about this"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"Here's"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":" my response"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1729171479,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["Let me think".to_string(), " about this".to_string()], - "reasoning_content deltas should be emitted as reasoning-delta parts" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); - let deltas = text_deltas(&parts); - assert!(deltas.iter().any(|d| d == "Here's"), "missing text delta"); - assert!( - deltas.iter().any(|d| d == " my response"), - "missing text delta" - ); -} - -/// TS: "should stream reasoning from reasoning field when reasoning_content is -/// not provided" (line ~1959). -/// -/// RED LIGHT: the `reasoning` delta field is dropped, so no reasoning parts. -#[tokio::test] -async fn should_stream_reasoning_from_reasoning_field_when_reasoning_content_not_provided() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"Let me consider"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"","reasoning":" this carefully"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"My answer is"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":" correct"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1729171479,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["Let me consider".to_string(), " this carefully".to_string()], - "reasoning field deltas should be emitted as reasoning-delta parts" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); -} - -/// TS: "should prefer reasoning_content over reasoning field in streaming when -/// both are provided" (line ~2062). -/// -/// RED LIGHT: both fields are dropped, so no reasoning parts are emitted (and -/// certainly not from `reasoning_content`). -#[tokio::test] -async fn should_prefer_reasoning_content_over_reasoning_field_in_streaming() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"From reasoning_content","reasoning":"From reasoning"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"Final response"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1729171479,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["From reasoning_content".to_string()], - "reasoning_content should be preferred over the reasoning field" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); - assert!( - text_deltas(&parts).iter().any(|d| d == "Final response"), - "missing text delta" - ); -} - -/// TS: deepseek doStream › reasoning › "should stream reasoning" (line ~764), -/// using the `deepseek-reasoning` chunks fixture. -/// -/// The fixture streams `reasoning_content` deltas ("We", " need", ...) before -/// the text content. The chunks below are an abbreviated inline subset of the -/// fixture; the assertion still verifies reasoning is streamed. -/// -/// RED LIGHT: `reasoning_content` deltas are dropped, so no reasoning parts. -#[tokio::test] -async fn should_stream_reasoning_from_deepseek_reasoning_fixture() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":"We"},"logprobs":null,"finish_reason":null}],"usage":null}"#, - ), - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":" need"},"logprobs":null,"finish_reason":null}],"usage":null}"#, - ), - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"The word","reasoning_content":null},"logprobs":null,"finish_reason":null}],"usage":null}"#, - ), - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":345,"total_tokens":363}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["We".to_string(), " need".to_string()], - "fixture reasoning_content deltas should be streamed" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); - assert!( - text_deltas(&parts).iter().any(|d| d == "The word"), - "missing text delta" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Local fixtures -// ───────────────────────────────────────────────────────────────────────────── diff --git a/aimux-providers/tests/elevenlabs_realtime_stream_test.rs b/aimux-providers/tests/elevenlabs_realtime_stream_test.rs index e00f15837..e3963235a 100644 --- a/aimux-providers/tests/elevenlabs_realtime_stream_test.rs +++ b/aimux-providers/tests/elevenlabs_realtime_stream_test.rs @@ -263,75 +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 mut provider_options = std::collections::HashMap::new(); - provider_options.insert( - "elevenlabs".to_string(), - 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/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..a14328afc 100644 --- a/aimux-providers/tests/google_embedding_test.rs +++ b/aimux-providers/tests/google_embedding_test.rs @@ -136,73 +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 mut provider_options = HashMap::new(); - provider_options.insert("google".to_string(), 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 mut provider_options = HashMap::new(); - provider_options.insert( - "google".to_string(), - 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 17f6a132b..f8cf2234e 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 ----------------------------------------------------------------- @@ -153,35 +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 mut po = HashMap::new(); - po.insert( - "google".to_string(), - 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; @@ -490,11 +459,11 @@ 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 }), - ); + ) + .unwrap(); let opts = UploadFileCallOptions { data: UploadFileData::Data { data: FileBytes::Binary(vec![1]), @@ -531,11 +500,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" })).unwrap(); 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..6292f6011 100644 --- a/aimux-providers/tests/google_image_test.rs +++ b/aimux-providers/tests/google_image_test.rs @@ -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.insert( - "google".to_string(), - 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.insert( - "google".to_string(), - 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 - .insert("google".to_string(), 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.insert( - "google".to_string(), - 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 - .insert("google".to_string(), 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 - .insert("google".to_string(), 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_pse_test.rs b/aimux-providers/tests/google_pse_test.rs index 79e01fe07..efeadf59f 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}; @@ -156,33 +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 mut po = HashMap::new(); - po.insert("google_pse".to_string(), 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 9a1623a87..1a425cd1f 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -1026,27 +1026,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 mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), 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() { @@ -1185,75 +1164,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 mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert( - "groq".to_string(), - 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 mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), 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 mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), 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() { @@ -1317,73 +1227,6 @@ mod do_generate { assert_eq!(body["response_format"]["json_schema"]["name"], "test-name"); 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 mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), 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 mut provider_opts = std::collections::HashMap::new(); - provider_opts.insert("groq".to_string(), 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); - } } // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 87b604d1e..bd43b9875 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -30,7 +30,7 @@ use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; use aimux_core::message::Role; use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; +use aimux_core::result::{GenerateContent, Source}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; @@ -1086,212 +1086,6 @@ async fn should_stream_tool_calls() { // 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(&json!({ "huggingface": { "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(&json!({ "huggingface": { "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/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" diff --git a/aimux-providers/tests/jina_ai_test.rs b/aimux-providers/tests/jina_ai_test.rs index 7729fd34a..bccf0b61a 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,15 +89,6 @@ 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 -} - async fn mount_rerank_mock(server: &MockServer) { Mock::given(method("POST")) .and(path("/v1/rerank")) @@ -221,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 898b2178e..73f25281b 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,15 +60,6 @@ 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(), - json!({ "depth": depth, "outputType": output_type }), - ); - po -} - async fn mount_search_mock(server: &MockServer) { Mock::given(method("POST")) .and(path("/v1/search")) @@ -196,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/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/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" })); } diff --git a/aimux-providers/tests/open_responses_test.rs b/aimux-providers/tests/open_responses_test.rs index 1b720a584..c07300eb5 100644 --- a/aimux-providers/tests/open_responses_test.rs +++ b/aimux-providers/tests/open_responses_test.rs @@ -1299,84 +1299,6 @@ 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) - } - - #[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 -- // -- Tool choice tests -- diff --git a/aimux-providers/tests/openai_convert_test.rs b/aimux-providers/tests/openai_convert_test.rs deleted file mode 100644 index 96235c409..000000000 --- a/aimux-providers/tests/openai_convert_test.rs +++ /dev/null @@ -1,827 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust translations of the AI SDK OpenAI provider pure-function tests. -//! -//! Sources (TS → Rust): -//! - `packages/openai/src/chat/convert-to-openai-chat-messages.test.ts` -//! → `convert_prompt_to_openai_messages` -//! - `packages/openai/src/chat/openai-chat-prepare-tools.test.ts` -//! → `prepare_tools` -//! - `packages/openai/src/chat/openai-chat-language-model.test.ts` -//! (`doGenerate` `requestBodyJson` assertions) → `build_request_body` -//! -//! Tests that depend on features absent from the Rust data model -//! (`providerOptions`, `systemMessageMode`, file URL/reference/audio/PDF -//! parts, reasoning models, etc.) are documented at the bottom of each -//! module rather than translated — see "Remaining untranslated cases". - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::tool::FunctionTool; -use aimux_providers::openai::convert::{ - build_request_body, convert_prompt_to_openai_messages, prepare_tools, -}; -use serde_json::{Value, json}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// The JSON schema used across the TS `requestBodyJson` tool tests. -fn value_schema() -> Value { - json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }) -} - -fn sys(content: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text(content)], - provider_options: None, - } -} - -fn user_parts(parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::User, - content: parts, - provider_options: None, - } -} - -fn assistant_text(content: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text(content)], - provider_options: None, - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert_prompt_to_openai_messages -// (convert-to-openai-chat-messages.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_messages { - use super::*; - use serde_json::json; - - // ── system messages ────────────────────────────────────────────────────── - - /// TS: "should forward system messages" - #[test] - fn forwards_system_messages() { - let prompt = vec![sys("You are a helpful assistant.")]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ "role": "system", "content": "You are a helpful assistant." }]) - ); - } - - // ── user messages ──────────────────────────────────────────────────────── - - /// TS: "should convert messages with only a text part to a string content" - #[test] - fn text_only_user_message_becomes_string_content() { - let prompt = vec![user_parts(vec![ContentPart::text("Hello")])]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ "role": "user", "content": "Hello" }]) - ); - } - - /// TS: "should convert messages with image parts" - #[test] - fn converts_image_parts_to_data_url() { - let prompt = vec![user_parts(vec![ - ContentPart::text("Hello"), - ContentPart::image(vec![0, 1, 2, 3], "image/png".to_string()), - ])]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ - "role": "user", - "content": [ - { "type": "text", "text": "Hello" }, - { - "type": "image_url", - "image_url": { "url": "data:image/png;base64,AAECAw==" } - } - ] - }]) - ); - } - - /// TS: "should convert messages with Uint8Array image parts to data URLs" - #[test] - fn converts_uint8array_image_parts_to_data_urls() { - let prompt = vec![user_parts(vec![ContentPart::image( - vec![0xff, 0xd8, 0xff, 0xe0], - "image/jpeg".to_string(), - )])]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ - "role": "user", - "content": [{ - "type": "image_url", - "image_url": { "url": "data:image/jpeg;base64,/9j/4A==" } - }] - }]) - ); - } - - // ── tool calls ─────────────────────────────────────────────────────────── - - /// TS: "should stringify arguments to tool calls" - #[test] - fn stringifies_tool_call_arguments() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux".to_string(), - "thwomp".to_string(), - json!({ "foo": "bar123" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "quux".to_string(), - json!({ "oof": "321rab" }), - )], - ..Default::default() - }, - ]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([ - { - "role": "assistant", - "content": null, - "tool_calls": [{ - "type": "function", - "id": "quux", - "function": { - "name": "thwomp", - "arguments": "{\"foo\":\"bar123\"}" - } - }] - }, - { - "role": "tool", - "content": "{\"oof\":\"321rab\"}", - "tool_call_id": "quux" - } - ]) - ); - } - - /// TS: "should send empty string content for assistant messages with no - /// tool calls" - #[test] - fn assistant_with_empty_text_sends_empty_string_content() { - let prompt = vec![assistant_text("")]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ "role": "assistant", "content": "" }]) - ); - } - - /// TS: "should default missing tool call input to an empty object" - #[test] - fn missing_tool_call_input_defaults_to_empty_object() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux".to_string(), - "thwomp".to_string(), - Value::Null, - )], - ..Default::default() - }]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ - "role": "assistant", - "content": null, - "tool_calls": [{ - "type": "function", - "id": "quux", - "function": { "name": "thwomp", "arguments": "{}" } - }] - }]) - ); - } - - /// TS: "should handle different tool output types" - /// - /// A single tool-role message with two tool-result parts expands into two - /// separate OpenAI `tool` messages. - #[test] - fn handles_different_tool_output_types() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ - ContentPart::tool_result( - "text-tool".to_string(), - Value::String("Hello world".to_string()), - ), - ContentPart::tool_result( - "error-tool".to_string(), - Value::String("Something went wrong".to_string()), - ), - ], - ..Default::default() - }]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([ - { - "role": "tool", - "content": "Hello world", - "tool_call_id": "text-tool" - }, - { - "role": "tool", - "content": "Something went wrong", - "tool_call_id": "error-tool" - } - ]) - ); - } - - // ── Extended tests in `convert_messages_extended` module below ─────────── -} - -// ════════════════════════════════════════════════════════════════════════════ -// prepare_tools -// (openai-chat-prepare-tools.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod prepare_tools_tests { - use super::*; - use serde_json::json; - - /// A single function tool reused across the tool-choice / strict tests. - fn function_tool(name: &str, desc: &str, strict: Option) -> FunctionTool { - FunctionTool { - name: name.to_string(), - description: Some(desc.to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict, - provider_options: None, - input_examples: None, - } - } - - /// TS: "should return undefined tools and toolChoice when tools are null" - #[test] - fn null_tools_returns_none() { - let result = prepare_tools(&None, None); - assert_eq!(result.tools, None); - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - } - - /// TS: "should return undefined tools and toolChoice when tools are empty" - #[test] - fn empty_tools_returns_none() { - let result = prepare_tools(&Some(vec![]), None); - assert_eq!(result.tools, None); - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - } - - /// TS: "should correctly prepare function tools" - #[test] - fn prepares_function_tools() { - let tools = Some(vec![FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict: None, - provider_options: None, - input_examples: None, - }]); - let result = prepare_tools(&tools, None); - - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - } - - /// TS: "should handle tool choice 'auto'" - #[test] - fn tool_choice_auto() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools(&tools, Some(&ToolChoice::Auto)); - assert_eq!(result.tool_choice, Some(json!("auto"))); - } - - /// TS: "should handle tool choice 'required'" - #[test] - fn tool_choice_required() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools(&tools, Some(&ToolChoice::Required)); - assert_eq!(result.tool_choice, Some(json!("required"))); - } - - /// TS: "should handle tool choice 'none'" - #[test] - fn tool_choice_none() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools(&tools, Some(&ToolChoice::None)); - assert_eq!(result.tool_choice, Some(json!("none"))); - } - - /// TS: "should handle tool choice 'tool'" - #[test] - fn tool_choice_tool() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools( - &tools, - Some(&ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }), - ); - assert_eq!( - result.tool_choice, - Some(json!({ "type": "function", "function": { "name": "testFunction" } })) - ); - } - - /// TS: "should pass through strict mode when strict is true" - #[test] - fn strict_true_is_passed_through() { - let tools = Some(vec![function_tool( - "testFunction", - "A test function", - Some(true), - )]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} }, - "strict": true - } - })]) - ); - } - - /// TS: "should pass through strict mode when strict is false" - #[test] - fn strict_false_is_passed_through() { - let tools = Some(vec![function_tool( - "testFunction", - "A test function", - Some(false), - )]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} }, - "strict": false - } - })]) - ); - } - - /// TS: "should not include strict mode when strict is undefined" - #[test] - fn strict_undefined_is_omitted() { - let tools = Some(vec![function_tool("testFunction", "A test function", None)]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - } - - /// TS: "should pass through strict mode for multiple tools with different - /// strict settings" - #[test] - fn multiple_tools_with_different_strict_settings() { - let tools = Some(vec![ - function_tool("strictTool", "A strict tool", Some(true)), - function_tool("nonStrictTool", "A non-strict tool", Some(false)), - function_tool("defaultTool", "A tool without strict setting", None), - ]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![ - json!({ - "type": "function", - "function": { - "name": "strictTool", - "description": "A strict tool", - "parameters": { "type": "object", "properties": {} }, - "strict": true - } - }), - json!({ - "type": "function", - "function": { - "name": "nonStrictTool", - "description": "A non-strict tool", - "parameters": { "type": "object", "properties": {} }, - "strict": false - } - }), - json!({ - "type": "function", - "function": { - "name": "defaultTool", - "description": "A tool without strict setting", - "parameters": { "type": "object", "properties": {} } - } - }), - ]) - ); - } - - // ── Remaining untranslated cases ───────────────────────────────────────── - // - // * "should add warnings for unsupported tools" — the Rust `FunctionTool` - // has no `type` discriminator (it is always a function tool), so there - // is no `provider`-type tool to reject and warn about. -} - -// ════════════════════════════════════════════════════════════════════════════ -// build_request_body -// (openai-chat-language-model.test.ts — doGenerate requestBodyJson assertions) -// ════════════════════════════════════════════════════════════════════════════ - -mod build_request_body_tests { - use super::*; - use serde_json::json; - - /// TS: "should pass the model and the messages" - #[test] - fn passes_model_and_messages() { - let options = default_options(test_prompt()); - let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-3.5-turbo", - "messages": [{ "role": "user", "content": "Hello" }] - }) - ); - } - - /// 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 mut provider_options = std::collections::HashMap::new(); - provider_options.insert( - "openai".to_string(), - 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() { - let options = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: value_schema(), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: Some(ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-3.5-turbo", - "messages": [{ "role": "user", "content": "Hello" }], - "tool_choice": { - "type": "function", - "function": { "name": "test-tool" } - }, - "tools": [{ - "type": "function", - "function": { - "name": "test-tool", - "parameters": value_schema() - } - }] - }) - ); - } - - /// TS: "should not send a response_format when response format is text" - #[test] - fn text_response_format_is_omitted() { - let options = CallOptions { - response_format: Some(ResponseFormat::Text), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }] - }) - ); - } - - /// TS: "should forward json response format as 'json_object' without schema" - #[test] - fn json_without_schema_becomes_json_object() { - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { "type": "json_object" } - }) - ); - } - - /// TS: "should forward json response format as 'json_object' and include - /// schema" - #[test] - fn json_with_schema_uses_json_schema() { - let schema = value_schema(); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { - "type": "json_schema", - "json_schema": { - "schema": schema, - "name": "response", - "strict": true - } - } - }) - ); - } - - /// TS: "should use json_schema & strict with responseFormat json" - /// - /// Same shape as above; the TS repeats the assertion to confirm strict - /// mode is used even without an explicit name/description. - #[test] - fn json_schema_strict_without_name() { - let schema = value_schema(); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body["response_format"], - json!({ - "type": "json_schema", - "json_schema": { - "schema": schema, - "name": "response", - "strict": true - } - }) - ); - } - - /// TS: "should set name & description with responseFormat json" - #[test] - fn json_schema_sets_name_and_description() { - let schema = value_schema(); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { - "type": "json_schema", - "json_schema": { - "schema": schema, - "name": "test-name", - "description": "test description", - "strict": true - } - } - }) - ); - } - - /// TS: "should allow for undefined schema with responseFormat json when - /// structuredOutputs are enabled" - #[test] - fn json_without_schema_even_with_name_and_description() { - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: None, - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { "type": "json_object" } - }) - ); - } - - /// TS: "should set strict with tool call" - /// - /// Tools with `toolChoice: required` and a description. - #[test] - fn strict_with_tool_call_required() { - let options = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: Some("test description".to_string()), - input_schema: value_schema(), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: Some(ToolChoice::Required), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "tool_choice": "required", - "tools": [{ - "type": "function", - "function": { - "name": "test-tool", - "description": "test description", - "parameters": value_schema() - } - }] - }) - ); - } - - /// TS: "should set strict for tool usage" - /// - /// Tools with `toolChoice: tool` (no description). - #[test] - fn strict_for_tool_usage() { - let options = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: value_schema(), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: Some(ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "tool_choice": { - "type": "function", - "function": { "name": "test-tool" } - }, - "tools": [{ - "type": "function", - "function": { - "name": "test-tool", - "parameters": value_schema() - } - }] - }) - ); - } - - // ── Remaining untranslated requestBodyJson cases ──────────────────────── - // - // * "should pass settings" (logitBias, parallelToolCalls, user) — these - // come from `providerOptions.openai`, which the Rust `CallOptions` does - // not surface as individual fields. - // * reasoning_effort / reasoning model tests — the Rust `CallOptions` has - // no `reasoning` field and `build_request_body` has no reasoning-model - // detection (which would remap `max_tokens` → `max_completion_tokens` - // and strip temperature/topP/frequencyPenalty/presencePenalty). - // * textVerbosity — `providerOptions.openai.textVerbosity`. - // * systemMessageMode 'developer' for o1 / forceReasoning models. -} diff --git a/aimux-providers/tests/openai_embedding_test.rs b/aimux-providers/tests/openai_embedding_test.rs index 1c7bc3eae..84324fedf 100644 --- a/aimux-providers/tests/openai_embedding_test.rs +++ b/aimux-providers/tests/openai_embedding_test.rs @@ -227,46 +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 mut provider_options = HashMap::new(); - provider_options.insert("openai".to_string(), 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 ab503b3ba..000000000 --- a/aimux-providers/tests/openai_files_test.rs +++ /dev/null @@ -1,321 +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}; -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 mut po = HashMap::new(); - po.insert("openai".to_string(), 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 mut po = HashMap::new(); - po.insert("openai".to_string(), 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 mut po = HashMap::new(); - po.insert("openai".to_string(), 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 mut po = HashMap::new(); - po.insert( - "openai".to_string(), - 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 mut po = HashMap::new(); - po.insert("openai".to_string(), 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 a76deedda..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}; @@ -128,138 +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 - .insert("openai".to_string(), 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.insert( - "openai".to_string(), - 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 - .insert("openai".to_string(), 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() { @@ -592,41 +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 - .insert("openai".to_string(), 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() { @@ -862,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.insert( - "openai".to_string(), - 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.insert( - "openai".to_string(), - 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_transcription_test.rs b/aimux-providers/tests/openai_transcription_test.rs index 73d930a33..f533f960e 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` //! @@ -318,93 +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 mut po = HashMap::new(); - po.insert( - "openai".to_string(), - 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 mut po = HashMap::new(); - po.insert( - "openai".to_string(), - 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 mut po = HashMap::new(); - po.insert( - "openai".to_string(), - 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() { @@ -439,105 +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 mut po = HashMap::new(); - po.insert( - "openai".to_string(), - 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 mut po = HashMap::new(); - po.insert( - "openai".to_string(), - 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 ad3d2705f..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.insert( - "recraft".to_string(), - 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 8d5206282..4ee5d239d 100644 --- a/aimux-providers/tests/replicate_image_test.rs +++ b/aimux-providers/tests/replicate_image_test.rs @@ -112,24 +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 - .insert("replicate".into(), 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 856c7dd54..000000000 --- a/aimux-providers/tests/vertex_embedding_test.rs +++ /dev/null @@ -1,335 +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 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_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(), - json!({ - "outputDimensionality": 768, - "taskType": "SEMANTIC_SIMILARITY", - "title": "test title", - "autoTruncate": false - }), - ); - opts -} - -/// 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 mut provider_options = HashMap::new(); - provider_options.insert( - "googleVertex".to_string(), - 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 mut provider_options = HashMap::new(); - provider_options.insert( - "google".to_string(), - 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 4498d0bcd..15accfcb0 100644 --- a/aimux-providers/tests/vertex_transcription_test.rs +++ b/aimux-providers/tests/vertex_transcription_test.rs @@ -186,39 +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 mut po = HashMap::new(); - po.insert( - "googleVertex".to_string(), - 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 8c32565ec..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 mut provider_options = HashMap::new(); - provider_options.insert("voyage".to_string(), 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 mut provider_options = HashMap::new(); - provider_options.insert("voyage".to_string(), 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/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/node/src/types/CallOptions.ts b/bindings/node/src/types/CallOptions.ts index 40f9151ad..3f8c21974 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 1c7a27fae..bd1ddab7e 100644 --- a/bindings/node/src/types/ContentPart.ts +++ b/bindings/node/src/types/ContentPart.ts @@ -7,32 +7,32 @@ import type { JsonValue } from "./serde_json/JsonValue"; * * Shared between `ModelMessage` (user-facing) and `LanguageModelPrompt` (provider-facing). */ -export type ContentPart = { "type": "text", text: string, +export type ContentPart = { "type": "text", text: string, /** * Provider-specific options for this part (e.g. `openai.promptCacheBreakpoint`). */ -provider_options?: JsonValue | null, } | { "type": "custom", kind: string, provider_options?: JsonValue | null, } | { "type": "reasoning_file", data: GeneratedFileData, media_type: string, provider_options?: JsonValue | null, } | { "type": "tool_approval_request", approval_id: string, tool_call_id: string, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, input_schema_input?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: JsonValue | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: JsonValue | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "reasoning", text: string, signature: string | null, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "custom", kind: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning_file", data: GeneratedFileData, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_approval_request", approval_id: string, tool_call_id: string, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, input_schema_input?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: { [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). */ -input: JsonValue, +input: JsonValue, /** * Whether the tool call is executed by the provider rather than by * the client. This is part of the prompt contract because providers * need it to replay server tool calls on a later turn. */ -provider_executed?: boolean | null, +provider_executed?: boolean | 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). * @@ -41,27 +41,27 @@ provider_options?: JsonValue | null, } | { "type": "tool_result", tool_call_id: * so multi-part tool messages constructed either way round-trip * correctly. Serialization always emits `result`. */ -result: JsonValue, +result: JsonValue, /** * The name of the tool that produced this result (optional on the * user-input side; providers that need it can look it up from the * preceding tool call). */ -tool_name?: string | null, +tool_name?: string | null, /** * Whether the result is an error or error message. */ -is_error?: boolean | null, +is_error?: boolean | null, /** * Whether the result is preliminary (replaces prior, e.g. image previews). */ -preliminary?: boolean | null, +preliminary?: boolean | null, /** * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ -dynamic?: boolean | null, +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/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, }; 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 efdcfaccf..c43b94a6a 100644 --- a/bindings/node/src/types/FunctionTool.ts +++ b/bindings/node/src/types/FunctionTool.ts @@ -27,7 +27,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 7fc35af7f..456e003b9 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -10,4 +10,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A content item in the generation result. */ -export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": C } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": R } | { "Custom": { kind: string, provider_metadata?: JsonValue | null, } } | { "ToolApprovalRequest": A } | { "ToolResult": ToolResult }; +export type GenerateContent = { "Text": { text: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": C } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": R } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": A } | { "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 260650d48..5dafcbfd9 100644 --- a/bindings/node/src/types/GenerateResult.ts +++ b/bindings/node/src/types/GenerateResult.ts @@ -11,4 +11,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Result of `LanguageModel::do_generate` (non-streaming). */ -export type GenerateResult = { content: Array>, finish_reason: FinishReason, usage: Usage, warnings: Array, provider_metadata: JsonValue | null, response: { id: string | null, timestamp: string | null, model_id: string | null, body?: JsonValue | null, } | null, request_body: JsonValue | null, response_headers: { [key in string]: string } | null, }; +export type GenerateResult = { content: Array>, finish_reason: FinishReason, usage: Usage, warnings: Array, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: { id: string | null, timestamp: string | null, model_id: string | null, body?: JsonValue | null, } | null, request_body: JsonValue | null, response_headers: { [key in string]: string } | null, }; 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 0243cf10b..1cfc38b31 100644 --- a/bindings/node/src/types/GenerateTextResult.ts +++ b/bindings/node/src/types/GenerateTextResult.ts @@ -76,7 +76,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 bf6a39e7c..48333ce42 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"; * or URL. The payload of both `GenerateContent::File` and * `StreamPart::File`, and the element of the text results' `files`. */ -export type GeneratedFile = { data: GeneratedFileData, media_type: string, provider_metadata?: JsonValue | null, }; +export type GeneratedFile = { data: GeneratedFileData, 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 31ac3d097..f9239ad68 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 = { "source_type": "url", id: string, url: string, title?: string | null, provider_metadata?: JsonValue | null, } | { "source_type": "document", id: string, media_type: string, title: string, filename?: string | null, provider_metadata?: JsonValue | null, }; +export type Source = { "source_type": "url", id: string, url: string, title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "source_type": "document", id: string, media_type: string, title: string, filename?: 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 032220e5c..adab7631d 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -19,28 +19,28 @@ 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. */ -provider_executed?: boolean | null, +provider_executed?: boolean | null, /** * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ -dynamic?: boolean | null, +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 } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: JsonValue | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, +title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: 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 86f3f1f75..305eacfb0 100644 --- a/bindings/node/src/types/StreamTextResultAggregated.ts +++ b/bindings/node/src/types/StreamTextResultAggregated.ts @@ -71,7 +71,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/TextContent.ts b/bindings/node/src/types/TextContent.ts index 7c55c1ac1..09ad96b47 100644 --- a/bindings/node/src/types/TextContent.ts +++ b/bindings/node/src/types/TextContent.ts @@ -10,4 +10,4 @@ import type { ToolCall } from "./ToolCall"; import type { ToolResult } from "./ToolResult"; import type { JsonValue } from "./serde_json/JsonValue"; -export type TextContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": ToolCall } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: JsonValue | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ToolResult": ToolResult }; +export type TextContent = { "Text": { text: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "Source": Source } | { "Reasoning": ReasoningOutput } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ToolResult": ToolResult }; diff --git a/bindings/node/src/types/ToolCall.ts b/bindings/node/src/types/ToolCall.ts index 605cb733d..46500aa89 100644 --- a/bindings/node/src/types/ToolCall.ts +++ b/bindings/node/src/types/ToolCall.ts @@ -30,7 +30,7 @@ dynamic?: boolean | 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. */ 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,