diff --git a/aimux-providers/layout.toml b/aimux-providers/layout.toml index f0c6c5728..c9036de33 100644 --- a/aimux-providers/layout.toml +++ b/aimux-providers/layout.toml @@ -1047,3 +1047,469 @@ issue = 275 upstream = "src/version.ts" status = "not-applicable" reason = "the crate version is used instead (RFC-0046 §4.2)" + +[google] +package = "@ai-sdk/google" +# google-language-model.test.ts has two cases named `doGenerate › should pass tools and +# toolChoice`; the second marker ends in `#2` (RFC-0045 §4). + +[[google.files]] +upstream = "src/convert-google-usage.ts" +rust = "google/convert.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/convert-to-google-messages.ts" +rust = "google/convert.rs" +status = "mapped" + +[google.files.tests] +sub-feature = [ + { case = "tool messages › should convert supported tool result URLs into functionResponse file data", issue = 275, reason = "convert_to_google_messages_for_namespace has no supportedFunctionResponseUrls allow-list; it keeps gs:// file data for the Vertex namespace only" }, + { case = "tool messages › should preserve the original supported GCS tool result URL", issue = 275, reason = "convert_to_google_messages_for_namespace has no supportedFunctionResponseUrls allow-list; it keeps gs:// file data for the Vertex namespace only" }, +] + +[[google.files]] +upstream = "src/download-tool-result-files.ts" +status = "missing" +issue = 275 +reason = "no tool result file download (#275 tool result file download, downloadToolResultFiles)" + +[[google.files]] +upstream = "src/get-model-path.ts" +rust = "google/utils.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/get-realtime-base-url.ts" +rust = "google/transcription.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-batch.ts" +status = "missing" +issue = 275 +reason = "no Batch API (#275 Google Batch API)" + +[[google.files]] +upstream = "src/google-embedding-model-options.ts" +rust = "google/embedding.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-embedding-model.ts" +rust = "google/embedding.rs" +status = "mapped" + +[google.files.tests] +sub-feature = [ + { case = "GoogleEmbeddingModel › should slice per-value multimodal content for automatic batches", issue = 275, reason = "EmbeddingModel has no per-batch provider-options transformer (EXPERIMENTAL_EMBEDDING_MODEL_PROVIDER_OPTIONS_TRANSFORMER)" }, +] + +[[google.files]] +upstream = "src/google-error.ts" +rust = "google/mod.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-files.ts" +rust = "google/files.rs" +status = "mapped" + +[google.files.tests] +sub-feature = [ + { case = "GoogleFiles › uploadFile › should thread per-call headers and abortSignal through the upload", issue = 275, reason = "UploadFileCallOptions has no per-call headers or abort signal" }, +] +ts-only = [ + { case = "GoogleFiles › uploadFile › should preserve ArrayBuffer-backed upload data", reason = "asserts that the same Uint8Array view reaches fetch (object identity of a typed-array view); Rust upload data is an owned Vec" }, + { case = "GoogleFiles › uploadFile › should reject stream data at runtime and cancel the stream", reason = "a ReadableStream passed where the type forbids it is a JS runtime check; UploadFileData has no stream variant, so Rust cannot express the input" }, +] + +[[google.files]] +upstream = "src/google-image-model-options.ts" +rust = "google/image.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-image-model.ts" +rust = "google/image.rs" +status = "mapped" + +[google.files.tests] +sub-feature = [ + { case = "GoogleImageModel › doGenerate › should preserve prompt feedback when a prompt block returns no candidates", issue = 275, reason = "aimux-core ImageResult has no isRetryable field" }, +] + +[[google.files]] +upstream = "src/google-image-settings.ts" +rust = "google/image.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-json-accumulator.ts" +rust = "google/utils.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-language-model-options.ts" +rust = "google/convert.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-language-model.ts" +rust = "google/model.rs" +status = "mapped" + +[google.files.tests] +sub-feature = [ + { case = "doGenerate › should not forward unsupported Vertex %s tool result URLs", issue = 275, reason = "the rejection comes from the tool result file download step (downloadToolResultFiles), which aimux does not have" }, + { case = "groundingMetadataSchema › rejects invalid data types", issue = 275, reason = "aimux keeps candidates[].groundingMetadata as untyped JSON (types::Candidate::grounding_metadata) and validates no grounding metadata schema" }, +] +ts-only = [ + { case = "doGenerate › should preserve Vertex tool result URL handling across workflow serialization", reason = "WORKFLOW_SERIALIZE / WORKFLOW_DESERIALIZE are JS workflow-runtime hooks; it also needs downloadToolResultFiles, which aimux does not have" }, +] + +[[google.files]] +upstream = "src/google-model-capabilities.ts" +rust = "google/convert.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-prepare-tools.ts" +rust = "google/convert.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-prompt.ts" +rust = "google/convert.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-provider.ts" +rust = "google/mod.rs" +status = "mapped" + +[google.files.tests] +sub-feature = [ + { case = "google-provider › should only advertise URL support shared by all batch models", issue = 275, reason = "no Batch API (experimental_batch)" }, +] +ts-only = [ + { case = "google provider - video › should pass custom generateId to video model", reason = "checks a mocked constructor argument; upstream GoogleVideoModel never calls generateId, so nothing observable follows from it" }, +] + +[[google.files]] +upstream = "src/google-speech-api.ts" +rust = "google/speech.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-speech-input.ts" +rust = "google/speech.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-speech-model-options.ts" +rust = "google/speech.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-speech-model.ts" +rust = "google/speech.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-supported-file-url.ts" +rust = "google/utils.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-tools.ts" +status = "missing" +issue = 275 +reason = "no tool factory on the Google provider (#275 厂商内置工具工厂); provider tools are sent by their id through prepare_tools in google/convert.rs" + +[[google.files]] +upstream = "src/google-video-model-options.ts" +rust = "google/video.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-video-model.ts" +rust = "google/video.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/google-video-settings.ts" +rust = "google/video.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/index.ts" +rust = "google/mod.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/interactions/build-google-interactions-stream-transform.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/cancel-google-interaction.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/convert-google-interactions-usage.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/convert-to-google-interactions-input.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/extract-google-interactions-sources.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/google-interactions-agent.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/google-interactions-api.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/google-interactions-language-model-options.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/google-interactions-language-model.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/google-interactions-prompt.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/google-interactions-provider-metadata.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/map-google-interactions-finish-reason.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/parse-google-interactions-outputs.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/poll-google-interactions.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/prepare-google-interactions-tools.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/stream-google-interactions.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/interactions/synthesize-google-interactions-agent-stream.ts" +status = "missing" +issue = 275 +reason = "no Google Interactions language model (#275 Google interactions model); aimux only calls the Interactions API for unary transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/internal/index.ts" +rust = "google/mod.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/map-google-finish-reason.ts" +rust = "google/convert.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/realtime/google-realtime-event-mapper.ts" +status = "missing" +issue = 275 +reason = "no realtime model (#275 realtime models); the Live API is used only by live transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/realtime/google-realtime-model-options.ts" +status = "missing" +issue = 275 +reason = "no realtime model (#275 realtime models); the Live API is used only by live transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/realtime/google-realtime-model.ts" +status = "missing" +issue = 275 +reason = "no realtime model (#275 realtime models); the Live API is used only by live transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/realtime/index.ts" +status = "missing" +issue = 275 +reason = "no realtime model (#275 realtime models); the Live API is used only by live transcription (google/transcription.rs)" + +[[google.files]] +upstream = "src/sanitize-response-json-schema.ts" +rust = "google/convert.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/speech-translation/google-speech-translation-model-options.ts" +status = "missing" +issue = 275 +reason = "no speech translation model (#275 speech translation models)" + +[[google.files]] +upstream = "src/speech-translation/google-speech-translation-model.ts" +status = "missing" +issue = 275 +reason = "no speech translation model (#275 speech translation models)" + +[[google.files]] +upstream = "src/tool/code-execution.ts" +status = "missing" +issue = 275 +reason = "no tool factory on the Google provider (#275 厂商内置工具工厂); provider tools are sent by their id through prepare_tools in google/convert.rs" + +[[google.files]] +upstream = "src/tool/enterprise-web-search.ts" +status = "missing" +issue = 275 +reason = "no tool factory on the Google provider (#275 厂商内置工具工厂); provider tools are sent by their id through prepare_tools in google/convert.rs" + +[[google.files]] +upstream = "src/tool/file-search.ts" +status = "missing" +issue = 275 +reason = "no tool factory on the Google provider (#275 厂商内置工具工厂); provider tools are sent by their id through prepare_tools in google/convert.rs" + +[[google.files]] +upstream = "src/tool/google-maps.ts" +status = "missing" +issue = 275 +reason = "no tool factory on the Google provider (#275 厂商内置工具工厂); provider tools are sent by their id through prepare_tools in google/convert.rs" + +[[google.files]] +upstream = "src/tool/google-search.ts" +status = "missing" +issue = 275 +reason = "no tool factory on the Google provider (#275 厂商内置工具工厂); provider tools are sent by their id through prepare_tools in google/convert.rs" + +[[google.files]] +upstream = "src/tool/url-context.ts" +status = "missing" +issue = 275 +reason = "no tool factory on the Google provider (#275 厂商内置工具工厂); provider tools are sent by their id through prepare_tools in google/convert.rs" + +[[google.files]] +upstream = "src/tool/vertex-rag-store.ts" +status = "missing" +issue = 275 +reason = "no tool factory on the Google provider (#275 厂商内置工具工厂); provider tools are sent by their id through prepare_tools in google/convert.rs" + +[[google.files]] +upstream = "src/transcription/google-transcription-model-options.ts" +rust = "google/transcription.rs" +status = "mapped" + +[google.files.tests] + +[[google.files]] +upstream = "src/transcription/google-transcription-model.ts" +rust = "google/transcription.rs" +status = "mapped" + +[google.files.tests] +sub-feature = [ + { case = "doGenerate › transcribes audio via the Interactions API with transcription_config", issue = 275, reason = "aimux-core TranscriptionResult has no usage field" }, + { case = "doStream › streams transcription over the Gemini Live API WebSocket", issue = 275, reason = "the aimux-core transcription stream Finish part has no usage field" }, +] + +[[google.files]] +upstream = "src/version.ts" +status = "not-applicable" +reason = "VERSION is the package version string; aimux pins it in shared/aisdk_pins.rs, which the user-agent tests read" diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index c6818eb42..1a84516f9 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -83,7 +83,7 @@ pub fn convert_to_google_messages(prompt: &LanguageModelPrompt) -> GooglePrompt convert_to_google_messages_for_namespace(prompt, Namespace::Google, true) } -fn convert_to_google_messages_for_namespace( +pub(super) fn convert_to_google_messages_for_namespace( prompt: &LanguageModelPrompt, namespace: Namespace, supports_function_response_parts: bool, diff --git a/aimux-providers/src/google/mod.rs b/aimux-providers/src/google/mod.rs index 788226717..8866faa22 100644 --- a/aimux-providers/src/google/mod.rs +++ b/aimux-providers/src/google/mod.rs @@ -514,3 +514,6 @@ pub(crate) async fn list_models_once( }) .collect()) } + +#[cfg(test)] +mod tests; diff --git a/aimux-providers/src/google/tests/convert_to_messages.rs b/aimux-providers/src/google/tests/convert_to_messages.rs new file mode 100644 index 000000000..90046cb3b --- /dev/null +++ b/aimux-providers/src/google/tests/convert_to_messages.rs @@ -0,0 +1,2667 @@ +//! `convertToGoogleMessages` (`convert-to-google-messages.test.ts`). +//! +//! The upstream converter takes options; aimux splits it: +//! - `validate_call_options_for_namespace` raises the errors upstream throws +//! while converting (it runs before the conversion in `GoogleModel`); +//! - `convert_to_google_messages_for_namespace(prompt, namespace, +//! supports_function_response_parts)` is the conversion +//! (`providerOptionsNames: ['googleVertex', 'vertex']` is +//! `Namespace::Vertex`, `supportsFunctionResponseParts` the flag); +//! - `isGemmaModel` and `isGemini3Model` (sentinel injection and its warning) +//! live in the request-body builder, keyed by the model id, so those cases +//! build the body for a Gemma / Gemini 3 / Gemini 2.5 model id and read +//! `contents` / `systemInstruction` and the warnings from it. + +use serde_json::{Value, json}; + +use aimux_core::error::AiMuxError; +use aimux_core::options::CallOptions; + +use crate::google::convert::{ + build_request_body_with_warnings_for_namespace, convert_to_google_messages_for_namespace, + validate_call_options_for_namespace, +}; +use crate::google::options::Namespace; + +const SKIP_THOUGHT_SIGNATURE_VALIDATOR: &str = "skip_thought_signature_validator"; + +/// A Gemma model id (`isGemmaModel: true`). +const GEMMA_MODEL: &str = "gemma-3-27b-it"; +/// A Gemini 3 model id (`isGemini3Model: true`). +const GEMINI_3_MODEL: &str = "gemini-3-pro-preview"; +/// A pre-Gemini-3 model id (`isGemini3Model: false`). +const GEMINI_2_5_MODEL: &str = "gemini-2.5-flash"; + +fn call_options(prompt: Value) -> CallOptions { + CallOptions::new(serde_json::from_value(prompt).expect("prompt deserializes")) +} + +fn try_convert_with( + prompt: Value, + namespace: Namespace, + supports_function_response_parts: bool, +) -> Result { + let options = call_options(prompt); + validate_call_options_for_namespace(&options, namespace)?; + let result = convert_to_google_messages_for_namespace( + &options.prompt, + namespace, + supports_function_response_parts, + ); + let mut value = json!({ "contents": result.contents }); + if let Some(system_instruction) = result.system_instruction { + value["systemInstruction"] = system_instruction; + } + Ok(value) +} + +/// `convertToGoogleMessages(prompt)` with the default options. +fn convert(prompt: Value) -> Value { + try_convert_with(prompt, Namespace::Google, true).expect("conversion succeeds") +} + +/// `providerOptionsNames: ['googleVertex', 'vertex']`. +fn convert_vertex(prompt: Value) -> Value { + try_convert_with(prompt, Namespace::Vertex, true).expect("conversion succeeds") +} + +/// Upstream's expected value without `functionCall.id`. The Rust conversion +/// for the Vertex namespace is the one the Vertex model runs, which passes +/// `includeFunctionCallIds: !isVertexProvider` (google-language-model.ts:329); +/// the direct upstream call keeps the ids because that option defaults to +/// `true`, a combination no aimux caller can reach. +fn without_function_call_ids(mut expected: Value) -> Value { + fn strip(value: &mut Value) { + match value { + Value::Object(map) => { + if let Some(Value::Object(call)) = map.get_mut("functionCall") { + call.remove("id"); + } + map.values_mut().for_each(strip); + } + Value::Array(items) => items.iter_mut().for_each(strip), + _ => {} + } + } + strip(&mut expected); + expected +} + +/// `supportsFunctionResponseParts: false`. +fn convert_legacy(prompt: Value) -> Value { + try_convert_with(prompt, Namespace::Google, false).expect("conversion succeeds") +} + +/// The message of the error the conversion throws. +fn convert_error(prompt: Value) -> String { + try_convert_with(prompt, Namespace::Google, true) + .expect_err("conversion fails") + .to_string() +} + +/// The conversion as the request-body builder runs it for `model_id`: the +/// `contents` / `systemInstruction` and the warnings (the `onWarning` calls). +fn convert_for_model(model_id: &str, namespace: Namespace, prompt: Value) -> (Value, Vec) { + let options = call_options(prompt); + validate_call_options_for_namespace(&options, namespace).expect("valid prompt"); + let (body, warnings) = + build_request_body_with_warnings_for_namespace(model_id, &options, namespace) + .expect("body builds"); + let mut value = json!({ "contents": body["contents"] }); + if let Some(system_instruction) = body.get("systemInstruction") { + value["systemInstruction"] = system_instruction.clone(); + } + let warnings = warnings + .into_iter() + .map(|warning| serde_json::to_value(warning).unwrap()) + .collect(); + (value, warnings) +} + +/// `expect(actual).toMatchObject(expected)`: every expected key is present +/// and matches, recursively; arrays match element-wise. +fn assert_match_object(actual: &Value, expected: &Value) { + fn matches(actual: &Value, expected: &Value) -> bool { + match (actual, expected) { + (Value::Object(actual), Value::Object(expected)) => expected + .iter() + .all(|(key, value)| actual.get(key).is_some_and(|a| matches(a, value))), + (Value::Array(actual), Value::Array(expected)) => { + actual.len() == expected.len() + && actual.iter().zip(expected).all(|(a, e)| matches(a, e)) + } + _ => actual == expected, + } + } + assert!( + matches(actual, expected), + "expected {actual:#} to match {expected:#}" + ); +} + +mod system_messages { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › system messages › should store system message in system instruction + #[test] + fn should_store_system_message_in_system_instruction() { + let result = convert(json!([{ "role": "system", "content": "Test" }])); + + assert_eq!( + result, + json!({ + "systemInstruction": { "parts": [{ "text": "Test" }] }, + "contents": [], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › system messages › should throw error when there was already a user message + #[test] + fn should_throw_error_when_there_was_already_a_user_message() { + let message = convert_error(json!([ + { "role": "user", "content": [{ "type": "text", "text": "Test" }] }, + { "role": "system", "content": "Test" }, + ])); + + assert!( + message.contains( + "system messages are only supported at the beginning of the conversation" + ), + "{message}" + ); + } +} + +mod thought_signatures { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › thought signatures › should preserve thought signatures in assistant messages + #[test] + fn should_preserve_thought_signatures_in_assistant_messages() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Regular text", + "providerOptions": { "google": { "thoughtSignature": "sig1" } }, + }, + { + "type": "reasoning", + "text": "Reasoning text", + "providerOptions": { "google": { "thoughtSignature": "sig2" } }, + }, + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "test", + "input": { "value": "test" }, + "providerOptions": { "google": { "thoughtSignature": "sig3" } }, + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "parts": [ + { "text": "Regular text", "thoughtSignature": "sig1" }, + { + "text": "Reasoning text", + "thought": true, + "thoughtSignature": "sig2", + }, + { + "functionCall": { + "args": { "value": "test" }, + "id": "call1", + "name": "test", + }, + "thoughtSignature": "sig3", + }, + ], + "role": "model", + }, + ], + }) + ); + } +} + +mod thought_signatures_with_vertex_provider_options_name { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › thought signatures with vertex providerOptionsName › should resolve thoughtSignature from google namespace when using vertex providerOptionsName + #[test] + fn should_resolve_thought_signature_from_google_namespace_when_using_vertex_name() { + let result = convert_vertex(json!([ + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Regular text", + "providerOptions": { "google": { "thoughtSignature": "sig1" } }, + }, + { + "type": "reasoning", + "text": "Reasoning text", + "providerOptions": { "google": { "thoughtSignature": "sig2" } }, + }, + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "getWeather", + "input": { "location": "London" }, + "providerOptions": { "google": { "thoughtSignature": "sig3" } }, + }, + ], + }, + ])); + + assert_eq!( + result, + without_function_call_ids(json!({ + "contents": [ + { + "parts": [ + { "text": "Regular text", "thoughtSignature": "sig1" }, + { + "text": "Reasoning text", + "thought": true, + "thoughtSignature": "sig2", + }, + { + "functionCall": { + "args": { "location": "London" }, + "id": "call1", + "name": "getWeather", + }, + "thoughtSignature": "sig3", + }, + ], + "role": "model", + }, + ], + })) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › thought signatures with vertex providerOptionsName › should prefer vertex namespace over google namespace when both are present + #[test] + fn should_prefer_vertex_namespace_over_google_namespace_when_both_are_present() { + let result = convert_vertex(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "getWeather", + "input": { "location": "London" }, + "providerOptions": { + "vertex": { "thoughtSignature": "vertex_sig" }, + "google": { "thoughtSignature": "google_sig" }, + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + without_function_call_ids(json!({ + "functionCall": { + "id": "call1", + "name": "getWeather", + "args": { "location": "London" }, + }, + "thoughtSignature": "vertex_sig", + })) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › thought signatures with vertex providerOptionsName › should resolve thoughtSignature from vertex namespace directly + #[test] + fn should_resolve_thought_signature_from_vertex_namespace_directly() { + let result = convert_vertex(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "getWeather", + "input": { "location": "London" }, + "providerOptions": { "vertex": { "thoughtSignature": "vertex_sig" } }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + without_function_call_ids(json!({ + "functionCall": { + "id": "call1", + "name": "getWeather", + "args": { "location": "London" }, + }, + "thoughtSignature": "vertex_sig", + })) + ); + } +} + +mod thought_signatures_with_google_provider_options_name_gateway_failover { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › thought signatures with google providerOptionsName (gateway failover) › should resolve thoughtSignature from vertex namespace when using google providerOptionsName + #[test] + fn should_resolve_thought_signature_from_vertex_namespace_when_using_google_name() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Regular text", + "providerOptions": { "vertex": { "thoughtSignature": "sig1" } }, + }, + { + "type": "reasoning", + "text": "Reasoning text", + "providerOptions": { "vertex": { "thoughtSignature": "sig2" } }, + }, + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "getWeather", + "input": { "location": "London" }, + "providerOptions": { "vertex": { "thoughtSignature": "sig3" } }, + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "parts": [ + { "text": "Regular text", "thoughtSignature": "sig1" }, + { + "text": "Reasoning text", + "thought": true, + "thoughtSignature": "sig2", + }, + { + "functionCall": { + "args": { "location": "London" }, + "id": "call1", + "name": "getWeather", + }, + "thoughtSignature": "sig3", + }, + ], + "role": "model", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › thought signatures with google providerOptionsName (gateway failover) › should prefer google namespace over vertex namespace when both are present + #[test] + fn should_prefer_google_namespace_over_vertex_namespace_when_both_are_present() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "getWeather", + "input": { "location": "London" }, + "providerOptions": { + "google": { "thoughtSignature": "google_sig" }, + "vertex": { "thoughtSignature": "vertex_sig" }, + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionCall": { + "id": "call1", + "name": "getWeather", + "args": { "location": "London" }, + }, + "thoughtSignature": "google_sig", + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › thought signatures with google providerOptionsName (gateway failover) › should resolve thoughtSignature from vertex namespace when google namespace is absent (default providerOptionsName) + #[test] + fn should_resolve_thought_signature_from_vertex_namespace_when_google_is_absent() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "getWeather", + "input": { "location": "London" }, + "providerOptions": { "vertex": { "thoughtSignature": "vertex_sig" } }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionCall": { + "id": "call1", + "name": "getWeather", + "args": { "location": "London" }, + }, + "thoughtSignature": "vertex_sig", + }) + ); + } +} + +mod gemma_model_system_instructions { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemma model system instructions › should prepend system instruction to first user message for Gemma models + #[test] + fn should_prepend_system_instruction_to_first_user_message_for_gemma_models() { + let (result, _) = convert_for_model( + GEMMA_MODEL, + Namespace::Google, + json!([ + { "role": "system", "content": "You are a helpful assistant." }, + { "role": "user", "content": [{ "type": "text", "text": "Hello" }] }, + ]), + ); + + assert_eq!( + result, + json!({ + "contents": [ + { + "parts": [ + { "text": "You are a helpful assistant.\n\n" }, + { "text": "Hello" }, + ], + "role": "user", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemma model system instructions › should handle multiple system messages for Gemma models + #[test] + fn should_handle_multiple_system_messages_for_gemma_models() { + let (result, _) = convert_for_model( + GEMMA_MODEL, + Namespace::Google, + json!([ + { "role": "system", "content": "You are helpful." }, + { "role": "system", "content": "Be concise." }, + { "role": "user", "content": [{ "type": "text", "text": "Hi" }] }, + ]), + ); + + assert_eq!( + result, + json!({ + "contents": [ + { + "parts": [ + { "text": "You are helpful.\n\nBe concise.\n\n" }, + { "text": "Hi" }, + ], + "role": "user", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemma model system instructions › should not affect non-Gemma models + #[test] + fn should_not_affect_non_gemma_models() { + let result = convert(json!([ + { "role": "system", "content": "You are helpful." }, + { "role": "user", "content": [{ "type": "text", "text": "Hello" }] }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { "parts": [{ "text": "Hello" }], "role": "user" }, + ], + "systemInstruction": { "parts": [{ "text": "You are helpful." }] }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemma model system instructions › should handle Gemma model with system instruction but no user messages + #[test] + fn should_handle_gemma_model_with_system_instruction_but_no_user_messages() { + let (result, _) = convert_for_model( + GEMMA_MODEL, + Namespace::Google, + json!([{ "role": "system", "content": "You are helpful." }]), + ); + + assert_eq!(result, json!({ "contents": [] })); + } +} + +mod user_messages { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › user messages › should preserve original Google Cloud Storage file URIs + #[test] + fn should_preserve_original_google_cloud_storage_file_uris() { + // `new URL('gs://my-bucket/folder/My File.pdf').toString()`. + let result = convert(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { + "type": "url", + "url": "gs://my-bucket/folder/My%20File.pdf", + "originalUrl": "gs://my-bucket/folder/My File.pdf", + }, + "mediaType": "application/pdf", + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "user", + "parts": [ + { + "fileData": { + "mimeType": "application/pdf", + "fileUri": "gs://my-bucket/folder/My File.pdf", + }, + }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › user messages › should add image parts + #[test] + fn should_add_image_parts() { + let result = convert(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "image/png", + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "user", + "parts": [ + { "inlineData": { "data": "AAECAw==", "mimeType": "image/png" } }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › user messages › should add file parts for base64 encoded files + #[test] + fn should_add_file_parts_for_base64_encoded_files() { + let result = convert(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "image/png", + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "user", + "parts": [ + { "inlineData": { "data": "AAECAw==", "mimeType": "image/png" } }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › user messages › should convert file parts with provider reference to fileData + #[test] + fn should_convert_file_parts_with_provider_reference_to_file_data() { + let result = convert(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { + "type": "reference", + "reference": { + "google": "https://generativelanguage.googleapis.com/v1beta/files/abc123", + "openai": "file-xyz789", + }, + }, + "mediaType": "image/png", + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "user", + "parts": [ + { + "fileData": { + "mimeType": "image/png", + "fileUri": "https://generativelanguage.googleapis.com/v1beta/files/abc123", + }, + }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › user messages › should convert image file parts with provider reference to fileData + #[test] + fn should_convert_image_file_parts_with_provider_reference_to_file_data() { + let result = convert(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { + "type": "reference", + "reference": { + "google": "https://generativelanguage.googleapis.com/v1beta/files/img456", + }, + }, + "mediaType": "image/jpeg", + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "user", + "parts": [ + { + "fileData": { + "mimeType": "image/jpeg", + "fileUri": "https://generativelanguage.googleapis.com/v1beta/files/img456", + }, + }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › user messages › should throw when provider reference is missing google key in user file part + #[test] + fn should_throw_when_provider_reference_is_missing_google_key_in_user_file_part() { + let message = convert_error(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "data": { "type": "reference", "reference": { "openai": "file-xyz789" } }, + "mediaType": "image/png", + }, + ], + }, + ])); + + assert!( + message.contains( + "No provider reference found for provider 'google'. Available providers: openai" + ), + "{message}" + ); + } +} + +mod tool_messages { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should convert tool result messages to function responses + #[test] + fn should_convert_tool_result_messages_to_function_responses() { + let result = convert(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "testFunction", + "toolCallId": "testCallId", + "output": { "type": "json", "value": { "someData": "test result" } }, + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "user", + "parts": [ + { + "functionResponse": { + "id": "testCallId", + "name": "testFunction", + "response": { + "name": "testFunction", + "content": { "someData": "test result" }, + }, + }, + }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should serialize JSON Schema references in function response content + #[test] + fn should_serialize_json_schema_references_in_function_response_content() { + let tool_result = json!({ + "tools": [ + { + "name": "find_records", + "inputSchema": { + "$defs": { + "Node": { + "type": "object", + "properties": { "child": { "$ref": "#/$defs/Node" } }, + }, + }, + "$ref": "#/$defs/Node", + }, + }, + ], + }); + + let result = convert(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "get_schema", + "toolCallId": "testCallId", + "output": { "type": "json", "value": tool_result }, + }, + ], + }, + ])); + + // `JSON.stringify(toolResult)`. + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionResponse": { + "id": "testCallId", + "name": "get_schema", + "response": { + "name": "get_schema", + "content": serde_json::to_string(&tool_result).unwrap(), + }, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should convert tool result content with image-data into functionResponse parts + #[test] + fn should_convert_tool_result_content_with_image_data_into_function_response_parts() { + let result = convert(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "imageGenerator", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { "type": "text", "text": "Here is the generated image:" }, + { + "type": "file", + "data": { "type": "data", "data": "base64encodedimagedata" }, + "mediaType": "image/jpeg", + }, + ], + }, + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "user", + "parts": [ + { + "functionResponse": { + "id": "testCallId", + "name": "imageGenerator", + "response": { + "name": "imageGenerator", + "content": "Here is the generated image:", + }, + "parts": [ + { + "inlineData": { + "mimeType": "image/jpeg", + "data": "base64encodedimagedata", + }, + }, + ], + }, + }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should convert tool result content with file-data into functionResponse parts + #[test] + fn should_convert_tool_result_content_with_file_data_into_function_response_parts() { + let result = convert(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "documentReader", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { + "type": "file", + "data": { "type": "data", "data": "base64pdfdata" }, + "mediaType": "application/pdf", + "filename": "report.pdf", + }, + ], + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionResponse": { + "id": "testCallId", + "name": "documentReader", + "response": { + "name": "documentReader", + "content": "Tool executed successfully.", + }, + "parts": [ + { "inlineData": { "mimeType": "application/pdf", "data": "base64pdfdata" } }, + ], + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should convert tool result content with image-url data URL into functionResponse parts + #[test] + fn should_convert_tool_result_content_with_image_url_data_url_into_function_response_parts() { + let result = convert(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "imageGenerator", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { + "type": "file", + "data": { + "type": "url", + "url": "data:image/png;base64,base64pngdata", + }, + "mediaType": "image/png", + }, + ], + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionResponse": { + "id": "testCallId", + "name": "imageGenerator", + "response": { + "name": "imageGenerator", + "content": "Tool executed successfully.", + }, + "parts": [ + { "inlineData": { "mimeType": "image/png", "data": "base64pngdata" } }, + ], + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should derive the full media type from a tool result data URL + #[test] + fn should_derive_the_full_media_type_from_a_tool_result_data_url() { + let result = convert(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "imageGenerator", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { + "type": "file", + "data": { + "type": "url", + "url": "data:image/png;base64,base64pngdata", + }, + "mediaType": "image", + }, + ], + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionResponse": { + "id": "testCallId", + "name": "imageGenerator", + "response": { + "name": "imageGenerator", + "content": "Tool executed successfully.", + }, + "parts": [ + { "inlineData": { "mimeType": "image/png", "data": "base64pngdata" } }, + ], + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should forward non-data image-url tool result parts as text content + #[test] + fn should_forward_non_data_image_url_tool_result_parts_as_text_content() { + let result = convert(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "imageGenerator", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/image.png" }, + "mediaType": "image/png", + }, + ], + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionResponse": { + "id": "testCallId", + "name": "imageGenerator", + "response": { + "name": "imageGenerator", + "content": r#"{"type":"file","data":{"type":"url","url":"https://example.com/image.png"},"mediaType":"image/png"}"#, + }, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should forward unsupported non-data URLs with top-level media types as text content + #[test] + fn should_forward_unsupported_non_data_urls_with_top_level_media_types_as_text() { + let result = convert(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "imageGenerator", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/image.png" }, + "mediaType": "image", + }, + ], + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionResponse": { + "id": "testCallId", + "name": "imageGenerator", + "response": { + "name": "imageGenerator", + "content": r#"{"type":"file","data":{"type":"url","url":"https://example.com/image.png"},"mediaType":"image"}"#, + }, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should forward non-data file-url tool result parts as text content + #[test] + fn should_forward_non_data_file_url_tool_result_parts_as_text_content() { + let result = convert(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "documentReader", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/report.pdf" }, + "mediaType": "application/pdf", + }, + ], + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionResponse": { + "id": "testCallId", + "name": "documentReader", + "response": { + "name": "documentReader", + "content": r#"{"type":"file","data":{"type":"url","url":"https://example.com/report.pdf"},"mediaType":"application/pdf"}"#, + }, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should use legacy tool-result conversion when functionResponse parts are unsupported + #[test] + fn should_use_legacy_tool_result_conversion_when_function_response_parts_are_unsupported() { + let result = convert_legacy(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "imageGenerator", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { "type": "text", "text": "Here is the generated image:" }, + { + "type": "file", + "data": { "type": "data", "data": "base64encodedimagedata" }, + "mediaType": "image/jpeg", + }, + { + "type": "file", + "data": { "type": "data", "data": "base64pdfdata" }, + "mediaType": "application/pdf", + "filename": "report.pdf", + }, + ], + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"], + json!([ + { + "functionResponse": { + "id": "testCallId", + "name": "imageGenerator", + "response": { + "name": "imageGenerator", + "content": "Here is the generated image:", + }, + }, + }, + { "inlineData": { "mimeType": "image/jpeg", "data": "base64encodedimagedata" } }, + { "text": "Tool executed successfully and returned this image as a response" }, + { "inlineData": { "mimeType": "application/pdf", "data": "base64pdfdata" } }, + { "text": "Tool executed successfully and returned this file as a response" }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › issue #16072: should not serialize PDF file tool results as text on the non-Gemini-3 path + #[test] + fn issue_16072_should_not_serialize_pdf_file_tool_results_as_text_on_non_gemini_3_path() { + let result = convert_legacy(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "catalogSearch", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { "type": "text", "text": "metadata" }, + { + "type": "file", + "data": { "type": "data", "data": "JVBERi0xLjQK" }, + "mediaType": "application/pdf", + }, + ], + }, + }, + ], + }, + ])); + + let text_parts: Vec<&str> = result["contents"] + .as_array() + .unwrap() + .iter() + .flat_map(|content| content["parts"].as_array().unwrap()) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect(); + + assert!( + !text_parts.iter().any(|text| text.contains("JVBERi0xLjQK")), + "{text_parts:?}" + ); + assert!( + result["contents"][0]["parts"] + .as_array() + .unwrap() + .contains(&json!({ + "inlineData": { "mimeType": "application/pdf", "data": "JVBERi0xLjQK" }, + })), + "{result:#}" + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool messages › should keep URL tool result parts on the legacy path + #[test] + fn should_keep_url_tool_result_parts_on_the_legacy_path() { + let result = convert_legacy(json!([ + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolName": "documentReader", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/image.png" }, + "mediaType": "image/png", + }, + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/report.pdf" }, + "mediaType": "application/pdf", + }, + ], + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"], + json!([ + { + "text": r#"{"type":"file","data":{"type":"url","url":"https://example.com/image.png"},"mediaType":"image/png"}"#, + }, + { + "text": r#"{"type":"file","data":{"type":"url","url":"https://example.com/report.pdf"},"mediaType":"application/pdf"}"#, + }, + ]) + ); + } +} + +mod assistant_messages { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should add PNG image parts for base64 encoded files + #[test] + fn should_add_png_image_parts_for_base64_encoded_files() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "image/png", + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "model", + "parts": [ + { "inlineData": { "data": "AAECAw==", "mimeType": "image/png" } }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should include thought flag on file parts when set in providerOptions + #[test] + fn should_include_thought_flag_on_file_parts_when_set_in_provider_options() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "image/png", + "providerOptions": { + "google": { "thought": true, "thoughtSignature": "sig1" }, + }, + }, + { + "type": "file", + "data": { "type": "data", "data": "BAUG" }, + "mediaType": "image/jpeg", + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "model", + "parts": [ + { + "inlineData": { "data": "AAECAw==", "mimeType": "image/png" }, + "thought": true, + "thoughtSignature": "sig1", + }, + { "inlineData": { "data": "BAUG", "mimeType": "image/jpeg" } }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should convert reasoning-file parts with thought flag and signature + #[test] + fn should_convert_reasoning_file_parts_with_thought_flag_and_signature() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "reasoning-file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "image/png", + "providerOptions": { + "google": { "thoughtSignature": "sig_reasoning_file" }, + }, + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "parts": [ + { + "inlineData": { "data": "AAECAw==", "mimeType": "image/png" }, + "thought": true, + "thoughtSignature": "sig_reasoning_file", + }, + ], + "role": "model", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should convert reasoning-file parts without thoughtSignature + #[test] + fn should_convert_reasoning_file_parts_without_thought_signature() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "reasoning-file", + "data": { "type": "data", "data": "BAUG" }, + "mediaType": "image/jpeg", + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "parts": [ + { + "inlineData": { "data": "BAUG", "mimeType": "image/jpeg" }, + "thought": true, + }, + ], + "role": "model", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should throw error for URL file data in reasoning-file assistant messages + #[test] + fn should_throw_error_for_url_file_data_in_reasoning_file_assistant_messages() { + let message = convert_error(json!([ + { + "role": "assistant", + "content": [ + { + "type": "reasoning-file", + "data": { "type": "url", "url": "https://example.com/image.png" }, + "mediaType": "image/png", + }, + ], + }, + ])); + + assert!( + message.contains("File data URLs in assistant messages are not supported"), + "{message}" + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should handle mixed reasoning, reasoning-file, text, and tool-call parts + #[test] + fn should_handle_mixed_reasoning_reasoning_file_text_and_tool_call_parts() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "reasoning", + "text": "Thinking about this...", + "providerOptions": { "google": { "thoughtSignature": "sig1" } }, + }, + { + "type": "reasoning-file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "image/png", + "providerOptions": { "google": { "thoughtSignature": "sig2" } }, + }, + { + "type": "text", + "text": "Here is my response", + "providerOptions": { "google": { "thoughtSignature": "sig3" } }, + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "parts": [ + { + "text": "Thinking about this...", + "thought": true, + "thoughtSignature": "sig1", + }, + { + "inlineData": { "data": "AAECAw==", "mimeType": "image/png" }, + "thought": true, + "thoughtSignature": "sig2", + }, + { "text": "Here is my response", "thoughtSignature": "sig3" }, + ], + "role": "model", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should throw error for URL file data in assistant messages + #[test] + fn should_throw_error_for_url_file_data_in_assistant_messages() { + let message = convert_error(json!([ + { + "role": "assistant", + "content": [ + { + "type": "file", + "data": { "type": "url", "url": "https://example.com/image.png" }, + "mediaType": "image/png", + }, + ], + }, + ])); + + assert!( + message.contains("File data URLs in assistant messages are not supported"), + "{message}" + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should convert assistant file parts with provider reference to fileData + #[test] + fn should_convert_assistant_file_parts_with_provider_reference_to_file_data() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "file", + "data": { + "type": "reference", + "reference": { + "google": "https://generativelanguage.googleapis.com/v1beta/files/abc123", + }, + }, + "mediaType": "image/png", + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "model", + "parts": [ + { + "fileData": { + "mimeType": "image/png", + "fileUri": "https://generativelanguage.googleapis.com/v1beta/files/abc123", + }, + }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should convert assistant file parts with provider reference and thought flag + #[test] + fn should_convert_assistant_file_parts_with_provider_reference_and_thought_flag() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "file", + "data": { + "type": "reference", + "reference": { + "google": "https://generativelanguage.googleapis.com/v1beta/files/abc123", + }, + }, + "mediaType": "image/png", + "providerOptions": { + "google": { "thought": true, "thoughtSignature": "sig1" }, + }, + }, + ], + }, + ])); + + assert_eq!( + result, + json!({ + "contents": [ + { + "role": "model", + "parts": [ + { + "fileData": { + "mimeType": "image/png", + "fileUri": "https://generativelanguage.googleapis.com/v1beta/files/abc123", + }, + "thought": true, + "thoughtSignature": "sig1", + }, + ], + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › assistant messages › should throw when provider reference is missing google key in assistant file part + #[test] + fn should_throw_when_provider_reference_is_missing_google_key_in_assistant_file_part() { + let message = convert_error(json!([ + { + "role": "assistant", + "content": [ + { + "type": "file", + "data": { "type": "reference", "reference": { "openai": "file-xyz789" } }, + "mediaType": "image/png", + }, + ], + }, + ])); + + assert!( + message.contains( + "No provider reference found for provider 'google'. Available providers: openai" + ), + "{message}" + ); + } +} + +mod parallel_tool_calls { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › parallel tool calls › should include thought signature on functionCall when provided + #[test] + fn should_include_thought_signature_on_function_call_when_provided() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "checkweather", + "input": { "city": "paris" }, + "providerOptions": { "google": { "thoughtSignature": "sig_parallel" } }, + }, + { + "type": "tool-call", + "toolCallId": "call2", + "toolName": "checkweather", + "input": { "city": "london" }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionCall": { "id": "call1", "args": { "city": "paris" }, "name": "checkweather" }, + "thoughtSignature": "sig_parallel", + }) + ); + assert_eq!( + result["contents"][0]["parts"][1], + json!({ + "functionCall": { "id": "call2", "args": { "city": "london" }, "name": "checkweather" }, + }) + ); + } +} + +mod tool_results_with_thought_signatures { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › tool results with thought signatures › should include thought signature on functionCall but not on functionResponse + #[test] + fn should_include_thought_signature_on_function_call_but_not_on_function_response() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "call1", + "toolName": "readdata", + "input": { "userId": "123" }, + "providerOptions": { "google": { "thoughtSignature": "sig_original" } }, + }, + ], + }, + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "call1", + "toolName": "readdata", + "output": { "type": "error-text", "value": "file not found" }, + "providerOptions": { "google": { "thoughtSignature": "sig_original" } }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionCall": { "id": "call1", "args": { "userId": "123" }, "name": "readdata" }, + "thoughtSignature": "sig_original", + }) + ); + assert_eq!( + result["contents"][1]["parts"][0], + json!({ + "functionResponse": { + "id": "call1", + "name": "readdata", + "response": { "content": "file not found", "name": "readdata" }, + }, + }) + ); + assert!( + result["contents"][1]["parts"][0] + .get("thoughtSignature") + .is_none() + ); + } +} + +mod server_tool_combination_round_trip { + use super::*; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › server tool combination round-trip › should parse stringified code execution input + #[test] + fn should_parse_stringified_code_execution_input() { + let input = + serde_json::to_string(&json!({ "language": "PYTHON", "code": "print(17 * 19)" })) + .unwrap(); + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "code-call-1", + "toolName": "code_execution", + "input": input, + "providerExecuted": true, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ "executableCode": { "language": "PYTHON", "code": "print(17 * 19)" } }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › server tool combination round-trip › should preserve code execution parts alongside a function tool call + #[test] + fn should_preserve_code_execution_parts_alongside_a_function_tool_call() { + let (result, _) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Google, + json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "code-call-1", + "toolName": "code_execution", + "input": { "language": "PYTHON", "code": "print(17 * 19)" }, + "providerExecuted": true, + }, + { + "type": "tool-call", + "toolCallId": "function-call-1", + "toolName": "listItems", + "input": {}, + "providerOptions": { + "google": { "thoughtSignature": "function-signature" }, + }, + }, + { + "type": "tool-result", + "toolCallId": "code-call-1", + "toolName": "code_execution", + "output": { + "type": "json", + "value": { "outcome": "OUTCOME_OK", "output": "323\n" }, + }, + }, + ], + }, + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "function-call-1", + "toolName": "listItems", + "output": { "type": "json", "value": { "items": ["a", "b"] } }, + }, + ], + }, + ]), + ); + + assert_eq!( + result["contents"], + json!([ + { + "role": "model", + "parts": [ + { "executableCode": { "language": "PYTHON", "code": "print(17 * 19)" } }, + { + "functionCall": { "id": "function-call-1", "name": "listItems", "args": {} }, + "thoughtSignature": "function-signature", + }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "323\n" } }, + ], + }, + { + "role": "user", + "parts": [ + { + "functionResponse": { + "id": "function-call-1", + "name": "listItems", + "response": { "name": "listItems", "content": { "items": ["a", "b"] } }, + }, + }, + ], + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › server tool combination round-trip › should convert assistant tool-call with serverToolCallId to toolCall wire format + #[test] + fn should_convert_assistant_tool_call_with_server_tool_call_id_to_tool_call_wire_format() { + let input = serde_json::to_string(&json!({ "query": "test" })).unwrap(); + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc-1", + "toolName": "server:GOOGLE_SEARCH_WEB", + "input": input, + "providerOptions": { + "google": { + "serverToolCallId": "server-id-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "sig-abc", + }, + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "toolCall": { + "toolType": "GOOGLE_SEARCH_WEB", + "args": { "query": "test" }, + "id": "server-id-1", + }, + "thoughtSignature": "sig-abc", + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › server tool combination round-trip › should convert assistant tool-call without serverToolCallId to functionCall wire format + #[test] + fn should_convert_assistant_tool_call_without_server_tool_call_id_to_function_call() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc-1", + "toolName": "weather", + "input": { "location": "SF" }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "functionCall": { "id": "tc-1", "name": "weather", "args": { "location": "SF" } }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › server tool combination round-trip › should convert tool result with serverToolCallId to toolResponse on last model content + #[test] + fn should_convert_tool_result_with_server_tool_call_id_to_tool_response_on_last_model() { + let input = serde_json::to_string(&json!({ "query": "test" })).unwrap(); + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc-1", + "toolName": "server:GOOGLE_SEARCH_WEB", + "input": input, + "providerOptions": { + "google": { + "serverToolCallId": "server-id-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + }, + }, + }, + ], + }, + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "tc-1", + "toolName": "server:GOOGLE_SEARCH_WEB", + "output": { "type": "json", "value": { "results": ["a"] } }, + "providerOptions": { + "google": { + "serverToolCallId": "server-id-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "sig-resp", + }, + }, + }, + ], + }, + ])); + + assert_eq!(result["contents"][0]["role"], "model"); + assert_eq!(result["contents"][0]["parts"].as_array().unwrap().len(), 2); + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "toolCall": { + "toolType": "GOOGLE_SEARCH_WEB", + "args": { "query": "test" }, + "id": "server-id-1", + }, + }) + ); + assert_eq!( + result["contents"][0]["parts"][1], + json!({ + "toolResponse": { + "toolType": "GOOGLE_SEARCH_WEB", + "response": { "results": ["a"] }, + "id": "server-id-1", + }, + "thoughtSignature": "sig-resp", + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › server tool combination round-trip › should parse string input for server tool call args + #[test] + fn should_parse_string_input_for_server_tool_call_args() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc-1", + "toolName": "server:GOOGLE_SEARCH_WEB", + "input": r#"{"query":"hello"}"#, + "providerOptions": { + "google": { + "serverToolCallId": "sid-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + }, + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "toolCall": { + "toolType": "GOOGLE_SEARCH_WEB", + "args": { "query": "hello" }, + "id": "sid-1", + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › server tool combination round-trip › should pass object input directly for server tool call args + #[test] + fn should_pass_object_input_directly_for_server_tool_call_args() { + let result = convert(json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc-1", + "toolName": "server:GOOGLE_SEARCH_WEB", + "input": { "query": "hello" }, + "providerOptions": { + "google": { + "serverToolCallId": "sid-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + }, + }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ + "toolCall": { + "toolType": "GOOGLE_SEARCH_WEB", + "args": { "query": "hello" }, + "id": "sid-1", + }, + }) + ); + } +} + +mod gemini_3_missing_thought_signature_mitigation { + use super::*; + + fn prompt_with_tool_call_missing_signature() -> Value { + json!([ + { "role": "user", "content": [{ "type": "text", "text": "hi" }] }, + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc_1", + "toolName": "weather", + "input": { "location": "SF" }, + }, + ], + }, + { + "role": "tool", + "content": [ + { + "type": "tool-result", + "toolCallId": "tc_1", + "toolName": "weather", + "output": { "type": "json", "value": { "temperature": 72 } }, + }, + ], + }, + ]) + } + + fn assistant(result: &Value) -> &Value { + result["contents"] + .as_array() + .unwrap() + .iter() + .find(|content| content["role"] == "model") + .expect("a model content") + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › injects skip_thought_signature_validator and emits a warning for Gemini 3 when a tool-call has no signature + #[test] + fn injects_skip_thought_signature_validator_and_emits_a_warning_for_gemini_3() { + let (result, warnings) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Google, + prompt_with_tool_call_missing_signature(), + ); + + assert_match_object( + &assistant(&result)["parts"][0], + &json!({ + "functionCall": { "id": "tc_1", "name": "weather", "args": { "location": "SF" } }, + "thoughtSignature": SKIP_THOUGHT_SIGNATURE_VALIDATOR, + }), + ); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert_eq!(warnings[0]["type"], "other"); + let message = warnings[0]["message"].as_str().unwrap(); + assert!( + message.contains("skip_thought_signature_validator"), + "{message}" + ); + assert!(message.contains("`weather`"), "{message}"); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › does NOT inject the sentinel or warn for unsigned parallel calls after a signed call + #[test] + fn does_not_inject_the_sentinel_or_warn_for_unsigned_parallel_calls_after_a_signed_call() { + let (result, warnings) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Vertex, + json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc_paris", + "toolName": "get_weather", + "input": { "city": "Paris" }, + "providerOptions": { + "vertex": { "thoughtSignature": "parallel_batch_signature" }, + }, + }, + { + "type": "tool-call", + "toolCallId": "tc_tokyo", + "toolName": "get_weather", + "input": { "city": "Tokyo" }, + }, + { + "type": "tool-call", + "toolCallId": "tc_new_york", + "toolName": "get_weather", + "input": { "city": "New York" }, + }, + ], + }, + ]), + ); + + assert_eq!( + result["contents"][0]["parts"], + without_function_call_ids(json!([ + { + "functionCall": { "id": "tc_paris", "name": "get_weather", "args": { "city": "Paris" } }, + "thoughtSignature": "parallel_batch_signature", + }, + { + "functionCall": { "id": "tc_tokyo", "name": "get_weather", "args": { "city": "Tokyo" } }, + }, + { + "functionCall": { + "id": "tc_new_york", + "name": "get_weather", + "args": { "city": "New York" }, + }, + }, + ])) + ); + assert!(warnings.is_empty(), "{warnings:?}"); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › does NOT inject the sentinel when other response parts separate parallel function calls + #[test] + fn does_not_inject_the_sentinel_when_other_response_parts_separate_parallel_calls() { + let (result, warnings) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Google, + json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc_signed", + "toolName": "weather", + "input": { "location": "SF" }, + "providerOptions": { "google": { "thoughtSignature": "signed_batch" } }, + }, + { "type": "text", "text": "Checking another city in the same response." }, + { + "type": "tool-call", + "toolCallId": "tc_unsigned", + "toolName": "weather", + "input": { "location": "NYC" }, + }, + ], + }, + ]), + ); + + assert_eq!( + result["contents"][0]["parts"][2], + json!({ + "functionCall": { "id": "tc_unsigned", "name": "weather", "args": { "location": "NYC" } }, + }) + ); + assert!(warnings.is_empty(), "{warnings:?}"); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › does NOT inject the sentinel when server tool parts separate parallel function calls + #[test] + fn does_not_inject_the_sentinel_when_server_tool_parts_separate_parallel_calls() { + let (result, warnings) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Google, + json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "signed_function_call", + "toolName": "weather", + "input": { "location": "SF" }, + "providerOptions": { + "google": { "thoughtSignature": "function_signature" }, + }, + }, + { + "type": "tool-call", + "toolCallId": "server_call", + "toolName": "server:GOOGLE_SEARCH_WEB", + "input": { "query": "weather" }, + "providerOptions": { + "google": { + "serverToolCallId": "server_call", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "server_call_signature", + }, + }, + }, + { + "type": "tool-result", + "toolCallId": "server_call", + "toolName": "server:GOOGLE_SEARCH_WEB", + "output": { "type": "json", "value": { "results": [] } }, + "providerOptions": { + "google": { + "serverToolCallId": "server_call", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "server_response_signature", + }, + }, + }, + { + "type": "tool-call", + "toolCallId": "unsigned_function_call", + "toolName": "weather", + "input": { "location": "NYC" }, + }, + ], + }, + ]), + ); + + assert_eq!( + result["contents"][0]["parts"][3], + json!({ + "functionCall": { + "id": "unsigned_function_call", + "name": "weather", + "args": { "location": "NYC" }, + }, + }) + ); + assert!(warnings.is_empty(), "{warnings:?}"); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › injects the sentinel when a signed server tool call precedes an unsigned function call + #[test] + fn injects_the_sentinel_when_a_signed_server_tool_call_precedes_an_unsigned_function_call() { + let (result, warnings) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Google, + json!([ + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "server_call", + "toolName": "server:GOOGLE_SEARCH_WEB", + "input": { "query": "weather" }, + "providerOptions": { + "google": { + "serverToolCallId": "server_call", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "server_signature", + }, + }, + }, + { + "type": "tool-call", + "toolCallId": "function_call", + "toolName": "weather", + "input": { "location": "NYC" }, + }, + ], + }, + ]), + ); + + assert_eq!( + result["contents"][0]["parts"], + json!([ + { + "toolCall": { + "toolType": "GOOGLE_SEARCH_WEB", + "args": { "query": "weather" }, + "id": "server_call", + }, + "thoughtSignature": "server_signature", + }, + { + "functionCall": { "id": "function_call", "name": "weather", "args": { "location": "NYC" } }, + "thoughtSignature": SKIP_THOUGHT_SIGNATURE_VALIDATOR, + }, + ]) + ); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0]["message"] + .as_str() + .unwrap() + .contains("`weather`"), + "{warnings:?}" + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › does NOT inject the sentinel for non-Gemini-3 models + #[test] + fn does_not_inject_the_sentinel_for_non_gemini_3_models() { + let (result, warnings) = convert_for_model( + GEMINI_2_5_MODEL, + Namespace::Google, + prompt_with_tool_call_missing_signature(), + ); + + let part = &assistant(&result)["parts"][0]; + assert_match_object( + part, + &json!({ + "functionCall": { "id": "tc_1", "name": "weather", "args": { "location": "SF" } }, + }), + ); + assert!(part.get("thoughtSignature").is_none(), "{part}"); + assert!(warnings.is_empty(), "{warnings:?}"); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › does NOT inject the sentinel when a real signature is present under `google` + #[test] + fn does_not_inject_the_sentinel_when_a_real_signature_is_present_under_google() { + let (result, warnings) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Google, + json!([ + { "role": "user", "content": [{ "type": "text", "text": "hi" }] }, + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc_1", + "toolName": "weather", + "input": { "location": "SF" }, + "providerOptions": { "google": { "thoughtSignature": "real_sig" } }, + }, + ], + }, + ]), + ); + + assert_match_object( + &assistant(&result)["parts"][0], + &json!({ "thoughtSignature": "real_sig" }), + ); + assert!(warnings.is_empty(), "{warnings:?}"); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › does NOT inject the sentinel when a real signature is present under `vertex` + #[test] + fn does_not_inject_the_sentinel_when_a_real_signature_is_present_under_vertex() { + let (result, warnings) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Vertex, + json!([ + { "role": "user", "content": [{ "type": "text", "text": "hi" }] }, + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc_1", + "toolName": "weather", + "input": { "location": "SF" }, + "providerOptions": { "vertex": { "thoughtSignature": "vertex_sig" } }, + }, + ], + }, + ]), + ); + + assert_match_object( + &assistant(&result)["parts"][0], + &json!({ "thoughtSignature": "vertex_sig" }), + ); + assert!(warnings.is_empty(), "{warnings:?}"); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › does NOT inject the sentinel when a real signature is present under `googleVertex` + #[test] + fn does_not_inject_the_sentinel_when_a_real_signature_is_present_under_google_vertex() { + let (result, warnings) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Google, + json!([ + { "role": "user", "content": [{ "type": "text", "text": "hi" }] }, + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc_1", + "toolName": "weather", + "input": { "location": "SF" }, + "providerOptions": { + "googleVertex": { "thoughtSignature": "google_vertex_sig" }, + }, + }, + ], + }, + ]), + ); + + assert_match_object( + &assistant(&result)["parts"][0], + &json!({ "thoughtSignature": "google_vertex_sig" }), + ); + assert!(warnings.is_empty(), "{warnings:?}"); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › Gemini 3 missing thoughtSignature mitigation › emits one warning per request listing each affected tool name + #[test] + fn emits_one_warning_per_request_listing_each_affected_tool_name() { + let (_, warnings) = convert_for_model( + GEMINI_3_MODEL, + Namespace::Google, + json!([ + { "role": "user", "content": [{ "type": "text", "text": "hi" }] }, + { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "toolCallId": "tc_1", + "toolName": "weather", + "input": { "location": "SF" }, + }, + { + "type": "tool-call", + "toolCallId": "tc_2", + "toolName": "weather", + "input": { "location": "NYC" }, + }, + { + "type": "tool-call", + "toolCallId": "tc_3", + "toolName": "search", + "input": { "query": "q" }, + }, + ], + }, + ]), + ); + + assert_eq!(warnings.len(), 1, "{warnings:?}"); + let message = warnings[0]["message"].as_str().unwrap(); + assert!(message.contains("3 "), "{message}"); + assert!(message.contains("`weather`"), "{message}"); + assert!(message.contains("`search`"), "{message}"); + } +} + +mod top_level_only_media_type_resolution { + use super::*; + + const PNG_BASE64: &str = "iVBORw0KGgo="; + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › top-level-only media type resolution › passes full image/png through unchanged for inline data + #[test] + fn passes_full_image_png_through_unchanged_for_inline_data() { + let result = convert(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "mediaType": "image/png", + "data": { "type": "data", "data": PNG_BASE64 }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ "inlineData": { "mimeType": "image/png", "data": PNG_BASE64 } }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › top-level-only media type resolution › detects image subtype from inline bytes for top-level "image" + #[test] + fn detects_image_subtype_from_inline_bytes_for_top_level_image() { + let result = convert(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "mediaType": "image", + "data": { "type": "data", "data": PNG_BASE64 }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ "inlineData": { "mimeType": "image/png", "data": PNG_BASE64 } }) + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › top-level-only media type resolution › throws for top-level-only image with URL source (no bytes to detect) + #[test] + fn throws_for_top_level_only_image_with_url_source() { + let message = convert_error(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "mediaType": "image", + "data": { "type": "url", "url": "https://example.com/x.png" }, + }, + ], + }, + ])); + + assert!( + regex::Regex::new(r#"media type "image".*not passed as inline bytes"#) + .unwrap() + .is_match(&message), + "{message}" + ); + } + + /// upstream: @ai-sdk/google src/convert-to-google-messages.test.ts › top-level-only media type resolution › normalizes image/* wildcard via detection + #[test] + fn normalizes_image_wildcard_via_detection() { + let result = convert(json!([ + { + "role": "user", + "content": [ + { + "type": "file", + "mediaType": "image/*", + "data": { "type": "data", "data": PNG_BASE64 }, + }, + ], + }, + ])); + + assert_eq!( + result["contents"][0]["parts"][0], + json!({ "inlineData": { "mimeType": "image/png", "data": PNG_BASE64 } }) + ); + } +} diff --git a/aimux-providers/src/google/tests/convert_usage.rs b/aimux-providers/src/google/tests/convert_usage.rs new file mode 100644 index 000000000..af34c31db --- /dev/null +++ b/aimux-providers/src/google/tests/convert_usage.rs @@ -0,0 +1,65 @@ +use serde_json::{Value, json}; + +use crate::google::convert::convert_usage; +use crate::google::types::GoogleUsageMetadata; + +/// `convertGoogleUsage(usageMetadata)` as the upstream JSON object. +fn convert_usage_json(usage_metadata: Value) -> Value { + let usage: GoogleUsageMetadata = + serde_json::from_value(usage_metadata).expect("usage metadata deserializes"); + serde_json::to_value(convert_usage(&usage)).expect("usage serializes") +} + +mod convert_google_usage { + use super::*; + + /// upstream: @ai-sdk/google src/convert-google-usage.test.ts › convertGoogleUsage › includes tool-use prompt tokens in input usage + #[test] + fn includes_tool_use_prompt_tokens_in_input_usage() { + assert_eq!( + convert_usage_json(json!({ + "promptTokenCount": 55, + "toolUsePromptTokenCount": 89, + "candidatesTokenCount": 51, + "thoughtsTokenCount": 56, + "totalTokenCount": 251, + })), + json!({ + "inputTokens": { + "total": 144, + "noCache": 144, + "cacheRead": 0, + }, + "outputTokens": { + "total": 107, + "text": 51, + "reasoning": 56, + }, + "raw": { + "promptTokenCount": 55, + "toolUsePromptTokenCount": 89, + "candidatesTokenCount": 51, + "thoughtsTokenCount": 56, + "totalTokenCount": 251, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/convert-google-usage.test.ts › convertGoogleUsage › includes tool-use prompt tokens when calculating uncached input + #[test] + fn includes_tool_use_prompt_tokens_when_calculating_uncached_input() { + assert_eq!( + convert_usage_json(json!({ + "promptTokenCount": 55, + "toolUsePromptTokenCount": 89, + "cachedContentTokenCount": 100, + }))["inputTokens"], + json!({ + "total": 144, + "noCache": 44, + "cacheRead": 100, + }) + ); + } +} diff --git a/aimux-providers/src/google/tests/embedding_model.rs b/aimux-providers/src/google/tests/embedding_model.rs new file mode 100644 index 000000000..030be418a --- /dev/null +++ b/aimux-providers/src/google/tests/embedding_model.rs @@ -0,0 +1,486 @@ +use std::sync::Arc; + +use serde_json::{Value, json}; + +use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; +use aimux_core::shared::SharedProviderOptions; + +use crate::google::{GoogleEmbeddingModel, GoogleProviderSettings, create_google}; +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +/// The pinned user agent (upstream mocks `0.0.0-test`). +const PINNED_USER_AGENT: &str = "ai-sdk-google/4.0.87 ai-sdk-provider-utils/5.0.53"; + +const TEST_VALUES: [&str; 2] = ["sunny day at the beach", "rainy day in the city"]; + +fn dummy_embeddings() -> Value { + json!([[0.1, 0.2, 0.3, 0.4, 0.5], [0.6, 0.7, 0.8, 0.9, 1]]) +} + +/// `prepareBatchJsonResponse`, the body as the test server writes it. +fn batch_json_response(headers: &[(&str, &str)]) -> Canned { + let body = json!({ + "embeddings": dummy_embeddings() + .as_array() + .unwrap() + .iter() + .map(|embedding| json!({"values": embedding})) + .collect::>(), + }); + let mut canned = Canned::json(&body); + canned.headers.extend( + headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())), + ); + canned +} + +/// `prepareSingleJsonResponse`. +fn single_json_response() -> Canned { + Canned::json(&json!({"embedding": {"values": dummy_embeddings()[0]}})) +} + +/// `createGoogle({ apiKey: 'test-api-key' })` with the mock transport. +fn embedding_model(mock: &Arc, model_id: &str) -> GoogleEmbeddingModel { + create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + fetch: Some(mock.transport()), + ..Default::default() + }) + .unwrap() + .embedding(model_id) +} + +/// The shared `model` (`gemini-embedding-001`). +fn model(mock: &Arc) -> GoogleEmbeddingModel { + embedding_model(mock, "gemini-embedding-001") +} + +/// The shared `multimodalModel` (`gemini-embedding-2-preview`). +fn multimodal_model(mock: &Arc) -> GoogleEmbeddingModel { + embedding_model(mock, "gemini-embedding-2-preview") +} + +fn embed_options(values: &[&str]) -> EmbeddingCallOptions { + EmbeddingCallOptions { + values: values.iter().map(ToString::to_string).collect(), + abort_signal: None, + provider_options: None, + headers: None, + max_retries: None, + timeout: None, + } +} + +fn google_options(value: Value) -> Option { + let Value::Object(map) = value else { + panic!("options must be an object") + }; + Some([("google".to_string(), map)].into_iter().collect()) +} + +mod google_embedding_model { + use super::*; + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should expose the raw response + #[tokio::test] + async fn should_expose_the_raw_response() { + // The test server writes the JSON body (80 bytes) with its length. + let mut canned = batch_json_response(&[("test-header", "test-value")]); + canned.body = + br#"{"embeddings":[{"values":[0.1,0.2,0.3,0.4,0.5]},{"values":[0.6,0.7,0.8,0.9,1]}]}"# + .to_vec(); + canned + .headers + .push(("content-length".into(), canned.body.len().to_string())); + let mock = MockFetch::new(vec![canned]); + + let result = model(&mock) + .do_embed(&embed_options(&TEST_VALUES)) + .await + .unwrap(); + + let response = result.response.as_ref().unwrap(); + assert_eq!( + serde_json::to_value(response.headers.as_ref().unwrap()).unwrap(), + json!({ + "content-length": "80", + "content-type": "application/json", + "test-header": "test-value", + }) + ); + let snapshot: Value = serde_json::from_str(include_str!( + "snapshots/embedding_expose_the_raw_response.json" + )) + .unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), snapshot); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should pass the model and the values + #[tokio::test] + async fn should_pass_the_model_and_the_values() { + let mock = MockFetch::new(vec![batch_json_response(&[])]); + + model(&mock) + .do_embed(&embed_options(&TEST_VALUES)) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "requests": [ + { + "content": {"parts": [{"text": "sunny day at the beach"}], "role": "user"}, + "model": "models/gemini-embedding-001", + }, + { + "content": {"parts": [{"text": "rainy day in the city"}], "role": "user"}, + "model": "models/gemini-embedding-001", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should pass the outputDimensionality setting + #[tokio::test] + async fn should_pass_the_output_dimensionality_setting() { + let mock = MockFetch::new(vec![batch_json_response(&[])]); + + let mut options = embed_options(&TEST_VALUES); + options.provider_options = google_options(json!({"outputDimensionality": 64})); + model(&mock).do_embed(&options).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "requests": [ + { + "content": {"parts": [{"text": "sunny day at the beach"}], "role": "user"}, + "model": "models/gemini-embedding-001", + "outputDimensionality": 64, + }, + { + "content": {"parts": [{"text": "rainy day in the city"}], "role": "user"}, + "model": "models/gemini-embedding-001", + "outputDimensionality": 64, + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should pass the taskType setting + #[tokio::test] + async fn should_pass_the_task_type_setting() { + let mock = MockFetch::new(vec![batch_json_response(&[])]); + + let mut options = embed_options(&TEST_VALUES); + options.provider_options = google_options(json!({"taskType": "SEMANTIC_SIMILARITY"})); + model(&mock).do_embed(&options).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "requests": [ + { + "content": {"parts": [{"text": "sunny day at the beach"}], "role": "user"}, + "model": "models/gemini-embedding-001", + "taskType": "SEMANTIC_SIMILARITY", + }, + { + "content": {"parts": [{"text": "rainy day in the city"}], "role": "user"}, + "model": "models/gemini-embedding-001", + "taskType": "SEMANTIC_SIMILARITY", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let mock = MockFetch::new(vec![batch_json_response(&[])]); + let provider = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + headers: Some( + [( + "Custom-Provider-Header".to_string(), + Some("provider-header-value".to_string()), + )] + .into_iter() + .collect(), + ), + fetch: Some(mock.transport()), + ..Default::default() + }) + .unwrap(); + + let mut options = embed_options(&TEST_VALUES); + options.headers = Some( + [( + "Custom-Request-Header".to_string(), + "request-header-value".to_string(), + )] + .into_iter() + .collect(), + ); + provider + .embedding("gemini-embedding-001") + .do_embed(&options) + .await + .unwrap(); + + // The test server reports the user agent separately. + let mut headers = mock.seen()[0].headers.clone(); + let user_agent = headers.remove("user-agent").unwrap_or_default(); + assert_eq!( + serde_json::to_value(&headers).unwrap(), + json!({ + "content-type": "application/json", + "custom-provider-header": "provider-header-value", + "custom-request-header": "request-header-value", + "x-goog-api-key": "test-api-key", + }) + ); + assert!(user_agent.contains(PINNED_USER_AGENT), "{user_agent}"); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should throw an error if too many values are provided + #[tokio::test] + async fn should_throw_an_error_if_too_many_values_are_provided() { + let mock = MockFetch::new(vec![]); + let model = model(&mock); + + let too_many_values = vec!["test"; 101]; + let error = model + .do_embed(&embed_options(&too_many_values)) + .await + .unwrap_err(); + + assert!( + error.to_string().contains( + "Too many values for a single embedding call. The google.generative-ai model \"gemini-embedding-001\" can only embed up to 100 values per call, but 101 values were provided." + ), + "{error}" + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should use the batch embeddings endpoint + #[tokio::test] + async fn should_use_the_batch_embeddings_endpoint() { + let mock = MockFetch::new(vec![batch_json_response(&[])]); + + model(&mock) + .do_embed(&embed_options(&TEST_VALUES)) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents" + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should use the single embeddings endpoint + #[tokio::test] + async fn should_use_the_single_embeddings_endpoint() { + let mock = MockFetch::new(vec![single_json_response()]); + + model(&mock) + .do_embed(&embed_options(&TEST_VALUES[..1])) + .await + .unwrap(); + + assert_eq!( + mock.seen()[0].url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent" + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should merge multimodal content for single embedding + #[tokio::test] + async fn should_merge_multimodal_content_for_single_embedding() { + let mock = MockFetch::new(vec![single_json_response()]); + + let mut options = embed_options(&TEST_VALUES[..1]); + options.provider_options = google_options(json!({ + "content": [[{"inlineData": {"mimeType": "image/png", "data": "abc123"}}]], + })); + model(&mock).do_embed(&options).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "content": { + "parts": [ + {"text": "sunny day at the beach"}, + {"inlineData": {"data": "abc123", "mimeType": "image/png"}}, + ], + }, + "model": "models/gemini-embedding-001", + }) + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should merge per-value multimodal content for batch embedding + #[tokio::test] + async fn should_merge_per_value_multimodal_content_for_batch_embedding() { + let mock = MockFetch::new(vec![batch_json_response(&[])]); + + let mut options = embed_options(&TEST_VALUES); + options.provider_options = google_options(json!({ + "content": [ + [{"inlineData": {"mimeType": "image/png", "data": "img1"}}], + [{"inlineData": {"mimeType": "image/jpeg", "data": "img2"}}], + ], + })); + model(&mock).do_embed(&options).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "requests": [ + { + "content": { + "parts": [ + {"text": "sunny day at the beach"}, + {"inlineData": {"data": "img1", "mimeType": "image/png"}}, + ], + "role": "user", + }, + "model": "models/gemini-embedding-001", + }, + { + "content": { + "parts": [ + {"text": "rainy day in the city"}, + {"inlineData": {"data": "img2", "mimeType": "image/jpeg"}}, + ], + "role": "user", + }, + "model": "models/gemini-embedding-001", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should handle null entries as text-only in batch embedding + #[tokio::test] + async fn should_handle_null_entries_as_text_only_in_batch_embedding() { + let mock = MockFetch::new(vec![batch_json_response(&[])]); + + let mut options = embed_options(&TEST_VALUES); + options.provider_options = google_options(json!({ + "content": [[{"inlineData": {"mimeType": "image/png", "data": "img1"}}], null], + })); + model(&mock).do_embed(&options).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "requests": [ + { + "content": { + "parts": [ + {"text": "sunny day at the beach"}, + {"inlineData": {"data": "img1", "mimeType": "image/png"}}, + ], + "role": "user", + }, + "model": "models/gemini-embedding-001", + }, + { + "content": {"parts": [{"text": "rainy day in the city"}], "role": "user"}, + "model": "models/gemini-embedding-001", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should merge fileData content for single embedding + #[tokio::test] + async fn should_merge_file_data_content_for_single_embedding() { + let mock = MockFetch::new(vec![single_json_response()]); + + let mut options = embed_options(&TEST_VALUES[..1]); + options.provider_options = google_options(json!({ + "content": [[{"fileData": {"fileUri": "gs://bucket/video.mp4", "mimeType": "video/mp4"}}]], + })); + multimodal_model(&mock).do_embed(&options).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "content": { + "parts": [ + {"text": "sunny day at the beach"}, + {"fileData": {"fileUri": "gs://bucket/video.mp4", "mimeType": "video/mp4"}}, + ], + }, + "model": "models/gemini-embedding-2-preview", + }) + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should merge fileData content for batch embedding + #[tokio::test] + async fn should_merge_file_data_content_for_batch_embedding() { + let mock = MockFetch::new(vec![batch_json_response(&[])]); + + let mut options = embed_options(&TEST_VALUES); + options.provider_options = google_options(json!({ + "content": [ + [{"fileData": {"fileUri": "gs://bucket/video.mp4", "mimeType": "video/mp4"}}], + null, + ], + })); + multimodal_model(&mock).do_embed(&options).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "requests": [ + { + "content": { + "parts": [ + {"text": "sunny day at the beach"}, + {"fileData": {"fileUri": "gs://bucket/video.mp4", "mimeType": "video/mp4"}}, + ], + "role": "user", + }, + "model": "models/gemini-embedding-2-preview", + }, + { + "content": {"parts": [{"text": "rainy day in the city"}], "role": "user"}, + "model": "models/gemini-embedding-2-preview", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should throw error when content length does not match values length + #[tokio::test] + async fn should_throw_error_when_content_length_does_not_match_values_length() { + let mock = MockFetch::new(vec![batch_json_response(&[])]); + + let mut options = embed_options(&TEST_VALUES); + options.provider_options = google_options(json!({ + "content": [[{"inlineData": {"mimeType": "image/png", "data": "img1"}}]], + })); + let error = model(&mock).do_embed(&options).await.unwrap_err(); + + assert!( + error.to_string().contains( + "The number of multimodal content entries (1) must match the number of values (2)." + ), + "{error}" + ); + } +} diff --git a/aimux-providers/src/google/tests/error.rs b/aimux-providers/src/google/tests/error.rs new file mode 100644 index 000000000..94d932e5d --- /dev/null +++ b/aimux-providers/src/google/tests/error.rs @@ -0,0 +1,82 @@ +use bytes::Bytes; +use reqwest::StatusCode; +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; +use serde_json::{Value, json}; + +use aimux_core::error::AiMuxError; +use aimux_provider_utils::{FetchResponse, ResponseHandlerInput}; + +use crate::google::google_failed_response_handler; + +/// `expect(actual).toMatchObject(expected)`: every key of an expected object +/// is present and matches, arrays match element by element with the same +/// length, everything else is equal. +fn assert_error_match_object(actual: &Value, expected: &Value, path: &str) { + match (actual, expected) { + (Value::Object(actual), Value::Object(expected)) => { + for (key, expected) in expected { + let actual = actual + .get(key) + .unwrap_or_else(|| panic!("missing key {path}.{key}")); + assert_error_match_object(actual, expected, &format!("{path}.{key}")); + } + } + (Value::Array(actual), Value::Array(expected)) => { + assert_eq!(actual.len(), expected.len(), "array length at {path}"); + for (index, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_error_match_object(actual, expected, &format!("{path}[{index}]")); + } + } + _ => assert_eq!(actual, expected, "value at {path}"), + } +} + +mod google_failed_response_handler { + use super::*; + + /// upstream: @ai-sdk/google src/google-error.test.ts › googleFailedResponseHandler › preserves google.rpc.RetryInfo in APICallError.data + #[tokio::test] + async fn preserves_google_rpc_retry_info_in_api_call_error_data() { + let response_body = include_str!("fixtures/google-429-retry-info.json"); + let url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"; + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let result = google_failed_response_handler() + .handle(ResponseHandlerInput { + url: url.to_owned(), + request_body_values: json!({ "contents": [] }), + response: FetchResponse::from_bytes( + StatusCode::TOO_MANY_REQUESTS, + headers, + url::Url::parse(url).unwrap(), + Bytes::from_static(response_body.as_bytes()), + ), + abort_signal: None, + max_json_response_bytes: None, + }) + .await + .expect("the handler produces an error value"); + + let AiMuxError::ApiCall(error) = result.value else { + panic!("expected an APICallError, got {:?}", result.value) + }; + assert_error_match_object( + error.data.as_ref().expect("APICallError.data"), + &json!({ + "error": { + "details": [ + { + "@type": "type.googleapis.com/google.rpc.QuotaFailure", + }, + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + "retryDelay": "34.4s", + }, + ], + }, + }), + "data", + ); + } +} diff --git a/aimux-providers/src/google/tests/evaluation.rs b/aimux-providers/src/google/tests/evaluation.rs new file mode 100644 index 000000000..6792c2a74 --- /dev/null +++ b/aimux-providers/src/google/tests/evaluation.rs @@ -0,0 +1,183 @@ +//! `evaluationModel` (`google-evaluation.test.ts`). In aimux the evaluation +//! model is the provider's `decision_model` and `doEvaluate` is +//! `do_decide`. The other cases of this file keep their ports in +//! `aimux-providers/tests/google_evaluation_test.rs`. +//! +//! Not asserted: `request.signal` being the caller's `AbortSignal` (the +//! transport sees a request, not the caller's signal object). + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{Value, json}; + +use aimux_core::AbortSignal; +use aimux_core::decision_model::{DecisionCallOptions, DecisionModel, DecisionQuestionType}; +use aimux_core::provider::Provider; + +use crate::google::{GoogleProviderSettings, create_google}; +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("fixtures/evaluation.json")).expect("fixture is JSON") +} + +fn options() -> Value { + json!({ + "state": { "message": "A billing issue with a workaround." }, + "questions": { + "department": { + "type": "choice", + "instructions": "Pick the team.", + "criteria": { "technical": "Bugs", "billing": "Charges" }, + }, + "severity": { + "type": "score", + "instructions": ["Rate severity."], + "criteria": ["Low", "Medium", "High"], + }, + }, + }) +} + +fn call_options(value: Value) -> DecisionCallOptions { + serde_json::from_value(value).expect("decision options deserialize") +} + +fn setup(body: &Value) -> (Arc, Arc) { + let mock = MockFetch::new(vec![Canned { + headers: vec![ + ("content-type".into(), "application/json".into()), + ("x-request-id".into(), "req-test".into()), + ], + ..Canned::json(body) + }]); + let provider = create_google(GoogleProviderSettings { + api_key: Some("test-key".into()), + base_url: Some("https://example.com/v1beta".into()), + headers: Some(HashMap::from([( + "x-provider".to_string(), + Some("configured".to_string()), + )])), + fetch: Some(mock.transport()), + ..Default::default() + }) + .expect("valid settings"); + let model = provider + .decision_model("gemini-3.5-flash-lite") + .expect("the provider has decision models") + .expect("model is created"); + (model, mock) +} + +/// upstream: @ai-sdk/google src/google-evaluation.test.ts › uses Gemini structured output and forwards thinking options and cancellation +#[tokio::test] +async fn uses_gemini_structured_output_and_forwards_thinking_options_and_cancellation() { + let fixture = fixture(); + let (model, mock) = setup(&fixture); + let mut options = call_options(options()); + options.abort_signal = Some(AbortSignal::new()); + options.headers = Some([("x-call".to_string(), "forwarded".to_string())].into()); + options.provider_options = Some( + serde_json::from_value( + json!({ "google": { "thinkingConfig": { "thinkingLevel": "high" } } }), + ) + .unwrap(), + ); + + let result = model.do_decide(&options).await.unwrap(); + + assert_eq!(model.provider(), "google.evaluation"); + assert_eq!( + model.supported_question_types(), + [ + DecisionQuestionType::Choice, + DecisionQuestionType::Score, + DecisionQuestionType::Boolean, + ] + ); + assert_eq!( + serde_json::to_value(&result.answers).unwrap(), + json!({ + "department": { "type": "choice", "choice": "billing" }, + "severity": { "type": "score", "score": 1.25 }, + }) + ); + assert_eq!( + serde_json::to_value(result.usage.as_ref().unwrap()).unwrap(), + json!({ + "inputTokens": fixture["usageMetadata"]["promptTokenCount"], + "outputTokens": fixture["usageMetadata"]["candidatesTokenCount"], + }) + ); + let response = result.response.as_ref().unwrap(); + assert_eq!( + response.headers.as_ref().unwrap()["x-request-id"], + "req-test" + ); + assert_eq!(model.model_id(), "gemini-3.5-flash-lite"); + assert_eq!( + response.id.as_deref(), + fixture["responseId"].as_str(), + "response id" + ); + assert!( + result + .provider_metadata + .as_ref() + .unwrap() + .contains_key("google") + ); + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + assert_eq!( + seen[0].url, + "https://example.com/v1beta/models/gemini-3.5-flash-lite:generateContent" + ); + assert_eq!(seen[0].headers["x-goog-api-key"], "test-key"); + assert_eq!(seen[0].headers["x-provider"], "configured"); + assert_eq!(seen[0].headers["x-call"], "forwarded"); + let body = seen[0].json_body(); + let generation_config = &body["generationConfig"]; + assert_eq!(generation_config["responseMimeType"], "application/json"); + assert_eq!(generation_config["thinkingConfig"]["thinkingLevel"], "high"); + let schema = &generation_config["responseJsonSchema"]; + assert_eq!(schema["type"], "object"); + assert_eq!( + schema["properties"]["q0"], + json!({ "type": "string", "enum": ["c0", "c1"] }) + ); + assert_eq!(schema["properties"]["q1"]["type"], "number"); + assert_eq!(schema["required"], json!(["q0", "q1"])); + assert_eq!(schema["additionalProperties"], json!(false)); + assert!(schema["properties"]["q1"].get("minimum").is_none()); + assert!(schema["properties"]["q1"].get("maximum").is_none()); +} + +/// upstream: @ai-sdk/google src/google-evaluation.test.ts › evaluates Boolean alongside Choice and Score in one Gemini request +#[tokio::test] +async fn evaluates_boolean_alongside_choice_and_score_in_one_gemini_request() { + let mut body = fixture(); + body["candidates"][0]["content"]["parts"] = + json!([{ "text": r#"{"q0":"c1","q1":1.25,"q2":0.02}"# }]); + let (model, mock) = setup(&body); + let mut value = options(); + value["questions"]["flag"] = + json!({ "type": "boolean", "instructions": "Is a refund requested?" }); + + let result = model.do_decide(&call_options(value)).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.answers).unwrap(), + json!({ + "department": { "type": "choice", "choice": "billing" }, + "severity": { "type": "score", "score": 1.25 }, + "flag": { "type": "boolean", "probability": 0.02 }, + }) + ); + let seen = mock.seen(); + assert_eq!(seen.len(), 1); + let schema = &seen[0].json_body()["generationConfig"]["responseJsonSchema"]; + assert_eq!(schema["properties"]["q2"]["type"], "number"); + assert_eq!(schema["required"], json!(["q0", "q1", "q2"])); +} diff --git a/aimux-providers/src/google/tests/files.rs b/aimux-providers/src/google/tests/files.rs new file mode 100644 index 000000000..1a057b69b --- /dev/null +++ b/aimux-providers/src/google/tests/files.rs @@ -0,0 +1,578 @@ +//! `GoogleFiles` (`google-files.test.ts`). +//! +//! Upstream scripts one `fetch` mock that answers the resumable-upload +//! initiation, the upload URL and the polls. aimux sends the upload itself +//! through its guarded download path (never through an injected transport), +//! so the script runs on a local `wiremock` server instead: the base URL is +//! `{server}/v1beta` and the upload URL is a path on the same server, where +//! upstream uses `generativelanguage.googleapis.com` and +//! `upload.example.com`. The provider's `x-goog-api-key` header comes from +//! `api_key`. +//! +//! The other cases of this file keep their ports in +//! `aimux-providers/tests/google_files_test.rs`. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +use aimux_core::AbortSignal; +use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData}; +use aimux_core::shared::FileBytes; + +use crate::google::{GoogleFiles, GoogleProviderSettings, create_google}; + +/// The path of the base URL (`{server}/v1beta`); requests are compared by +/// path, the origin being the local server's. +const BASE_PATH: &str = "/v1beta/"; + +fn default_file_resource() -> Value { + json!({ + "name": "files/abc123", + "displayName": "test-file", + "mimeType": "application/pdf", + "sizeBytes": "1024", + "createTime": "2025-01-01T00:00:00Z", + "updateTime": "2025-01-01T00:00:00Z", + "expirationTime": "2025-01-02T00:00:00Z", + "sha256Hash": "abc123hash", + "uri": "https://generativelanguage.googleapis.com/v1beta/files/abc123", + "state": "ACTIVE", + }) +} + +fn with_state(resource: &Value, state: &str) -> Value { + let mut resource = resource.clone(); + resource["state"] = json!(state); + resource +} + +/// `createMockFiles` options. +struct MockFilesOptions { + file_resource: Value, + upload_path: &'static str, + init_status: u16, + upload_status: u16, + poll_responses: Vec<&'static str>, +} + +impl Default for MockFilesOptions { + fn default() -> Self { + Self { + file_resource: default_file_resource(), + upload_path: "/resume", + init_status: 200, + upload_status: 200, + poll_responses: Vec::new(), + } + } +} + +/// The poll endpoint: the n-th poll answers `poll_responses[n]` (the upload +/// answered index 0), then the resource's own state. +struct PollResponder { + file_resource: Value, + poll_responses: Vec<&'static str>, + poll_index: AtomicUsize, +} + +impl Respond for PollResponder { + fn respond(&self, _: &Request) -> ResponseTemplate { + let poll_index = self.poll_index.fetch_add(1, Ordering::SeqCst) + 1; + let state = self + .poll_responses + .get(poll_index) + .copied() + .unwrap_or_else(|| self.file_resource["state"].as_str().unwrap()); + ResponseTemplate::new(200).set_body_json(with_state(&self.file_resource, state)) + } +} + +struct MockFiles { + server: MockServer, + files: GoogleFiles, +} + +impl MockFiles { + async fn requests(&self) -> Vec { + self.server.received_requests().await.unwrap() + } + + /// The requests sent to the upload URL. + async fn upload_requests(&self, upload_path: &str) -> Vec { + self.requests() + .await + .into_iter() + .filter(|request| request.url.path() == upload_path) + .collect() + } + + /// The requests sent to the initiation endpoint. + async fn init_requests(&self) -> Vec { + self.requests() + .await + .into_iter() + .filter(|request| request.url.path().contains("/upload/v1beta/files")) + .collect() + } + + /// The requests under the base URL (the polls). + async fn poll_requests(&self) -> Vec { + self.requests() + .await + .into_iter() + .filter(|request| request.url.path().starts_with(BASE_PATH)) + .collect() + } +} + +fn files_for(server: &MockServer) -> GoogleFiles { + create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(format!("{}/v1beta", server.uri())), + ..Default::default() + }) + .expect("valid settings") + .files() +} + +async fn create_mock_files(options: MockFilesOptions) -> MockFiles { + // A server of its own, not one from wiremock's pool: the abort tests drop + // the client mid-upload, and a late upload byte must not reach the next + // test that borrows the same pooled server. + let server = MockServer::builder().start().await; + let upload_url = format!("{}{}", server.uri(), options.upload_path); + + let init = if options.init_status == 200 { + ResponseTemplate::new(200).insert_header("x-goog-upload-url", upload_url.as_str()) + } else { + ResponseTemplate::new(options.init_status).set_body_string("Init error") + }; + Mock::given(method("POST")) + .and(path("/upload/v1beta/files")) + .respond_with(init) + .mount(&server) + .await; + + let upload = if options.upload_status == 200 { + let state = options + .poll_responses + .first() + .copied() + .unwrap_or_else(|| options.file_resource["state"].as_str().unwrap()); + ResponseTemplate::new(200) + .set_body_json(json!({ "file": with_state(&options.file_resource, state) })) + } else { + ResponseTemplate::new(options.upload_status).set_body_string("Upload error") + }; + Mock::given(method("POST")) + .and(path(options.upload_path)) + .respond_with(upload) + .mount(&server) + .await; + + Mock::given(method("GET")) + .respond_with(PollResponder { + file_resource: options.file_resource.clone(), + poll_responses: options.poll_responses.clone(), + poll_index: AtomicUsize::new(0), + }) + .mount(&server) + .await; + + let files = files_for(&server); + MockFiles { server, files } +} + +fn upload_options( + data: Vec, + media_type: &str, + provider_options: Option, +) -> UploadFileCallOptions { + UploadFileCallOptions { + data: UploadFileData::Data { + data: FileBytes::Binary(data), + }, + media_type: media_type.to_string(), + filename: None, + provider_options: provider_options + .map(|value| serde_json::from_value(value).expect("provider options deserialize")), + abort_signal: None, + } +} + +mod google_files { + use super::*; + + mod upload_file { + use super::*; + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › should include displayName in initiation body when provided + #[tokio::test] + async fn should_include_display_name_in_initiation_body_when_provided() { + let mock = create_mock_files(MockFilesOptions::default()).await; + + mock.files + .upload_file(&upload_options( + vec![1], + "text/plain", + Some(json!({ "google": { "displayName": "my-document" } })), + )) + .await + .unwrap(); + + let init = mock.init_requests().await; + let body: Value = serde_json::from_slice(&init[0].body).unwrap(); + assert_eq!(body, json!({ "file": { "display_name": "my-document" } })); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › should send ArrayBuffer-backed data and let fetch derive its content length + #[tokio::test] + async fn should_send_array_buffer_backed_data_and_let_fetch_derive_its_content_length() { + // Not asserted: that the code passes no `Content-Length` to fetch + // (the server sees the wire request, whose length the HTTP client + // derives) and the JS buffer types. + let mock = create_mock_files(MockFilesOptions { + upload_path: "/resume-session", + ..Default::default() + }) + .await; + + mock.files + .upload_file(&upload_options( + vec![10, 20, 30], + "application/octet-stream", + Some(json!({})), + )) + .await + .unwrap(); + + let upload = mock.upload_requests("/resume-session").await; + assert_eq!(upload.len(), 1); + assert_eq!(upload[0].method.as_str(), "POST"); + let header = |name: &str| { + upload[0] + .headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + assert_eq!(header("x-goog-upload-offset").as_deref(), Some("0")); + assert_eq!( + header("x-goog-upload-command").as_deref(), + Some("upload, finalize") + ); + assert_eq!(upload[0].body, vec![10, 20, 30]); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › should return unsupported warning when filename is provided + #[tokio::test] + async fn should_return_unsupported_warning_when_filename_is_provided() { + let mock = create_mock_files(MockFilesOptions::default()).await; + + let result = mock + .files + .upload_file(&UploadFileCallOptions { + filename: Some("test.pdf".into()), + ..upload_options(vec![1], "application/octet-stream", Some(json!({}))) + }) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ "type": "unsupported", "feature": "filename" }]) + ); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › should return providerMetadata with file details + #[tokio::test] + async fn should_return_provider_metadata_with_file_details() { + let mock = create_mock_files(MockFilesOptions::default()).await; + + let result = mock + .files + .upload_file(&upload_options( + vec![1], + "application/octet-stream", + Some(json!({})), + )) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result.provider_metadata).unwrap(), + json!({ + "google": { + "name": "files/abc123", + "displayName": "test-file", + "mimeType": "application/pdf", + "sizeBytes": "1024", + "state": "ACTIVE", + "uri": "https://generativelanguage.googleapis.com/v1beta/files/abc123", + "createTime": "2025-01-01T00:00:00Z", + "updateTime": "2025-01-01T00:00:00Z", + "expirationTime": "2025-01-02T00:00:00Z", + "sha256Hash": "abc123hash", + }, + }) + ); + } + + mod polling { + use super::*; + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › polling › should preserve $name in the polling URL + #[tokio::test] + async fn should_preserve_name_in_the_polling_url() { + for (name, expected_path) in [ + ("files/abc/../../secret", "files%2Fabc%2F..%2F..%2Fsecret"), + ("files/.", "files/%252E"), + ("files/..", "files/%252E%252E"), + (".", "%252E"), + ("..", "%252E%252E"), + ] { + let mut file_resource = default_file_resource(); + file_resource["name"] = json!(name); + let mock = create_mock_files(MockFilesOptions { + file_resource, + poll_responses: vec!["PROCESSING", "ACTIVE"], + ..Default::default() + }) + .await; + + mock.files + .upload_file(&upload_options( + vec![1], + "application/octet-stream", + Some(json!({ "google": { "pollIntervalMs": 1 } })), + )) + .await + .unwrap(); + + let poll_url = mock + .poll_requests() + .await + .last() + .map(|request| request.url.path().to_string()); + assert_eq!( + poll_url, + Some(format!("{BASE_PATH}{expected_path}")), + "{name}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › polling › should reject promptly when aborted during the polling delay + #[tokio::test] + async fn should_reject_promptly_when_aborted_during_the_polling_delay() { + let mock = create_mock_files(MockFilesOptions { + poll_responses: vec!["PROCESSING", "PROCESSING"], + ..Default::default() + }) + .await; + let signal = AbortSignal::new(); + + // a long interval that only an abort-aware delay can escape + let options = UploadFileCallOptions { + abort_signal: Some(signal.clone()), + ..upload_options( + vec![1], + "application/octet-stream", + Some(json!({ + "google": { "pollIntervalMs": 60_000, "pollTimeoutMs": 120_000 }, + })), + ) + }; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + signal.abort(); + }); + + let start = Instant::now(); + let result = mock.files.upload_file(&options).await; + assert!(result.is_err()); + assert!(start.elapsed() < Duration::from_millis(5000)); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › polling › should poll until file state becomes ACTIVE + #[tokio::test] + async fn should_poll_until_file_state_becomes_active() { + let mock = create_mock_files(MockFilesOptions { + poll_responses: vec!["PROCESSING", "PROCESSING", "ACTIVE"], + ..Default::default() + }) + .await; + + let result = mock + .files + .upload_file(&upload_options( + vec![1], + "application/octet-stream", + Some(json!({ "google": { "pollIntervalMs": 10 } })), + )) + .await + .unwrap(); + + assert_eq!( + result.provider_reference.get("google").map(String::as_str), + default_file_resource()["uri"].as_str() + ); + let poll_calls: Vec = mock + .requests() + .await + .iter() + .map(|request| request.url.path().to_string()) + .filter(|url| url.contains("files/abc123")) + .collect(); + assert!(!poll_calls.is_empty()); + assert_eq!(poll_calls[0], format!("{BASE_PATH}files/abc123")); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › polling › should throw on poll timeout + #[tokio::test] + async fn should_throw_on_poll_timeout() { + let processing_resource = with_state(&default_file_resource(), "PROCESSING"); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/upload/v1beta/files")) + .respond_with(ResponseTemplate::new(200).insert_header( + "x-goog-upload-url", + format!("{}/resume", server.uri()).as_str(), + )) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/resume")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "file": processing_resource })), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(&processing_resource)) + .mount(&server) + .await; + let files = files_for(&server); + + let error = files + .upload_file(&upload_options( + vec![1], + "application/octet-stream", + Some(json!({ "google": { "pollIntervalMs": 10, "pollTimeoutMs": 50 } })), + )) + .await + .unwrap_err(); + + assert!(error.to_string().contains("timed out"), "{error}"); + } + } + + mod provider_options { + use super::*; + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › provider options › should accept valid provider options + #[tokio::test] + async fn should_accept_valid_provider_options() { + let mock = create_mock_files(MockFilesOptions::default()).await; + + let result = mock + .files + .upload_file(&upload_options( + vec![1], + "text/plain", + Some(json!({ + "google": { + "displayName": "test", + "pollIntervalMs": 5000, + "pollTimeoutMs": 60000, + }, + })), + )) + .await + .unwrap(); + + assert!(result.provider_reference.contains_key("google")); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › provider options › should pass through unknown properties via passthrough + #[tokio::test] + async fn should_pass_through_unknown_properties_via_passthrough() { + let mock = create_mock_files(MockFilesOptions::default()).await; + + let result = mock + .files + .upload_file(&upload_options( + vec![1], + "text/plain", + Some(json!({ "google": { "customField": "custom-value" } })), + )) + .await + .unwrap(); + + assert!(result.provider_reference.contains_key("google")); + } + } + + mod response_metadata { + use super::*; + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › response metadata › should omit optional fields from metadata when not present + #[tokio::test] + async fn should_omit_optional_fields_from_metadata_when_not_present() { + let minimal_file_resource = json!({ + "name": "files/minimal", + "mimeType": "text/plain", + "uri": "https://generativelanguage.googleapis.com/v1beta/files/minimal", + "state": "ACTIVE", + }); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/upload/v1beta/files")) + .respond_with(ResponseTemplate::new(200).insert_header( + "x-goog-upload-url", + format!("{}/resume", server.uri()).as_str(), + )) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/resume")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "file": minimal_file_resource })), + ) + .mount(&server) + .await; + let files = files_for(&server); + + let result = files + .upload_file(&upload_options( + vec![1], + "application/octet-stream", + Some(json!({})), + )) + .await + .unwrap(); + + let google = + serde_json::to_value(&result.provider_metadata.unwrap()["google"]).unwrap(); + assert_eq!( + google, + json!({ + "name": "files/minimal", + "mimeType": "text/plain", + "state": "ACTIVE", + "uri": "https://generativelanguage.googleapis.com/v1beta/files/minimal", + }) + ); + assert!(google.get("createTime").is_none()); + assert!(google.get("expirationTime").is_none()); + assert!(google.get("sha256Hash").is_none()); + } + } + } +} diff --git a/aimux-providers/src/google/tests/fixtures/evaluation.json b/aimux-providers/src/google/tests/fixtures/evaluation.json new file mode 100644 index 000000000..9f50cb707 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/evaluation.json @@ -0,0 +1,31 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "{\n \"q0\": \"c1\",\n \"q1\": 1.25\n}", + "thoughtSignature": "El4KXAERTTIPMd78Z3zYWtz6Q3NtDTPVJokZntX+MiaoiIJx9wRewb2wAGzOedHMq8M7i+Am1AKSrJIzMvHSjke7YnaOSnWfi61150vZdYgDakxauXf/3q0Uab2HMaD1" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 21, + "candidatesTokenCount": 24, + "totalTokenCount": 45, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 21 + } + ], + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "a9-qapPZOLm4mtkPluzysAg" +} diff --git a/aimux-providers/src/google/tests/fixtures/google-429-retry-info.json b/aimux-providers/src/google/tests/fixtures/google-429-retry-info.json new file mode 100644 index 000000000..131e51ef1 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-429-retry-info.json @@ -0,0 +1,21 @@ +{ + "error": { + "code": 429, + "message": "You exceeded your current quota, please check your plan.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.QuotaFailure", + "violations": [ + { + "quotaId": "GenerateRequestsPerMinutePerProjectPerModel-FreeTier" + } + ] + }, + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + "retryDelay": "34.4s" + } + ] + } +} diff --git a/aimux-providers/src/google/tests/fixtures/google-reasoning-gemini3.chunks.txt b/aimux-providers/src/google/tests/fixtures/google-reasoning-gemini3.chunks.txt new file mode 100644 index 000000000..edf236f97 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-reasoning-gemini3.chunks.txt @@ -0,0 +1,3 @@ +{"candidates":[{"content":{"parts":[{"text":"There are **3** \"r\"s in strawberry.\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":13,"totalTokenCount":324,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":302},"modelVersion":"gemini-3-pro-preview","responseId":"M3iLaY-AI7zTxN8P3Piw4Qg"} +{"candidates":[{"content":{"parts":[{"text":"St**r**awbe**rr**y"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":23,"totalTokenCount":334,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":302},"modelVersion":"gemini-3-pro-preview","responseId":"M3iLaY-AI7zTxN8P3Piw4Qg"} +{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"EpAICo0IAb4+9vuku3oDHR5EQSMfPm5IpmH06lgm9aGICNAJKKfpVXU3971qpSGSFiIVb60jKjMU182kVfRyl9bViwRtwI1o4nFI+BqC+kLUuUaG+hCfQZBYcCiq8rmQK+LQJxRey5s3kXIDNUcpyOk4I2Y/wFpbcnQC/8xzXHo8D0y7eZrayZwAsusgHwcww988h0JOA8zPXCaN/q7bDfnQqX4tHps3VezQBKlNQOGdbB/cb+Sr2qRPqoOYKrLhxJ+dWGqkbv4wYSF6rYLIXh8h95CkzmerYWkLg5O5fYuTyz0/cHZLnF0MJFCMZtwk8TWEc2+HVLDvS7lw/fFSqKmaIC/CBDSZA1N8VOPq9FZ+t1kJ2lwG5t6CfXcD2xz20eWpdf0p3G51XkTbF6Bghj1TZi0RiXJUgThyqB3gGSdE5nd0HAX2dU3YCVdwX31wNd6atyTeUx7itA39sP6EQteHbz8coTM77ueppUnU0bGUY85V7PCeQTFP47jIj0LuwCM8P3AE3Fj/QPstzMu42lW4kd95ngwaUKdo2QxFXB7/6mwFWWOLoDWgy1a1mgQYnk70GcLPnIcEwC+Yo+M3vb4F6VLIXtZhs+q9vubASDpCHxjcbFUmAoqdajCGh/q48VWFVBybiS5gYOPoR3dH+o+ugY3evyv1vO3fJqEeEj9lh7jgwJiy1CvjZlC+fzB75RGIeiI3R7gGeob6QjLjuSfvkSGbdZBlTShO3mQIxE7CKYyB7RNuYfaYW0E/4mTDEppuFsR6ub1JySylx+0klPfH2GP7pnVnWOZ1itHPXCexpAPfCoW5ulpgRxDUIvZUUvA2rRJoaCSWgRWx12OM9wzWEonLKEW122OPSUV3zEmHqjujJzHgkKoEMfGS/OAuIAusR8mv6VMKERLI2r5v4y/vqBXutwO0S7ztoU9qPJm60z4hDgkuSkjFCYh17U3Ah4LHUlBCO3BaOy/M68JtDHU3+hPmwMNjXByl68FacTWEOtjW4PoPoWFsWbUZ1uU4BtR6/aGsgARJPVQlZ9eRVke5JAQKk43jRVhzxHRkfunvRU7b1FAejU1J5byc2xes0saIwU0+tETySrKTe2ktkWvk/a4TgPnm8SGiV+pVZ91r3uTtjds6h+VA0lVMdqfY/vuYiNnYybR4J6r6U+DTpIAgNgyIx4r/N9zy+Z5nqNFFrwS85B+aWqVf8vaT/jW4u/vh8ZEuoJ1NhsGdefZjokyv+wm+G1N5ykmdo5qO5aR/oGbH/YSIFfALFAIgWK+6og1jjkjf2bVBVsmtns2/w+au7xvFpkjLT+klKwiuYjaTHMEBk4+Tb4Uq/VPO7ZbPPts9XwsWsv/P3FNrCXyy0/RCAUsr7SEP3u0kIsk9vG9i114="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":23,"totalTokenCount":334,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":302},"modelVersion":"gemini-3-pro-preview","responseId":"M3iLaY-AI7zTxN8P3Piw4Qg"} \ No newline at end of file diff --git a/aimux-providers/src/google/tests/fixtures/google-reasoning-gemini3.json b/aimux-providers/src/google/tests/fixtures/google-reasoning-gemini3.json new file mode 100644 index 000000000..dd248c4a9 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-reasoning-gemini3.json @@ -0,0 +1,31 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "There are **3** \"r\"s in strawberry.\n\nHere is the breakdown: st**r**awbe**rr**y.", + "thoughtSignature": "EswFCskFAb4+9vu5+eCedpmcjHHK+yjyrSAIGVBHj03GrObE+hduQ213f7FDz0UD+aLw3SDPDBkaK69ObgjN0U84Q1yKpmpLk6GnphX0+DrmRFYKnw2TJ8c7/DKa3z5x" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 9, + "candidatesTokenCount": 29, + "totalTokenCount": 296, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 258 + }, + "modelVersion": "gemini-3-pro-preview", + "responseId": "DniLab2dFPeSxN8PpqXY4Ag" +} diff --git a/aimux-providers/src/google/tests/fixtures/google-reasoning.chunks.txt b/aimux-providers/src/google/tests/fixtures/google-reasoning.chunks.txt new file mode 100644 index 000000000..2fd9e76cc --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-reasoning.chunks.txt @@ -0,0 +1,3 @@ +{"candidates":[{"content":{"parts":[{"text":"There are **3** \"r\"s in"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":10,"totalTokenCount":275,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":256},"modelVersion":"gemini-3-pro-preview","responseId":"dX6LadKVC7SZ28oPr9yJoQs"} +{"candidates":[{"content":{"parts":[{"text":" strawberry.\n\nHere is the breakdown: st**r**awbe**rr**y."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":29,"totalTokenCount":294,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":256},"modelVersion":"gemini-3-pro-preview","responseId":"dX6LadKVC7SZ28oPr9yJoQs"} +{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"Eo0HCooHAb4+9vutXdtKMt+r7Z3gLh52ZQ3RKYDzp4A6PHh0dLuvI1KwnLxDS/qm8wn4Gm9NNNQ1nBS/rrCAvr4GUTqanTkyxQb0ctdgoMiQJ/BwIXliPqJ5C+YtnXZfktX+roGCDmCE4qimrCzgW4TytiSC4glx6CPNVCUT2S/vrK3WT1Dn8DpykEIddvSH5PRxAOXQHjPfwQ9BUcBVw3/6+dTv/IgIIDbSkt0Lmy/Vww80oAWSDph0YpK2OXdpuZnUjscFeKJFqMJeuc3OAuAMIeb1GSwD2l5bcufkMqBDSZb00QxjTqrQm7DHbauxrz7oNCJRtRFQlrGrrMx2ut2eXzeEamNGXf433pMBZBi0BxhFWBCuZemJQIzY/LHiHT7jTjaiZIISyAMFC7u/WTY5ONRa9wWQ76NJVmriCm2+Mo1GXVGAkwwD54qiKgGxvFEWeJqP+13Esuw02QwHEVWnIbQUhaEYDIvR//AGPvDJVpzZmDfv9mcJSa09z4/UG2sadoftByt44eW3gBINtmeUAGjn52wETOxUsq/2qz/zkxvySOpUcXFKAktjgLdA7tHMYRgrCUgYcfznZc1XlyCivb7imNJadsVg0O5SMcnBLAme/0cnnOUrZK1CW+7S5UM8408GE9soyHAXf92y8mZ3n9YoEPKhuf0OjMSYoI9MOqhkIZxj8IaOAuaH8PrAd2nI6Y82jCFF81ZxJN+YyL/NNL3iURvtPHJ2hGjQGdzYnm62CYEAG8t5bEZwix6agxhyY1uM/Atj1nvD/bpVvvBENrMPWFhI0O8IvJ2bdJh2AcKSLQveWZpqs8HQoUMZcOQPs61wn9se+NKh760oCvLDcfNxWSInIFpLuZdL8xzluDxu1FXm75LFPoTRnbCUAG9PcJJWyoM3SLgw3SaoRIjwkAbdOK47NwYhS1WSvgxQwbnMix75XKez8Ja17/o847oplrjiezqJVpQsMqs6mxRWnFIDl1mgw+BQSEl67wKTAaPC7nX6x2Px+H2PM20RqdWTd5RiwST6Gm0eQjekJG3G+Xtnhte1ubcnCu3bMMaza0uwsy5imuHj8KJaQbAEWtIEAhFJBNvnlZGnqFZPRWwMnJ28nSBe8VAdGOLOPP96hlbCe/3D0PBYEFGz1K7G108h7jr082IZ47P9Vk4CrOI7DdfmbfcnfiQKys8pHj6H981zw5xogUmwAj/uUJKN"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":29,"totalTokenCount":294,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":256},"modelVersion":"gemini-3-pro-preview","responseId":"dX6LadKVC7SZ28oPr9yJoQs"} \ No newline at end of file diff --git a/aimux-providers/src/google/tests/fixtures/google-stream-no-args-tool-call.chunks.txt b/aimux-providers/src/google/tests/fixtures/google-stream-no-args-tool-call.chunks.txt new file mode 100644 index 000000000..30b5363a0 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-stream-no-args-tool-call.chunks.txt @@ -0,0 +1,15 @@ +{"candidates":[{"content":{"role":"model","parts":[{"text":"**Processing User Requests**\n\nI've started by understanding the user's instructions. Currently, I'm focusing on the initial steps: reading the specified theme using the appropriate tool. Next, I plan to tackle reading the screens, beginning with screen \"A,\" then proceeding with \"B\" and \"C\" in parallel as instructed.\n\n\n","thought":true}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read_theme"},"thoughtSignature":"AY89a18a8/Loc2wl5oft+F3qHoU4VPX0L9ljRtKxfjhEuimSIqhzYEJAGT5Sgd8OH2C+Te2YY1DKfGsr+ajyR7o2gTbljQMXyvmOimX/W3ZzWOabOHpiC5zNjii2CfPBV9+LG5Gu0efVfzdcNTnomFaak9hP/xEoWUk/Ahh2GcT1DC1UgMjqOARxYu7gP6i8LGrEC5TL7u+92163O2B33MCJxY7NHE6M99exsMJ2KOf2Exe3R5az0cmgxokiBW/ZOZacqzm1svBpoMb3PS1YFEWgXiyc1ExblrjL2O4rgC0qouMSvC0Z8fzYpbLrwiSko+/4vIsJpwSfI1RU3BIcmA389ZvZK8wO5N4cATH8vnlHUPD+RjeB4YXO2ymqGRPfvN0uGt6ojWyZfFphhP88um6MgyjcVMfabUrpd1uM5PbhLO+QfcQaMbozADjypuU/Z39i6L+DsGIrWWlxYTCpj4hClQ0mQg3heLPnD3DRmazSVbtJM+HGH0bc893sK51Wv4wCr8Fm++qL1PzIjEodb/0rq99OZ+1dgAlj6Uwo61i9x7i1EhiLzuWvlgsaLC0nM2GyNcODbueEHvucYpL6nhRpqIhX/yZWb3Yvg2WgBAisv0iSGLpntdrSSHYMZROAb5TnTKwxO3VJ7UwULqohGW2RU9NnKX8BzNI2POe1Jj0enxar+5LcuRE2q5V7jVTEiG6Y6thdFyXWVrvAHjnGXLSiYhDGngToAVjJ3Lynh6Zl77nAqV5QIcQMOyS+KBS1VW13eOFqG+cI6Bu2Dqfsv4Fb+0Qe5gjblTy54g52mxHi6FpdmqE4Q2lmxE3wUTyoWuzQLAanOf5m+hkTjjD08ljZkphCktxIEMt5+ZIXzfWDld7nD76+v0ICUxWyj+Ip1Gg5L2Zj2baFSn9VQ8qkFDKActLJetMBdRK+mkL7ZoBLB8wNLu3cX28m+EEyfq8+8bEuQv1hjTMtKN+5/BMCFmNvkb/pAxNnWUzzSyjaCjCiEiDM5kJXClomcvF2BTo/nQYepQ/8mmxdyc8ZCmdytGJB49ZeNTtCJA=="}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read_screen","willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.id","stringValue":"A","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.id","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read_screen","willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.id","stringValue":"B","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.id","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read_screen","willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.id","stringValue":"C","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.id","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} +{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":249,"candidatesTokenCount":58,"totalTokenCount":490,"trafficType":"PROVISIONED_THROUGHPUT","promptTokensDetails":[{"modality":"TEXT","tokenCount":249}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":58}],"thoughtsTokenCount":183},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-04T20:01:02.264968Z","responseId":"_vr4aYiWEJnYodAPkujX0QM"} \ No newline at end of file diff --git a/aimux-providers/src/google/tests/fixtures/google-stream-tool-call-arguments.chunks.txt b/aimux-providers/src/google/tests/fixtures/google-stream-tool-call-arguments.chunks.txt new file mode 100644 index 000000000..76b248938 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-stream-tool-call-arguments.chunks.txt @@ -0,0 +1,8 @@ +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"getWeather","willContinue":true},"thoughtSignature":"CiMBjz1rX25KieIB4d4AwFn8/WbsHTRNHBXso88PtemwfsSfRAp6AY89a1/e2FrmrE2HtlOxvV7vy8Kn5Og9n6CepcBKYW9aBT5QdXtTD4PGv9pDDWLEDAfsd86Et6k7HDEV976M3kC0ahLS8FCrwRntUKr+GF5uiUfPvgpzqNl5m8d8EwYykfwo1VwuTStR7q3mr/TzDcNzepafpVLdZloKYQGPPWtfkohmdau0GqGi3DuPbFTUBe5Ac/UbCvUk+P7KFbCGb93vFmnjmmTV7228DcAddqtWfe4iVePtSumw3JptYE6PIeVao2r8q7FXZKazzEiA9c04nmq6Vv9lOtt5NsgKrwEBjz1rX1ajIx3yeG/QI78uO+MsKoqgwrCI7PAEaDRqbyDCW/NX/4Vfuz1wp96oT3h/ttJ5ejzQyGamOrYreUUFvJBR/0TvFBdWOXwwCOWtvJ0Jh7SinV9HNqBDM1WWH8e+tGWY0r7xU/o/M1Fkwd73bvyp3BmSm5orrQL4jUZ1TN6ECqw7T7wHEkIfuBXGMZhQwAPzaapGVCdn0LjFJwd1aVReWmt6pE4ut993/K+0CqsBAY89a1+ts0Nggt7GZxVw/dQhH2Gc6KxRbAZKFMTuYRcNufJ5CyTvq85rMWEhTPWQxlQe1V7NIOlsQzyDhlejiCVbq0chDVrwRY23BmLrhSLi+SfKukEc0M+V9LdFiqxxLgUBrVdCvxA17g11j6HGdmXiNze2WDKm7ZXgMYvEiVmglpRZOb4JeJzxNPFsxhv2k6VkKB+GRtO48XwNz/i2taQ8Nq+LO5tED6HhCp4BAY89a18wBKoSkBT2IRo530FeNq/lpVozY0+k+p+H//TAtKoWZBamKCwra81idaMm1TTaWZGlX85Pq0kBvx1sQLFEhILeQrbH0O3Iuk9JlKLKctAwnBj8BZUGJEGtbigTKaSgWcMBhW+jfKWi1xwSxyJLRkIcXmE8U3FE/XHCLeiBy0NRa0yKn3xh62JU9087Q2nOMClxy5Zy5VsZ0qQ="}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-02T17:03:50.399550Z","responseId":"dqHOab6xGLzWodAPkPuViA4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.location","stringValue":"Boston","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-02T17:03:50.399550Z","responseId":"dqHOab6xGLzWodAPkPuViA4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.location","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-02T17:03:50.399550Z","responseId":"dqHOab6xGLzWodAPkPuViA4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-02T17:03:50.399550Z","responseId":"dqHOab6xGLzWodAPkPuViA4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"getWeather","willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-02T17:03:50.399550Z","responseId":"dqHOab6xGLzWodAPkPuViA4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.location","stringValue":"San Francisco","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-02T17:03:50.399550Z","responseId":"dqHOab6xGLzWodAPkPuViA4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.location","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-02T17:03:50.399550Z","responseId":"dqHOab6xGLzWodAPkPuViA4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":26,"candidatesTokenCount":23,"totalTokenCount":181,"trafficType":"ON_DEMAND","promptTokensDetails":[{"modality":"TEXT","tokenCount":26}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}],"thoughtsTokenCount":132},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-02T17:03:50.399550Z","responseId":"dqHOab6xGLzWodAPkPuViA4"} \ No newline at end of file diff --git a/aimux-providers/src/google/tests/fixtures/google-stream-tool-call-array-arguments-missing-terminal-function-call.chunks.txt b/aimux-providers/src/google/tests/fixtures/google-stream-tool-call-array-arguments-missing-terminal-function-call.chunks.txt new file mode 100644 index 000000000..7c44e03a5 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-stream-tool-call-array-arguments-missing-terminal-function-call.chunks.txt @@ -0,0 +1,16 @@ +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"writeItems","willContinue":true},"thoughtSignature":"AY89a19ZkXSMGh/bSOlwTCxl62lQdtTZw/RrFa7T+iYbR+RaEkjrJoZjdPES1AOHFBS4hgxEYEx4DG4eCp4FJVxYxxhbJZYSfKMFw6GDrRcxTC25H/7E0LMsJgquyecqHJqTIn51wvdX8Fz3+BN74d/4mz8lW2OxG07eV/s2FONQ9D5KIoVvnKiW6SXOXoAcEgCifVQsSRMfkj0mD69Iz3cx9rBWBKuj6lqAVchqsONkQ90/xxPiAJ7nEaFG+XtXOjbOEZekiA3jvCYwGi6ARSMElDqZJTOabSNGKVJkzNUX/KJTileejJW82kEu16qWkLZffx4WuwSMi4XK2uuibQI+xj+IPyfzUQRlNPewHAwegmrsd3qIuaum0aNKqHwG0kID2x2ycFaKLWfpzbO1yY4XrnX8wGwXPe/04allTmxn0ni8YYlysVRT5/hhDyBG7JF5ApZp+gzj9EiCLitbyqMe6a9dJTDp2NJmygmEbEFOI2emmGJ3G/xHRqVNBj++utQeqduE2LezNp/D8CeDnZTpuEkklDFN1+olp9b9XkRSJ/TClyxAnNUVzjkm+/Agzcfvg7/HtW04py6M7fZ/gFd7yW9uvAlc9NrhpZLnUalBcnHbzTEsUwT3+Ks8KuXCnpAfG3wCcMKnRGHH/9SZdZeTyE337T6++P3HK7HKoza5wj5pEohf1AF5LwBkUcGSkZA8LXPT3dMyQ4AQM/RB+TNqtgGW"}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[0].action","stringValue":"add","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[0].action","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[0].description","stringValue":"Fresh red apple","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[0].description","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[0].itemid","stringValue":"apple_001","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[0].itemid","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[0].price","numberValue":0.5}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[1].action","stringValue":"add","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[1].action","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[1].description","stringValue":"Ripe yellow banana","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[1].description","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[1].itemid","stringValue":"banana_001","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[1].itemid","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.operations[1].price","numberValue":0.3}]}}]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} +{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":54,"candidatesTokenCount":74,"totalTokenCount":249,"trafficType":"PROVISIONED_THROUGHPUT","promptTokensDetails":[{"modality":"TEXT","tokenCount":54}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":74}],"thoughtsTokenCount":121},"modelVersion":"gemini-3-flash-preview","createTime":"2026-05-19T14:59:42.780296Z","responseId":"3noMaojQL_2s6tkPiO26qQ4"} \ No newline at end of file diff --git a/aimux-providers/src/google/tests/fixtures/google-text.chunks.txt b/aimux-providers/src/google/tests/fixtures/google-text.chunks.txt new file mode 100644 index 000000000..01294b403 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-text.chunks.txt @@ -0,0 +1,3 @@ +{"candidates":[{"content":{"parts":[{"text":"There are **3**"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":5,"totalTokenCount":199,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":185},"modelVersion":"gemini-3-pro-preview","responseId":"bH6LaZW8Fp_3nsEPqtaSwQ4"} +{"candidates":[{"content":{"parts":[{"text":" \"r\"s in strawberry.\n\nst**r**awbe**rr**y"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":23,"totalTokenCount":217,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":185},"modelVersion":"gemini-3-pro-preview","responseId":"bH6LaZW8Fp_3nsEPqtaSwQ4"} +{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"EqsFCqgFAb4+9vvtAF5n87lB4OGDOoTRMOqp35jW65XsYXh6BySMwl9nvrbAvPcl2U0xITaYUyV4CmREEDB1z0ZPpCg7iEwiZcj40Eh1jXoL8Y/BbPqxdgZKvKxdBsJx92y2ML5ytajQHVFQb9ohEMMnjs9uNadLAhDEsOU1nC5tl3FQkx94uaGfWvg61bJT3Y9OxFdo/kbpm4RBngvYhVkBzHKkHBj72T2bUd8J4HPssi7ORC5iPosPRIOyH/CAVHEtMzFYMwb7OhRu+CW8Z9u7gDieME5iJjXtJtLrNGDxgR7XtWfRRyGjsj6uDS+KvjR3SUSWPdn5eeH6w+LXZm1X///Hvhhcx+NHxsuGjF3fGhyzTVAoIzk0lxyB4+/A9I4Xa0o/T4coVDiewMzGZDwmket//ig8x9UC8cyWr/hy1joZWUO7ooJlLncv8gy4Ng+y1JdievZokSFDNWfMMNAQr3kgUwJDucqDp44C1xMtgR3lhJ75IBBnprHCE/ThgvNXujmqNkwAjp5dS4PjVbrw8fqSylfE80tvU0g9dXqg4pEyG+hGIxbANLhsWjAKLqh69hyqvVLg2Ds3wppphf61IfC4VoeLWj85CjBZMf+k85NsUIJQ6+DQS9IPNbM29ZOzpUbHoWKJB6VzNCSJse7Pi07L+pd6skl77km00y4lJdHIGHfEgi8PaOonakBcxbRqKzGJAA/urlP0tiWya2fTWrvNZOybJHyyofNNSI4s5y76yKEjP1wnPqC7ujrQk6xb7eyCeqH9ekByy3vv0JfgERFptoSUoG2toIr9M3lS/LKpnwfCvZh+z3J0iMb83d4MaPKhGhE49J4660XUsEmjygAZNi9HnjfC3KtaU/07Sx4JCezMtpsLKUxBgy4xaNqwew3FwAG37eeWcow="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":23,"totalTokenCount":217,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":185},"modelVersion":"gemini-3-pro-preview","responseId":"bH6LaZW8Fp_3nsEPqtaSwQ4"} \ No newline at end of file diff --git a/aimux-providers/src/google/tests/fixtures/google-text.json b/aimux-providers/src/google/tests/fixtures/google-text.json new file mode 100644 index 000000000..581218294 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-text.json @@ -0,0 +1,31 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "There are **3** r's in strawberry.\n\nHere is the breakdown: st**r**awbe**rr**y.", + "thoughtSignature": "EtoFCtcFAb4+9vtfe4MXRxQjw48U1WKrR/7lYsgFkVi/bepqsSPjY0VU7HEzkeCBIfy1fu5t9aUZ4IZ65aWagqbBrV45fc97olcg" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 9, + "candidatesTokenCount": 28, + "totalTokenCount": 281, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 244 + }, + "modelVersion": "gemini-3-pro-preview", + "responseId": "Un6LacrVMcjUxs0PmJfWoQc" +} diff --git a/aimux-providers/src/google/tests/fixtures/google-tool-call-gemini3.chunks.txt b/aimux-providers/src/google/tests/fixtures/google-tool-call-gemini3.chunks.txt new file mode 100644 index 000000000..3629b67f6 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-tool-call-gemini3.chunks.txt @@ -0,0 +1,2 @@ +{"candidates":[{"content":{"parts":[{"functionCall":{"name":"weather","args":{"location":"San Francisco"}},"thoughtSignature":"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"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":29,"candidatesTokenCount":15,"totalTokenCount":848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":29}],"thoughtsTokenCount":804},"modelVersion":"gemini-3-pro-preview","responseId":"QHiLaa6LBrb8vdIPoNztsAg"} +{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":29,"candidatesTokenCount":15,"totalTokenCount":848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":29}],"thoughtsTokenCount":804},"modelVersion":"gemini-3-pro-preview","responseId":"QHiLaa6LBrb8vdIPoNztsAg"} \ No newline at end of file diff --git a/aimux-providers/src/google/tests/fixtures/google-tool-call-gemini3.json b/aimux-providers/src/google/tests/fixtures/google-tool-call-gemini3.json new file mode 100644 index 000000000..1959ab290 --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-tool-call-gemini3.json @@ -0,0 +1,37 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "weather", + "args": { + "location": "San Francisco" + } + }, + "thoughtSignature": "Eqo+Cqc+Ab4+9vtgONaaz6qwy6WXdp7gCd2w0X+Wz2gaBgY0Gv6A12JKo0y5vQwf9YQFyhMbKr1E9m17VT6HXd7jXzjaGYaE" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "finishMessage": "Model generated function call(s)." + } + ], + "usageMetadata": { + "promptTokenCount": 29, + "candidatesTokenCount": 15, + "totalTokenCount": 1845, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 1801 + }, + "modelVersion": "gemini-3-pro-preview", + "responseId": "JniLacKqGqH0xs0P0O776As" +} diff --git a/aimux-providers/src/google/tests/fixtures/google-tool-call.chunks.txt b/aimux-providers/src/google/tests/fixtures/google-tool-call.chunks.txt new file mode 100644 index 000000000..b0881b3cf --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-tool-call.chunks.txt @@ -0,0 +1,2 @@ +{"candidates":[{"content":{"parts":[{"functionCall":{"name":"weather","args":{"location":"San Francisco"}},"thoughtSignature":"EqUCCqICAb4+9vsh8Pd5taZVoPzSvjWWwzBrvhEQWBLCGa7IdY8FBMm7Z6dCKFU3Ft0la15gF7RaHe1NlPRygQec0bFwPDfMwGcUOMNiJiNIKxusCs4ejCZRuouNYQ4etEIt7CujEUHiILLfZXSJZYhs4UCrD2bLqPq0sE0lWgYJnzHkkKUOnMsA2hKffAhtF4DWn5INYj8pPssvch/2VpDFW2F9XSE04zLDzkIWF2eztJX50Y0lTehRZC3FW7fOrXCzGx+PwdataD6eXlF5O1zn+86XtmktOs2DEp4o1PMvXFFAXe8GGvPt8Idf3UtHMq7AsapwMW9sjiKj+FJk54m+9LMTSaj7C86smfvoQryYBEHTVazr1bEnpl4bPG5JUtm2yAMkHj4="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":29,"candidatesTokenCount":15,"totalTokenCount":89,"promptTokensDetails":[{"modality":"TEXT","tokenCount":29}],"thoughtsTokenCount":45},"modelVersion":"gemini-3-pro-preview","responseId":"b36LacjwM668nsEP2tbsgQQ"} +{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":29,"candidatesTokenCount":15,"totalTokenCount":89,"promptTokensDetails":[{"modality":"TEXT","tokenCount":29}],"thoughtsTokenCount":45},"modelVersion":"gemini-3-pro-preview","responseId":"b36LacjwM668nsEP2tbsgQQ"} \ No newline at end of file diff --git a/aimux-providers/src/google/tests/fixtures/google-tool-call.json b/aimux-providers/src/google/tests/fixtures/google-tool-call.json new file mode 100644 index 000000000..927892a9d --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-tool-call.json @@ -0,0 +1,37 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "weather", + "args": { + "location": "San Francisco" + } + }, + "thoughtSignature": "EskgCsYgAb4+9vtF7/499YQS2bjZs3xcQI+iAl+ILn29nK1j0Kg6su7QsUUUk3nrAAfnS2w5WiVvlcCqu9fAebJ2cvfaEyBahEt5" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "finishMessage": "Model generated function call(s)." + } + ], + "usageMetadata": { + "promptTokenCount": 29, + "candidatesTokenCount": 15, + "totalTokenCount": 937, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 893 + }, + "modelVersion": "gemini-3-pro-preview", + "responseId": "m36LaZGyCLz1xs0PtNSB-QU" +} diff --git a/aimux-providers/src/google/tests/fixtures/google-vertex-stream-tool-call-arguments-nested.1.chunks.txt b/aimux-providers/src/google/tests/fixtures/google-vertex-stream-tool-call-arguments-nested.1.chunks.txt new file mode 100644 index 000000000..1c656512b --- /dev/null +++ b/aimux-providers/src/google/tests/fixtures/google-vertex-stream-tool-call-arguments-nested.1.chunks.txt @@ -0,0 +1,76 @@ +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"cookRecipe","willContinue":true},"thoughtSignature":"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"}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[0].amount","stringValue":"16 oz","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[0].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[0].name","stringValue":"Lasagna noodles","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[0].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[1].amount","stringValue":"1 lb","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[1].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[1].name","stringValue":"Ground beef","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[1].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[2].amount","stringValue":"15 oz","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[2].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[2].name","stringValue":"Ricotta cheese","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[2].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[3].amount","stringValue":"3 cups","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[3].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[3].name","stringValue":"Mozzarella cheese","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[3].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[4].amount","stringValue":"1/2 cup","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[4].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[4].name","stringValue":"Parmesan cheese","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[4].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[5].amount","stringValue":"24 oz","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[5].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[5].name","stringValue":"Tomato sauce","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[5].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[6].amount","stringValue":"1","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[6].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[6].name","stringValue":"Egg","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[6].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[7].amount","stringValue":"2 cloves","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[7].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[7].name","stringValue":"Garlic","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[7].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[8].amount","stringValue":"1 tsp","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[8].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[8].name","stringValue":"Salt","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[8].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[9].amount","stringValue":"1/2 tsp","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[9].amount","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[9].name","stringValue":"Pepper","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.ingredients[9].name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.name","stringValue":"Lasagna","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.name","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[0]","stringValue":"Preheat oven to 375°F (190°C).","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[0]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[1]","stringValue":"Cook lasagna noodles according to package directions, drain and set aside","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[1]","stringValue":".","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[1]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[2]","stringValue":"Brown ground beef with minced garlic in a skillet. Drain fat and stir in tomato sauce. Simmer for 10 minutes.","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[2]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[3]","stringValue":"In a bowl, mix ricotta cheese, egg, salt, pepper, and Parmesan cheese.","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[3]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[4]","stringValue":"In a 9x13 baking dish, spread a","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[4]","stringValue":" thin layer of meat sauce.","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[4]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[5]","stringValue":"Layer noodles, ricotta mixture, mozzarella, and meat sauce. Repeat.","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[5]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[6]","stringValue":"Top with remaining mozzarella cheese.","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[6]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[7]","stringValue":"Cover with foil and bake for 25 minutes.","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[7]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[8]","stringValue":"Remove foil and bake for another 25 minutes until golden.","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[8]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[9]","stringValue":"Let stand for 15 minutes before serving.","willContinue":true}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"partialArgs":[{"jsonPath":"$.recipe.steps[9]","stringValue":""}],"willContinue":true}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":31,"candidatesTokenCount":684,"totalTokenCount":1741,"trafficType":"ON_DEMAND","promptTokensDetails":[{"modality":"TEXT","tokenCount":31}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":684}],"thoughtsTokenCount":1026},"modelVersion":"gemini-3.1-pro-preview","createTime":"2026-04-07T16:49:58.333958Z","responseId":"tjXVaYaxFISTq8YP_MWiyAo"} \ No newline at end of file diff --git a/aimux-providers/src/google/tests/get_model_path.rs b/aimux-providers/src/google/tests/get_model_path.rs new file mode 100644 index 000000000..94bea4d66 --- /dev/null +++ b/aimux-providers/src/google/tests/get_model_path.rs @@ -0,0 +1,22 @@ +use crate::google::utils::get_model_path; + +/// upstream: @ai-sdk/google src/get-model-path.test.ts › should pass through model path for models/* +#[test] +fn should_pass_through_model_path_for_models() { + assert_eq!(get_model_path("models/some-model"), "models/some-model"); +} + +/// upstream: @ai-sdk/google src/get-model-path.test.ts › should pass through model path for tunedModels/* +#[test] +fn should_pass_through_model_path_for_tuned_models() { + assert_eq!( + get_model_path("tunedModels/some-model"), + "tunedModels/some-model" + ); +} + +/// upstream: @ai-sdk/google src/get-model-path.test.ts › should add model path prefix to models without slash +#[test] +fn should_add_model_path_prefix_to_models_without_slash() { + assert_eq!(get_model_path("some-model"), "models/some-model"); +} diff --git a/aimux-providers/src/google/tests/image_model.rs b/aimux-providers/src/google/tests/image_model.rs new file mode 100644 index 000000000..39cd62580 --- /dev/null +++ b/aimux-providers/src/google/tests/image_model.rs @@ -0,0 +1,507 @@ +use std::sync::Arc; + +use serde_json::{Value, json}; + +use aimux_core::image_model::{ + ImageCallOptions, ImageFile, ImageFileData, ImageModel, ImageOutputs, +}; +use aimux_core::shared::{AspectRatio, SharedProviderOptions, Size}; + +use crate::google::{GoogleImageModel, GoogleImageSettings, GoogleProviderSettings, create_google}; +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +const TEST_URL: &str = + "https://api.example.com/v1beta/models/gemini-2.5-flash-image:generateContent"; +const BASE_URL: &str = "https://api.example.com/v1beta"; + +/// The arguments of `prepareJsonResponse`. +struct JsonResponse { + images: Vec<(&'static str, &'static str)>, + usage: Value, + finish_reason: &'static str, + headers: Vec<(String, String)>, + grounding_metadata: Option, +} + +impl Default for JsonResponse { + fn default() -> Self { + Self { + images: vec![("image/png", "base64-generated-image")], + usage: json!({"promptTokenCount": 10, "candidatesTokenCount": 100, "totalTokenCount": 110}), + finish_reason: "STOP", + headers: Vec::new(), + grounding_metadata: None, + } + } +} + +/// `prepareJsonResponse`. +fn prepare_json_response(response: JsonResponse) -> Canned { + let mut candidate = json!({ + "content": { + "parts": response + .images + .iter() + .map(|(mime_type, data)| json!({"inlineData": {"mimeType": mime_type, "data": data}})) + .collect::>(), + "role": "model", + }, + "finishReason": response.finish_reason, + }); + if let Some(grounding_metadata) = response.grounding_metadata { + candidate["groundingMetadata"] = grounding_metadata; + } + let mut canned = Canned::json(&json!({ + "candidates": [candidate], + "usageMetadata": response.usage, + })); + canned.headers.extend(response.headers); + canned +} + +/// `new GoogleImageModel(modelId, settings, { provider: 'google.generative-ai', +/// baseURL: 'https://api.example.com/v1beta', … })`. +fn image_model( + model_id: &str, + settings: GoogleImageSettings, + mock: &Arc, +) -> GoogleImageModel { + create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(BASE_URL.into()), + fetch: Some(mock.transport()), + ..Default::default() + }) + .unwrap() + .image_with_settings(model_id, settings) +} + +/// The shared `model`: `gemini-2.5-flash-image` with default settings. +fn model(mock: &Arc) -> GoogleImageModel { + image_model( + "gemini-2.5-flash-image", + GoogleImageSettings::default(), + mock, + ) +} + +fn call_options(prompt: &str) -> ImageCallOptions { + ImageCallOptions::new(prompt.to_string()) +} + +fn google_options(value: Value) -> SharedProviderOptions { + let Value::Object(map) = value else { + panic!("options must be an object") + }; + [("google".to_string(), map)].into_iter().collect() +} + +fn base64_images(outputs: &ImageOutputs) -> Vec { + match outputs { + ImageOutputs::Base64(images) => images.clone(), + ImageOutputs::Binary(_) => panic!("expected base64 images"), + } +} + +fn google_metadata(result: &aimux_core::image_model::ImageResult) -> Value { + serde_json::to_value(result.provider_metadata.as_ref().unwrap()).unwrap()["google"].clone() +} + +/// `expect(actual).toMatchObject(expected)`. +fn assert_match_object(actual: &Value, expected: &Value) { + fn matches(actual: &Value, expected: &Value) -> bool { + match (actual, expected) { + (Value::Object(actual), Value::Object(expected)) => expected + .iter() + .all(|(key, value)| actual.get(key).is_some_and(|a| matches(a, value))), + (Value::Array(actual), Value::Array(expected)) => { + actual.len() == expected.len() + && actual.iter().zip(expected).all(|(a, e)| matches(a, e)) + } + _ => actual == expected, + } + } + assert!( + matches(actual, expected), + "{actual:#} does not match {expected:#}" + ); +} + +mod google_image_model { + use super::*; + + mod capabilities { + use super::*; + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › capabilities › advertises file=$supportsFileInputs and mask=$supportsMaskInputs for $modelId + #[test] + fn advertises_file_and_mask_inputs_for_model_id() { + for (model_id, supports_file_inputs, supports_mask_inputs) in [ + ("gemini-2.5-flash-image", Some(true), Some(false)), + ("gemini-3-pro-image-preview", Some(true), Some(false)), + ("gemini-3.1-flash-image-preview", Some(true), Some(false)), + ("gemini-2.5-pro", None, None), + ("gemini-3-pro-preview", None, None), + ("gemini-custom", None, None), + ("legacy-image-model", None, None), + ] { + let capability_model = image_model( + model_id, + GoogleImageSettings::default(), + &MockFetch::new(vec![]), + ); + + assert_eq!( + capability_model.supports_file_inputs(), + supports_file_inputs, + "{model_id}" + ); + assert_eq!( + capability_model.supports_mask_inputs(), + supports_mask_inputs, + "{model_id}" + ); + } + } + } + + mod do_generate { + use super::*; + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should reject non-Gemini model IDs before sending a request + #[tokio::test] + async fn should_reject_non_gemini_model_ids_before_sending_a_request() { + let mock = MockFetch::new(vec![]); + let non_gemini_model = + image_model("legacy-image-model", GoogleImageSettings::default(), &mock); + + let error = non_gemini_model + .do_generate(&call_options("A beautiful sunset")) + .await + .unwrap_err(); + + assert!( + error.to_string().contains( + "Google image models other than Gemini are no longer supported. Use a model ID that starts with `gemini-`." + ), + "{error}" + ); + assert!(mock.seen().is_empty()); + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should use the language model endpoint and extract generated images + #[tokio::test] + async fn should_use_the_language_model_endpoint_and_extract_generated_images() { + let mock = MockFetch::new(vec![prepare_json_response(JsonResponse::default())]); + + let result = model(&mock) + .do_generate(&call_options("A beautiful sunset")) + .await + .unwrap(); + + assert_eq!(mock.seen()[0].url, TEST_URL); + assert_eq!(base64_images(&result.images), ["base64-generated-image"]); + assert_eq!( + serde_json::to_value(result.provider_metadata.as_ref().unwrap()).unwrap(), + json!({ + "google": { + "finishMessage": null, + "finishReason": "STOP", + "groundingMetadata": null, + "images": [{}], + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 100, + "promptTokenCount": 10, + "totalTokenCount": 110, + }, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should expose the candidate finish reason in provider metadata + #[tokio::test] + async fn should_expose_the_candidate_finish_reason_in_provider_metadata() { + let mock = MockFetch::new(vec![prepare_json_response(JsonResponse { + images: vec![], + finish_reason: "IMAGE_SAFETY", + ..Default::default() + })]); + + let result = model(&mock) + .do_generate(&call_options("A blocked image prompt")) + .await + .unwrap(); + + assert_match_object( + &google_metadata(&result), + &json!({"finishReason": "IMAGE_SAFETY", "images": []}), + ); + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should leave empty responses with default prompt block reason %j retryable + #[tokio::test] + async fn should_leave_empty_responses_with_default_prompt_block_reason_j_retryable() { + for block_reason in ["", "BLOCK_REASON_UNSPECIFIED", "BLOCKED_REASON_UNSPECIFIED"] { + let mock = MockFetch::new(vec![Canned::json(&json!({ + "candidates": [], + "promptFeedback": {"blockReason": block_reason}, + "usageMetadata": {"promptTokenCount": 9, "totalTokenCount": 9}, + }))]); + + let result = model(&mock) + .do_generate(&call_options("An image prompt with an empty response")) + .await + .unwrap(); + + assert!(base64_images(&result.images).is_empty(), "{block_reason:?}"); + assert_eq!( + serde_json::to_value(&result).unwrap().get("isRetryable"), + None, + "{block_reason:?}" + ); + assert_match_object( + &google_metadata(&result), + &json!({"promptFeedback": {"blockReason": block_reason}}), + ); + } + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should send response modalities, aspect ratio, seed, and headers + #[tokio::test] + async fn should_send_response_modalities_aspect_ratio_seed_and_headers() { + let mock = MockFetch::new(vec![prepare_json_response(JsonResponse::default())]); + // The config headers are only `Custom-Provider-Header`: the key + // header is removed. + let model_with_headers = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + base_url: Some(BASE_URL.into()), + headers: Some( + [ + ("x-goog-api-key".to_string(), None), + ( + "Custom-Provider-Header".to_string(), + Some("provider-header-value".to_string()), + ), + ] + .into_iter() + .collect(), + ), + fetch: Some(mock.transport()), + ..Default::default() + }) + .unwrap() + .image("gemini-2.5-flash-image"); + + let mut options = call_options("A beautiful sunset"); + options.aspect_ratio = Some(AspectRatio::new(21, 9)); + options.seed = Some(12345); + options.provider_options = google_options(json!({"imageConfig": {"imageSize": "4K"}})); + options.headers = Some( + [( + "Custom-Request-Header".to_string(), + "request-header-value".to_string(), + )] + .into_iter() + .collect(), + ); + model_with_headers.do_generate(&options).await.unwrap(); + + let seen = &mock.seen()[0]; + // The test server reports the user agent separately. + let mut headers = seen.headers.clone(); + headers.remove("user-agent"); + assert_eq!( + serde_json::to_value(&headers).unwrap(), + json!({ + "content-type": "application/json", + "custom-provider-header": "provider-header-value", + "custom-request-header": "request-header-value", + }) + ); + assert_eq!( + seen.json_body(), + json!({ + "contents": [{"parts": [{"text": "A beautiful sunset"}], "role": "user"}], + "generationConfig": { + "imageConfig": {"aspectRatio": "21:9", "imageSize": "4K"}, + "responseModalities": ["IMAGE"], + "seed": 12345, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should include usage and response metadata + #[tokio::test] + async fn should_include_usage_and_response_metadata() { + let mock = MockFetch::new(vec![prepare_json_response(JsonResponse { + usage: json!({"promptTokenCount": 20, "candidatesTokenCount": 200, "totalTokenCount": 220}), + headers: vec![("request-id".into(), "test-request-id".into())], + ..Default::default() + })]); + // No `currentDate` hook in Rust: the timestamp is the real time. + let before = aimux_core::Timestamp::now(); + let result = model(&mock) + .do_generate(&call_options("A beautiful sunset")) + .await + .unwrap(); + let after = aimux_core::Timestamp::now(); + + assert_eq!( + serde_json::to_value(result.usage.as_ref().unwrap()).unwrap(), + json!({"inputTokens": 20, "outputTokens": 200, "totalTokens": 220}) + ); + let timestamp = result.response.timestamp.unwrap(); + assert!(before <= timestamp && timestamp <= after); + assert_eq!( + result.response.model_id.as_deref(), + Some("gemini-2.5-flash-image") + ); + assert_eq!( + result + .response + .headers + .as_ref() + .unwrap() + .get("request-id") + .map(String::as_str), + Some("test-request-id") + ); + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should return a warning for the unsupported size option + #[tokio::test] + async fn should_return_a_warning_for_the_unsupported_size_option() { + let mock = MockFetch::new(vec![prepare_json_response(JsonResponse::default())]); + + let mut options = call_options("A beautiful sunset"); + options.size = Some(Size::new(1024, 1024)); + let result = model(&mock).do_generate(&options).await.unwrap(); + + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ + "type": "unsupported", + "feature": "size", + "details": "This model does not support the `size` option. Use `aspectRatio` instead.", + }]) + ); + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should forward Google Search grounding and its metadata + #[tokio::test] + async fn should_forward_google_search_grounding_and_its_metadata() { + let grounding_metadata = json!({ + "webSearchQueries": ["example query"], + "groundingChunks": [{"web": {"uri": "https://example.com/source", "title": "Example"}}], + }); + let mock = MockFetch::new(vec![prepare_json_response(JsonResponse { + grounding_metadata: Some(grounding_metadata), + ..Default::default() + })]); + + let mut options = call_options("A beautiful sunset"); + options.provider_options = + google_options(json!({"googleSearch": {"searchTypes": {"imageSearch": {}}}})); + let result = model(&mock).do_generate(&options).await.unwrap(); + + let request_body = mock.seen()[0].json_body(); + assert_eq!( + request_body["tools"], + json!([{"googleSearch": {"searchTypes": {"imageSearch": {}}}}]) + ); + assert!( + request_body["generationConfig"] + .get("googleSearch") + .is_none() + ); + assert_eq!( + google_metadata(&result), + json!({ + "finishMessage": null, + "finishReason": "STOP", + "groundingMetadata": { + "groundingChunks": [ + {"web": {"title": "Example", "uri": "https://example.com/source"}}, + ], + "webSearchQueries": ["example query"], + }, + "images": [{}], + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 100, + "promptTokenCount": 10, + "totalTokenCount": 110, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should include input images for editing + #[tokio::test] + async fn should_include_input_images_for_editing() { + let mock = MockFetch::new(vec![prepare_json_response(JsonResponse::default())]); + + let mut options = call_options("Add a hat to this cat"); + options.files = Some(vec![ImageFile::File { + media_type: "image/png".into(), + data: ImageFileData::Base64("base64-source-image".into()), + }]); + model(&mock).do_generate(&options).await.unwrap(); + + assert_eq!( + mock.seen()[0].json_body()["contents"], + json!([{ + "role": "user", + "parts": [ + {"text": "Add a hat to this cat"}, + {"inlineData": {"mimeType": "image/png", "data": "base64-source-image"}}, + ], + }]) + ); + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › doGenerate › should reject unsupported URL editing input and masks + #[tokio::test] + async fn should_reject_unsupported_url_editing_input_and_masks() { + let mock = MockFetch::new(vec![ + prepare_json_response(JsonResponse::default()), + prepare_json_response(JsonResponse::default()), + ]); + let model = model(&mock); + + let mut options = call_options("Add a hat to this cat"); + options.files = Some(vec![ImageFile::Url { + url: "https://example.com/cat.png".into(), + }]); + let error = model.do_generate(&options).await.unwrap_err(); + assert!( + regex::Regex::new(r#"media type "image/\*".*not passed as inline bytes"#) + .unwrap() + .is_match(&error.to_string()), + "{error}" + ); + + let mut options = call_options("Edit this image"); + options.mask = Some(ImageFile::File { + media_type: "image/png".into(), + data: ImageFileData::Base64("base64-mask-image".into()), + }); + let error = model.do_generate(&options).await.unwrap_err(); + assert!( + error + .to_string() + .contains("Gemini image models do not support mask-based image editing."), + "{error}" + ); + } + } +} diff --git a/aimux-providers/src/google/tests/json_accumulator.rs b/aimux-providers/src/google/tests/json_accumulator.rs new file mode 100644 index 000000000..a70b0cc2e --- /dev/null +++ b/aimux-providers/src/google/tests/json_accumulator.rs @@ -0,0 +1,841 @@ +use serde_json::{Value, json}; + +use crate::google::utils::{FinalizeResult, GoogleJsonAccumulator, PartialArg, ProcessResult}; + +/// One upstream `partialArgs` entry (`{ jsonPath, stringValue, ... }`) as a +/// [`PartialArg`]. +fn partial_arg(arg: Value) -> PartialArg { + PartialArg { + json_path: arg["jsonPath"].as_str().unwrap_or_default().to_owned(), + string_value: arg + .get("stringValue") + .and_then(Value::as_str) + .map(str::to_owned), + number_value: arg.get("numberValue").and_then(Value::as_f64), + bool_value: arg.get("boolValue").and_then(Value::as_bool), + null_value: arg.get("nullValue").map(|_| ()), + will_continue: arg.get("willContinue").and_then(Value::as_bool), + } +} + +/// `accumulator.processPartialArgs([...])`. +fn process(accumulator: &mut GoogleJsonAccumulator, args: Vec) -> ProcessResult { + let args: Vec = args.into_iter().map(partial_arg).collect(); + accumulator + .process_partial_args(&args) + .expect("partial args accumulate") +} + +/// The upstream `{ currentJSON, textDelta }` object. +fn process_json(result: &ProcessResult) -> Value { + json!({ "currentJSON": result.current_json, "textDelta": result.text_delta }) +} + +/// The upstream `{ finalJSON, closingDelta }` object. +fn finalize_json(result: &FinalizeResult) -> Value { + json!({ "finalJSON": result.final_json, "closingDelta": result.closing_delta }) +} + +mod google_json_accumulator { + use super::*; + + mod flat_paths { + use super::*; + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should accumulate a simple string arg with willContinue + #[test] + fn should_accumulate_a_simple_string_arg_with_will_continue() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![ + json!({ "jsonPath": "$.location", "stringValue": "Boston", "willContinue": true }), + ], + ); + + assert_eq!( + process_json(&result), + json!({ + "currentJSON": { "location": "Boston" }, + "textDelta": r#"{"location":"Boston"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should continue a string arg across multiple chunks + #[test] + fn should_continue_a_string_arg_across_multiple_chunks() { + let mut accumulator = GoogleJsonAccumulator::new(); + + process( + &mut accumulator, + vec![ + json!({ "jsonPath": "$.location", "stringValue": "Boston", "willContinue": true }), + ], + ); + + let continuation_result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.location", "stringValue": ", MA" })], + ); + + assert_eq!( + process_json(&continuation_result), + json!({ + "currentJSON": { "location": "Boston, MA" }, + "textDelta": ", MA", + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should accumulate a complete string arg (no willContinue) + #[test] + fn should_accumulate_a_complete_string_arg_no_will_continue() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.location", "stringValue": "Boston" })], + ); + + assert_eq!( + process_json(&result), + json!({ + "currentJSON": { "location": "Boston" }, + "textDelta": r#"{"location":"Boston""#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should accumulate a number arg + #[test] + fn should_accumulate_a_number_arg() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.brightness", "numberValue": 50 })], + ); + + assert_eq!( + process_json(&result), + json!({ + "currentJSON": { "brightness": 50 }, + "textDelta": r#"{"brightness":50"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should accumulate a boolean arg + #[test] + fn should_accumulate_a_boolean_arg() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.enabled", "boolValue": true })], + ); + + assert_eq!( + process_json(&result), + json!({ + "currentJSON": { "enabled": true }, + "textDelta": r#"{"enabled":true"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should accumulate a null arg + #[test] + fn should_accumulate_a_null_arg() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.nickname", "nullValue": {} })], + ); + + assert_eq!( + process_json(&result), + json!({ + "currentJSON": { "nickname": null }, + "textDelta": r#"{"nickname":null"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should accumulate multiple args with commas between them + #[test] + fn should_accumulate_multiple_args_with_commas_between_them() { + let mut accumulator = GoogleJsonAccumulator::new(); + + let first_result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.brightness", "numberValue": 50 })], + ); + assert_eq!( + process_json(&first_result), + json!({ + "currentJSON": { "brightness": 50 }, + "textDelta": r#"{"brightness":50"#, + }) + ); + + let second_result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.enabled", "boolValue": true })], + ); + assert_eq!( + process_json(&second_result), + json!({ + "currentJSON": { "brightness": 50, "enabled": true }, + "textDelta": r#","enabled":true"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should accumulate multiple args in a single call + #[test] + fn should_accumulate_multiple_args_in_a_single_call() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![ + json!({ "jsonPath": "$.brightness", "numberValue": 50 }), + json!({ "jsonPath": "$.enabled", "boolValue": false }), + json!({ "jsonPath": "$.nickname", "nullValue": {} }), + ], + ); + + assert_eq!( + process_json(&result), + json!({ + "currentJSON": { "brightness": 50, "enabled": false, "nickname": null }, + "textDelta": r#"{"brightness":50,"enabled":false,"nickname":null"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should escape special characters in continued strings + #[test] + fn should_escape_special_characters_in_continued_strings() { + let mut accumulator = GoogleJsonAccumulator::new(); + + process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.query", + "stringValue": "Boston \"Lo", + "willContinue": true, + })], + ); + + let continuation_result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.query", "stringValue": "gan\"" })], + ); + + assert_eq!( + process_json(&continuation_result), + json!({ + "currentJSON": { "query": "Boston \"Logan\"" }, + "textDelta": r#"gan\""#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should skip args with empty jsonPath after stripping $. prefix + #[test] + fn should_skip_args_with_empty_json_path_after_stripping_prefix() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.", "stringValue": "ignored" })], + ); + + assert_eq!( + process_json(&result), + json!({ "currentJSON": {}, "textDelta": "" }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should skip args with no resolvable value + #[test] + fn should_skip_args_with_no_resolvable_value() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process(&mut accumulator, vec![json!({ "jsonPath": "$.something" })]); + + assert_eq!( + process_json(&result), + json!({ "currentJSON": {}, "textDelta": "" }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › flat paths › should return empty textDelta for empty partialArgs array + #[test] + fn should_return_empty_text_delta_for_empty_partial_args_array() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process(&mut accumulator, vec![]); + + assert_eq!( + process_json(&result), + json!({ "currentJSON": {}, "textDelta": "" }) + ); + } + } + + mod prototype_pollution_protection { + use super::*; + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › prototype pollution protection › should not pollute Object.prototype through __proto__ path segments + #[test] + fn should_not_pollute_object_prototype_through_proto_path_segments() { + // Rust has no `Object.prototype` to pollute; the accumulated + // object is asserted the same way (an own `__proto__` key). + let polluted_key = "pollutedByGoogleAccumulatorTest"; + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![json!({ + "jsonPath": format!("$.__proto__.{polluted_key}"), + "stringValue": "true", + })], + ); + + assert_eq!( + result.current_json["__proto__"][polluted_key], + json!("true") + ); + assert_eq!( + result.current_json, + json!({ "__proto__": { "pollutedByGoogleAccumulatorTest": "true" } }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › prototype pollution protection › should not pollute Object.prototype through constructor.prototype path segments + #[test] + fn should_not_pollute_object_prototype_through_constructor_prototype_path_segments() { + let polluted_key = "pollutedByGoogleConstructorTest"; + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![json!({ + "jsonPath": format!("$.constructor.prototype.{polluted_key}"), + "stringValue": "true", + })], + ); + + assert_eq!( + result.current_json, + json!({ + "constructor": { + "prototype": { "pollutedByGoogleConstructorTest": "true" }, + }, + }) + ); + } + } + + mod nested_paths { + use super::*; + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › nested paths › should build nested object from dotted jsonPath + #[test] + fn should_build_nested_object_from_dotted_json_path() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.recipe.name", "stringValue": "Lasagna" })], + ); + + assert_eq!( + process_json(&result), + json!({ + "currentJSON": { "recipe": { "name": "Lasagna" } }, + "textDelta": r#"{"recipe":{"name":"Lasagna""#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › nested paths › should build nested object with array from indexed jsonPath + #[test] + fn should_build_nested_object_with_array_from_indexed_json_path() { + let mut accumulator = GoogleJsonAccumulator::new(); + let amount_result = process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.recipe.ingredients[0].amount", + "stringValue": "16 oz", + })], + ); + assert_eq!( + process_json(&amount_result), + json!({ + "currentJSON": { "recipe": { "ingredients": [{ "amount": "16 oz" }] } }, + "textDelta": r#"{"recipe":{"ingredients":[{"amount":"16 oz""#, + }) + ); + + let name_result = process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.recipe.ingredients[0].name", + "stringValue": "Lasagna noodles", + })], + ); + assert_eq!( + process_json(&name_result), + json!({ + "currentJSON": { + "recipe": { + "ingredients": [{ "amount": "16 oz", "name": "Lasagna noodles" }], + }, + }, + "textDelta": r#","name":"Lasagna noodles""#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › nested paths › should accumulate multiple array elements across chunks + #[test] + fn should_accumulate_multiple_array_elements_across_chunks() { + let mut accumulator = GoogleJsonAccumulator::new(); + let mut deltas: Vec = Vec::new(); + + let result = process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.recipe.ingredients[0].amount", + "stringValue": "16 oz", + })], + ); + deltas.push(result.text_delta); + + let result = process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.recipe.ingredients[0].name", + "stringValue": "Noodles", + })], + ); + deltas.push(result.text_delta); + + let result = process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.recipe.ingredients[1].amount", + "stringValue": "1 lb", + })], + ); + deltas.push(result.text_delta.clone()); + assert_eq!(result.text_delta, r#"},{"amount":"1 lb""#); + + let result = process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.recipe.ingredients[1].name", + "stringValue": "Beef", + })], + ); + deltas.push(result.text_delta.clone()); + assert_eq!(result.text_delta, r#","name":"Beef""#); + + assert_eq!( + result.current_json, + json!({ + "recipe": { + "ingredients": [ + { "amount": "16 oz", "name": "Noodles" }, + { "amount": "1 lb", "name": "Beef" }, + ], + }, + }) + ); + + let FinalizeResult { + final_json, + closing_delta, + } = accumulator.finalize(); + deltas.push(closing_delta); + assert_eq!(deltas.join(""), final_json); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › nested paths › should handle string continuation on nested paths + #[test] + fn should_handle_string_continuation_on_nested_paths() { + let mut accumulator = GoogleJsonAccumulator::new(); + let start_result = process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.recipe.steps[0]", + "stringValue": "Preheat oven", + "willContinue": true, + })], + ); + assert_eq!( + start_result.text_delta, + r#"{"recipe":{"steps":["Preheat oven"# + ); + + let continuation_result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.recipe.steps[0]", "stringValue": " to 375°F." })], + ); + assert_eq!( + process_json(&continuation_result), + json!({ + "currentJSON": { "recipe": { "steps": ["Preheat oven to 375°F."] } }, + "textDelta": " to 375°F.", + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › nested paths › should handle mixed nested and flat paths + #[test] + fn should_handle_mixed_nested_and_flat_paths() { + let mut accumulator = GoogleJsonAccumulator::new(); + let location_result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.location", "stringValue": "Boston" })], + ); + assert_eq!(location_result.text_delta, r#"{"location":"Boston""#); + + let details_result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.details.zip", "stringValue": "02101" })], + ); + assert_eq!( + process_json(&details_result), + json!({ + "currentJSON": { "details": { "zip": "02101" }, "location": "Boston" }, + "textDelta": r#","details":{"zip":"02101""#, + }) + ); + + assert_eq!( + finalize_json(&accumulator.finalize()), + json!({ + "closingDelta": "}}", + "finalJSON": r#"{"location":"Boston","details":{"zip":"02101"}}"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › nested paths › should handle array elements that are direct string values + #[test] + fn should_handle_array_elements_that_are_direct_string_values() { + let mut accumulator = GoogleJsonAccumulator::new(); + let first_step = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.steps[0]", "stringValue": "Step one" })], + ); + assert_eq!(first_step.text_delta, r#"{"steps":["Step one""#); + + let second_step = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.steps[1]", "stringValue": "Step two" })], + ); + assert_eq!( + process_json(&second_step), + json!({ + "currentJSON": { "steps": ["Step one", "Step two"] }, + "textDelta": r#","Step two""#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › nested paths › should handle deeply nested paths + #[test] + fn should_handle_deeply_nested_paths() { + let mut accumulator = GoogleJsonAccumulator::new(); + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.a.b.c.d", "stringValue": "deep" })], + ); + + assert_eq!( + process_json(&result), + json!({ + "currentJSON": { "a": { "b": { "c": { "d": "deep" } } } }, + "textDelta": r#"{"a":{"b":{"c":{"d":"deep""#, + }) + ); + + assert_eq!( + finalize_json(&accumulator.finalize()), + json!({ + "closingDelta": "}}}}", + "finalJSON": r#"{"a":{"b":{"c":{"d":"deep"}}}}"#, + }) + ); + } + } + + mod finalize { + use super::*; + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › finalize › should produce closing delta for a continued string + #[test] + fn should_produce_closing_delta_for_a_continued_string() { + let mut accumulator = GoogleJsonAccumulator::new(); + process( + &mut accumulator, + vec![ + json!({ "jsonPath": "$.location", "stringValue": "Boston", "willContinue": true }), + ], + ); + + assert_eq!( + finalize_json(&accumulator.finalize()), + json!({ + "closingDelta": r#""}"#, + "finalJSON": r#"{"location":"Boston"}"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › finalize › should produce closing delta for a complete string + #[test] + fn should_produce_closing_delta_for_a_complete_string() { + let mut accumulator = GoogleJsonAccumulator::new(); + process( + &mut accumulator, + vec![json!({ "jsonPath": "$.location", "stringValue": "Boston" })], + ); + + assert_eq!( + finalize_json(&accumulator.finalize()), + json!({ + "closingDelta": "}", + "finalJSON": r#"{"location":"Boston"}"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › finalize › should produce closing delta for multiple args + #[test] + fn should_produce_closing_delta_for_multiple_args() { + let mut accumulator = GoogleJsonAccumulator::new(); + process( + &mut accumulator, + vec![ + json!({ "jsonPath": "$.brightness", "numberValue": 50 }), + json!({ "jsonPath": "$.enabled", "boolValue": true }), + ], + ); + + assert_eq!( + finalize_json(&accumulator.finalize()), + json!({ + "closingDelta": "}", + "finalJSON": r#"{"brightness":50,"enabled":true}"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › finalize › should produce closing delta for continued string with continuation + #[test] + fn should_produce_closing_delta_for_continued_string_with_continuation() { + let mut accumulator = GoogleJsonAccumulator::new(); + process( + &mut accumulator, + vec![ + json!({ "jsonPath": "$.location", "stringValue": "Boston", "willContinue": true }), + ], + ); + process( + &mut accumulator, + vec![json!({ "jsonPath": "$.location", "stringValue": ", MA" })], + ); + + assert_eq!( + finalize_json(&accumulator.finalize()), + json!({ + "closingDelta": r#""}"#, + "finalJSON": r#"{"location":"Boston, MA"}"#, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › finalize › should handle empty accumulator + #[test] + fn should_handle_empty_accumulator() { + let accumulator = GoogleJsonAccumulator::new(); + assert_eq!( + finalize_json(&accumulator.finalize()), + json!({ "closingDelta": "{}", "finalJSON": "{}" }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › finalize › should finalize nested structure to proper JSON + #[test] + fn should_finalize_nested_structure_to_proper_json() { + let mut accumulator = GoogleJsonAccumulator::new(); + process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.recipe.ingredients[0].name", + "stringValue": "Noodles", + })], + ); + process( + &mut accumulator, + vec![json!({ "jsonPath": "$.recipe.name", "stringValue": "Lasagna" })], + ); + + let FinalizeResult { final_json, .. } = accumulator.finalize(); + assert_eq!( + serde_json::from_str::(&final_json).unwrap(), + json!({ + "recipe": { + "ingredients": [{ "name": "Noodles" }], + "name": "Lasagna", + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › finalize › should finalize nested arrays with string continuation + #[test] + fn should_finalize_nested_arrays_with_string_continuation() { + let mut accumulator = GoogleJsonAccumulator::new(); + process( + &mut accumulator, + vec![json!({ + "jsonPath": "$.recipe.steps[0]", + "stringValue": "Preheat", + "willContinue": true, + })], + ); + process( + &mut accumulator, + vec![json!({ "jsonPath": "$.recipe.steps[0]", "stringValue": " oven." })], + ); + process( + &mut accumulator, + vec![json!({ "jsonPath": "$.recipe.steps[1]", "stringValue": "Cook." })], + ); + + let FinalizeResult { final_json, .. } = accumulator.finalize(); + assert_eq!( + serde_json::from_str::(&final_json).unwrap(), + json!({ "recipe": { "steps": ["Preheat oven.", "Cook."] } }) + ); + } + } + + mod concatenation_invariant { + use super::*; + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › concatenation invariant › flat args: concatenated deltas + closingDelta === JSON.stringify + #[test] + fn flat_args_concatenated_deltas_closing_delta_json_stringify() { + let mut accumulator = GoogleJsonAccumulator::new(); + let mut deltas: Vec = Vec::new(); + + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.brightness", "numberValue": 50 })], + ); + deltas.push(result.text_delta); + + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.enabled", "boolValue": true })], + ); + deltas.push(result.text_delta); + + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.name", "stringValue": "test" })], + ); + deltas.push(result.text_delta); + + let FinalizeResult { + final_json, + closing_delta, + } = accumulator.finalize(); + deltas.push(closing_delta); + assert_eq!(deltas.join(""), final_json); + assert_eq!( + serde_json::from_str::(&final_json).unwrap(), + json!({ "brightness": 50, "enabled": true, "name": "test" }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › concatenation invariant › nested args: concatenated deltas + closingDelta === JSON.stringify + #[test] + fn nested_args_concatenated_deltas_closing_delta_json_stringify() { + let mut accumulator = GoogleJsonAccumulator::new(); + let mut deltas: Vec = Vec::new(); + + for arg in [ + json!({ "jsonPath": "$.recipe.ingredients[0].amount", "stringValue": "16 oz" }), + json!({ "jsonPath": "$.recipe.ingredients[0].name", "stringValue": "Noodles" }), + json!({ "jsonPath": "$.recipe.ingredients[1].amount", "stringValue": "1 lb" }), + json!({ "jsonPath": "$.recipe.ingredients[1].name", "stringValue": "Beef" }), + json!({ "jsonPath": "$.recipe.name", "stringValue": "Lasagna" }), + json!({ + "jsonPath": "$.recipe.steps[0]", + "stringValue": "Preheat", + "willContinue": true, + }), + json!({ "jsonPath": "$.recipe.steps[0]", "stringValue": " oven." }), + json!({ "jsonPath": "$.recipe.steps[1]", "stringValue": "Cook." }), + ] { + let result = process(&mut accumulator, vec![arg]); + deltas.push(result.text_delta); + } + + let FinalizeResult { + final_json, + closing_delta, + } = accumulator.finalize(); + deltas.push(closing_delta); + assert_eq!(deltas.join(""), final_json); + assert_eq!( + serde_json::from_str::(&final_json).unwrap(), + json!({ + "recipe": { + "ingredients": [ + { "amount": "16 oz", "name": "Noodles" }, + { "amount": "1 lb", "name": "Beef" }, + ], + "name": "Lasagna", + "steps": ["Preheat oven.", "Cook."], + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-json-accumulator.test.ts › GoogleJSONAccumulator › concatenation invariant › willContinue strings: concatenated deltas + closingDelta === JSON.stringify + #[test] + fn will_continue_strings_concatenated_deltas_closing_delta_json_stringify() { + let mut accumulator = GoogleJsonAccumulator::new(); + let mut deltas: Vec = Vec::new(); + + let result = process( + &mut accumulator, + vec![ + json!({ "jsonPath": "$.location", "stringValue": "Bos", "willContinue": true }), + ], + ); + deltas.push(result.text_delta); + + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.location", "stringValue": "ton" })], + ); + deltas.push(result.text_delta); + + let result = process( + &mut accumulator, + vec![json!({ "jsonPath": "$.count", "numberValue": 42 })], + ); + deltas.push(result.text_delta); + + let FinalizeResult { + final_json, + closing_delta, + } = accumulator.finalize(); + deltas.push(closing_delta); + assert_eq!(deltas.join(""), final_json); + } + } +} diff --git a/aimux-providers/src/google/tests/language_model.rs b/aimux-providers/src/google/tests/language_model.rs new file mode 100644 index 000000000..e78980194 --- /dev/null +++ b/aimux-providers/src/google/tests/language_model.rs @@ -0,0 +1,7471 @@ +//! `google-language-model.test.ts` of `@ai-sdk/google` 4.0.87. +//! +//! Upstream drives `GoogleLanguageModel` against `createTestServer`; here the +//! same models come from [`create_google`] (or `GoogleModel::from_config` where +//! upstream passes a custom provider string) with a scripted transport +//! ([`MockFetch`]). Results, stream parts and errors are compared in upstream's +//! JSON shape (`undefined` values are omitted keys). + +use std::collections::BTreeMap; +use std::sync::Arc; + +use aimux_core::language_model::LanguageModel; +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateResult, StreamResult}; +use aimux_core::stream_part::StreamPart; +use aimux_provider_utils::{HeaderMapOpt, Resolvable}; +use futures::StreamExt; +use serde_json::{Value, json}; + +use crate::google::options::Namespace; +use crate::google::types::Candidate; +use crate::google::{GoogleModel, GoogleProvider, GoogleProviderSettings, create_google}; +use crate::shared::{AuthScheme, Credential, EndpointConfig, EndpointHeaders, ProviderHeaders}; +use crate::test_support::mock_fetch::{Canned, MockFetch, Seen}; + +// ── doGenerate helpers ─────────────────────────────────────────────────────── + +/// The default base URL every upstream `doGenerate` request goes to. +const GENERATE_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; + +/// `TEST_PROMPT` of the upstream file. +fn generate_test_prompt() -> Value { + json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) +} + +/// `SAFETY_RATINGS` of the upstream file. +fn generate_safety_ratings() -> Value { + json!([ + { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "probability": "NEGLIGIBLE" }, + { "category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE" }, + { "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" }, + { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "probability": "NEGLIGIBLE" }, + ]) +} + +/// `COMPLETE_USAGE_METADATA` of the upstream file. +fn generate_complete_usage_metadata() -> Value { + json!({ + "promptTokenCount": 12, + "cachedContentTokenCount": 4, + "candidatesTokenCount": 71, + "toolUsePromptTokenCount": 65, + "thoughtsTokenCount": 89, + "totalTokenCount": 237, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 12, "nestedSentinel": "prompt" }], + "cacheTokensDetails": [{ "modality": "TEXT", "tokenCount": 4, "nestedSentinel": "cache" }], + "candidatesTokensDetails": [{ "modality": "TEXT", "tokenCount": 71, "nestedSentinel": "candidate" }], + "toolUsePromptTokensDetails": [{ "modality": "TEXT", "tokenCount": 65, "nestedSentinel": "tool" }], + "serviceTier": "standard", + "topLevelSentinel": "preserve-me", + }) +} + +/// An upstream `__fixtures__/.json` response body. +fn generate_fixture(name: &str) -> Value { + let text = match name { + "google-text" => include_str!("fixtures/google-text.json"), + "google-tool-call" => include_str!("fixtures/google-tool-call.json"), + "google-reasoning-gemini3" => include_str!("fixtures/google-reasoning-gemini3.json"), + "google-tool-call-gemini3" => include_str!("fixtures/google-tool-call-gemini3.json"), + other => panic!("unknown fixture {other}"), + }; + serde_json::from_str(text).expect("fixture is JSON") +} + +/// A `toMatchSnapshot()` value of the upstream `doGenerate` tests. +fn generate_snapshot(name: &str) -> Value { + let text = match name { + "do_generate_text_extract_text_response" => { + include_str!("snapshots/do_generate_text_extract_text_response.json") + } + "do_generate_text_extract_usage" => { + include_str!("snapshots/do_generate_text_extract_usage.json") + } + "do_generate_tool_call_extract_tool_calls" => { + include_str!("snapshots/do_generate_tool_call_extract_tool_calls.json") + } + "do_generate_reasoning_gemini3_extract_reasoning" => { + include_str!("snapshots/do_generate_reasoning_gemini3_extract_reasoning.json") + } + "do_generate_tool_call_gemini3_extract_tool_call" => { + include_str!("snapshots/do_generate_tool_call_gemini3_extract_tool_call.json") + } + other => panic!("unknown snapshot {other}"), + }; + serde_json::from_str(text).expect("snapshot is JSON") +} + +/// The `json-value` response of the upstream test server: the body as +/// compact JSON with its `content-type` and `content-length`, plus `headers`. +fn generate_json_value_response_with_headers(body: &Value, headers: &[(&str, &str)]) -> Canned { + let bytes = serde_json::to_vec(body).expect("body serializes"); + let mut response_headers = vec![ + ("content-type".to_string(), "application/json".to_string()), + ("content-length".to_string(), bytes.len().to_string()), + ]; + response_headers.extend( + headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())), + ); + Canned { + status: 200, + headers: response_headers, + body: bytes, + } +} + +/// [`generate_json_value_response_with_headers`] without extra headers. +fn generate_json_value_response(body: &Value) -> Canned { + generate_json_value_response_with_headers(body, &[]) +} + +/// A mock transport answering one request with `body` as JSON. +fn generate_mock_with_body(body: &Value) -> Arc { + MockFetch::new(vec![generate_json_value_response(body)]) +} + +/// A mock transport answering one request with an upstream fixture. +fn generate_mock_with_fixture(name: &str) -> Arc { + generate_mock_with_body(&generate_fixture(name)) +} + +/// The body `prepareJsonResponse({ content, groundingMetadata })` serves. +fn generate_prepared_json_body(content: &str, grounding_metadata: Option) -> Value { + let mut candidate = json!({ + "content": { "parts": [{ "text": content }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }); + if let Some(grounding_metadata) = grounding_metadata { + candidate["groundingMetadata"] = grounding_metadata; + } + json!({ + "candidates": [candidate], + "promptFeedback": { "safetyRatings": generate_safety_ratings() }, + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3 }, + }) +} + +/// `createGoogle({ apiKey: 'test-api-key', generateId: () => 'test-id' })` +/// with the mock transport (the upstream `provider`). +fn generate_test_provider(mock: &Arc) -> GoogleProvider { + create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + fetch: Some(mock.transport()), + generate_id: Some(Arc::new(|| "test-id".into())), + ..Default::default() + }) + .expect("valid settings") +} + +/// `new GoogleLanguageModel(modelId, { provider, baseURL, headers, +/// generateId: () => 'test-id' })`: Vertex AI behavior when `provider` +/// contains `vertex`, as upstream's `isVertexProvider`. +fn generate_direct_model( + model_id: &str, + provider: &str, + headers: &[(&str, &str)], + mock: &Arc, +) -> GoogleModel { + let provider_headers = ProviderHeaders::new( + Credential::None, + AuthScheme::Bearer, + headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect(), + None, + ); + generate_direct_model_with_endpoint_headers( + model_id, + provider, + EndpointHeaders::Provider(provider_headers), + mock, + ) +} + +/// [`generate_direct_model`] whose config `headers` is an async function. +fn generate_direct_model_with_async_headers( + model_id: &str, + provider: &str, + headers: &[(&str, &str)], + mock: &Arc, +) -> GoogleModel { + let produced: HeaderMapOpt = headers + .iter() + .map(|(name, value)| ((*name).to_string(), Some((*value).to_string()))) + .collect(); + let endpoint_headers = EndpointHeaders::WithUserHeaders { + provider: ProviderHeaders::new(Credential::None, AuthScheme::Bearer, Vec::new(), None), + user: Some(Resolvable::AsyncFn(Arc::new(move || { + let produced = produced.clone(); + Box::pin(async move { Ok(produced) }) + }))), + user_agent_suffix: None, + }; + generate_direct_model_with_endpoint_headers(model_id, provider, endpoint_headers, mock) +} + +fn generate_direct_model_with_endpoint_headers( + model_id: &str, + provider: &str, + headers: EndpointHeaders, + mock: &Arc, +) -> GoogleModel { + let namespace = if provider.contains("vertex") { + Namespace::Vertex + } else { + Namespace::Google + }; + let config = EndpointConfig::fixed( + provider.to_string(), + GENERATE_BASE_URL.to_string(), + headers, + Some(mock.transport()), + None, + ); + GoogleModel::from_config(model_id.to_string(), config, namespace) + .with_generate_id(Some(Arc::new(|| "test-id".into()))) +} + +/// `LanguageModelV4CallOptions` written as upstream JSON. +fn generate_call_options(options: Value) -> CallOptions { + serde_json::from_value(options).expect("call options deserialize") +} + +/// `model.doGenerate(options)`, which must succeed. +async fn generate_with(model: &impl LanguageModel, options: Value) -> GenerateResult { + model + .do_generate(&generate_call_options(options)) + .await + .expect("doGenerate succeeds") +} + +/// `model.doGenerate({ prompt: TEST_PROMPT })`. +async fn generate_with_test_prompt(model: &impl LanguageModel) -> GenerateResult { + generate_with(model, json!({ "prompt": generate_test_prompt() })).await +} + +/// The JSON of any serializable value. +fn generate_json(value: &T) -> Value { + serde_json::to_value(value).expect("value serializes") +} + +/// The single request the mock saw. +fn generate_only_call(mock: &MockFetch) -> Seen { + let seen = mock.seen(); + assert_eq!(seen.len(), 1, "expected exactly one request"); + seen[0].clone() +} + +/// The request body of the single request the mock saw. +fn generate_only_body(mock: &MockFetch) -> Value { + generate_only_call(mock).json_body() +} + +/// `server.calls[0].requestHeaders`: the headers without `user-agent`, which +/// the test server reports separately as `requestUserAgent`. +fn generate_request_headers(seen: &Seen) -> BTreeMap { + seen.headers + .iter() + .filter(|(name, _)| name.as_str() != "user-agent") + .map(|(name, value)| (name.clone(), value.clone())) + .collect() +} + +/// `expect(actual).toMatchObject(expected)`. +fn generate_assert_match_object(actual: &Value, expected: &Value) { + fn matches(actual: &Value, expected: &Value) -> bool { + match (actual, expected) { + (Value::Object(actual), Value::Object(expected)) => { + expected.iter().all(|(key, expected)| { + actual + .get(key) + .is_some_and(|actual| matches(actual, expected)) + }) + } + (Value::Array(actual), Value::Array(expected)) => { + actual.len() == expected.len() + && actual + .iter() + .zip(expected) + .all(|(actual, expected)| matches(actual, expected)) + } + (actual, expected) => actual == expected, + } + } + assert!( + matches(actual, expected), + "expected {actual:#} to match object {expected:#}" + ); +} + +/// The `type: 'provider'` tool `{ id, name, args: {} }`. +fn generate_provider_tool(id: &str, name: &str) -> Value { + json!({ "type": "provider", "id": id, "name": name, "args": {} }) +} + +/// The body upstream's `simpleResponseBody` of the top-level reasoning tests. +fn generate_simple_response_body() -> Value { + json!({ + "candidates": [{ + "content": { "parts": [{ "text": "response" }], "role": "model" }, + "finishReason": "STOP", + "safetyRatings": generate_safety_ratings(), + }], + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3 }, + }) +} + +/// A one-candidate body answering `done`, as the Vertex tool result tests +/// serve. +fn generate_done_body() -> Value { + json!({ + "candidates": [{ + "content": { "parts": [{ "text": "done" }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + }], + }) +} + +/// A tool message with one `viewFiles` content result holding `value`. +fn generate_view_files_prompt(value: Value) -> Value { + json!([{ + "role": "tool", + "content": [{ + "type": "tool-result", + "toolCallId": "testCallId", + "toolName": "viewFiles", + "output": { "type": "content", "value": value }, + }], + }]) +} + +/// The request body of `model.doGenerate({ prompt: TEST_PROMPT, +/// providerOptions: { google: options } })` against `google-text`. +async fn generate_body_with_google_options(options: Value) -> Value { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + generate_with( + &model, + json!({ "prompt": generate_test_prompt(), "providerOptions": { "google": options } }), + ) + .await; + generate_only_body(&mock) +} + +/// The request body of `modelId.doGenerate({ prompt: TEST_PROMPT, reasoning })` +/// against `simpleResponseBody`, and the result. +async fn generate_body_with_reasoning(model_id: &str, reasoning: &str) -> (Value, GenerateResult) { + let mock = generate_mock_with_body(&generate_simple_response_body()); + let model = generate_test_provider(&mock).chat(model_id); + let result = generate_with( + &model, + json!({ "prompt": generate_test_prompt(), "reasoning": reasoning }), + ) + .await; + (generate_only_body(&mock), result) +} + +// ── Shared helpers of the stream / schema ports ────────────────────────────── + +/// The base URL of the upstream test server. +const STREAM_TEST_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; + +/// `TEST_PROMPT`. +fn stream_test_prompt() -> Value { + json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) +} + +/// `SAFETY_RATINGS`. +fn stream_safety_ratings() -> Value { + json!([ + { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "probability": "NEGLIGIBLE" }, + { "category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE" }, + { "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" }, + { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "probability": "NEGLIGIBLE" }, + ]) +} + +/// `COMPLETE_USAGE_METADATA`. +fn stream_complete_usage_metadata() -> Value { + json!({ + "promptTokenCount": 12, + "cachedContentTokenCount": 4, + "candidatesTokenCount": 71, + "toolUsePromptTokenCount": 65, + "thoughtsTokenCount": 89, + "totalTokenCount": 237, + "promptTokensDetails": [ + { "modality": "TEXT", "tokenCount": 12, "nestedSentinel": "prompt" }, + ], + "cacheTokensDetails": [ + { "modality": "TEXT", "tokenCount": 4, "nestedSentinel": "cache" }, + ], + "candidatesTokensDetails": [ + { "modality": "TEXT", "tokenCount": 71, "nestedSentinel": "candidate" }, + ], + "toolUsePromptTokensDetails": [ + { "modality": "TEXT", "tokenCount": 65, "nestedSentinel": "tool" }, + ], + "serviceTier": "standard", + "topLevelSentinel": "preserve-me", + }) +} + +/// A function tool of the upstream calls (`{ type: 'function', name, +/// inputSchema }`) whose schema is `{ : string }`, required. +fn stream_string_function_tool(name: &str, property: &str) -> Value { + json!({ + "type": "function", + "name": name, + "inputSchema": { + "type": "object", + "properties": { property: { "type": "string" } }, + "required": [property], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }, + }) +} + +/// A provider tool of the upstream calls (`{ type: 'provider', id, name, +/// args: {} }`). +fn stream_provider_tool(id: &str, name: &str) -> Value { + json!({ "type": "provider", "id": id, "name": name, "args": {} }) +} + +/// One `data: \n\n` SSE chunk. +fn sse_data_chunk(value: &Value) -> String { + format!("data: {value}\n\n") +} + +/// `prepareChunksFixtureResponse`: one SSE chunk per non-empty fixture line. +fn sse_fixture_chunks(fixture: &str) -> Vec { + fixture + .split('\n') + .filter(|line| !line.trim().is_empty()) + .map(|line| format!("data: {line}\n\n")) + .collect() +} + +/// `prepareStreamResponse`: one chunk per text, `STOP` on every chunk and the +/// usage on the last one only. +fn sse_text_chunks( + content: &[&str], + grounding_metadata: Option, + url_context_metadata: Option, +) -> Vec { + content + .iter() + .enumerate() + .map(|(index, text)| { + let mut candidate = json!({ + "content": { "parts": [{ "text": text }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": stream_safety_ratings(), + }); + if let Some(grounding_metadata) = &grounding_metadata { + candidate["groundingMetadata"] = grounding_metadata.clone(); + } + if let Some(url_context_metadata) = &url_context_metadata { + candidate["urlContextMetadata"] = url_context_metadata.clone(); + } + let mut chunk = json!({ "candidates": [candidate] }); + if index == content.len() - 1 { + chunk["usageMetadata"] = json!({ + "promptTokenCount": 294, + "candidatesTokenCount": 233, + "totalTokenCount": 527, + }); + } + sse_data_chunk(&chunk) + }) + .collect() +} + +/// The test server's `stream-chunks` response: the SSE headers it always +/// sends, then `headers`. +fn sse_stream_response(chunks: Vec, headers: &[(&str, &str)]) -> Canned { + let mut all_headers: Vec<(String, String)> = vec![ + ("content-type".into(), "text/event-stream".into()), + ("cache-control".into(), "no-cache".into()), + ("connection".into(), "keep-alive".into()), + ]; + all_headers.extend( + headers + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())), + ); + Canned { + status: 200, + headers: all_headers, + body: chunks.concat().into_bytes(), + } +} + +/// A mock transport answering one request with the SSE `chunks`. +fn sse_stream_mock(chunks: Vec) -> Arc { + MockFetch::new(vec![sse_stream_response(chunks, &[])]) +} + +/// `createGoogle({ apiKey: 'test-api-key', generateId: () => 'test-id' })` +/// on the mock transport. +fn stream_test_provider(mock: &Arc) -> GoogleProvider { + create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + generate_id: Some(Arc::new(|| "test-id".into())), + fetch: Some(mock.transport()), + ..Default::default() + }) + .expect("valid settings") +} + +/// `new GoogleLanguageModel(modelId, { provider, baseURL, headers: +/// { 'x-goog-api-key': 'test-api-key' }, generateId: () => 'test-id' })` on +/// the mock transport. Upstream picks the Vertex metadata keys when the +/// provider string contains `vertex`; aimux takes that as the namespace. +fn stream_test_model_for_provider( + model_id: &str, + provider: &str, + mock: &Arc, +) -> GoogleModel { + let namespace = if provider.contains("vertex") { + Namespace::Vertex + } else { + Namespace::Google + }; + GoogleModel::from_config( + model_id.to_owned(), + EndpointConfig::fixed( + provider.to_owned(), + STREAM_TEST_BASE_URL.to_owned(), + ProviderHeaders::new( + Credential::None, + AuthScheme::Bearer, + vec![("x-goog-api-key".into(), "test-api-key".into())], + None, + ), + Some(mock.transport()), + None, + ), + namespace, + ) + .with_generate_id(Some(Arc::new(|| "test-id".into()))) +} + +/// The `google.vertex.chat` model of the upstream Vertex cases. +fn vertex_stream_test_model(model_id: &str, mock: &Arc) -> GoogleModel { + stream_test_model_for_provider(model_id, "google.vertex.chat", mock) +} + +/// Call options from their upstream (V4) JSON form. +fn stream_call_options(options: Value) -> CallOptions { + serde_json::from_value(options).expect("call options deserialize") +} + +/// `{ prompt: TEST_PROMPT, ...extra }` as call options. +fn stream_call_options_with(extra: Value) -> CallOptions { + let mut options = json!({ "prompt": stream_test_prompt() }); + if let Value::Object(extra) = extra { + options.as_object_mut().unwrap().extend(extra); + } + stream_call_options(options) +} + +/// `convertReadableStreamToArray(stream)`, each part as its V4 JSON. +async fn stream_parts_of(result: StreamResult) -> Vec { + result + .stream + .collect::>() + .await + .into_iter() + .map(|part| serde_json::to_value(part.expect("stream part")).expect("part serializes")) + .collect() +} + +/// `model.doStream(options)` then all of its parts. +async fn stream_events_of(model: &dyn LanguageModel, options: Value) -> Vec { + let result = model + .do_stream(&stream_call_options_with(options)) + .await + .expect("doStream succeeds"); + stream_parts_of(result).await +} + +/// `events.filter(e => e.type === )`. +fn stream_events_of_type(events: &[Value], types: &[&str]) -> Vec { + events + .iter() + .filter(|event| types.iter().any(|ty| event["type"] == *ty)) + .cloned() + .collect() +} + +/// `events.find(e => e.type === 'finish')`. +fn stream_finish_event(events: &[Value]) -> Value { + events + .iter() + .find(|event| event["type"] == "finish") + .cloned() + .expect("a finish event") +} + +/// The JSON body of the `index`-th request the mock saw +/// (`server.calls[index].requestBodyJson`). +fn stream_request_body(mock: &Arc, index: usize) -> Value { + mock.seen()[index].json_body() +} + +/// `expect(actual).toMatchObject(expected)`: every key of an expected object +/// is present and matches, arrays match element by element with the same +/// length, everything else is equal. +fn assert_stream_match_object(actual: &Value, expected: &Value) { + fn walk(actual: &Value, expected: &Value, path: &str) { + match (actual, expected) { + (Value::Object(actual), Value::Object(expected)) => { + for (key, expected) in expected { + let actual = actual + .get(key) + .unwrap_or_else(|| panic!("missing key {path}.{key}")); + walk(actual, expected, &format!("{path}.{key}")); + } + } + (Value::Array(actual), Value::Array(expected)) => { + assert_eq!(actual.len(), expected.len(), "array length at {path}"); + for (index, (actual, expected)) in actual.iter().zip(expected).enumerate() { + walk(actual, expected, &format!("{path}[{index}]")); + } + } + _ => assert_eq!(actual, expected, "value at {path}"), + } + } + walk(actual, expected, "$"); +} + +/// A `toMatchSnapshot()` value stored under `snapshots/`. +fn stream_snapshot(text: &str) -> Value { + serde_json::from_str(text).expect("snapshot is JSON") +} + +/// `groundingMetadataSchema.safeParse(metadata).success`: aimux has no +/// standalone schema, so the metadata is parsed where the response carries +/// it (`candidates[].groundingMetadata`). +fn grounding_metadata_parses(metadata: Value) -> bool { + serde_json::from_value::(json!({ "groundingMetadata": metadata })).is_ok() +} + +/// `urlContextMetadataSchema.safeParse(output).success`, parsed where the +/// response carries it (`candidates[].urlContextMetadata`). +fn url_context_metadata_parses(output: Value) -> bool { + serde_json::from_value::(json!({ "urlContextMetadata": output })).is_ok() +} + +mod do_generate { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should use the custom code execution tool name for generated and streamed results + #[tokio::test] + async fn should_use_the_custom_code_execution_tool_name_for_generated_and_streamed_results() { + let response = json!({ + "candidates": [{ + "content": { + "parts": [ + { "executableCode": { "language": "PYTHON", "code": "print('ok')\nprint(1/0)" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "ok\n" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_FAILED", "output": "ZeroDivisionError: division by zero\n" } }, + ], + "role": "model", + }, + "finishReason": "STOP", + }], + }); + let mock = MockFetch::new(vec![ + Canned::json(&response), + Canned { + status: 200, + headers: vec![("content-type".into(), "text/event-stream".into())], + body: format!("data: {response}\n\n").into_bytes(), + }, + ]); + let test_provider = generate_test_provider(&mock); + let tools = json!([{ "type": "provider", "id": "google.code_execution", "name": "CodeExecutionTool", "args": {} }]); + + let result = generate_with( + &test_provider.chat("gemini-2.0-pro"), + json!({ "tools": tools, "prompt": generate_test_prompt() }), + ) + .await; + + assert_eq!(mock.seen().len(), 1); + let expected = json!([ + { + "input": "{\"language\":\"PYTHON\",\"code\":\"print('ok')\\nprint(1/0)\"}", + "providerExecuted": true, + "toolCallId": "test-id", + "toolName": "CodeExecutionTool", + "type": "tool-call", + }, + { + "result": { "outcome": "OUTCOME_OK", "output": "ok\n" }, + "toolCallId": "test-id", + "toolName": "CodeExecutionTool", + "type": "tool-result", + }, + { + "result": { "outcome": "OUTCOME_FAILED", "output": "ZeroDivisionError: division by zero\n" }, + "toolCallId": "test-id", + "toolName": "CodeExecutionTool", + "type": "tool-result", + }, + ]); + assert_eq!(generate_json(&result.content), expected); + + let stream = test_provider + .chat("gemini-2.0-pro") + .do_stream(&generate_call_options( + json!({ "tools": tools, "prompt": generate_test_prompt() }), + )) + .await + .expect("doStream succeeds"); + let events: Vec = stream + .stream + .collect::>() + .await + .into_iter() + .map(|event| event.expect("stream part")) + .collect(); + let tool_events: Vec = events + .iter() + .filter(|event| matches!(event, StreamPart::ToolCall(_) | StreamPart::ToolResult(_))) + .map(generate_json) + .collect(); + + assert_eq!(mock.seen().len(), 2); + assert_eq!(Value::Array(tool_events), expected); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should omit function call IDs from Vertex requests + #[tokio::test] + async fn should_omit_function_call_ids_from_vertex_requests() { + let mock = generate_mock_with_body(&generate_prepared_json_body("done", None)); + let vertex_model = generate_direct_model( + "gemini-pro", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + generate_with( + &vertex_model, + json!({ + "prompt": [ + { "role": "user", "content": [{ "type": "text", "text": "Look up the answer." }] }, + { + "role": "assistant", + "content": [{ + "type": "tool-call", + "toolCallId": "call_repro_1", + "toolName": "lookup", + "input": { "query": "vertex function id repro" }, + }], + }, + { + "role": "tool", + "content": [{ + "type": "tool-result", + "toolCallId": "call_repro_1", + "toolName": "lookup", + "output": { "type": "json", "value": { "answer": "known" } }, + }], + }, + ], + }), + ) + .await; + + let request_body = generate_only_body(&mock); + assert_eq!( + request_body["contents"][1]["parts"][0]["functionCall"], + json!({ "name": "lookup", "args": { "query": "vertex function id repro" } }) + ); + assert_eq!( + request_body["contents"][2]["parts"][0]["functionResponse"], + json!({ "name": "lookup", "response": { "name": "lookup", "content": { "answer": "known" } } }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should forward supported Vertex tool result URLs as function response file data + #[tokio::test] + async fn should_forward_supported_vertex_tool_result_urls_as_function_response_file_data() { + // Upstream also passes `downloadToolResultFiles: { maxBytes, + // supportsGoogleCloudStorageUrls: true }`; aimux's Vertex model always + // forwards supported `gs://` URLs and has no such setting. + let mock = generate_mock_with_body(&generate_done_body()); + let vertex_model = generate_direct_model( + "gemini-3.7-flash", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + generate_with( + &vertex_model, + json!({ + "prompt": generate_view_files_prompt(json!([ + { "type": "text", "text": "hero.png activated" }, + { + "type": "file", + "data": { "type": "url", "url": "gs://example-bucket/renditions/hero.png" }, + "mediaType": "image/png", + }, + ])), + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock)["contents"][0], + json!({ + "role": "user", + "parts": [{ + "functionResponse": { + "name": "viewFiles", + "response": { "name": "viewFiles", "content": "hero.png activated" }, + "parts": [{ + "fileData": { + "mimeType": "image/png", + "fileUri": "gs://example-bucket/renditions/hero.png", + }, + }], + }, + }], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should forward supported Vertex %s tool result URLs as function response file data + #[tokio::test] + async fn should_forward_supported_vertex_media_type_tool_result_urls_as_function_response_file_data() + { + for media_type in [ + "image/png", + "image/jpeg", + "image/webp", + "application/pdf", + "text/plain", + ] { + let mock = generate_mock_with_body(&generate_done_body()); + let vertex_model = generate_direct_model( + "gemini-3.7-flash", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + generate_with( + &vertex_model, + json!({ + "prompt": generate_view_files_prompt(json!([{ + "type": "file", + "data": { "type": "url", "url": "gs://example-bucket/result" }, + "mediaType": media_type, + }])), + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock)["contents"][0]["parts"][0]["functionResponse"]["parts"], + json!([{ "fileData": { "mimeType": media_type, "fileUri": "gs://example-bucket/result" } }]), + "{media_type}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should send PDF tool result data as inlineData for Gemini 2.5 legacy tool results + #[tokio::test] + async fn should_send_pdf_tool_result_data_as_inline_data_for_gemini_2_5_legacy_tool_results() { + let mock = generate_mock_with_body(&generate_done_body()); + let model = generate_test_provider(&mock).chat("gemini-2.5-flash-lite"); + + generate_with( + &model, + json!({ + "prompt": [{ + "role": "tool", + "content": [{ + "type": "tool-result", + "toolName": "catalogSearch", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { "type": "text", "text": "metadata" }, + { + "type": "file", + "data": { "type": "data", "data": "JVBERi0xLjQK" }, + "mediaType": "application/pdf", + }, + ], + }, + }], + }], + }), + ) + .await; + + let request_body = generate_only_body(&mock); + let parts = &request_body["contents"][0]["parts"]; + let text_parts: Vec<&str> = parts + .as_array() + .expect("parts") + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect(); + + assert!( + !text_parts.iter().any(|text| text.contains("JVBERi0xLjQK")), + "{text_parts:?}" + ); + assert_eq!( + parts, + &json!([ + { + "functionResponse": { + "id": "testCallId", + "name": "catalogSearch", + "response": { "name": "catalogSearch", "content": "metadata" }, + }, + }, + { "inlineData": { "mimeType": "application/pdf", "data": "JVBERi0xLjQK" } }, + { "text": "Tool executed successfully and returned this file as a response" }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should use newest request behavior for an unknown future Gemini model + #[tokio::test] + async fn should_use_newest_request_behavior_for_an_unknown_future_gemini_model() { + let mock = generate_mock_with_body(&generate_done_body()); + let model = generate_test_provider(&mock).chat("gemini-99-pro-preview"); + + generate_with( + &model, + json!({ + "reasoning": "high", + "prompt": [ + { "role": "user", "content": [{ "type": "text", "text": "Create a weather chart." }] }, + { + "role": "assistant", + "content": [{ + "type": "tool-call", + "toolName": "createWeatherChart", + "toolCallId": "testCallId", + "input": { "location": "San Francisco" }, + }], + }, + { + "role": "tool", + "content": [{ + "type": "tool-result", + "toolName": "createWeatherChart", + "toolCallId": "testCallId", + "output": { + "type": "content", + "value": [ + { "type": "text", "text": "Weather chart" }, + { + "type": "file", + "data": { "type": "data", "data": "iVBORw0KGgo=" }, + "mediaType": "image/png", + }, + ], + }, + }], + }, + ], + }), + ) + .await; + + let request_body = generate_only_body(&mock); + assert_eq!( + request_body["generationConfig"]["thinkingConfig"], + json!({ "thinkingLevel": "high" }) + ); + generate_assert_match_object( + &request_body["contents"][1]["parts"][0], + &json!({ + "functionCall": { + "id": "testCallId", + "name": "createWeatherChart", + "args": { "location": "San Francisco" }, + }, + "thoughtSignature": "skip_thought_signature_validator", + }), + ); + assert_eq!( + request_body["contents"][2]["parts"], + json!([{ + "functionResponse": { + "id": "testCallId", + "name": "createWeatherChart", + "response": { "name": "createWeatherChart", "content": "Weather chart" }, + "parts": [{ "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo=" } }], + }, + }]) + ); + } + + mod text { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › text › should extract text response + #[tokio::test] + async fn should_extract_text_response() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result), + generate_snapshot("do_generate_text_extract_text_response") + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › text › should extract usage + #[tokio::test] + async fn should_extract_usage() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.usage), + generate_snapshot("do_generate_text_extract_usage") + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › text › should preserve complete raw usage metadata + #[tokio::test] + async fn should_preserve_complete_raw_usage_metadata() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { "parts": [{ "text": "Blue." }], "role": "model" }, + "finishReason": "STOP", + }], + "usageMetadata": generate_complete_usage_metadata(), + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.usage), + json!({ + "inputTokens": { "total": 77, "noCache": 73, "cacheRead": 4 }, + "outputTokens": { "total": 160, "text": 71, "reasoning": 89 }, + "raw": generate_complete_usage_metadata(), + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › text › should send additional response information + #[tokio::test] + async fn should_send_additional_response_information() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + let response = result.response.expect("response"); + assert_eq!( + json!({ + "id": response.id, + "timestamp": response.timestamp.map(|timestamp| generate_json(×tamp)), + "modelId": response.model_id, + }), + json!({ "id": "Un6LacrVMcjUxs0PmJfWoQc", "modelId": null, "timestamp": null }) + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should reject invalid usage metadata values: %j + #[tokio::test] + async fn should_reject_invalid_usage_metadata_values() { + for invalid_usage in [ + json!({ "toolUsePromptTokenCount": "65" }), + json!({ "cacheTokensDetails": [{ "modality": "TEXT", "tokenCount": "4" }] }), + json!({ "toolUsePromptTokensDetails": [{ "modality": 1, "tokenCount": 65 }] }), + ] { + let mut usage_metadata = json!({ "promptTokenCount": 12 }); + usage_metadata + .as_object_mut() + .unwrap() + .extend(invalid_usage.as_object().unwrap().clone()); + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { "parts": [{ "text": "Blue." }], "role": "model" }, + "finishReason": "STOP", + }], + "usageMetadata": usage_metadata, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = model + .do_generate(&generate_call_options( + json!({ "prompt": generate_test_prompt() }), + )) + .await; + + assert!(result.is_err(), "{invalid_usage} was accepted"); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should handle MALFORMED_FUNCTION_CALL finish reason and empty content object + #[tokio::test] + async fn should_handle_malformed_function_call_finish_reason_and_empty_content_object() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ "content": {}, "finishReason": "MALFORMED_FUNCTION_CALL" }], + "usageMetadata": { + "promptTokenCount": 9056, + "totalTokenCount": 9056, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 9056 }], + }, + "modelVersion": "gemini-2.0-flash-lite", + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!(generate_json(&result.content), json!([])); + assert_eq!( + generate_json(&result.finish_reason), + json!({ "raw": "MALFORMED_FUNCTION_CALL", "unified": "error" }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should expose finishMessage in provider metadata + #[tokio::test] + async fn should_expose_finish_message_in_provider_metadata() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": {}, + "finishReason": "MALFORMED_FUNCTION_CALL", + "finishMessage": "Malformed function call: print(default_api.create(name='test'))", + }], + "usageMetadata": { + "promptTokenCount": 130, + "totalTokenCount": 130, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 130 }], + }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.provider_metadata)["google"]["finishMessage"], + json!("Malformed function call: print(default_api.create(name='test'))") + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should expose null finishMessage in provider metadata when not present + #[tokio::test] + async fn should_expose_null_finish_message_in_provider_metadata_when_not_present() { + let mock = generate_mock_with_body(&generate_prepared_json_body("test response", None)); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + let metadata = generate_json(&result.provider_metadata); + assert_eq!(metadata["google"].get("finishMessage"), Some(&Value::Null)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should send serviceTier in request body when specified + #[tokio::test] + async fn should_send_service_tier_in_request_body_when_specified() { + let mock = generate_mock_with_body(&generate_prepared_json_body("test response", None)); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ "prompt": generate_test_prompt(), "providerOptions": { "google": { "serviceTier": "flex" } } }), + ) + .await; + + generate_assert_match_object( + &generate_only_body(&mock), + &json!({ "serviceTier": "flex" }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should not send serviceTier in request body when not specified + #[tokio::test] + async fn should_not_send_service_tier_in_request_body_when_not_specified() { + let mock = generate_mock_with_body(&generate_prepared_json_body("test response", None)); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with_test_prompt(&model).await; + + assert!(generate_only_body(&mock).get("serviceTier").is_none()); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should send sharedRequestType as X-Vertex-AI-LLM-Shared-Request-Type header on Vertex + #[tokio::test] + async fn should_send_shared_request_type_as_x_vertex_ai_llm_shared_request_type_header_on_vertex() + { + let mock = generate_mock_with_body(&generate_prepared_json_body("test response", None)); + let vertex_model = generate_direct_model( + "gemini-pro", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + generate_with( + &vertex_model, + json!({ "prompt": generate_test_prompt(), "providerOptions": { "google": { "sharedRequestType": "flex" } } }), + ) + .await; + + let call = generate_only_call(&mock); + assert_eq!( + generate_request_headers(&call) + .get("x-vertex-ai-llm-shared-request-type") + .map(String::as_str), + Some("flex") + ); + assert!(call.json_body().get("serviceTier").is_none()); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should send requestType as X-Vertex-AI-LLM-Request-Type header on Vertex + #[tokio::test] + async fn should_send_request_type_as_x_vertex_ai_llm_request_type_header_on_vertex() { + let mock = generate_mock_with_body(&generate_prepared_json_body("test response", None)); + let vertex_model = generate_direct_model( + "gemini-pro", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + generate_with( + &vertex_model, + json!({ + "prompt": generate_test_prompt(), + "providerOptions": { "google": { "sharedRequestType": "priority", "requestType": "shared" } }, + }), + ) + .await; + + let headers = generate_request_headers(&generate_only_call(&mock)); + assert_eq!( + headers + .get("x-vertex-ai-llm-shared-request-type") + .map(String::as_str), + Some("priority") + ); + assert_eq!( + headers + .get("x-vertex-ai-llm-request-type") + .map(String::as_str), + Some("shared") + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should warn and drop serviceTier on Vertex provider + #[tokio::test] + async fn should_warn_and_drop_service_tier_on_vertex_provider() { + let mock = generate_mock_with_body(&generate_prepared_json_body("test response", None)); + let vertex_model = generate_direct_model( + "gemini-pro", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + let result = generate_with( + &vertex_model, + json!({ "prompt": generate_test_prompt(), "providerOptions": { "google": { "serviceTier": "priority" } } }), + ) + .await; + + let call = generate_only_call(&mock); + assert!(call.json_body().get("serviceTier").is_none()); + assert!( + !generate_request_headers(&call).contains_key("x-vertex-ai-llm-shared-request-type") + ); + let warnings = generate_json(&result.warnings); + assert!( + warnings.as_array().unwrap().iter().any(|warning| { + warning["type"] == "other" + && warning["message"].as_str().is_some_and(|message| { + message.contains("'serviceTier' is a Gemini API option") + }) + }), + "{warnings}" + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should warn when sharedRequestType is set on a non-Vertex provider + #[tokio::test] + async fn should_warn_when_shared_request_type_is_set_on_a_non_vertex_provider() { + let mock = generate_mock_with_body(&generate_prepared_json_body("test response", None)); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with( + &model, + json!({ "prompt": generate_test_prompt(), "providerOptions": { "google": { "sharedRequestType": "priority" } } }), + ) + .await; + + assert!( + !generate_request_headers(&generate_only_call(&mock)) + .contains_key("x-vertex-ai-llm-shared-request-type") + ); + let warnings = generate_json(&result.warnings); + assert!( + warnings.as_array().unwrap().iter().any(|warning| { + warning["type"] == "other" + && warning["message"].as_str().is_some_and(|message| { + message.contains("'sharedRequestType' and 'requestType'") + }) + }), + "{warnings}" + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should read serviceTier from usageMetadata.serviceTier + #[tokio::test] + async fn should_read_service_tier_from_usage_metadata_service_tier() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { "parts": [{ "text": "Blue." }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "promptFeedback": { "safetyRatings": generate_safety_ratings() }, + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 2, + "totalTokenCount": 3, + "serviceTier": "priority", + }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.provider_metadata)["google"]["serviceTier"], + json!("priority") + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should expose null serviceTier in provider metadata when not present + #[tokio::test] + async fn should_expose_null_service_tier_in_provider_metadata_when_not_present() { + let mock = generate_mock_with_body(&generate_prepared_json_body("test response", None)); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + let metadata = generate_json(&result.provider_metadata); + assert_eq!(metadata["google"].get("serviceTier"), Some(&Value::Null)); + } + + mod tool_call { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › tool-call › should extract tool calls + #[tokio::test] + async fn should_extract_tool_calls() { + let mock = generate_mock_with_fixture("google-tool-call"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with( + &model, + json!({ + "tools": [{ + "type": "function", + "name": "weather", + "inputSchema": { + "type": "object", + "properties": { "location": { "type": "string" } }, + "required": ["location"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }, + }], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_json(&result), + generate_snapshot("do_generate_tool_call_extract_tool_calls") + ); + } + } + + mod reasoning_gemini3 { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › reasoning-gemini3 › should extract reasoning with thoughtSignature + #[tokio::test] + async fn should_extract_reasoning_with_thought_signature() { + let mock = generate_mock_with_fixture("google-reasoning-gemini3"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result), + generate_snapshot("do_generate_reasoning_gemini3_extract_reasoning") + ); + } + } + + mod tool_call_gemini3 { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › tool-call-gemini3 › should extract tool call with thoughtSignature + #[tokio::test] + async fn should_extract_tool_call_with_thought_signature() { + let mock = generate_mock_with_fixture("google-tool-call-gemini3"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result), + generate_snapshot("do_generate_tool_call_gemini3_extract_tool_call") + ); + } + } + + mod no_args_tool_call_unary { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › no-args tool call (unary) › should extract a no-args tool call with thoughtSignature as input "{}" + #[tokio::test] + async fn should_extract_a_no_args_tool_call_with_thought_signature_as_input() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "role": "model", + "parts": [{ + "functionCall": { "name": "read_theme" }, + "thoughtSignature": "sig-no-args-unary", + }], + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3 }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + let tool_calls: Vec = generate_json(&result.content) + .as_array() + .unwrap() + .iter() + .filter(|content| content["type"] == "tool-call") + .cloned() + .collect(); + assert_eq!( + Value::Array(tool_calls), + json!([{ + "type": "tool-call", + "toolCallId": "test-id", + "toolName": "read_theme", + "input": "{}", + "providerMetadata": { "google": { "thoughtSignature": "sig-no-args-unary" } }, + }]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › no-args tool call (unary) › should generate an ID when the function call ID is empty + #[tokio::test] + async fn should_generate_an_id_when_the_function_call_id_is_empty() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "role": "model", + "parts": [{ "functionCall": { "id": "", "name": "read_theme", "args": {} } }], + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3 }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + let content = generate_json(&result.content); + let expected = json!({ + "type": "tool-call", + "toolCallId": "test-id", + "toolName": "read_theme", + "input": "{}", + }); + assert!( + content.as_array().unwrap().contains(&expected), + "{content:#}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should expose the raw response headers + #[tokio::test] + async fn should_expose_the_raw_response_headers() { + let mock = MockFetch::new(vec![generate_json_value_response_with_headers( + &generate_fixture("google-text"), + &[("test-header", "test-value")], + )]); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.response.expect("response").headers), + json!({ + "content-length": "549", + "content-type": "application/json", + "test-header": "test-value", + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass the model, messages, and options + #[tokio::test] + async fn should_pass_the_model_messages_and_options() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ + "prompt": [ + { "role": "system", "content": "test system instruction" }, + { "role": "user", "content": [{ "type": "text", "text": "Hello" }] }, + ], + "seed": 123, + "temperature": 0.5, + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock), + json!({ + "contents": [{ "parts": [{ "text": "Hello" }], "role": "user" }], + "generationConfig": { "seed": 123, "temperature": 0.5 }, + "systemInstruction": { "parts": [{ "text": "test system instruction" }] }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should omit unsupported %s for %s + #[tokio::test] + async fn should_omit_unsupported_penalty_for_model() { + for (penalty, model_id) in [ + ("frequencyPenalty", "gemini-2.5-pro"), + ("presencePenalty", "gemini-2.5-pro"), + ("frequencyPenalty", "gemini-2.5-flash"), + ("presencePenalty", "gemini-2.5-flash"), + ("frequencyPenalty", "gemini-2.5-flash-lite"), + ("presencePenalty", "gemini-2.5-flash-lite"), + ] { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat(model_id); + + let mut options = json!({ "prompt": generate_test_prompt() }); + options[penalty] = json!(0.5); + let result = generate_with(&model, options).await; + + let request_body = generate_only_body(&mock); + assert_eq!( + request_body["generationConfig"], + json!({}), + "{penalty} {model_id}" + ); + assert_eq!( + generate_json(&result.warnings), + json!([{ "type": "unsupported", "feature": penalty }]), + "{penalty} {model_id}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass penalty settings for Gemini 2.0 models + #[tokio::test] + async fn should_pass_penalty_settings_for_gemini_2_0_models() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-2.0-pro"); + + let result = generate_with( + &model, + json!({ "prompt": generate_test_prompt(), "frequencyPenalty": 0.5, "presencePenalty": 0.5 }), + ) + .await; + + generate_assert_match_object( + &generate_only_body(&mock), + &json!({ "generationConfig": { "frequencyPenalty": 0.5, "presencePenalty": 0.5 } }), + ); + assert_eq!(generate_json(&result.warnings), json!([])); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass penalty settings for Vertex Gemini 2.5 models + #[tokio::test] + async fn should_pass_penalty_settings_for_vertex_gemini_2_5_models() { + let mock = generate_mock_with_fixture("google-text"); + let vertex_model = generate_direct_model( + "gemini-2.5-flash", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + let result = generate_with( + &vertex_model, + json!({ "prompt": generate_test_prompt(), "frequencyPenalty": 0.5, "presencePenalty": 0.5 }), + ) + .await; + + generate_assert_match_object( + &generate_only_body(&mock), + &json!({ "generationConfig": { "frequencyPenalty": 0.5, "presencePenalty": 0.5 } }), + ); + assert_eq!(generate_json(&result.warnings), json!([])); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should only pass valid provider options + #[tokio::test] + async fn should_only_pass_valid_provider_options() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ + "prompt": [ + { "role": "system", "content": "test system instruction" }, + { "role": "user", "content": [{ "type": "text", "text": "Hello" }] }, + ], + "seed": 123, + "temperature": 0.5, + "providerOptions": { "google": { "foo": "bar", "responseModalities": ["TEXT", "IMAGE"] } }, + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock), + json!({ + "contents": [{ "parts": [{ "text": "Hello" }], "role": "user" }], + "generationConfig": { + "responseModalities": ["TEXT", "IMAGE"], + "seed": 123, + "temperature": 0.5, + }, + "systemInstruction": { "parts": [{ "text": "test system instruction" }] }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should expand standalone threshold provider option into safetySettings + #[tokio::test] + async fn should_expand_standalone_threshold_provider_option_into_safety_settings() { + let request_body = + generate_body_with_google_options(json!({ "threshold": "BLOCK_NONE" })).await; + + let expected_safety_settings = [ + json!({ "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE" }), + json!({ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE" }), + json!({ "category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE" }), + json!({ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE" }), + ]; + let safety_settings = request_body["safetySettings"] + .as_array() + .unwrap_or_else(|| panic!("no safetySettings in {request_body}")); + for expected in &expected_safety_settings { + assert!(safety_settings.contains(expected), "{request_body}"); + } + assert_eq!(safety_settings.len(), expected_safety_settings.len()); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should let safetySettings take precedence over standalone threshold provider option + #[tokio::test] + async fn should_let_safety_settings_take_precedence_over_standalone_threshold_provider_option() + { + let request_body = generate_body_with_google_options(json!({ + "threshold": "BLOCK_NONE", + "safetySettings": [{ "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_ONLY_HIGH" }], + })) + .await; + + assert_eq!( + request_body["safetySettings"], + json!([{ "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_ONLY_HIGH" }]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass tools and toolChoice + #[tokio::test] + async fn should_pass_tools_and_tool_choice() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ + "tools": [{ + "type": "function", + "name": "test-tool", + "inputSchema": { + "type": "object", + "properties": { "value": { "type": "string" } }, + "required": ["value"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }, + }], + "toolChoice": { "type": "tool", "toolName": "test-tool" }, + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock), + json!({ + "contents": [{ "parts": [{ "text": "Hello" }], "role": "user" }], + "generationConfig": {}, + "toolConfig": { + "functionCallingConfig": { "allowedFunctionNames": ["test-tool"], "mode": "ANY" }, + }, + "tools": [{ + "functionDeclarations": [{ + "description": "", + "name": "test-tool", + "parametersJsonSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { "value": { "type": "string" } }, + "required": ["value"], + "type": "object", + }, + }], + }], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should preserve local JSON Schema references in $name tool requests + #[tokio::test] + async fn should_preserve_local_json_schema_references_in_name_tool_requests() { + for name in ["Gemini Developer API", "Vertex AI"] { + let mock = generate_mock_with_fixture("google-text"); + let model = if name == "Vertex AI" { + generate_direct_model( + "gemini-pro", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ) + } else { + generate_test_provider(&mock).chat("gemini-pro") + }; + + generate_with( + &model, + json!({ + "tools": [{ + "type": "function", + "name": "format-date", + "description": "Format a date", + "inputSchema": { + "type": "object", + "properties": { + "locale": { "$ref": "#/$defs/Locale", "description": "Locale for formatting" }, + }, + "required": ["locale"], + "additionalProperties": false, + "$defs": { "Locale": { "type": "string", "enum": ["de", "en"] } }, + }, + }], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock)["tools"][0]["functionDeclarations"][0]["parametersJsonSchema"], + json!({ + "type": "object", + "properties": { + "locale": { "$ref": "#/$defs/Locale", "description": "Locale for formatting" }, + }, + "required": ["locale"], + "additionalProperties": false, + "$defs": { "Locale": { "type": "string", "enum": ["de", "en"] } }, + }), + "{name}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should send recursive tool schemas as JSON Schema + #[tokio::test] + async fn should_send_recursive_tool_schemas_as_json_schema() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + let input_schema = json!({ + "type": "object", + "properties": { "condition": { "$ref": "#/$defs/Condition" } }, + "required": ["condition"], + "$defs": { + "Condition": { + "type": "object", + "properties": { + "children": { "type": "array", "items": { "$ref": "#/$defs/Condition" } }, + }, + }, + }, + }); + + generate_with( + &model, + json!({ + "tools": [{ + "type": "function", + "name": "search", + "description": "Search with a condition tree", + "inputSchema": input_schema, + }], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!(mock.seen().len(), 1); + assert_eq!( + generate_only_body(&mock)["tools"][0]["functionDeclarations"][0], + json!({ + "name": "search", + "description": "Search with a condition tree", + "parametersJsonSchema": input_schema, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should set response mime type with responseFormat + #[tokio::test] + async fn should_set_response_mime_type_with_response_format() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ + "responseFormat": { + "type": "json", + "schema": { "type": "object", "properties": { "location": { "type": "string" } } }, + }, + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock), + json!({ + "contents": [{ "parts": [{ "text": "Hello" }], "role": "user" }], + "generationConfig": { + "responseJsonSchema": { + "properties": { "location": { "type": "string" } }, + "type": "object", + }, + "responseMimeType": "application/json", + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass array length constraints in response schemas + #[tokio::test] + async fn should_pass_array_length_constraints_in_response_schemas() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + let schema = json!({ + "type": "object", + "properties": { + "elements": { "type": "array", "items": { "type": "string" }, "minItems": 2, "maxItems": 4 }, + }, + "required": ["elements"], + }); + + generate_with( + &model, + json!({ "responseFormat": { "type": "json", "schema": schema }, "prompt": generate_test_prompt() }), + ) + .await; + + assert_eq!( + generate_only_body(&mock)["generationConfig"]["responseJsonSchema"], + schema + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should preserve local JSON Schema references in response schemas + #[tokio::test] + async fn should_preserve_local_json_schema_references_in_response_schemas() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + let schema = json!({ + "type": "object", + "properties": { "locale": { "$ref": "#/$defs/Locale" } }, + "required": ["locale"], + "$defs": { "Locale": { "type": "string", "enum": ["de", "en"] } }, + }); + + generate_with( + &model, + json!({ "responseFormat": { "type": "json", "schema": schema }, "prompt": generate_test_prompt() }), + ) + .await; + + assert_eq!( + generate_only_body(&mock)["generationConfig"]["responseJsonSchema"], + schema + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass specification with responseFormat and structuredOutputs = true (default) + #[tokio::test] + async fn should_pass_specification_with_response_format_and_structured_outputs_true_default() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ + "responseFormat": { + "type": "json", + "schema": { + "type": "object", + "properties": { "property1": { "type": "string" }, "property2": { "type": "number" } }, + "required": ["property1", "property2"], + "additionalProperties": false, + }, + }, + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock), + json!({ + "contents": [{ "parts": [{ "text": "Hello" }], "role": "user" }], + "generationConfig": { + "responseJsonSchema": { + "additionalProperties": false, + "properties": { "property1": { "type": "string" }, "property2": { "type": "number" } }, + "required": ["property1", "property2"], + "type": "object", + }, + "responseMimeType": "application/json", + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should not pass specification with responseFormat and structuredOutputs = false + #[tokio::test] + async fn should_not_pass_specification_with_response_format_and_structured_outputs_false() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ + "providerOptions": { "google": { "structuredOutputs": false } }, + "responseFormat": { + "type": "json", + "schema": { + "type": "object", + "properties": { "property1": { "type": "string" }, "property2": { "type": "number" } }, + "required": ["property1", "property2"], + "additionalProperties": false, + }, + }, + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock), + json!({ + "contents": [{ "parts": [{ "text": "Hello" }], "role": "user" }], + "generationConfig": { "responseMimeType": "application/json" }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass tools and toolChoice #2 + #[tokio::test] + async fn should_pass_tools_and_tool_choice_2() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ + "tools": [{ + "name": "test-tool", + "type": "function", + "inputSchema": { + "type": "object", + "properties": { "property1": { "type": "string" }, "property2": { "type": "number" } }, + "required": ["property1", "property2"], + "additionalProperties": false, + }, + }], + "toolChoice": { "type": "required" }, + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock), + json!({ + "contents": [{ "parts": [{ "text": "Hello" }], "role": "user" }], + "generationConfig": {}, + "toolConfig": { "functionCallingConfig": { "mode": "ANY" } }, + "tools": [{ + "functionDeclarations": [{ + "description": "", + "name": "test-tool", + "parametersJsonSchema": { + "additionalProperties": false, + "properties": { "property1": { "type": "string" }, "property2": { "type": "number" } }, + "required": ["property1", "property2"], + "type": "object", + }, + }], + }], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let mock = generate_mock_with_fixture("google-text"); + let provider = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + headers: Some(HeaderMapOpt::from([( + "Custom-Provider-Header".to_string(), + Some("provider-header-value".to_string()), + )])), + fetch: Some(mock.transport()), + ..Default::default() + }) + .expect("valid settings"); + + generate_with( + &provider.chat("gemini-pro"), + json!({ + "prompt": generate_test_prompt(), + "headers": { "Custom-Request-Header": "request-header-value" }, + }), + ) + .await; + + let call = generate_only_call(&mock); + assert_eq!( + generate_json(&generate_request_headers(&call)), + json!({ + "content-type": "application/json", + "custom-provider-header": "provider-header-value", + "custom-request-header": "request-header-value", + "x-goog-api-key": "test-api-key", + }) + ); + // Upstream mocks the package version as `0.0.0-test`; aimux pins + // `4.0.87`. + let user_agent = call.headers.get("user-agent").cloned().unwrap_or_default(); + assert!(user_agent.contains("ai-sdk-google/4.0.87"), "{user_agent}"); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass response format + #[tokio::test] + async fn should_pass_response_format() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ + "prompt": generate_test_prompt(), + "responseFormat": { + "type": "json", + "schema": { + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + }, + }, + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock), + json!({ + "contents": [{ "parts": [{ "text": "Hello" }], "role": "user" }], + "generationConfig": { + "responseJsonSchema": { + "properties": { "text": { "type": "string" } }, + "required": ["text"], + "type": "object", + }, + "responseMimeType": "application/json", + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should send request body + #[tokio::test] + async fn should_send_request_body() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with_test_prompt(&model).await; + + assert_eq!( + generate_only_body(&mock), + json!({ + "contents": [{ "parts": [{ "text": "Hello" }], "role": "user" }], + "generationConfig": {}, + }) + ); + } + + /// The content of `model.doGenerate({ prompt: TEST_PROMPT })` against + /// `prepareJsonResponse({ content, groundingMetadata })`. + async fn generate_content_with_grounding(content: &str, grounding_metadata: Value) -> Value { + let mock = generate_mock_with_body(&generate_prepared_json_body( + content, + Some(grounding_metadata), + )); + let model = generate_test_provider(&mock).chat("gemini-pro"); + generate_json(&generate_with_test_prompt(&model).await.content) + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should extract sources from grounding metadata + #[tokio::test] + async fn should_extract_sources_from_grounding_metadata() { + let content = generate_content_with_grounding( + "test response", + json!({ + "groundingChunks": [{ "web": { "uri": "https://source.example.com", "title": "Source Title" } }], + }), + ) + .await; + + assert_eq!( + content, + json!([ + { "text": "test response", "type": "text" }, + { + "id": "test-id", + "sourceType": "url", + "title": "Source Title", + "type": "source", + "url": "https://source.example.com", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should extract sources from RAG retrievedContext chunks + #[tokio::test] + async fn should_extract_sources_from_rag_retrieved_context_chunks() { + let content = generate_content_with_grounding( + "test response with RAG", + json!({ + "groundingChunks": [ + { "web": { "uri": "https://web.example.com", "title": "Web Source" } }, + { + "retrievedContext": { + "uri": "gs://rag-corpus/document.pdf", + "title": "RAG Document", + "text": "Retrieved context...", + }, + }, + { + "retrievedContext": { + "uri": "https://external-rag-source.com/page", + "title": "External RAG Source", + "text": "External retrieved context...", + }, + }, + ], + }), + ) + .await; + + assert_eq!( + content, + json!([ + { "text": "test response with RAG", "type": "text" }, + { + "id": "test-id", + "sourceType": "url", + "title": "Web Source", + "type": "source", + "url": "https://web.example.com", + }, + { + "filename": "document.pdf", + "id": "test-id", + "mediaType": "application/pdf", + "sourceType": "document", + "title": "RAG Document", + "type": "source", + }, + { + "id": "test-id", + "sourceType": "url", + "title": "External RAG Source", + "type": "source", + "url": "https://external-rag-source.com/page", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should extract sources from File Search retrievedContext (new format) + #[tokio::test] + async fn should_extract_sources_from_file_search_retrieved_context_new_format() { + let content = generate_content_with_grounding( + "test response with File Search", + json!({ + "groundingChunks": [ + { + "retrievedContext": { + "text": "Sample content for testing...", + "fileSearchStore": "fileSearchStores/test-store-xyz", + "title": "Test Document", + }, + }, + { + "retrievedContext": { + "text": "Another document content...", + "fileSearchStore": "fileSearchStores/another-store-abc", + }, + }, + ], + }), + ) + .await; + + assert_eq!( + content, + json!([ + { "text": "test response with File Search", "type": "text" }, + { + "filename": "test-store-xyz", + "id": "test-id", + "mediaType": "application/octet-stream", + "sourceType": "document", + "title": "Test Document", + "type": "source", + }, + { + "filename": "another-store-abc", + "id": "test-id", + "mediaType": "application/octet-stream", + "sourceType": "document", + "title": "Unknown Document", + "type": "source", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should handle URL sources with undefined title correctly + #[tokio::test] + async fn should_handle_url_sources_with_undefined_title_correctly() { + let content = generate_content_with_grounding( + "test response with URLs", + json!({ + "groundingChunks": [ + { "web": { "uri": "https://example.com/page1" } }, + { "retrievedContext": { "uri": "https://example.com/page2" } }, + ], + }), + ) + .await; + + assert_eq!( + content, + json!([ + { "text": "test response with URLs", "type": "text" }, + { "id": "test-id", "sourceType": "url", "type": "source", "url": "https://example.com/page1" }, + { "id": "test-id", "sourceType": "url", "type": "source", "url": "https://example.com/page2" }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should extract sources from maps grounding metadata + #[tokio::test] + async fn should_extract_sources_from_maps_grounding_metadata() { + let content = generate_content_with_grounding( + "test response with Maps", + json!({ + "groundingChunks": [ + { + "maps": { + "uri": "https://maps.google.com/maps?cid=12345", + "title": "Best Italian Restaurant", + "placeId": "ChIJ12345", + }, + }, + { "maps": { "uri": "https://maps.google.com/maps?cid=67890" } }, + ], + }), + ) + .await; + + assert_eq!( + content, + json!([ + { "text": "test response with Maps", "type": "text" }, + { + "id": "test-id", + "sourceType": "url", + "title": "Best Italian Restaurant", + "type": "source", + "url": "https://maps.google.com/maps?cid=12345", + }, + { + "id": "test-id", + "sourceType": "url", + "type": "source", + "url": "https://maps.google.com/maps?cid=67890", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should extract sources from image grounding metadata + #[tokio::test] + async fn should_extract_sources_from_image_grounding_metadata() { + let content = generate_content_with_grounding( + "test response with image search", + json!({ + "groundingChunks": [ + { + "image": { + "sourceUri": "https://example.com/article", + "imageUri": "https://example.com/image.jpg", + "title": "Image Result", + "domain": "example.com", + }, + }, + { + "image": { + "sourceUri": "https://other.example.com/page", + "imageUri": "https://other.example.com/photo.png", + }, + }, + ], + }), + ) + .await; + + assert_eq!( + content, + json!([ + { "text": "test response with image search", "type": "text" }, + { + "id": "test-id", + "sourceType": "url", + "title": "Image Result", + "type": "source", + "url": "https://example.com/article", + }, + { + "id": "test-id", + "sourceType": "url", + "type": "source", + "url": "https://other.example.com/page", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should handle mixed source types with correct title defaults + #[tokio::test] + async fn should_handle_mixed_source_types_with_correct_title_defaults() { + let content = generate_content_with_grounding( + "test response with mixed sources", + json!({ + "groundingChunks": [ + { "web": { "uri": "https://web.example.com" } }, + { "retrievedContext": { "uri": "https://external.example.com" } }, + { "retrievedContext": { "uri": "gs://bucket/document.pdf" } }, + { "retrievedContext": { "fileSearchStore": "fileSearchStores/store-123" } }, + ], + }), + ) + .await; + + assert_eq!( + content, + json!([ + { "text": "test response with mixed sources", "type": "text" }, + { "id": "test-id", "sourceType": "url", "type": "source", "url": "https://web.example.com" }, + { "id": "test-id", "sourceType": "url", "type": "source", "url": "https://external.example.com" }, + { + "filename": "document.pdf", + "id": "test-id", + "mediaType": "application/pdf", + "sourceType": "document", + "title": "Unknown Document", + "type": "source", + }, + { + "filename": "store-123", + "id": "test-id", + "mediaType": "application/octet-stream", + "sourceType": "document", + "title": "Unknown Document", + "type": "source", + }, + ]) + ); + } + + mod async_headers_handling { + use super::*; + + /// The body the async headers tests serve. + fn generate_empty_text_body() -> Value { + json!({ + "candidates": [{ + "content": { "parts": [{ "text": "" }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "promptFeedback": { "safetyRatings": generate_safety_ratings() }, + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3 }, + }) + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › async headers handling › merges async config headers with sync request headers + #[tokio::test] + async fn merges_async_config_headers_with_sync_request_headers() { + // Upstream also passes `supportedUrls`, which the request does not + // read. + let mock = generate_mock_with_body(&generate_empty_text_body()); + let model = generate_direct_model_with_async_headers( + "gemini-pro", + "google.generative-ai", + &[ + ("X-Async-Config", "async-config-value"), + ("X-Common", "config-value"), + ], + &mock, + ); + + generate_with( + &model, + json!({ + "prompt": generate_test_prompt(), + "headers": { "X-Sync-Request": "sync-request-value", "X-Common": "request-value" }, + }), + ) + .await; + + assert_eq!( + generate_json(&generate_request_headers(&generate_only_call(&mock))), + json!({ + "content-type": "application/json", + "x-async-config": "async-config-value", + "x-common": "request-value", + "x-sync-request": "sync-request-value", + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › async headers handling › handles Promise-based headers + #[tokio::test] + async fn handles_promise_based_headers() { + let mock = generate_mock_with_body(&generate_empty_text_body()); + let model = generate_direct_model_with_async_headers( + "gemini-pro", + "google.generative-ai", + &[("X-Promise-Header", "promise-value")], + &mock, + ); + + generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&generate_request_headers(&generate_only_call(&mock))), + json!({ "content-type": "application/json", "x-promise-header": "promise-value" }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › async headers handling › handles async function headers from config + #[tokio::test] + async fn handles_async_function_headers_from_config() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_direct_model_with_async_headers( + "gemini-pro", + "google.generative-ai", + &[("X-Async-Header", "async-value")], + &mock, + ); + + generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&generate_request_headers(&generate_only_call(&mock))), + json!({ "content-type": "application/json", "x-async-header": "async-value" }) + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should expose safety ratings in provider metadata + #[tokio::test] + async fn should_expose_safety_ratings_in_provider_metadata() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { "parts": [{ "text": "test response" }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [{ + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE", + "probabilityScore": 0.1, + "severity": "LOW", + "severityScore": 0.2, + "blocked": false, + }], + }], + "promptFeedback": { "safetyRatings": generate_safety_ratings() }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.provider_metadata)["google"]["safetyRatings"], + json!([{ + "blocked": false, + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE", + "probabilityScore": 0.1, + "severity": "LOW", + "severityScore": 0.2, + }]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should expose PromptFeedback in provider metadata + #[tokio::test] + async fn should_expose_prompt_feedback_in_provider_metadata() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { "parts": [{ "text": "No" }], "role": "model" }, + "finishReason": "SAFETY", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "promptFeedback": { "blockReason": "SAFETY", "safetyRatings": generate_safety_ratings() }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.provider_metadata)["google"]["promptFeedback"], + json!({ + "blockReason": "SAFETY", + "safetyRatings": [ + { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "probability": "NEGLIGIBLE" }, + { "category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE" }, + { "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" }, + { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "probability": "NEGLIGIBLE" }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should surface prompt-level blocks without candidates + #[tokio::test] + async fn should_surface_prompt_level_blocks_without_candidates() { + let mock = generate_mock_with_body(&json!({ + "promptFeedback": { "blockReason": "PROHIBITED_CONTENT" }, + "usageMetadata": { "promptTokenCount": 9, "totalTokenCount": 9, "serviceTier": "standard" }, + "responseId": "blocked-response-id", + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!(generate_json(&result.content), json!([])); + assert_eq!( + generate_json(&result.finish_reason), + json!({ "unified": "content-filter", "raw": "PROHIBITED_CONTENT" }) + ); + generate_assert_match_object( + &generate_json(&result.provider_metadata)["google"], + &json!({ + "promptFeedback": { "blockReason": "PROHIBITED_CONTENT" }, + "groundingMetadata": null, + "urlContextMetadata": null, + "safetyRatings": null, + "usageMetadata": { "promptTokenCount": 9, "totalTokenCount": 9, "serviceTier": "standard" }, + "finishMessage": null, + "serviceTier": "standard", + }), + ); + assert_eq!( + result.response.expect("response").id.as_deref(), + Some("blocked-response-id") + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should not classify the default prompt block reason %j as a content filter + #[tokio::test] + async fn should_not_classify_the_default_prompt_block_reason_as_a_content_filter() { + for block_reason in ["", "BLOCK_REASON_UNSPECIFIED", "BLOCKED_REASON_UNSPECIFIED"] { + let mock = generate_mock_with_body(&json!({ + "candidates": [], + "promptFeedback": { "blockReason": block_reason }, + "usageMetadata": { "promptTokenCount": 9, "totalTokenCount": 9 }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.content), + json!([]), + "{block_reason:?}" + ); + assert_eq!( + generate_json(&result.finish_reason), + json!({ "unified": "other" }), + "{block_reason:?}" + ); + assert_eq!( + generate_json(&result.provider_metadata)["google"]["promptFeedback"], + json!({ "blockReason": block_reason }), + "{block_reason:?}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should expose grounding metadata in provider metadata + #[tokio::test] + async fn should_expose_grounding_metadata_in_provider_metadata() { + let grounding_metadata = json!({ + "webSearchQueries": ["What's the weather in Chicago this weekend?"], + "searchEntryPoint": { "renderedContent": "Sample rendered content for search results" }, + "groundingChunks": [{ + "web": { "uri": "https://example.com/weather", "title": "Chicago Weather Forecast" }, + }], + "groundingSupports": [{ + "segment": { + "startIndex": 0, + "endIndex": 65, + "text": "Chicago weather changes rapidly, so layers let you adjust easily.", + }, + "groundingChunkIndices": [0], + "confidenceScores": [0.99], + }], + "retrievalMetadata": { "webDynamicRetrievalScore": 0.96879 }, + }); + let mock = generate_mock_with_body(&generate_prepared_json_body( + "test response", + Some(grounding_metadata), + )); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.provider_metadata)["google"]["groundingMetadata"], + json!({ + "groundingChunks": [{ + "web": { "title": "Chicago Weather Forecast", "uri": "https://example.com/weather" }, + }], + "groundingSupports": [{ + "confidenceScores": [0.99], + "groundingChunkIndices": [0], + "segment": { + "endIndex": 65, + "startIndex": 0, + "text": "Chicago weather changes rapidly, so layers let you adjust easily.", + }, + }], + "retrievalMetadata": { "webDynamicRetrievalScore": 0.96879 }, + "searchEntryPoint": { "renderedContent": "Sample rendered content for search results" }, + "webSearchQueries": ["What's the weather in Chicago this weekend?"], + }) + ); + } + + /// A one-candidate `STOP` body with `parts`. + fn generate_parts_body(parts: Value) -> Value { + json!({ "candidates": [{ "content": { "parts": parts, "role": "model" }, "finishReason": "STOP" }] }) + } + + /// The `print(1+1)` code execution body of the code execution tests. + fn generate_code_execution_body() -> Value { + generate_parts_body(json!([ + { "executableCode": { "language": "PYTHON", "code": "print(1+1)" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, + ])) + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should handle code execution tool calls + #[tokio::test] + async fn should_handle_code_execution_tool_calls() { + let mock = generate_mock_with_body(&generate_code_execution_body()); + let model = generate_test_provider(&mock).chat("gemini-2.0-pro"); + + let result = generate_with( + &model, + json!({ + "tools": [generate_provider_tool("google.code_execution", "code_execution")], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock)["tools"], + json!([{ "codeExecution": {} }]) + ); + assert_eq!( + generate_json(&result.content), + json!([ + { + "input": "{\"language\":\"PYTHON\",\"code\":\"print(1+1)\"}", + "providerExecuted": true, + "toolCallId": "test-id", + "toolName": "code_execution", + "type": "tool-call", + }, + { + "result": { "outcome": "OUTCOME_OK", "output": "2" }, + "toolCallId": "test-id", + "toolName": "code_execution", + "type": "tool-result", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should handle code execution result with missing output field + #[tokio::test] + async fn should_handle_code_execution_result_with_missing_output_field() { + let mock = generate_mock_with_body(&generate_parts_body(json!([ + { + "executableCode": { + "language": "PYTHON", + "code": "import PIL.Image\nimg = PIL.Image.open('input.png')\nimg.save('output.png')\n", + }, + }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK" } }, + ]))); + let model = generate_test_provider(&mock).chat("gemini-2.0-pro"); + + let result = generate_with( + &model, + json!({ + "tools": [generate_provider_tool("google.code_execution", "code_execution")], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_json(&result.content), + json!([ + { + "input": "{\"language\":\"PYTHON\",\"code\":\"import PIL.Image\\nimg = PIL.Image.open('input.png')\\nimg.save('output.png')\\n\"}", + "providerExecuted": true, + "toolCallId": "test-id", + "toolName": "code_execution", + "type": "tool-call", + }, + { + "result": { "outcome": "OUTCOME_OK", "output": "" }, + "toolCallId": "test-id", + "toolName": "code_execution", + "type": "tool-result", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should return stop finish reason for code execution (provider-executed tool) + #[tokio::test] + async fn should_return_stop_finish_reason_for_code_execution_provider_executed_tool() { + let mock = generate_mock_with_body(&generate_code_execution_body()); + let model = generate_test_provider(&mock).chat("gemini-2.0-pro"); + + let result = generate_with( + &model, + json!({ + "tools": [generate_provider_tool("google.code_execution", "code_execution")], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_json(&result.finish_reason), + json!({ "raw": "STOP", "unified": "stop" }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should return stop finish reason for code execution with text response (structured output scenario) + #[tokio::test] + async fn should_return_stop_finish_reason_for_code_execution_with_text_response() { + let mock = generate_mock_with_body(&generate_parts_body(json!([ + { + "executableCode": { + "language": "PYTHON", + "code": "primes = [2, 3, 5, 7, 11]\nprint(sum(primes))", + }, + }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "28" } }, + { "text": "{\"answer\": 28, \"explanation\": \"Sum of first 5 primes\"}" }, + ]))); + let model = generate_test_provider(&mock).chat("gemini-2.0-pro"); + + let result = generate_with( + &model, + json!({ + "tools": [generate_provider_tool("google.code_execution", "code_execution")], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_json(&result.finish_reason), + json!({ "raw": "STOP", "unified": "stop" }) + ); + let content = generate_json(&result.content); + let text_part = content + .as_array() + .unwrap() + .iter() + .find(|part| part["type"] == "text") + .expect("text part"); + assert_eq!( + text_part["text"], + json!("{\"answer\": 28, \"explanation\": \"Sum of first 5 primes\"}") + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should return tool-calls finish reason when code execution is combined with function tools + #[tokio::test] + async fn should_return_tool_calls_finish_reason_when_code_execution_is_combined_with_function_tools() + { + let mock = generate_mock_with_body(&generate_parts_body(json!([ + { "executableCode": { "language": "PYTHON", "code": "print(1+1)" } }, + { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, + { "functionCall": { "name": "test-tool", "args": { "value": "test" } } }, + ]))); + let model = generate_test_provider(&mock).chat("gemini-2.0-pro"); + + let result = generate_with( + &model, + json!({ + "tools": [ + generate_provider_tool("google.code_execution", "code_execution"), + { + "type": "function", + "name": "test-tool", + "inputSchema": { "type": "object", "properties": { "value": { "type": "string" } } }, + }, + ], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_json(&result.finish_reason), + json!({ "raw": "STOP", "unified": "tool-calls" }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should handle server-side toolCall and toolResponse parts (tool combination) + #[tokio::test] + async fn should_handle_server_side_tool_call_and_tool_response_parts_tool_combination() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "parts": [ + { + "toolCall": { + "toolType": "GOOGLE_SEARCH_WEB", + "args": { "query": "San Francisco weather" }, + "id": "server-call-1", + }, + "thoughtSignature": "sig-abc", + }, + { + "toolResponse": { + "toolType": "GOOGLE_SEARCH_WEB", + "response": { "results": [{ "title": "Weather in SF" }] }, + "id": "server-call-1", + }, + "thoughtSignature": "sig-def", + }, + { "text": "The weather in San Francisco is sunny." }, + ], + "role": "model", + }, + "finishReason": "STOP", + }], + "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 20, "totalTokenCount": 30 }, + })); + let model = generate_test_provider(&mock).chat("gemini-3-pro-preview"); + + let result = generate_with( + &model, + json!({ + "tools": [ + generate_provider_tool("google.google_search", "google_search"), + { + "type": "function", + "name": "weather", + "inputSchema": { "type": "object", "properties": { "location": { "type": "string" } } }, + }, + ], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_json(&result.content), + json!([ + { + "dynamic": true, + "input": "{\"query\":\"San Francisco weather\"}", + "providerExecuted": true, + "providerMetadata": { + "google": { + "serverToolCallId": "server-call-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "sig-abc", + }, + }, + "toolCallId": "server-call-1", + "toolName": "server:GOOGLE_SEARCH_WEB", + "type": "tool-call", + }, + { + "providerMetadata": { + "google": { + "serverToolCallId": "server-call-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "sig-def", + }, + }, + "result": { "results": [{ "title": "Weather in SF" }] }, + "toolCallId": "server-call-1", + "toolName": "server:GOOGLE_SEARCH_WEB", + "type": "tool-result", + }, + { "text": "The weather in San Francisco is sunny.", "type": "text" }, + ]) + ); + assert_eq!( + generate_json(&result.finish_reason), + json!({ "raw": "STOP", "unified": "stop" }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should return stop finish reason for server tool calls (provider-executed) + #[tokio::test] + async fn should_return_stop_finish_reason_for_server_tool_calls_provider_executed() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "parts": [ + { "toolCall": { "toolType": "GOOGLE_SEARCH_WEB", "args": {}, "id": "sc-1" } }, + { "toolResponse": { "toolType": "GOOGLE_SEARCH_WEB", "response": { "results": [] }, "id": "sc-1" } }, + ], + "role": "model", + }, + "finishReason": "STOP", + }], + "usageMetadata": { "promptTokenCount": 5, "candidatesTokenCount": 10, "totalTokenCount": 15 }, + })); + let model = generate_test_provider(&mock).chat("gemini-3-pro-preview"); + + let result = generate_with( + &model, + json!({ + "tools": [generate_provider_tool("google.google_search", "google_search")], + "prompt": generate_test_prompt(), + }), + ) + .await; + + assert_eq!( + generate_json(&result.finish_reason), + json!({ "raw": "STOP", "unified": "stop" }) + ); + } + + mod search_tool_selection { + use super::*; + + /// `modelId.doGenerate({ prompt: TEST_PROMPT, tools: [tool] })` against + /// `google-text`: the request body and the result. + async fn generate_with_one_tool(model_id: &str, tool: Value) -> (Value, GenerateResult) { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat(model_id); + let result = generate_with( + &model, + json!({ "prompt": generate_test_prompt(), "tools": [tool] }), + ) + .await; + (generate_only_body(&mock), result) + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › search tool selection › should use googleSearch for gemini-2.0-pro + #[tokio::test] + async fn should_use_google_search_for_gemini_2_0_pro() { + let (request_body, _) = generate_with_one_tool( + "gemini-2.0-pro", + generate_provider_tool("google.google_search", "google_search"), + ) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "tools": [{ "googleSearch": {} }] }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › search tool selection › should use googleSearch for gemini-2.0-flash-exp + #[tokio::test] + async fn should_use_google_search_for_gemini_2_0_flash_exp() { + let (request_body, _) = generate_with_one_tool( + "gemini-2.0-flash-exp", + generate_provider_tool("google.google_search", "google_search"), + ) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "tools": [{ "googleSearch": {} }] }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › search tool selection › should warn for google search on non-gemini-2 models + #[tokio::test] + async fn should_warn_for_google_search_on_non_gemini_2_models() { + let (_, result) = generate_with_one_tool( + "gemini-1.0-pro", + generate_provider_tool("google.google_search", "google_search"), + ) + .await; + + assert_eq!( + generate_json(&result.warnings), + json!([{ + "details": "Google Search requires Gemini 2.0 or newer.", + "feature": "provider-defined tool google.google_search", + "type": "unsupported", + }]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › search tool selection › should use urlContextTool for gemini-2.0-pro + #[tokio::test] + async fn should_use_url_context_tool_for_gemini_2_0_pro() { + let (request_body, _) = generate_with_one_tool( + "gemini-2.0-pro", + generate_provider_tool("google.url_context", "url_context"), + ) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "tools": [{ "urlContext": {} }] }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › search tool selection › should use vertexRagStore for gemini-2.0-pro + #[tokio::test] + async fn should_use_vertex_rag_store_for_gemini_2_0_pro() { + let (request_body, _) = generate_with_one_tool( + "gemini-2.0-pro", + json!({ + "type": "provider", + "id": "google.vertex_rag_store", + "name": "vertex_rag_store", + "args": { + "ragCorpus": "projects/my-project/locations/us-central1/ragCorpora/my-rag-corpus", + "topK": 5, + }, + }), + ) + .await; + + generate_assert_match_object( + &request_body, + &json!({ + "tools": [{ + "retrieval": { + "vertex_rag_store": { + "rag_resources": { + "rag_corpus": "projects/my-project/locations/us-central1/ragCorpora/my-rag-corpus", + }, + "similarity_top_k": 5, + }, + }, + }], + }), + ); + } + } + + /// The body of the image output tests: a one-candidate `STOP` response + /// with `parts`, safety ratings and usage. + fn generate_image_parts_body(parts: Value) -> Value { + json!({ + "candidates": [{ + "content": { "parts": parts, "role": "model" }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "promptFeedback": { "safetyRatings": generate_safety_ratings() }, + "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 20, "totalTokenCount": 30 }, + }) + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should extract image file outputs + #[tokio::test] + async fn should_extract_image_file_outputs() { + let mock = generate_mock_with_body(&generate_image_parts_body(json!([ + { "text": "Here is an image:" }, + { "inlineData": { "mimeType": "image/jpeg", "data": "base64encodedimagedata" } }, + { "text": "And another image:" }, + { "inlineData": { "mimeType": "image/png", "data": "anotherbase64encodedimagedata" } }, + ]))); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.content), + json!([ + { "text": "Here is an image:", "type": "text" }, + { + "data": { "data": "base64encodedimagedata", "type": "data" }, + "mediaType": "image/jpeg", + "type": "file", + }, + { "text": "And another image:", "type": "text" }, + { + "data": { "data": "anotherbase64encodedimagedata", "type": "data" }, + "mediaType": "image/png", + "type": "file", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should handle responses with only images and no text + #[tokio::test] + async fn should_handle_responses_with_only_images_and_no_text() { + let mock = generate_mock_with_body(&generate_image_parts_body(json!([ + { "inlineData": { "mimeType": "image/jpeg", "data": "imagedata1" } }, + { "inlineData": { "mimeType": "image/png", "data": "imagedata2" } }, + ]))); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.content), + json!([ + { "data": { "data": "imagedata1", "type": "data" }, "mediaType": "image/jpeg", "type": "file" }, + { "data": { "data": "imagedata2", "type": "data" }, "mediaType": "image/png", "type": "file" }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should preserve thought flag on file parts in providerMetadata + #[tokio::test] + async fn should_preserve_thought_flag_on_file_parts_in_provider_metadata() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "parts": [ + { + "inlineData": { "mimeType": "image/png", "data": "thoughtimagedata" }, + "thought": true, + "thoughtSignature": "img_sig1", + }, + { "inlineData": { "mimeType": "image/jpeg", "data": "regularimagedata" } }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 20, "totalTokenCount": 30 }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.content), + json!([ + { + "data": { "data": "thoughtimagedata", "type": "data" }, + "mediaType": "image/png", + "providerMetadata": { "google": { "thoughtSignature": "img_sig1" } }, + "type": "reasoning-file", + }, + { + "data": { "data": "regularimagedata", "type": "data" }, + "mediaType": "image/jpeg", + "type": "file", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass responseModalities in provider options + #[tokio::test] + async fn should_pass_response_modalities_in_provider_options() { + let request_body = + generate_body_with_google_options(json!({ "responseModalities": ["TEXT", "IMAGE"] })) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "responseModalities": ["TEXT", "IMAGE"] } }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass mediaResolution in provider options + #[tokio::test] + async fn should_pass_media_resolution_in_provider_options() { + let request_body = + generate_body_with_google_options(json!({ "mediaResolution": "MEDIA_RESOLUTION_LOW" })) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "mediaResolution": "MEDIA_RESOLUTION_LOW" } }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass imageConfig.aspectRatio in provider options + #[tokio::test] + async fn should_pass_image_config_aspect_ratio_in_provider_options() { + let request_body = + generate_body_with_google_options(json!({ "imageConfig": { "aspectRatio": "16:9" } })) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "imageConfig": { "aspectRatio": "16:9" } } }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass imageConfig.imageSize in provider options + #[tokio::test] + async fn should_pass_image_config_image_size_in_provider_options() { + let request_body = + generate_body_with_google_options(json!({ "imageConfig": { "imageSize": "4K" } })) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "imageConfig": { "imageSize": "4K" } } }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass imageConfig with both aspectRatio and imageSize + #[tokio::test] + async fn should_pass_image_config_with_both_aspect_ratio_and_image_size() { + let request_body = generate_body_with_google_options( + json!({ "imageConfig": { "aspectRatio": "16:9", "imageSize": "2K" } }), + ) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "imageConfig": { "aspectRatio": "16:9", "imageSize": "2K" } } }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass imageConfig.personGeneration, imageConfig.prominentPeople and imageConfig.imageOutputOptions on Vertex + #[tokio::test] + async fn should_pass_image_config_person_generation_prominent_people_and_image_output_options_on_vertex() + { + let mock = generate_mock_with_fixture("google-text"); + let vertex_model = generate_direct_model( + "gemini-pro", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + let image_config = json!({ + "aspectRatio": "16:9", + "personGeneration": "ALLOW_ADULT", + "prominentPeople": "BLOCK_PROMINENT_PEOPLE", + "imageOutputOptions": { "mimeType": "image/jpeg", "compressionQuality": 75 }, + }); + + let result = generate_with( + &vertex_model, + json!({ + "prompt": generate_test_prompt(), + "providerOptions": { "google": { "imageConfig": image_config } }, + }), + ) + .await; + + generate_assert_match_object( + &generate_only_body(&mock), + &json!({ "generationConfig": { "imageConfig": image_config } }), + ); + assert_eq!(generate_json(&result.warnings), json!([])); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should warn and drop Vertex-only imageConfig fields on the Gemini API + #[tokio::test] + async fn should_warn_and_drop_vertex_only_image_config_fields_on_the_gemini_api() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with( + &model, + json!({ + "prompt": generate_test_prompt(), + "providerOptions": { + "google": { + "imageConfig": { "aspectRatio": "16:9", "prominentPeople": "BLOCK_PROMINENT_PEOPLE" }, + }, + }, + }), + ) + .await; + + assert_eq!( + generate_only_body(&mock)["generationConfig"]["imageConfig"], + json!({ "aspectRatio": "16:9" }) + ); + assert_eq!( + generate_json(&result.warnings), + json!([{ + "type": "other", + "message": "'imageConfig.prominentPeople' is a Vertex AI option and is ignored with the current Google provider (google.generative-ai).", + }]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass retrievalConfig in provider options + #[tokio::test] + async fn should_pass_retrieval_config_in_provider_options() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-2.0-flash-exp"); + + generate_with( + &model, + json!({ + "prompt": generate_test_prompt(), + "tools": [generate_provider_tool("google.google_maps", "google_maps")], + "providerOptions": { + "google": { + "retrievalConfig": { "latLng": { "latitude": 34.090199, "longitude": -117.881081 } }, + }, + }, + }), + ) + .await; + + generate_assert_match_object( + &generate_only_body(&mock), + &json!({ + "tools": [{ "googleMaps": {} }], + "toolConfig": { + "retrievalConfig": { "latLng": { "latitude": 34.090199, "longitude": -117.881081 } }, + }, + }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should include non-image inlineData parts + #[tokio::test] + async fn should_include_non_image_inline_data_parts() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "parts": [ + { "text": "Here is content:" }, + { "inlineData": { "mimeType": "image/jpeg", "data": "validimagedata" } }, + { "inlineData": { "mimeType": "application/pdf", "data": "pdfdata" } }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "promptFeedback": { "safetyRatings": generate_safety_ratings() }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.content), + json!([ + { "text": "Here is content:", "type": "text" }, + { "data": { "data": "validimagedata", "type": "data" }, "mediaType": "image/jpeg", "type": "file" }, + { "data": { "data": "pdfdata", "type": "data" }, "mediaType": "application/pdf", "type": "file" }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should correctly parse and separate reasoning parts from text output + #[tokio::test] + async fn should_correctly_parse_and_separate_reasoning_parts_from_text_output() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "parts": [ + { "text": "Visible text part 1. " }, + { "text": "This is a thought process.", "thought": true }, + { "text": "Visible text part 2." }, + { "text": "Another internal thought.", "thought": true }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 20, "totalTokenCount": 30 }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.content), + json!([ + { "text": "Visible text part 1. ", "type": "text" }, + { "text": "This is a thought process.", "type": "reasoning" }, + { "text": "Visible text part 2.", "type": "text" }, + { "text": "Another internal thought.", "type": "reasoning" }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should correctly parse thought signatures with reasoning parts + #[tokio::test] + async fn should_correctly_parse_thought_signatures_with_reasoning_parts() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "parts": [ + { "text": "Visible text part 1. ", "thoughtSignature": "sig1" }, + { "text": "This is a thought process.", "thought": true, "thoughtSignature": "sig2" }, + { "text": "Visible text part 2.", "thoughtSignature": "sig3" }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 20, "totalTokenCount": 30 }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.content), + json!([ + { + "providerMetadata": { "google": { "thoughtSignature": "sig1" } }, + "text": "Visible text part 1. ", + "type": "text", + }, + { + "providerMetadata": { "google": { "thoughtSignature": "sig2" } }, + "text": "This is a thought process.", + "type": "reasoning", + }, + { + "providerMetadata": { "google": { "thoughtSignature": "sig3" } }, + "text": "Visible text part 2.", + "type": "text", + }, + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should correctly parse thought signatures with function calls + #[tokio::test] + async fn should_correctly_parse_thought_signatures_with_function_calls() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "parts": [{ + "functionCall": { "name": "test-tool", "args": { "value": "test" } }, + "thoughtSignature": "func_sig1", + }], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": generate_safety_ratings(), + }], + "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 20, "totalTokenCount": 30 }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with_test_prompt(&model).await; + + assert_eq!( + generate_json(&result.content), + json!([{ + "input": "{\"value\":\"test\"}", + "providerMetadata": { "google": { "thoughtSignature": "func_sig1" } }, + "toolCallId": "test-id", + "toolName": "test-tool", + "type": "tool-call", + }]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should support includeThoughts with google generative ai provider + #[tokio::test] + async fn should_support_include_thoughts_with_google_generative_ai_provider() { + let mock = generate_mock_with_body(&json!({ + "candidates": [{ + "content": { + "parts": [ + { + "text": "let me think about this problem", + "thought": true, + "thoughtSignature": "reasoning_sig", + }, + { "text": "the answer is 42" }, + ], + "role": "model", + }, + "finishReason": "STOP", + "safetyRatings": generate_safety_ratings(), + }], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 15, + "totalTokenCount": 25, + "thoughtsTokenCount": 8, + }, + })); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + let result = generate_with( + &model, + json!({ + "prompt": generate_test_prompt(), + "providerOptions": { + "google": { "thinkingConfig": { "includeThoughts": true, "thinkingBudget": 1024 } }, + }, + }), + ) + .await; + + assert_eq!( + generate_json(&result.content), + json!([ + { + "providerMetadata": { "google": { "thoughtSignature": "reasoning_sig" } }, + "text": "let me think about this problem", + "type": "reasoning", + }, + { "text": "the answer is 42", "type": "text" }, + ]) + ); + assert_eq!( + generate_json(&result.usage), + json!({ + "inputTokens": { "cacheRead": 0, "noCache": 10, "total": 10 }, + "outputTokens": { "reasoning": 8, "text": 15, "total": 23 }, + "raw": { + "candidatesTokenCount": 15, + "promptTokenCount": 10, + "thoughtsTokenCount": 8, + "totalTokenCount": 25, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass thinkingLevel in provider options + #[tokio::test] + async fn should_pass_thinking_level_in_provider_options() { + let request_body = generate_body_with_google_options( + json!({ "thinkingConfig": { "thinkingLevel": "high" } }), + ) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "thinkingConfig": { "thinkingLevel": "high" } } }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass thinkingLevel "minimal" in provider options + #[tokio::test] + async fn should_pass_thinking_level_minimal_in_provider_options() { + let request_body = generate_body_with_google_options( + json!({ "thinkingConfig": { "thinkingLevel": "minimal" } }), + ) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "thinkingConfig": { "thinkingLevel": "minimal" } } }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › should pass thinkingLevel "medium" in provider options + #[tokio::test] + async fn should_pass_thinking_level_medium_in_provider_options() { + let request_body = generate_body_with_google_options( + json!({ "thinkingConfig": { "thinkingLevel": "medium" } }), + ) + .await; + + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "thinkingConfig": { "thinkingLevel": "medium" } } }), + ); + } + + mod top_level_reasoning_option { + use super::*; + + mod gemini_3_models_thinking_level { + use super::*; + + /// Asserts the request of `model_id` with `reasoning` sends + /// `thinkingConfig: { thinkingLevel }`, and returns the result. + async fn generate_assert_thinking_level( + model_id: &str, + reasoning: &str, + thinking_level: &str, + ) -> GenerateResult { + let (request_body, result) = + generate_body_with_reasoning(model_id, reasoning).await; + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "thinkingConfig": { "thinkingLevel": thinking_level } } }), + ); + result + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should map reasoning "minimal" to thinkingLevel "minimal" + #[tokio::test] + async fn should_map_reasoning_minimal_to_thinking_level_minimal() { + generate_assert_thinking_level("gemini-3-pro-preview", "minimal", "minimal").await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should map reasoning "low" to thinkingLevel "low" + #[tokio::test] + async fn should_map_reasoning_low_to_thinking_level_low() { + generate_assert_thinking_level("gemini-3-pro-preview", "low", "low").await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should map reasoning "medium" to thinkingLevel "medium" + #[tokio::test] + async fn should_map_reasoning_medium_to_thinking_level_medium() { + generate_assert_thinking_level("gemini-3-pro-preview", "medium", "medium").await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should map reasoning "high" to thinkingLevel "high" + #[tokio::test] + async fn should_map_reasoning_high_to_thinking_level_high() { + generate_assert_thinking_level("gemini-3-pro-preview", "high", "high").await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should map reasoning "none" to thinkingLevel "minimal" + #[tokio::test] + async fn should_map_reasoning_none_to_thinking_level_minimal() { + generate_assert_thinking_level("gemini-3-pro-preview", "none", "minimal").await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should coerce reasoning "xhigh" to "high" with compatibility warning + #[tokio::test] + async fn should_coerce_reasoning_xhigh_to_high_with_compatibility_warning() { + let result = + generate_assert_thinking_level("gemini-3-pro-preview", "xhigh", "high").await; + + let warnings = generate_json(&result.warnings); + assert!( + warnings.as_array().unwrap().contains(&json!({ + "type": "compatibility", + "feature": "reasoning", + "details": "reasoning \"xhigh\" is not directly supported by this model. mapped to effort \"high\".", + })), + "{warnings}" + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should coerce reasoning "minimal" to thinkingLevel "low" for Gemini 3.7 Flash + #[tokio::test] + async fn should_coerce_reasoning_minimal_to_thinking_level_low_for_gemini_3_7_flash() { + let result = + generate_assert_thinking_level("gemini-3.7-flash", "minimal", "low").await; + + let warnings = generate_json(&result.warnings); + assert!( + warnings.as_array().unwrap().contains(&json!({ + "type": "compatibility", + "feature": "reasoning", + "details": "reasoning \"minimal\" is not directly supported by this model. mapped to effort \"low\".", + })), + "{warnings}" + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should coerce reasoning "none" to thinkingLevel "low" for Gemini 3.7 Flash + #[tokio::test] + async fn should_coerce_reasoning_none_to_thinking_level_low_for_gemini_3_7_flash() { + generate_assert_thinking_level("gemini-3.7-flash", "none", "low").await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should map reasoning "$reasoning" to thinkingLevel "$expectedThinkingLevel" for $modelId + #[tokio::test] + async fn should_map_reasoning_to_thinking_level_for_model_id() { + for (model_id, reasoning, expected_thinking_level) in [ + ("gemini-3.7-flash-video-understanding-eap", "minimal", "low"), + ("gemini-3.7-flash-video-understanding-eap", "none", "low"), + ("gemini-flash-latest", "minimal", "low"), + ("gemini-flash-latest", "none", "low"), + ("models/gemini-3.7-flash", "minimal", "low"), + ("gemini-3.8-flash", "minimal", "low"), + ("gemini-3.10-flash-preview", "minimal", "low"), + ("gemini-4.0-flash", "minimal", "low"), + ("gemini-3-flash-preview", "minimal", "minimal"), + ("gemini-3.6-flash", "minimal", "minimal"), + ("gemini-3.7-flash-lite", "minimal", "minimal"), + ("gemini-3.10-flash-lite-preview", "minimal", "minimal"), + ("gemini-flash-lite-latest", "minimal", "minimal"), + ] { + let (request_body, _) = generate_body_with_reasoning(model_id, reasoning).await; + + assert_eq!( + request_body["generationConfig"]["thinkingConfig"]["thinkingLevel"], + json!(expected_thinking_level), + "{model_id} {reasoning}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 3 models (thinkingLevel) › should also detect gemini-3.1 models as Gemini 3 + #[tokio::test] + async fn should_also_detect_gemini_3_1_models_as_gemini_3() { + generate_assert_thinking_level("gemini-3.1-pro-preview", "medium", "medium").await; + } + } + + mod gemini_2_5_models_thinking_budget { + use super::*; + + /// Asserts the request of `model_id` with `reasoning` sends + /// `thinkingConfig: { thinkingBudget }`. + async fn generate_assert_thinking_budget( + model_id: &str, + reasoning: &str, + thinking_budget: i64, + ) { + let (request_body, _) = generate_body_with_reasoning(model_id, reasoning).await; + generate_assert_match_object( + &request_body, + &json!({ "generationConfig": { "thinkingConfig": { "thinkingBudget": thinking_budget } } }), + ); + } + + /// `Math.round(65536 * share)`. + fn generate_share_of_max_output_tokens(share: f64) -> i64 { + (65536.0 * share).round() as i64 + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 2.5 models (thinkingBudget) › should map reasoning "none" to thinkingBudget 0 + #[tokio::test] + async fn should_map_reasoning_none_to_thinking_budget_0() { + generate_assert_thinking_budget("gemini-2.5-pro", "none", 0).await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 2.5 models (thinkingBudget) › should map reasoning "minimal" to ~2% of maxOutputTokens + #[tokio::test] + async fn should_map_reasoning_minimal_to_2_of_max_output_tokens() { + generate_assert_thinking_budget( + "gemini-2.5-pro", + "minimal", + generate_share_of_max_output_tokens(0.02), + ) + .await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 2.5 models (thinkingBudget) › should map reasoning "low" to ~10% of maxOutputTokens + #[tokio::test] + async fn should_map_reasoning_low_to_10_of_max_output_tokens() { + generate_assert_thinking_budget( + "gemini-2.5-pro", + "low", + generate_share_of_max_output_tokens(0.1), + ) + .await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 2.5 models (thinkingBudget) › should map reasoning "medium" to ~30% of maxOutputTokens + #[tokio::test] + async fn should_map_reasoning_medium_to_30_of_max_output_tokens() { + generate_assert_thinking_budget( + "gemini-2.5-pro", + "medium", + generate_share_of_max_output_tokens(0.3), + ) + .await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 2.5 models (thinkingBudget) › should map reasoning "high" to ~60% of maxOutputTokens + #[tokio::test] + async fn should_map_reasoning_high_to_60_of_max_output_tokens() { + generate_assert_thinking_budget("gemini-2.5-pro", "high", 32768).await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 2.5 models (thinkingBudget) › should map reasoning "xhigh" to ~90% of maxOutputTokens + #[tokio::test] + async fn should_map_reasoning_xhigh_to_90_of_max_output_tokens() { + generate_assert_thinking_budget("gemini-2.5-pro", "xhigh", 32768).await; + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › Gemini 2.5 models (thinkingBudget) › should use lower maxOutputTokens for flash-lite models + #[tokio::test] + async fn should_use_lower_max_output_tokens_for_flash_lite_models() { + generate_assert_thinking_budget( + "gemini-2.5-flash-lite", + "medium", + generate_share_of_max_output_tokens(0.3), + ) + .await; + } + } + + mod provider_options_precedence { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › providerOptions precedence › should use providerOptions thinkingConfig when both reasoning and providerOptions are set + #[tokio::test] + async fn should_use_provider_options_thinking_config_when_both_reasoning_and_provider_options_are_set() + { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ + "prompt": generate_test_prompt(), + "reasoning": "high", + "providerOptions": { "google": { "thinkingConfig": { "thinkingBudget": 999 } } }, + }), + ) + .await; + + let body = generate_only_body(&mock); + generate_assert_match_object( + &body, + &json!({ "generationConfig": { "thinkingConfig": { "thinkingBudget": 999 } } }), + ); + assert!( + body["generationConfig"]["thinkingConfig"] + .get("thinkingLevel") + .is_none() + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › providerOptions precedence › should not set thinkingConfig when neither reasoning nor providerOptions are set + #[tokio::test] + async fn should_not_set_thinking_config_when_neither_reasoning_nor_provider_options_are_set() + { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with_test_prompt(&model).await; + + let body = generate_only_body(&mock); + assert!(body["generationConfig"].get("thinkingConfig").is_none()); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › top-level reasoning option › providerOptions precedence › should not set thinkingConfig when reasoning is "provider-default" + #[tokio::test] + async fn should_not_set_thinking_config_when_reasoning_is_provider_default() { + let mock = generate_mock_with_fixture("google-text"); + let model = generate_test_provider(&mock).chat("gemini-pro"); + + generate_with( + &model, + json!({ "prompt": generate_test_prompt(), "reasoning": "provider-default" }), + ) + .await; + + let body = generate_only_body(&mock); + assert!(body["generationConfig"].get("thinkingConfig").is_none()); + } + } + } + + mod provider_metadata_key_based_on_provider_string { + use super::*; + + /// The body of the metadata key tests: one `Hello!` candidate with + /// grounding metadata, safety ratings, prompt feedback and usage. + fn generate_hello_with_grounding_body() -> Value { + json!({ + "candidates": [{ + "content": { "parts": [{ "text": "Hello!" }], "role": "model" }, + "finishReason": "STOP", + "safetyRatings": generate_safety_ratings(), + "groundingMetadata": { "webSearchQueries": ["test query"] }, + }], + "promptFeedback": { "safetyRatings": generate_safety_ratings() }, + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3 }, + }) + } + + /// A one-candidate body with `parts`, safety ratings, prompt feedback + /// and usage. + fn generate_metadata_parts_body(parts: Value) -> Value { + json!({ + "candidates": [{ + "content": { "parts": parts, "role": "model" }, + "finishReason": "STOP", + "safetyRatings": generate_safety_ratings(), + }], + "promptFeedback": { "safetyRatings": generate_safety_ratings() }, + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3 }, + }) + } + + /// Asserts `metadata[key]` has object `promptFeedback` and + /// `groundingMetadata` and an array `safetyRatings`. + fn generate_assert_response_metadata_shape(metadata: &Value, key: &str) { + let namespace = &metadata[key]; + assert!(namespace["promptFeedback"].is_object(), "{metadata}"); + assert!(namespace["groundingMetadata"].is_object(), "{metadata}"); + assert!(namespace["safetyRatings"].is_array(), "{metadata}"); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › providerMetadata key based on provider string › should use "vertex" as providerMetadata key when provider includes "vertex" + #[tokio::test] + async fn should_use_vertex_as_provider_metadata_key_when_provider_includes_vertex() { + let mock = generate_mock_with_body(&generate_hello_with_grounding_body()); + let model = generate_direct_model( + "gemini-pro", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + let result = generate_with_test_prompt(&model).await; + + let metadata = generate_json(&result.provider_metadata); + assert!(metadata.get("vertex").is_some(), "{metadata}"); + assert!(metadata.get("google").is_none(), "{metadata}"); + generate_assert_response_metadata_shape(&metadata, "vertex"); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › providerMetadata key based on provider string › should use "google" as providerMetadata key when provider does not include "vertex" + #[tokio::test] + async fn should_use_google_as_provider_metadata_key_when_provider_does_not_include_vertex() + { + let mock = generate_mock_with_body(&generate_hello_with_grounding_body()); + let model = generate_direct_model( + "gemini-pro", + "google.generative-ai", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + let result = generate_with_test_prompt(&model).await; + + let metadata = generate_json(&result.provider_metadata); + assert!(metadata.get("google").is_some(), "{metadata}"); + assert!(metadata.get("vertex").is_none(), "{metadata}"); + generate_assert_response_metadata_shape(&metadata, "google"); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › providerMetadata key based on provider string › should use "vertex" as providerMetadata key in thoughtSignature content when provider includes "vertex" + #[tokio::test] + async fn should_use_vertex_as_provider_metadata_key_in_thought_signature_content() { + let mock = generate_mock_with_body(&generate_metadata_parts_body(json!([ + { "text": "thinking...", "thought": true, "thoughtSignature": "sig123" }, + { "text": "Final answer" }, + ]))); + let model = generate_direct_model( + "gemini-pro", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + let result = generate_with_test_prompt(&model).await; + + let content = generate_json(&result.content); + let reasoning_part = content + .as_array() + .unwrap() + .iter() + .find(|part| part["type"] == "reasoning") + .expect("reasoning part"); + let metadata = &reasoning_part["providerMetadata"]; + assert!(metadata.get("vertex").is_some(), "{metadata}"); + assert!(metadata.get("google").is_none(), "{metadata}"); + generate_assert_match_object( + &metadata["vertex"], + &json!({ "thoughtSignature": "sig123" }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doGenerate › providerMetadata key based on provider string › should use "vertex" as providerMetadata key in tool call content when provider includes "vertex" + #[tokio::test] + async fn should_use_vertex_as_provider_metadata_key_in_tool_call_content() { + let mock = generate_mock_with_body(&generate_metadata_parts_body(json!([{ + "functionCall": { "name": "test-tool", "args": { "value": "test" } }, + "thoughtSignature": "tool_sig", + }]))); + let model = generate_direct_model( + "gemini-pro", + "google.vertex.chat", + &[("x-goog-api-key", "test-api-key")], + &mock, + ); + + let result = generate_with_test_prompt(&model).await; + + let content = generate_json(&result.content); + let tool_call_part = content + .as_array() + .unwrap() + .iter() + .find(|part| part["type"] == "tool-call") + .expect("tool call part"); + let metadata = &tool_call_part["providerMetadata"]; + assert!(metadata.get("vertex").is_some(), "{metadata}"); + assert!(metadata.get("google").is_none(), "{metadata}"); + generate_assert_match_object( + &metadata["vertex"], + &json!({ "thoughtSignature": "tool_sig" }), + ); + } + } +} + +mod grounding_metadata_schema { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates complete grounding metadata with web search results + #[test] + fn validates_complete_grounding_metadata_with_web_search_results() { + let metadata = json!({ + "webSearchQueries": ["What's the weather in Chicago this weekend?"], + "searchEntryPoint": { + "renderedContent": "Sample rendered content for search results", + }, + "groundingChunks": [ + { + "web": { + "uri": "https://example.com/weather", + "title": "Chicago Weather Forecast", + }, + }, + ], + "groundingSupports": [ + { + "segment": { + "startIndex": 0, + "endIndex": 65, + "text": "Chicago weather changes rapidly, so layers let you adjust easily.", + }, + "groundingChunkIndices": [0], + "confidenceScores": [0.99], + }, + ], + "retrievalMetadata": { + "webDynamicRetrievalScore": 0.96879, + }, + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates groundingChunks[].web with missing title + #[test] + fn validates_grounding_chunks_web_with_missing_title() { + let metadata = json!({ + "groundingChunks": [ + { + "web": { + // Missing `title` + "uri": "https://example.com/weather", + }, + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates complete grounding metadata with Vertex AI Search results + #[test] + fn validates_complete_grounding_metadata_with_vertex_ai_search_results() { + let metadata = json!({ + "retrievalQueries": ["How to make appointment to renew driving license?"], + "groundingChunks": [ + { + "retrievedContext": { + "uri": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/AXiHM.....QTN92V5ePQ==", + "title": "dmv", + }, + }, + ], + "groundingSupports": [ + { + "segment": { + "startIndex": 25, + "endIndex": 147, + }, + "segment_text": "ipsum lorem ...", + "supportChunkIndices": [1, 2], + "confidenceScore": [0.9541752, 0.97726375], + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates groundingChunks[].retrievedContext with missing title + #[test] + fn validates_grounding_chunks_retrieved_context_with_missing_title() { + let metadata = json!({ + "groundingChunks": [ + { + "retrievedContext": { + // Missing `title` + "uri": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/AXiHM.....QTN92V5ePQ==", + }, + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates groundingChunks[].retrievedContext with fileSearchStore (new format) + #[test] + fn validates_grounding_chunks_retrieved_context_with_file_search_store_new_format() { + let metadata = json!({ + "groundingChunks": [ + { + "retrievedContext": { + "text": "Sample content for testing...", + "fileSearchStore": "fileSearchStores/test-store-xyz", + "title": "Test Document", + }, + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates groundingChunks[].retrievedContext with fileSearchStore and missing uri + #[test] + fn validates_grounding_chunks_retrieved_context_with_file_search_store_and_missing_uri() { + let metadata = json!({ + "groundingChunks": [ + { + "retrievedContext": { + "text": "Content without uri field", + "fileSearchStore": "fileSearchStores/store-abc", + // Missing `uri` - should still be valid + }, + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates groundingChunks[].image + #[test] + fn validates_grounding_chunks_image() { + let metadata = json!({ + "imageSearchQueries": ["Super Bowl halftime show in space"], + "groundingChunks": [ + { + "image": { + "sourceUri": "https://example.com/article", + "imageUri": "https://example.com/image.jpg", + "title": "Image Title", + "domain": "example.com", + }, + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates groundingChunks[].image with missing optional fields + #[test] + fn validates_grounding_chunks_image_with_missing_optional_fields() { + let metadata = json!({ + "groundingChunks": [ + { + "image": { + "sourceUri": "https://example.com/article", + "imageUri": "https://example.com/image.jpg", + }, + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates partial grounding metadata + #[test] + fn validates_partial_grounding_metadata() { + let metadata = json!({ + "webSearchQueries": ["sample query"], + // Missing other optional fields + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates empty grounding metadata + #[test] + fn validates_empty_grounding_metadata() { + let metadata = json!({}); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates grounding metadata with maps chunks + #[test] + fn validates_grounding_metadata_with_maps_chunks() { + let metadata = json!({ + "groundingChunks": [ + { + "maps": { + "uri": "https://maps.google.com/maps?cid=12345", + "title": "Best Italian Restaurant", + "text": "A great Italian restaurant", + "placeId": "ChIJ12345", + }, + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates groundingChunks[].maps with missing optional fields + #[test] + fn validates_grounding_chunks_maps_with_missing_optional_fields() { + let metadata = json!({ + "groundingChunks": [ + { + "maps": { + "uri": "https://maps.google.com/maps?cid=12345", + }, + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates metadata with empty retrievalMetadata + #[test] + fn validates_metadata_with_empty_retrieval_metadata() { + let metadata = json!({ + "webSearchQueries": ["sample query"], + "retrievalMetadata": {}, + }); + + assert!(grounding_metadata_parses(metadata)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › groundingMetadataSchema › validates groundingSupports with missing segment + #[test] + fn validates_grounding_supports_with_missing_segment() { + let metadata = json!({ + "groundingSupports": [ + { + // Missing `segment` + "groundingChunkIndices": [0], + }, + ], + }); + + assert!(grounding_metadata_parses(metadata)); + } +} + +mod url_context_metadata { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › urlContextMetadata › validates complete url context output + #[test] + fn validates_complete_url_context_output() { + let output = json!({ + "urlMetadata": [ + { + "retrievedUrl": "https://example.com/weather", + "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS", + }, + ], + }); + + assert!(url_context_metadata_parses(output)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › urlContextMetadata › validates empty url context output + #[test] + fn validates_empty_url_context_output() { + let output = json!({ "urlMetadata": [] }); + + assert!(url_context_metadata_parses(output)); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › urlContextMetadata › validates empty object without urlMetadata key + #[test] + fn validates_empty_object_without_url_metadata_key() { + let output = json!({}); + + assert!(url_context_metadata_parses(output)); + } +} + +mod do_stream { + use super::*; + + mod text { + use super::*; + + fn text_fixture_mock() -> Arc { + sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-text.chunks.txt" + ))) + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › text › should stream text deltas + #[tokio::test] + async fn should_stream_text_deltas() { + let mock = text_fixture_mock(); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + + assert_eq!( + Value::Array(events), + stream_snapshot(include_str!( + "snapshots/do_stream_text_stream_text_deltas.json" + )) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › text › should include raw chunks when includeRawChunks is enabled + #[tokio::test] + async fn should_include_raw_chunks_when_include_raw_chunks_is_enabled() { + let mock = text_fixture_mock(); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let chunks = stream_events_of(&model, json!({ "includeRawChunks": true })).await; + + assert_eq!( + Value::Array(stream_events_of_type(&chunks, &["raw"])), + stream_snapshot(include_str!("snapshots/do_stream_text_raw_chunks.json")) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › text › should not include raw chunks when includeRawChunks is false + #[tokio::test] + async fn should_not_include_raw_chunks_when_include_raw_chunks_is_false() { + let mock = text_fixture_mock(); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let chunks = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + + assert_eq!(stream_events_of_type(&chunks, &["raw"]).len(), 0); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › text › should emit response-metadata with the provider responseId + #[tokio::test] + async fn should_emit_response_metadata_with_the_provider_response_id() { + // Gemini repeats `responseId` on every chunk; the provider emits a single + // response-metadata part carrying it (see google-text.chunks.txt fixture). + let mock = text_fixture_mock(); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let response_metadata = stream_events_of_type(&events, &["response-metadata"]); + + assert_eq!( + Value::Array(response_metadata), + json!([{ "type": "response-metadata", "id": "bH6LaZW8Fp_3nsEPqtaSwQ4" }]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › text › should expose the raw response headers + #[tokio::test] + async fn should_expose_the_raw_response_headers() { + let mock = MockFetch::new(vec![sse_stream_response( + sse_fixture_chunks(include_str!("fixtures/google-text.chunks.txt")), + &[("test-header", "test-value")], + )]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let result = model + .do_stream(&stream_call_options_with( + json!({ "includeRawChunks": false }), + )) + .await + .expect("doStream succeeds"); + + assert_eq!( + serde_json::to_value(result.response.and_then(|response| response.headers)) + .unwrap(), + json!({ + "cache-control": "no-cache", + "connection": "keep-alive", + "content-type": "text/event-stream", + "test-header": "test-value", + }) + ); + } + } + + mod tool_call { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › tool-call › should stream tool call + #[tokio::test] + async fn should_stream_tool_call() { + let mock = sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-tool-call.chunks.txt" + ))); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of( + &model, + json!({ + "tools": [stream_string_function_tool("weather", "location")], + "includeRawChunks": false, + }), + ) + .await; + + assert_eq!( + Value::Array(events), + stream_snapshot(include_str!("snapshots/do_stream_tool_call.json")) + ); + } + } + + mod reasoning { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › reasoning › should stream reasoning and text parts + #[tokio::test] + async fn should_stream_reasoning_and_text_parts() { + let mock = sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-reasoning.chunks.txt" + ))); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + + assert_eq!( + Value::Array(events), + stream_snapshot(include_str!("snapshots/do_stream_reasoning.json")) + ); + } + } + + mod reasoning_gemini3 { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › reasoning-gemini3 › should stream reasoning with thoughtSignature + #[tokio::test] + async fn should_stream_reasoning_with_thought_signature() { + let mock = sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-reasoning-gemini3.chunks.txt" + ))); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + + assert_eq!( + Value::Array(events), + stream_snapshot(include_str!("snapshots/do_stream_reasoning_gemini3.json")) + ); + } + } + + mod tool_call_gemini3 { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › tool-call-gemini3 › should stream tool call with thoughtSignature + #[tokio::test] + async fn should_stream_tool_call_with_thought_signature() { + let mock = sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-tool-call-gemini3.chunks.txt" + ))); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + + assert_eq!( + Value::Array(events), + stream_snapshot(include_str!("snapshots/do_stream_tool_call_gemini3.json")) + ); + } + } + + mod streaming_tool_call_arguments { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › streaming-tool-call-arguments › should stream partial function call arguments with parallel tool calls + #[tokio::test] + async fn should_stream_partial_function_call_arguments_with_parallel_tool_calls() { + let mock = sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-stream-tool-call-arguments.chunks.txt" + ))); + let vertex_model = vertex_stream_test_model("gemini-pro", &mock); + + let events = stream_events_of( + &vertex_model, + json!({ + "includeRawChunks": false, + "tools": [stream_string_function_tool("getWeather", "location")], + }), + ) + .await; + + assert_eq!( + Value::Array(events), + stream_snapshot(include_str!( + "snapshots/do_stream_streaming_tool_call_arguments.json" + )) + ); + } + } + + mod streaming_tool_call_array_arguments { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › streaming-tool-call-array-arguments › should finalize streamed function call arguments when the final partialArgs chunk omits willContinue + #[tokio::test] + async fn should_finalize_streamed_arguments_when_final_partial_args_chunk_omits_will_continue() + { + let mock = sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-stream-tool-call-array-arguments-missing-terminal-function-call.chunks.txt" + ))); + let vertex_model = vertex_stream_test_model("gemini-pro", &mock); + + let events = stream_events_of( + &vertex_model, + json!({ + "includeRawChunks": false, + "tools": [ + { + "type": "function", + "name": "writeItems", + "inputSchema": { + "type": "object", + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { "type": "string" }, + "description": { "type": "string" }, + "itemid": { "type": "string" }, + "price": { "type": "number" }, + }, + "required": ["action", "description", "itemid", "price"], + "additionalProperties": false, + }, + }, + }, + "required": ["operations"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }, + }, + ], + "providerOptions": { + "vertex": { + "streamFunctionCallArguments": true, + }, + }, + }), + ) + .await; + let input_deltas = stream_events_of_type(&events, &["tool-input-delta"]); + let tool_calls = stream_events_of_type(&events, &["tool-call"]); + let finish = stream_finish_event(&events); + + assert_eq!( + input_deltas + .iter() + .map(|event| event["delta"].as_str().unwrap()) + .collect::(), + r#"{"operations":[{"action":"add","description":"Fresh red apple","itemid":"apple_001","price":0.5},{"action":"add","description":"Ripe yellow banana","itemid":"banana_001","price":0.3}]}"# + ); + assert_eq!( + tool_calls + .iter() + .map(|call| json!({ "toolName": call["toolName"], "input": call["input"] })) + .collect::>(), + vec![json!({ + "input": r#"{"operations":[{"action":"add","description":"Fresh red apple","itemid":"apple_001","price":0.5},{"action":"add","description":"Ripe yellow banana","itemid":"banana_001","price":0.3}]}"#, + "toolName": "writeItems", + })] + ); + assert_eq!(finish["finishReason"]["unified"], "tool-calls"); + } + } + + mod streaming_no_args_tool_call { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › streaming-no-args-tool-call › should emit no-args function calls and preserve thoughtSignature alongside streamed-args calls + #[tokio::test] + async fn should_emit_no_args_calls_and_preserve_thought_signature_with_streamed_args_calls() + { + let mock = sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-stream-no-args-tool-call.chunks.txt" + ))); + let vertex_model = vertex_stream_test_model("gemini-pro", &mock); + + let events = stream_events_of( + &vertex_model, + json!({ + "includeRawChunks": false, + "tools": [ + { + "type": "function", + "name": "read_theme", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + }, + }, + stream_string_function_tool("read_screen", "id"), + ], + "providerOptions": { + "vertex": { + "streamFunctionCallArguments": true, + }, + }, + }), + ) + .await; + + // The fixture is a real Vertex recording where the model picked the + // no-args `read_theme` first (carrying the response's only + // thoughtSignature), then streamed three parallel `read_screen` calls + // for A, B, C. Pre-fix the read_theme call and its signature were + // dropped entirely. + let tool_calls = stream_events_of_type(&events, &["tool-call"]); + assert_eq!( + tool_calls + .iter() + .map(|call| json!({ "toolName": call["toolName"], "input": call["input"] })) + .collect::>(), + vec![ + json!({ "toolName": "read_theme", "input": "{}" }), + json!({ "toolName": "read_screen", "input": r#"{"id":"A"}"# }), + json!({ "toolName": "read_screen", "input": r#"{"id":"B"}"# }), + json!({ "toolName": "read_screen", "input": r#"{"id":"C"}"# }), + ] + ); + + let read_theme_call = tool_calls + .iter() + .find(|call| call["toolName"] == "read_theme") + .unwrap(); + let signature = &read_theme_call["providerMetadata"]["vertex"]["thoughtSignature"]; + assert!(signature.is_string()); + assert!(signature.as_str().unwrap().len() > 100); + assert_eq!( + &read_theme_call["providerMetadata"]["googleVertex"]["thoughtSignature"], + signature + ); + + // The no-args call must emit start/end framing too, so the signature + // is exposed at every stage downstream consumers might inspect. + let tool_input_starts = stream_events_of_type(&events, &["tool-input-start"]); + let no_args_start = tool_input_starts + .iter() + .find(|event| event["toolName"] == "read_theme"); + assert_eq!( + no_args_start.map(|event| &event["providerMetadata"]["vertex"]["thoughtSignature"]), + Some(signature) + ); + + let tool_input_ends = stream_events_of_type(&events, &["tool-input-end"]); + let no_args_end = tool_input_ends.iter().find(|event| { + &event["providerMetadata"]["vertex"]["thoughtSignature"] == signature + }); + assert!(no_args_end.is_some()); + + // The no-args branch must not emit any tool-input-delta events. + let deltas = stream_events_of_type(&events, &["tool-input-delta"]); + assert!(deltas.iter().all( + |event| &event["providerMetadata"]["vertex"]["thoughtSignature"] != signature + )); + } + } + + mod streaming_tool_call_arguments_nested { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › streaming-tool-call-arguments-nested › should stream nested partial function call arguments into proper nested JSON + #[tokio::test] + async fn should_stream_nested_partial_function_call_arguments_into_proper_nested_json() { + let mock = sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-vertex-stream-tool-call-arguments-nested.1.chunks.txt" + ))); + let vertex_model = vertex_stream_test_model("gemini-pro", &mock); + + let events = stream_events_of( + &vertex_model, + json!({ + "includeRawChunks": false, + "tools": [ + { + "type": "function", + "name": "cookRecipe", + "inputSchema": { + "type": "object", + "properties": { + "recipe": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "ingredients": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "amount": { "type": "string" }, + }, + }, + }, + "steps": { "type": "array", "items": { "type": "string" } }, + }, + }, + }, + }, + }, + ], + }), + ) + .await; + + assert_eq!( + Value::Array(events), + stream_snapshot(include_str!( + "snapshots/do_stream_streaming_tool_call_arguments_nested.json" + )) + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should expose grounding metadata in provider metadata on finish + #[tokio::test] + async fn should_expose_grounding_metadata_in_provider_metadata_on_finish() { + let mock = sse_stream_mock(sse_text_chunks( + &["test"], + Some(json!({ + "webSearchQueries": ["What's the weather in Chicago this weekend?"], + "searchEntryPoint": { + "renderedContent": "Sample rendered content for search results", + }, + "groundingChunks": [ + { + "web": { + "uri": "https://example.com/weather", + "title": "Chicago Weather Forecast", + }, + }, + ], + "groundingSupports": [ + { + "segment": { + "startIndex": 0, + "endIndex": 65, + "text": "Chicago weather changes rapidly, so layers let you adjust easily.", + }, + "groundingChunkIndices": [0], + "confidenceScores": [0.99], + }, + ], + "retrievalMetadata": { + "webDynamicRetrievalScore": 0.96879, + }, + })), + None, + )); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"]["groundingMetadata"], + json!({ + "groundingChunks": [ + { + "web": { + "title": "Chicago Weather Forecast", + "uri": "https://example.com/weather" + } + } + ], + "groundingSupports": [ + { + "confidenceScores": [ + 0.99 + ], + "groundingChunkIndices": [ + 0 + ], + "segment": { + "endIndex": 65, + "startIndex": 0, + "text": "Chicago weather changes rapidly, so layers let you adjust easily." + } + } + ], + "retrievalMetadata": { + "webDynamicRetrievalScore": 0.96879 + }, + "searchEntryPoint": { + "renderedContent": "Sample rendered content for search results" + }, + "webSearchQueries": [ + "What's the weather in Chicago this weekend?" + ] + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should preserve grounding metadata when it arrives before the finishReason chunk + #[tokio::test] + async fn should_preserve_grounding_metadata_when_it_arrives_before_the_finish_reason_chunk() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "hello" }], "role": "model" }, + "index": 0, + "groundingMetadata": { + "webSearchQueries": ["super bowl 2026 halftime show"], + "groundingChunks": [ + { + "web": { + "uri": "https://example.com/superbowl", + "title": "Super Bowl 2026", + }, + }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": " world" }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + }, + ], + "usageMetadata": { + "promptTokenCount": 38, + "candidatesTokenCount": 1335, + "totalTokenCount": 1890, + }, + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"]["groundingMetadata"], + json!({ + "webSearchQueries": ["super bowl 2026 halftime show"], + "groundingChunks": [ + { + "web": { + "uri": "https://example.com/superbowl", + "title": "Super Bowl 2026", + }, + }, + ], + }) + ); + + assert_eq!(stream_events_of_type(&events, &["source"]).len(), 1); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should preserve url context metadata when it arrives before the finishReason chunk + #[tokio::test] + async fn should_preserve_url_context_metadata_when_it_arrives_before_the_finish_reason_chunk() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "hello" }], "role": "model" }, + "index": 0, + "urlContextMetadata": { + "urlMetadata": [ + { + "retrievedUrl": "https://example.com/page", + "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS", + }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": " world" }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + }, + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 5, + "totalTokenCount": 15, + }, + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"]["urlContextMetadata"], + json!({ + "urlMetadata": [ + { + "retrievedUrl": "https://example.com/page", + "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS", + }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should expose url context metadata in provider metadata on finish + #[tokio::test] + async fn should_expose_url_context_metadata_in_provider_metadata_on_finish() { + let mock = sse_stream_mock(sse_text_chunks( + &["test"], + None, + Some(json!({ + "urlMetadata": [ + { + "retrievedUrl": "https://example.com/weather", + "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS", + }, + ], + })), + )); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"]["urlContextMetadata"], + json!({ + "urlMetadata": [ + { + "retrievedUrl": "https://example.com/weather", + "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS" + } + ] + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should expose safety ratings in provider metadata on finish + #[tokio::test] + async fn should_expose_safety_ratings_in_provider_metadata_on_finish() { + let mock = sse_stream_mock(vec![ + concat!( + r#"data: {"candidates": [{"content": {"parts": [{"text": "test"}],"role": "model"},"#, + r#""finishReason": "STOP","index": 0,"safetyRatings": ["#, + r#"{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","#, + r#""probabilityScore": 0.1,"severity": "LOW","severityScore": 0.2,"blocked": false}]}]}"#, + "\n\n" + ) + .to_owned(), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"]["safetyRatings"], + json!([ + { + "blocked": false, + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE", + "probabilityScore": 0.1, + "severity": "LOW", + "severityScore": 0.2 + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should expose PromptFeedback in provider metadata on finish + #[tokio::test] + async fn should_expose_prompt_feedback_in_provider_metadata_on_finish() { + let mock = sse_stream_mock(vec![ + concat!( + r#"data: {"candidates": [{"content": {"parts": [{"text": "No"}],"role": "model"},"#, + r#""finishReason": "PROHIBITED_CONTENT","index": 0}],"#, + r#""promptFeedback": {"blockReason": "PROHIBITED_CONTENT","safetyRatings": ["#, + r#"{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},"#, + r#"{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},"#, + r#"{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},"#, + r#"{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}}"#, + "\n\n" + ) + .to_owned(), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({})).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"]["promptFeedback"], + json!({ + "blockReason": "PROHIBITED_CONTENT", + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should surface streamed prompt-level blocks without candidates + #[tokio::test] + async fn should_surface_streamed_prompt_level_blocks_without_candidates() { + let mock = sse_stream_mock(vec![sse_data_chunk(&json!({ + "promptFeedback": { + "blockReason": "PROHIBITED_CONTENT", + }, + "usageMetadata": { + "promptTokenCount": 9, + "totalTokenCount": 9, + "serviceTier": "standard", + }, + "responseId": "blocked-response-id", + }))]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({})).await; + + assert!(events.contains(&json!({ + "type": "response-metadata", + "id": "blocked-response-id", + }))); + assert_stream_match_object( + &stream_finish_event(&events), + &json!({ + "type": "finish", + "finishReason": { + "unified": "content-filter", + "raw": "PROHIBITED_CONTENT", + }, + "providerMetadata": { + "google": { + "promptFeedback": { + "blockReason": "PROHIBITED_CONTENT", + }, + "groundingMetadata": null, + "urlContextMetadata": null, + "safetyRatings": null, + "usageMetadata": { + "promptTokenCount": 9, + "totalTokenCount": 9, + "serviceTier": "standard", + }, + "finishMessage": null, + "serviceTier": "standard", + }, + }, + }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should not classify the default prompt block reason %j as a content filter + #[tokio::test] + async fn should_not_classify_the_default_prompt_block_reason_as_a_content_filter() { + for block_reason in ["", "BLOCK_REASON_UNSPECIFIED", "BLOCKED_REASON_UNSPECIFIED"] { + let mock = sse_stream_mock(vec![sse_data_chunk(&json!({ + "candidates": [], + "promptFeedback": { "blockReason": block_reason }, + "usageMetadata": { + "promptTokenCount": 9, + "totalTokenCount": 9, + }, + }))]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let finish_event = stream_finish_event(&stream_events_of(&model, json!({})).await); + + assert_eq!( + finish_event["finishReason"], + json!({ "unified": "other" }), + "{block_reason:?}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should preserve prompt feedback and trailing usage from separate chunks + #[tokio::test] + async fn should_preserve_prompt_feedback_and_trailing_usage_from_separate_chunks() { + let prompt_feedback = json!({ + "blockReason": "BLOCK_REASON_UNSPECIFIED", + "safetyRatings": [], + }); + let usage_metadata = json!({ + "promptTokenCount": 10, + "candidatesTokenCount": 3, + "totalTokenCount": 13, + }); + + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ "promptFeedback": prompt_feedback })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "role": "model", + "parts": [{ "text": "Fixture text." }], + }, + "finishReason": "STOP", + }, + ], + })), + sse_data_chunk(&json!({ "usageMetadata": usage_metadata })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let finish_event = stream_finish_event(&stream_events_of(&model, json!({})).await); + + assert_stream_match_object( + &finish_event, + &json!({ + "type": "finish", + "finishReason": { + "unified": "stop", + "raw": "STOP", + }, + "usage": { + "outputTokens": { + "total": 3, + }, + }, + "providerMetadata": { + "google": { + "promptFeedback": prompt_feedback, + "usageMetadata": usage_metadata, + }, + }, + }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should keep a confirmed prompt block terminal across later chunks + #[tokio::test] + async fn should_keep_a_confirmed_prompt_block_terminal_across_later_chunks() { + let usage_metadata = json!({ + "promptTokenCount": 10, + "candidatesTokenCount": 0, + "totalTokenCount": 10, + }); + + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "promptFeedback": { "blockReason": "SAFETY" }, + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "role": "model", + "parts": [{ "text": "Fixture text." }], + }, + "finishReason": "STOP", + }, + ], + })), + sse_data_chunk(&json!({ + "promptFeedback": { + "blockReason": "BLOCK_REASON_UNSPECIFIED", + }, + "usageMetadata": usage_metadata, + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({})).await; + + assert_eq!( + stream_events_of_type(&events, &["text-delta"]), + Vec::::new() + ); + assert_stream_match_object( + &stream_finish_event(&events), + &json!({ + "type": "finish", + "finishReason": { + "unified": "content-filter", + "raw": "SAFETY", + }, + "providerMetadata": { + "google": { + "promptFeedback": { "blockReason": "SAFETY" }, + "usageMetadata": usage_metadata, + }, + }, + }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should expose finishMessage in provider metadata on finish + #[tokio::test] + async fn should_expose_finish_message_in_provider_metadata_on_finish() { + let mock = sse_stream_mock(vec![sse_data_chunk(&json!({ + "candidates": [ + { + "content": {}, + "finishReason": "MALFORMED_FUNCTION_CALL", + "finishMessage": "Malformed function call: print(default_api.create(name='test'))", + }, + ], + "usageMetadata": { + "promptTokenCount": 130, + "totalTokenCount": 130, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 130 }], + }, + }))]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({})).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"]["finishMessage"], + "Malformed function call: print(default_api.create(name='test'))" + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should expose null finishMessage in provider metadata on finish when not present + #[tokio::test] + async fn should_expose_null_finish_message_in_provider_metadata_on_finish_when_not_present() { + let mock = sse_stream_mock(sse_text_chunks(&["test"], None, None)); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({})).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"].get("finishMessage"), + Some(&Value::Null) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should read serviceTier from chunk usageMetadata + #[tokio::test] + async fn should_read_service_tier_from_chunk_usage_metadata() { + // Gemini streaming does not set the x-gemini-service-tier response header + // but does include serviceTier in each chunk's usageMetadata. + let mock = sse_stream_mock(vec![sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "Blue" }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + }, + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 1, + "totalTokenCount": 11, + "serviceTier": "priority", + }, + }))]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({})).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"]["serviceTier"], + "priority" + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should preserve the final complete raw usage metadata + #[tokio::test] + async fn should_preserve_the_final_complete_raw_usage_metadata() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "Blue" }], "role": "model" }, + }, + ], + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 1, + "totalTokenCount": 2, + }, + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "." }], "role": "model" }, + "finishReason": "STOP", + }, + ], + "usageMetadata": stream_complete_usage_metadata(), + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let finish_event = stream_finish_event(&stream_events_of(&model, json!({})).await); + + assert_eq!( + finish_event["usage"], + json!({ + "inputTokens": { + "total": 77, + "noCache": 73, + "cacheRead": 4, + }, + "outputTokens": { + "total": 160, + "text": 71, + "reasoning": 89, + }, + "raw": stream_complete_usage_metadata(), + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should expose null serviceTier in provider metadata on finish when not present + #[tokio::test] + async fn should_expose_null_service_tier_in_provider_metadata_on_finish_when_not_present() { + let mock = sse_stream_mock(sse_text_chunks(&["test"], None, None)); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({})).await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["providerMetadata"]["google"].get("serviceTier"), + Some(&Value::Null) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream code execution tool calls and results + #[tokio::test] + async fn should_stream_code_execution_tool_calls_and_results() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "executableCode": { + "language": "PYTHON", + "code": "print(\"hello\")", + }, + }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + "output": "hello\n", + }, + }, + ], + }, + "finishReason": "STOP", + }, + ], + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-2.0-pro"); + + let events = stream_events_of( + &model, + json!({ "tools": [stream_provider_tool("google.code_execution", "code_execution")] }), + ) + .await; + + let tool_events = stream_events_of_type(&events, &["tool-call", "tool-result"]); + + assert_eq!( + Value::Array(tool_events), + json!([ + { + "input": "{\"language\":\"PYTHON\",\"code\":\"print(\\\"hello\\\")\"}", + "providerExecuted": true, + "toolCallId": "test-id", + "toolName": "code_execution", + "type": "tool-call" + }, + { + "result": { + "outcome": "OUTCOME_OK", + "output": "hello\n" + }, + "toolCallId": "test-id", + "toolName": "code_execution", + "type": "tool-result" + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream code execution result with missing output field + #[tokio::test] + async fn should_stream_code_execution_result_with_missing_output_field() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "executableCode": { + "language": "PYTHON", + "code": "img = PIL.Image.open('input.png')\nimg.save('output.png')\n", + }, + }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + }, + }, + ], + }, + "finishReason": "STOP", + }, + ], + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-2.0-pro"); + + let events = stream_events_of( + &model, + json!({ "tools": [stream_provider_tool("google.code_execution", "code_execution")] }), + ) + .await; + + let tool_events = stream_events_of_type(&events, &["tool-call", "tool-result"]); + + assert_eq!( + Value::Array(tool_events), + json!([ + { + "input": "{\"language\":\"PYTHON\",\"code\":\"img = PIL.Image.open('input.png')\\nimg.save('output.png')\\n\"}", + "providerExecuted": true, + "toolCallId": "test-id", + "toolName": "code_execution", + "type": "tool-call" + }, + { + "result": { + "outcome": "OUTCOME_OK", + "output": "" + }, + "toolCallId": "test-id", + "toolName": "code_execution", + "type": "tool-result" + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should return stop finish reason for streamed code execution (provider-executed tool) + #[tokio::test] + async fn should_return_stop_finish_reason_for_streamed_code_execution_provider_executed_tool() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "executableCode": { + "language": "PYTHON", + "code": "print(\"hello\")", + }, + }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + "output": "hello\n", + }, + }, + { + "text": "{\"result\": \"hello\"}", + }, + ], + }, + "finishReason": "STOP", + }, + ], + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-2.0-pro"); + + let events = stream_events_of( + &model, + json!({ "tools": [stream_provider_tool("google.code_execution", "code_execution")] }), + ) + .await; + + let finish_event = stream_finish_event(&events); + + // Provider-executed tools should not trigger 'tool-calls' finish reason + // since they don't require SDK iteration - allows structured output to work + assert_stream_match_object( + &finish_event, + &json!({ + "type": "finish", + "finishReason": { + "raw": "STOP", + "unified": "stop", + }, + }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream server-side toolCall and toolResponse parts (tool combination) + #[tokio::test] + async fn should_stream_server_side_tool_call_and_tool_response_parts_tool_combination() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "toolCall": { + "toolType": "GOOGLE_SEARCH_WEB", + "args": { "query": "SF weather" }, + "id": "sc-1", + }, + "thoughtSignature": "sig-1", + }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "toolResponse": { + "toolType": "GOOGLE_SEARCH_WEB", + "response": { "results": [{ "title": "Weather" }] }, + "id": "sc-1", + }, + }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [{ "text": "It is sunny." }], + }, + "finishReason": "STOP", + }, + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 20, + "totalTokenCount": 30, + }, + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-3-pro-preview"); + + let events = stream_events_of( + &model, + json!({ "tools": [stream_provider_tool("google.google_search", "google_search")] }), + ) + .await; + + let tool_events = stream_events_of_type(&events, &["tool-call", "tool-result"]); + + assert_eq!( + Value::Array(tool_events), + json!([ + { + "dynamic": true, + "input": "{\"query\":\"SF weather\"}", + "providerExecuted": true, + "providerMetadata": { + "google": { + "serverToolCallId": "sc-1", + "serverToolType": "GOOGLE_SEARCH_WEB", + "thoughtSignature": "sig-1" + } + }, + "toolCallId": "sc-1", + "toolName": "server:GOOGLE_SEARCH_WEB", + "type": "tool-call" + }, + { + "providerMetadata": { + "google": { + "serverToolCallId": "sc-1", + "serverToolType": "GOOGLE_SEARCH_WEB" + } + }, + "result": { + "results": [ + { + "title": "Weather" + } + ] + }, + "toolCallId": "sc-1", + "toolName": "server:GOOGLE_SEARCH_WEB", + "type": "tool-result" + } + ]) + ); + + let text_events = stream_events_of_type(&events, &["text-delta"]); + assert_eq!( + Value::Array(text_events), + json!([ + { + "delta": "It is sunny.", + "id": "0", + "type": "text-delta" + } + ]) + ); + + let finish_event = stream_finish_event(&events); + assert_stream_match_object( + &finish_event, + &json!({ + "type": "finish", + "finishReason": { + "raw": "STOP", + "unified": "stop", + }, + }), + ); + } + + mod search_tool_selection { + use super::*; + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › search tool selection › should use googleSearch for gemini-2.0-pro + #[tokio::test] + async fn should_use_google_search_for_gemini_2_0_pro() { + let mock = sse_stream_mock(sse_text_chunks(&[""], None, None)); + + let gemini2_pro = stream_test_provider(&mock).chat("gemini-2.0-pro"); + gemini2_pro + .do_stream(&stream_call_options_with(json!({ + "includeRawChunks": false, + "tools": [stream_provider_tool("google.google_search", "google_search")], + }))) + .await + .expect("doStream succeeds"); + + assert_stream_match_object( + &stream_request_body(&mock, 0), + &json!({ "tools": [{ "googleSearch": {} }] }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › search tool selection › should use googleSearch for gemini-2.0-flash-exp + #[tokio::test] + async fn should_use_google_search_for_gemini_2_0_flash_exp() { + let mock = sse_stream_mock(sse_text_chunks(&[""], None, None)); + + let gemini2_flash = stream_test_provider(&mock).chat("gemini-2.0-flash-exp"); + + gemini2_flash + .do_stream(&stream_call_options_with(json!({ + "includeRawChunks": false, + "tools": [stream_provider_tool("google.google_search", "google_search")], + }))) + .await + .expect("doStream succeeds"); + + assert_stream_match_object( + &stream_request_body(&mock, 0), + &json!({ "tools": [{ "googleSearch": {} }] }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › search tool selection › should warn for google search on non-gemini-2 models + #[tokio::test] + async fn should_warn_for_google_search_on_non_gemini_2_models() { + let mock = sse_stream_mock(sse_text_chunks(&[""], None, None)); + + let gemini_pro = stream_test_provider(&mock).chat("gemini-1.0-pro"); + let events = stream_events_of( + &gemini_pro, + json!({ + "includeRawChunks": false, + "tools": [stream_provider_tool("google.google_search", "google_search")], + }), + ) + .await; + + assert_eq!( + events[0], + json!({ + "type": "stream-start", + "warnings": [ + { + "details": "Google Search requires Gemini 2.0 or newer.", + "feature": "provider-defined tool google.google_search", + "type": "unsupported" + } + ] + }) + ); + } + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream source events + #[tokio::test] + async fn should_stream_source_events() { + let mock = sse_stream_mock(sse_text_chunks( + &["Some initial text"], + Some(json!({ + "groundingChunks": [ + { + "web": { + "uri": "https://source.example.com", + "title": "Source Title", + }, + }, + ], + })), + None, + )); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let source_events = stream_events_of_type(&events, &["source"]); + + assert_eq!( + Value::Array(source_events), + json!([ + { + "id": "test-id", + "sourceType": "url", + "title": "Source Title", + "type": "source", + "url": "https://source.example.com" + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream source events from image grounding metadata + #[tokio::test] + async fn should_stream_source_events_from_image_grounding_metadata() { + let mock = sse_stream_mock(sse_text_chunks( + &["image search response"], + Some(json!({ + "groundingChunks": [ + { + "image": { + "sourceUri": "https://example.com/article", + "imageUri": "https://example.com/image.jpg", + "title": "Image Source", + "domain": "example.com", + }, + }, + ], + })), + None, + )); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let source_events = stream_events_of_type(&events, &["source"]); + + assert_eq!( + Value::Array(source_events), + json!([ + { + "id": "test-id", + "sourceType": "url", + "title": "Image Source", + "type": "source", + "url": "https://example.com/article" + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream sources during intermediate chunks + #[tokio::test] + async fn should_stream_sources_during_intermediate_chunks() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "text" }], "role": "model" }, + "index": 0, + "safetyRatings": stream_safety_ratings(), + "groundingMetadata": { + "groundingChunks": [ + { "web": { "uri": "https://a.com", "title": "A" } }, + { "web": { "uri": "https://b.com", "title": "B" } }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "more" }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": stream_safety_ratings(), + }, + ], + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let source_events = stream_events_of_type(&events, &["source"]); + + assert_eq!( + Value::Array(source_events), + json!([ + { + "id": "test-id", + "sourceType": "url", + "title": "A", + "type": "source", + "url": "https://a.com" + }, + { + "id": "test-id", + "sourceType": "url", + "title": "B", + "type": "source", + "url": "https://b.com" + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should deduplicate sources across chunks + #[tokio::test] + async fn should_deduplicate_sources_across_chunks() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "first chunk" }], "role": "model" }, + "index": 0, + "safetyRatings": stream_safety_ratings(), + "groundingMetadata": { + "groundingChunks": [ + { "web": { "uri": "https://example.com", "title": "Example" } }, + { "web": { "uri": "https://unique.com", "title": "Unique" } }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "second chunk" }], "role": "model" }, + "index": 0, + "safetyRatings": stream_safety_ratings(), + "groundingMetadata": { + "groundingChunks": [ + { + "web": { + "uri": "https://example.com", + "title": "Example Duplicate", + }, + }, + { "web": { "uri": "https://another.com", "title": "Another" } }, + ], + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "final chunk" }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": stream_safety_ratings(), + }, + ], + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + let source_events = stream_events_of_type(&events, &["source"]); + + assert_eq!( + Value::Array(source_events), + json!([ + { + "id": "test-id", + "sourceType": "url", + "title": "Example", + "type": "source", + "url": "https://example.com" + }, + { + "id": "test-id", + "sourceType": "url", + "title": "Unique", + "type": "source", + "url": "https://unique.com" + }, + { + "id": "test-id", + "sourceType": "url", + "title": "Another", + "type": "source", + "url": "https://another.com" + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream files + #[tokio::test] + async fn should_stream_files() { + let mock = sse_stream_mock(vec![ + concat!( + r#"data: {"candidates": [{"content": {"parts": [{"inlineData": {"data": "test","mimeType": "text/plain"}}]"#, + r#","role": "model"},"#, + r#""finishReason": "STOP","index": 0,"safetyRatings": ["#, + r#"{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},"#, + r#"{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},"#, + r#"{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},"#, + r#"{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]}"#, + "\n\n" + ) + .to_owned(), + concat!( + r#"data: {"usageMetadata": {"promptTokenCount": 294,"candidatesTokenCount": 233,"totalTokenCount": 527}}"#, + "\n\n" + ) + .to_owned(), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + + assert_eq!( + Value::Array(events), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "data": { + "data": "test", + "type": "data" + }, + "mediaType": "text/plain", + "type": "file" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "stop" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 233, + "promptTokenCount": 294, + "totalTokenCount": 527 + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 294, + "total": 294 + }, + "outputTokens": { + "reasoning": 0, + "text": 233, + "total": 233 + }, + "raw": { + "candidatesTokenCount": 233, + "promptTokenCount": 294, + "totalTokenCount": 527 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream file parts with thought flag in providerMetadata + #[tokio::test] + async fn should_stream_file_parts_with_thought_flag_in_provider_metadata() { + let mock = sse_stream_mock(vec![sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "inlineData": { "data": "thoughtimg", "mimeType": "image/png" }, + "thought": true, + "thoughtSignature": "stream_sig", + }, + { + "inlineData": { + "data": "regularimg", + "mimeType": "image/jpeg", + }, + }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": stream_safety_ratings(), + }, + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 20, + "totalTokenCount": 30, + }, + }))]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + + let file_events = stream_events_of_type(&events, &["file", "reasoning-file"]); + assert_eq!( + Value::Array(file_events), + json!([ + { + "data": { + "data": "thoughtimg", + "type": "data" + }, + "mediaType": "image/png", + "providerMetadata": { + "google": { + "thoughtSignature": "stream_sig" + } + }, + "type": "reasoning-file" + }, + { + "data": { + "data": "regularimg", + "type": "data" + }, + "mediaType": "image/jpeg", + "type": "file" + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream text and files in correct order + #[tokio::test] + async fn should_stream_text_and_files_in_correct_order() { + let mock = sse_stream_mock(vec![sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { "text": "Step 1: " }, + { "inlineData": { "data": "image1", "mimeType": "image/png" } }, + { "text": " Step 2: " }, + { "inlineData": { "data": "image2", "mimeType": "image/jpeg" } }, + { "text": " Done" }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": stream_safety_ratings(), + }, + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 20, + "totalTokenCount": 30, + }, + }))]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + + // Filter to content events only (excluding metadata) + let content_events = + stream_events_of_type(&events, &["text-start", "text-delta", "text-end", "file"]); + + // Verify that text and file parts are interleaved in the correct order + assert_eq!( + Value::Array(content_events), + json!([ + { + "id": "0", + "type": "text-start" + }, + { + "delta": "Step 1: ", + "id": "0", + "type": "text-delta" + }, + { + "id": "0", + "type": "text-end" + }, + { + "data": { + "data": "image1", + "type": "data" + }, + "mediaType": "image/png", + "type": "file" + }, + { + "id": "1", + "type": "text-start" + }, + { + "delta": " Step 2: ", + "id": "1", + "type": "text-delta" + }, + { + "id": "1", + "type": "text-end" + }, + { + "data": { + "data": "image2", + "type": "data" + }, + "mediaType": "image/jpeg", + "type": "file" + }, + { + "id": "2", + "type": "text-start" + }, + { + "delta": " Done", + "id": "2", + "type": "text-delta" + }, + { + "id": "2", + "type": "text-end" + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should set finishReason to tool-calls when chunk contains functionCall + #[tokio::test] + async fn should_set_finish_reason_to_tool_calls_when_chunk_contains_function_call() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [{ "text": "Initial text response" }], + "role": "model", + }, + "index": 0, + "safetyRatings": stream_safety_ratings(), + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "test-tool", + "args": { "value": "example value" }, + }, + }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": stream_safety_ratings(), + }, + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 20, + "totalTokenCount": 30, + }, + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of( + &model, + json!({ + "tools": [stream_string_function_tool("test-tool", "value")], + "includeRawChunks": false, + }), + ) + .await; + let finish_event = stream_finish_event(&events); + + assert_eq!( + finish_event["finishReason"], + json!({ + "raw": "STOP", + "unified": "tool-calls" + }) + ); + } + + /// One chunk of text `Hello` with `STOP`, the upstream reply of the + /// request-shape cases. + fn stream_hello_chunks() -> Vec { + vec![sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "Hello" }], "role": "model" }, + "finishReason": "STOP", + "safetyRatings": stream_safety_ratings(), + }, + ], + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 1, + "totalTokenCount": 2, + }, + }))] + } + + /// The same reply as a unary (`json-value`) response. + fn generate_hello_response() -> Canned { + Canned::json(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "Hello" }], "role": "model" }, + "finishReason": "STOP", + "safetyRatings": stream_safety_ratings(), + }, + ], + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 1, + "totalTokenCount": 2, + }, + })) + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should omit server-side tool invocation flag for Vertex Gemini 3 + #[tokio::test] + async fn should_omit_server_side_tool_invocation_flag_for_vertex_gemini_3() { + let mock = sse_stream_mock(stream_hello_chunks()); + let vertex_model = vertex_stream_test_model("gemini-3-pro-preview", &mock); + + let result = vertex_model + .do_stream(&stream_call_options_with(json!({ + "includeRawChunks": false, + "tools": [ + stream_string_function_tool("test-tool", "value"), + stream_provider_tool("google.google_search", "google_search"), + ], + }))) + .await + .expect("doStream succeeds"); + + let request_body = stream_request_body(&mock, 0); + assert_eq!( + json!({ + "toolConfig": request_body.get("toolConfig"), + "tools": request_body.get("tools"), + }), + json!({ + "toolConfig": { + "functionCallingConfig": { + "mode": "VALIDATED" + } + }, + "tools": [ + { + "googleSearch": {} + }, + { + "functionDeclarations": [ + { + "description": "", + "name": "test-tool", + "parametersJsonSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + } + } + ] + } + ] + }) + ); + + let events = stream_parts_of(result).await; + assert_eq!( + events[0], + json!({ + "type": "stream-start", + "warnings": [] + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should send streamFunctionCallArguments in toolConfig when provider option is set with Vertex provider + #[tokio::test] + async fn should_send_stream_function_call_arguments_when_option_is_set_with_vertex_provider() { + let mock = sse_stream_mock(stream_hello_chunks()); + let vertex_model = vertex_stream_test_model("gemini-pro", &mock); + + vertex_model + .do_stream(&stream_call_options_with(json!({ + "includeRawChunks": false, + "tools": [stream_string_function_tool("test-tool", "value")], + "providerOptions": { + "vertex": { + "streamFunctionCallArguments": true, + }, + }, + }))) + .await + .expect("doStream succeeds"); + + assert_eq!( + stream_request_body(&mock, 0)["toolConfig"], + json!({ + "functionCallingConfig": { + "streamFunctionCallArguments": true + } + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should emit warning and not send streamFunctionCallArguments when using non-Vertex provider + #[tokio::test] + async fn should_emit_warning_and_not_send_stream_function_call_arguments_for_non_vertex() { + let mock = sse_stream_mock(stream_hello_chunks()); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let events = stream_events_of( + &model, + json!({ + "includeRawChunks": false, + "tools": [stream_string_function_tool("test-tool", "value")], + "providerOptions": { + "google": { + "streamFunctionCallArguments": true, + }, + }, + }), + ) + .await; + + assert_eq!( + Value::Array(events), + json!([ + { + "type": "stream-start", + "warnings": [ + { + "message": "'streamFunctionCallArguments' is only supported on the Vertex AI API and will be ignored with the current Google provider (google.generative-ai). See https://docs.cloud.google.com/vertex-ai/generative-ai/docs/multimodal/function-calling#streaming-fc", + "type": "other" + } + ] + }, + { + "id": "0", + "type": "text-start" + }, + { + "delta": "Hello", + "id": "0", + "type": "text-delta" + }, + { + "id": "0", + "type": "text-end" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "stop" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 1, + "promptTokenCount": 1, + "totalTokenCount": 2 + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 1, + "total": 1 + }, + "outputTokens": { + "reasoning": 0, + "text": 1, + "total": 1 + }, + "raw": { + "candidatesTokenCount": 1, + "promptTokenCount": 1, + "totalTokenCount": 2 + } + } + } + ]) + ); + + assert_eq!( + stream_request_body(&mock, 0) + .get("toolConfig") + .and_then(|config| config.get("functionCallingConfig")) + .and_then(|config| config.get("streamFunctionCallArguments")), + None + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should default streamFunctionCallArguments to false for Vertex provider without provider option + #[tokio::test] + async fn should_default_stream_function_call_arguments_to_false_for_vertex_without_option() { + let mock = sse_stream_mock(stream_hello_chunks()); + let vertex_model = vertex_stream_test_model("gemini-pro", &mock); + + vertex_model + .do_stream(&stream_call_options_with(json!({ + "includeRawChunks": false, + "tools": [stream_string_function_tool("test-tool", "value")], + }))) + .await + .expect("doStream succeeds"); + + assert_eq!( + stream_request_body(&mock, 0) + .get("toolConfig") + .and_then(|config| config.get("functionCallingConfig")) + .and_then(|config| config.get("streamFunctionCallArguments")), + None + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should allow Vertex provider to opt out of streamFunctionCallArguments by setting it to false + #[tokio::test] + async fn should_allow_vertex_to_opt_out_of_stream_function_call_arguments_by_setting_false() { + let mock = sse_stream_mock(stream_hello_chunks()); + let vertex_model = vertex_stream_test_model("gemini-pro", &mock); + + vertex_model + .do_stream(&stream_call_options_with(json!({ + "includeRawChunks": false, + "tools": [stream_string_function_tool("test-tool", "value")], + "providerOptions": { + "vertex": { + "streamFunctionCallArguments": false, + }, + }, + }))) + .await + .expect("doStream succeeds"); + + assert_eq!( + stream_request_body(&mock, 0) + .get("toolConfig") + .and_then(|config| config.get("functionCallingConfig")) + .and_then(|config| config.get("streamFunctionCallArguments")), + None + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should not send streamFunctionCallArguments for Vertex provider doGenerate (unary API) + #[tokio::test] + async fn should_not_send_stream_function_call_arguments_for_vertex_do_generate_unary_api() { + let mock = MockFetch::new(vec![generate_hello_response()]); + let vertex_model = vertex_stream_test_model("gemini-pro", &mock); + + vertex_model + .do_generate(&stream_call_options_with(json!({ + "tools": [stream_string_function_tool("test-tool", "value")], + }))) + .await + .expect("doGenerate succeeds"); + + assert_eq!( + stream_request_body(&mock, 0), + json!({ + "contents": [ + { + "parts": [ + { + "text": "Hello" + } + ], + "role": "user" + } + ], + "generationConfig": {}, + "tools": [ + { + "functionDeclarations": [ + { + "description": "", + "name": "test-tool", + "parametersJsonSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + } + } + ] + } + ] + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should not send streamFunctionCallArguments for Vertex provider doGenerate even when explicitly set + #[tokio::test] + async fn should_not_send_stream_function_call_arguments_for_vertex_do_generate_when_set() { + let mock = MockFetch::new(vec![generate_hello_response()]); + let vertex_model = vertex_stream_test_model("gemini-pro", &mock); + + vertex_model + .do_generate(&stream_call_options_with(json!({ + "providerOptions": { + "vertex": { + "streamFunctionCallArguments": true, + }, + }, + }))) + .await + .expect("doGenerate succeeds"); + + assert_eq!( + stream_request_body(&mock, 0), + json!({ + "contents": [ + { + "parts": [ + { + "text": "Hello" + } + ], + "role": "user" + } + ], + "generationConfig": {} + }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should only pass valid provider options + #[tokio::test] + async fn should_only_pass_valid_provider_options() { + let mock = sse_stream_mock(sse_fixture_chunks(include_str!( + "fixtures/google-text.chunks.txt" + ))); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + model + .do_stream(&stream_call_options_with(json!({ + "includeRawChunks": false, + "providerOptions": { + "google": { "foo": "bar", "responseModalities": ["TEXT", "IMAGE"] }, + }, + }))) + .await + .expect("doStream succeeds"); + + assert_stream_match_object( + &stream_request_body(&mock, 0), + &json!({ + "contents": [ + { + "role": "user", + "parts": [{ "text": "Hello" }], + }, + ], + "generationConfig": { + "responseModalities": ["TEXT", "IMAGE"], + }, + }), + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream reasoning parts separately from text parts + #[tokio::test] + async fn should_stream_reasoning_parts_separately_from_text_parts() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "I need to think about this carefully. The user wants a simple explanation.", + "thought": true, + }, + ], + "role": "model", + }, + "index": 0, + }, + ], + "usageMetadata": { + "promptTokenCount": 14, + "totalTokenCount": 84, + "thoughtsTokenCount": 70, + }, + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Let me organize my thoughts and provide a clear answer.", + "thought": true, + }, + ], + "role": "model", + }, + "index": 0, + }, + ], + "usageMetadata": { + "promptTokenCount": 14, + "totalTokenCount": 156, + "thoughtsTokenCount": 142, + }, + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Here is a simple explanation: ", + }, + ], + "role": "model", + }, + "index": 0, + }, + ], + "usageMetadata": { + "promptTokenCount": 14, + "candidatesTokenCount": 8, + "totalTokenCount": 164, + "thoughtsTokenCount": 142, + }, + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "The concept works because of basic principles.", + }, + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + }, + ], + "usageMetadata": { + "promptTokenCount": 14, + "candidatesTokenCount": 18, + "totalTokenCount": 174, + "thoughtsTokenCount": 142, + }, + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let all_events = stream_events_of(&model, json!({ "includeRawChunks": false })).await; + + assert_eq!( + Value::Array(all_events), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "0", + "type": "reasoning-start" + }, + { + "delta": "I need to think about this carefully. The user wants a simple explanation.", + "id": "0", + "type": "reasoning-delta" + }, + { + "delta": "Let me organize my thoughts and provide a clear answer.", + "id": "0", + "type": "reasoning-delta" + }, + { + "id": "0", + "type": "reasoning-end" + }, + { + "id": "1", + "type": "text-start" + }, + { + "delta": "Here is a simple explanation: ", + "id": "1", + "type": "text-delta" + }, + { + "delta": "The concept works because of basic principles.", + "id": "1", + "type": "text-delta" + }, + { + "id": "1", + "type": "text-end" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "stop" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 18, + "promptTokenCount": 14, + "thoughtsTokenCount": 142, + "totalTokenCount": 174 + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 14, + "total": 14 + }, + "outputTokens": { + "reasoning": 142, + "text": 18, + "total": 160 + }, + "raw": { + "candidatesTokenCount": 18, + "promptTokenCount": 14, + "thoughtsTokenCount": 142, + "totalTokenCount": 174 + } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › should stream thought signatures with reasoning and text parts + #[tokio::test] + async fn should_stream_thought_signatures_with_reasoning_and_text_parts() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "I need to think about this.", + "thought": true, + "thoughtSignature": "reasoning_sig1", + }, + ], + "role": "model", + }, + "index": 0, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Here is the answer.", + "thoughtSignature": "text_sig1", + }, + ], + "role": "model", + }, + "index": 0, + "finishReason": "STOP", + "safetyRatings": stream_safety_ratings(), + }, + ], + })), + ]); + let model = stream_test_provider(&mock).chat("gemini-pro"); + + let chunks = stream_events_of(&model, json!({})).await; + + assert_eq!( + Value::Array(chunks), + json!([ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "0", + "providerMetadata": { + "google": { + "thoughtSignature": "reasoning_sig1" + } + }, + "type": "reasoning-start" + }, + { + "delta": "I need to think about this.", + "id": "0", + "providerMetadata": { + "google": { + "thoughtSignature": "reasoning_sig1" + } + }, + "type": "reasoning-delta" + }, + { + "id": "0", + "type": "reasoning-end" + }, + { + "id": "1", + "providerMetadata": { + "google": { + "thoughtSignature": "text_sig1" + } + }, + "type": "text-start" + }, + { + "delta": "Here is the answer.", + "id": "1", + "providerMetadata": { + "google": { + "thoughtSignature": "text_sig1" + } + }, + "type": "text-delta" + }, + { + "id": "1", + "type": "text-end" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "stop" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": null + } + }, + "type": "finish", + "usage": { + "inputTokens": {}, + "outputTokens": {} + } + } + ]) + ); + } + + mod provider_metadata_key_based_on_provider_string { + use super::*; + + /// The two chunks `Hello` / ` World!` with grounding metadata on the + /// last one. + fn hello_world_grounding_chunks() -> Vec { + vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "Hello" }], "role": "model" }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": " World!" }], "role": "model" }, + "finishReason": "STOP", + "safetyRatings": stream_safety_ratings(), + "groundingMetadata": { + "webSearchQueries": ["test query"], + }, + }, + ], + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 2, + "totalTokenCount": 3, + }, + })), + ] + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › providerMetadata key based on provider string › should use "vertex" as providerMetadata key in finish event when provider includes "vertex" + #[tokio::test] + async fn should_use_vertex_as_provider_metadata_key_in_finish_event_when_provider_is_vertex() + { + let mock = sse_stream_mock(hello_world_grounding_chunks()); + let model = stream_test_model_for_provider("gemini-pro", "google.vertex.chat", &mock); + + let events = stream_events_of(&model, json!({})).await; + let finish_event = stream_finish_event(&events); + + assert_eq!(finish_event["type"], "finish"); + let provider_metadata = &finish_event["providerMetadata"]; + assert!(provider_metadata.get("vertex").is_some()); + assert_stream_match_object( + &provider_metadata["vertex"], + &json!({ "promptFeedback": null }), + ); + assert!(provider_metadata["vertex"]["groundingMetadata"].is_object()); + assert!(provider_metadata["vertex"]["safetyRatings"].is_array()); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › providerMetadata key based on provider string › should use "google" as providerMetadata key in finish event when provider does not include "vertex" + #[tokio::test] + async fn should_use_google_as_provider_metadata_key_in_finish_event_when_not_vertex() { + let mock = sse_stream_mock(hello_world_grounding_chunks()); + let model = stream_test_model_for_provider("gemini-pro", "google.generative-ai", &mock); + + let events = stream_events_of(&model, json!({})).await; + let finish_event = stream_finish_event(&events); + + assert_eq!(finish_event["type"], "finish"); + let provider_metadata = &finish_event["providerMetadata"]; + assert!(provider_metadata.get("google").is_some()); + assert!(provider_metadata.get("vertex").is_none()); + assert_stream_match_object( + &provider_metadata["google"], + &json!({ "promptFeedback": null }), + ); + assert!(provider_metadata["google"]["groundingMetadata"].is_object()); + assert!(provider_metadata["google"]["safetyRatings"].is_array()); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › doStream › providerMetadata key based on provider string › should use "vertex" as providerMetadata key in streaming reasoning events when provider includes "vertex" + #[tokio::test] + async fn should_use_vertex_as_provider_metadata_key_in_streaming_reasoning_events() { + let mock = sse_stream_mock(vec![ + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "thinking...", + "thought": true, + "thoughtSignature": "stream_sig", + }, + ], + "role": "model", + }, + }, + ], + })), + sse_data_chunk(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "Final answer" }], "role": "model" }, + "finishReason": "STOP", + "safetyRatings": stream_safety_ratings(), + }, + ], + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 2, + "totalTokenCount": 3, + }, + })), + ]); + let model = stream_test_model_for_provider("gemini-pro", "google.vertex.chat", &mock); + + let events = stream_events_of(&model, json!({})).await; + + let reasoning_start_event = stream_events_of_type(&events, &["reasoning-start"]) + .into_iter() + .next() + .expect("a reasoning-start event"); + let provider_metadata = &reasoning_start_event["providerMetadata"]; + assert!(provider_metadata.get("vertex").is_some()); + assert!(provider_metadata.get("google").is_none()); + assert_stream_match_object( + &provider_metadata["vertex"], + &json!({ "thoughtSignature": "stream_sig" }), + ); + + let reasoning_delta_event = stream_events_of_type(&events, &["reasoning-delta"]) + .into_iter() + .next() + .expect("a reasoning-delta event"); + let provider_metadata = &reasoning_delta_event["providerMetadata"]; + assert!(provider_metadata.get("vertex").is_some()); + assert!(provider_metadata.get("google").is_none()); + } + } +} + +mod gemma_model_system_instruction_fix { + use super::*; + + /// `TEST_PROMPT_WITH_SYSTEM`. + fn prompt_with_system() -> Value { + json!([ + { "role": "system", "content": "You are a helpful assistant." }, + { "role": "user", "content": [{ "type": "text", "text": "Hello" }] }, + ]) + } + + /// The `Hello!` reply of every GEMMA case. + fn gemma_hello_response() -> Canned { + Canned::json(&json!({ + "candidates": [ + { + "content": { "parts": [{ "text": "Hello!" }], "role": "model" }, + "finishReason": "STOP", + "index": 0, + }, + ], + })) + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › GEMMA Model System Instruction Fix › should NOT send systemInstruction for GEMMA-3-12b-it model + #[tokio::test] + async fn should_not_send_system_instruction_for_gemma_3_12b_it_model() { + let mock = MockFetch::new(vec![gemma_hello_response()]); + let model = stream_test_model_for_provider("gemma-3-12b-it", "google.generative-ai", &mock); + + model + .do_generate(&stream_call_options( + json!({ "prompt": prompt_with_system() }), + )) + .await + .expect("doGenerate succeeds"); + + // Verify that systemInstruction was NOT sent for GEMMA model + let request_body = mock.seen().last().unwrap().json_body(); + + assert!(request_body.get("systemInstruction").is_none()); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › GEMMA Model System Instruction Fix › should NOT send systemInstruction for GEMMA-3-27b-it model + #[tokio::test] + async fn should_not_send_system_instruction_for_gemma_3_27b_it_model() { + let mock = MockFetch::new(vec![gemma_hello_response()]); + let model = stream_test_model_for_provider("gemma-3-27b-it", "google.generative-ai", &mock); + + model + .do_generate(&stream_call_options( + json!({ "prompt": prompt_with_system() }), + )) + .await + .expect("doGenerate succeeds"); + + let request_body = mock.seen().last().unwrap().json_body(); + + assert!(request_body.get("systemInstruction").is_none()); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › GEMMA Model System Instruction Fix › should still send systemInstruction for Gemini models (regression test) + #[tokio::test] + async fn should_still_send_system_instruction_for_gemini_models_regression_test() { + let mock = MockFetch::new(vec![gemma_hello_response()]); + let model = stream_test_model_for_provider("gemini-pro", "google.generative-ai", &mock); + + model + .do_generate(&stream_call_options( + json!({ "prompt": prompt_with_system() }), + )) + .await + .expect("doGenerate succeeds"); + + let request_body = mock.seen().last().unwrap().json_body(); + + assert!(request_body.get("systemInstruction").is_some()); + assert_eq!( + request_body["systemInstruction"], + json!({ "parts": [{ "text": "You are a helpful assistant." }] }) + ); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › GEMMA Model System Instruction Fix › should NOT generate warning when GEMMA model is used without system instructions + #[tokio::test] + async fn should_not_generate_warning_when_gemma_model_is_used_without_system_instructions() { + let mock = MockFetch::new(vec![gemma_hello_response()]); + let model = stream_test_model_for_provider("gemma-3-12b-it", "google.generative-ai", &mock); + + let prompt_without_system = json!([ + { "role": "user", "content": [{ "type": "text", "text": "Hello" }] }, + ]); + + let result = model + .do_generate(&stream_call_options( + json!({ "prompt": prompt_without_system }), + )) + .await + .expect("doGenerate succeeds"); + + assert_eq!(result.warnings.len(), 0); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › GEMMA Model System Instruction Fix › should NOT generate warning when Gemini model is used with system instructions + #[tokio::test] + async fn should_not_generate_warning_when_gemini_model_is_used_with_system_instructions() { + let mock = MockFetch::new(vec![gemma_hello_response()]); + let model = stream_test_model_for_provider("gemini-pro", "google.generative-ai", &mock); + + let result = model + .do_generate(&stream_call_options( + json!({ "prompt": prompt_with_system() }), + )) + .await + .expect("doGenerate succeeds"); + + assert_eq!(result.warnings.len(), 0); + } + + /// upstream: @ai-sdk/google src/google-language-model.test.ts › GEMMA Model System Instruction Fix › should prepend system instruction to first user message for GEMMA models + #[tokio::test] + async fn should_prepend_system_instruction_to_first_user_message_for_gemma_models() { + let mock = MockFetch::new(vec![gemma_hello_response()]); + let model = stream_test_model_for_provider("gemma-3-12b-it", "google.generative-ai", &mock); + + model + .do_generate(&stream_call_options( + json!({ "prompt": prompt_with_system() }), + )) + .await + .expect("doGenerate succeeds"); + + let request_body = mock.seen().last().unwrap().json_body(); + + assert_eq!( + request_body, + json!({ + "contents": [ + { + "parts": [ + { + "text": "You are a helpful assistant.\n\n" + }, + { + "text": "Hello" + } + ], + "role": "user" + } + ], + "generationConfig": {} + }) + ); + } +} diff --git a/aimux-providers/src/google/tests/mod.rs b/aimux-providers/src/google/tests/mod.rs new file mode 100644 index 000000000..2fc24062c --- /dev/null +++ b/aimux-providers/src/google/tests/mod.rs @@ -0,0 +1,21 @@ +//! Tests ported from `@ai-sdk/google` 4.0.87, one file per upstream test file. + +mod convert_to_messages; +mod convert_usage; +mod embedding_model; +mod error; +mod evaluation; +mod files; +mod get_model_path; +mod image_model; +mod json_accumulator; +mod language_model; +mod model_capabilities; +mod prepare_tools; +mod provider; +mod sanitize_response_json_schema; +mod speech_input; +mod speech_model; +mod supported_file_url; +mod transcription; +mod video_model; diff --git a/aimux-providers/src/google/tests/model_capabilities.rs b/aimux-providers/src/google/tests/model_capabilities.rs new file mode 100644 index 000000000..285bf5d58 --- /dev/null +++ b/aimux-providers/src/google/tests/model_capabilities.rs @@ -0,0 +1,70 @@ +use serde_json::{Value, json}; + +use crate::google::convert::get_google_model_capabilities; + +/// The capabilities as the upstream `{ supportsGemini2Tools, ... }` object. +fn capabilities_json(model_id: &str) -> Value { + let capabilities = get_google_model_capabilities(model_id); + json!({ + "supportsGemini2Tools": capabilities.supports_gemini_2_tools, + "supportsFileSearch": capabilities.supports_file_search, + "usesGemini3Features": capabilities.uses_gemini_3_features, + }) +} + +mod get_google_model_capabilities { + use super::*; + + /// upstream: @ai-sdk/google src/google-model-capabilities.test.ts › getGoogleModelCapabilities › classifies $modelId without falling back to legacy behavior + #[test] + fn classifies_model_id_without_falling_back_to_legacy_behavior() { + let cases = [ + ( + "gemini-pro", + json!({ "supportsGemini2Tools": false, "supportsFileSearch": false, "usesGemini3Features": false }), + ), + ( + "gemini-pro-vision", + json!({ "supportsGemini2Tools": false, "supportsFileSearch": false, "usesGemini3Features": false }), + ), + ( + "gemini-1.5-flash", + json!({ "supportsGemini2Tools": false, "supportsFileSearch": false, "usesGemini3Features": false }), + ), + ( + "gemini-robotics-er-1.5-preview", + json!({ "supportsGemini2Tools": false, "supportsFileSearch": false, "usesGemini3Features": false }), + ), + ( + "gemini-2.0-flash", + json!({ "supportsGemini2Tools": true, "supportsFileSearch": false, "usesGemini3Features": false }), + ), + ( + "gemini-2.5-flash", + json!({ "supportsGemini2Tools": true, "supportsFileSearch": true, "usesGemini3Features": false }), + ), + ( + "gemini-3.1-pro-preview", + json!({ "supportsGemini2Tools": true, "supportsFileSearch": true, "usesGemini3Features": true }), + ), + ( + // Use a deliberately distant generation so this remains an unknown + // future-model fixture as new Gemini generations are released. + "gemini-99-pro-preview", + json!({ "supportsGemini2Tools": true, "supportsFileSearch": true, "usesGemini3Features": true }), + ), + ( + "gemini-ultra-latest", + json!({ "supportsGemini2Tools": true, "supportsFileSearch": true, "usesGemini3Features": true }), + ), + ( + "nano-banana-pro-preview", + json!({ "supportsGemini2Tools": true, "supportsFileSearch": false, "usesGemini3Features": false }), + ), + ]; + + for (model_id, expected) in cases { + assert_eq!(capabilities_json(model_id), expected, "{model_id}"); + } + } +} diff --git a/aimux-providers/src/google/tests/prepare_tools.rs b/aimux-providers/src/google/tests/prepare_tools.rs new file mode 100644 index 000000000..6e9dd6ea1 --- /dev/null +++ b/aimux-providers/src/google/tests/prepare_tools.rs @@ -0,0 +1,973 @@ +//! `prepareTools` (`google-prepare-tools.test.ts`), through +//! `prepare_all_tools(tools, tool_choice, model_id)`. `isVertexProvider: true` +//! has no flag there: aimux drops the server-side tool invocation flag while +//! building a Vertex request body, so that case builds the body with +//! `Namespace::Vertex` and reads `tools` / `toolConfig` and the warnings back. + +use serde_json::{Value, json}; + +use aimux_core::options::CallOptions; +use aimux_core::tool::{LanguageModelV4ToolChoice, Tool}; + +use crate::google::convert::{build_request_body_with_warnings_for_namespace, prepare_all_tools}; +use crate::google::options::Namespace; + +fn tools(value: Value) -> Option> { + Some(serde_json::from_value(value).expect("tools deserialize")) +} + +fn tool_choice(value: Value) -> Option { + Some(serde_json::from_value(value).expect("tool choice deserializes")) +} + +/// The `prepareTools` result as JSON (`undefined` members omitted). +fn prepare( + tools: Option>, + tool_choice: Option, + model_id: &str, +) -> Value { + let prepared = prepare_all_tools(&tools, tool_choice.as_ref(), model_id); + let mut result = json!({ "toolWarnings": prepared.warnings }); + if let Some(tools) = prepared.tools { + result["tools"] = Value::Array(tools); + } + if let Some(tool_config) = prepared.tool_config { + result["toolConfig"] = tool_config; + } + result +} + +/// `prepareTools({ ..., isVertexProvider: true })`: the tool members of a +/// Vertex request body and its warnings. +fn prepare_vertex( + tools: Option>, + tool_choice: Option, + model_id: &str, +) -> Value { + let mut options = CallOptions::new(Vec::new()); + options.tools = tools; + options.tool_choice = tool_choice; + let (body, warnings) = + build_request_body_with_warnings_for_namespace(model_id, &options, Namespace::Vertex) + .expect("body builds"); + let mut result = json!({ "toolWarnings": warnings }); + if let Some(tools) = body.get("tools") { + result["tools"] = tools.clone(); + } + if let Some(tool_config) = body.get("toolConfig") { + result["toolConfig"] = tool_config.clone(); + } + result +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should return undefined tools and tool_choice when tools are null +#[test] +fn should_return_undefined_tools_and_tool_choice_when_tools_are_null() { + let result = prepare(None, None, "gemini-2.5-flash"); + assert_eq!(result, json!({ "toolWarnings": [] })); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should return undefined tools and tool_choice when tools are empty +#[test] +fn should_return_undefined_tools_and_tool_choice_when_tools_are_empty() { + let result = prepare(tools(json!([])), None, "gemini-2.5-flash"); + assert_eq!(result, json!({ "toolWarnings": [] })); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should correctly prepare function tools +#[test] +fn should_correctly_prepare_function_tools() { + let result = prepare( + tools(json!([ + { + "type": "function", + "name": "testFunction", + "description": "A test function", + "inputSchema": { "type": "object", "properties": {} }, + }, + ])), + None, + "gemini-2.5-flash", + ); + assert_eq!( + result["tools"], + json!([ + { + "functionDeclarations": [ + { + "name": "testFunction", + "description": "A test function", + "parametersJsonSchema": { "type": "object", "properties": {} }, + }, + ], + }, + ]) + ); + assert!(result.get("toolConfig").is_none()); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should preserve recursive function tool schemas as JSON Schema +#[test] +fn should_preserve_recursive_function_tool_schemas_as_json_schema() { + let input_schema = json!({ + "type": "object", + "properties": { "condition": { "$ref": "#/$defs/Condition" } }, + "required": ["condition"], + "$defs": { + "Condition": { + "type": "object", + "properties": { + "children": { "type": "array", "items": { "$ref": "#/$defs/Condition" } }, + }, + }, + }, + }); + + let result = prepare( + tools(json!([ + { + "type": "function", + "name": "search", + "description": "Search with a condition tree", + "inputSchema": input_schema, + }, + ])), + None, + "gemini-2.5-flash", + ); + + assert_eq!( + result["tools"], + json!([ + { + "functionDeclarations": [ + { + "name": "search", + "description": "Search with a condition tree", + "parametersJsonSchema": input_schema, + }, + ], + }, + ]) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should correctly prepare provider-defined tools as array +#[test] +fn should_correctly_prepare_provider_defined_tools_as_array() { + let result = prepare( + tools(json!([ + { "type": "provider", "id": "google.google_search", "name": "google_search", "args": {} }, + { "type": "provider", "id": "google.url_context", "name": "url_context", "args": {} }, + { + "type": "provider", + "id": "google.file_search", + "name": "file_search", + "args": { "fileSearchStoreNames": ["projects/foo/fileSearchStores/bar"] }, + }, + ])), + None, + "gemini-2.5-flash", + ); + assert_eq!( + result["tools"], + json!([ + { "googleSearch": {} }, + { "urlContext": {} }, + { "fileSearch": { "fileSearchStoreNames": ["projects/foo/fileSearchStores/bar"] } }, + ]) + ); + assert!(result.get("toolConfig").is_none()); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should correctly prepare single provider-defined tool +#[test] +fn should_correctly_prepare_single_provider_defined_tool() { + let result = prepare( + tools(json!([ + { "type": "provider", "id": "google.google_search", "name": "google_search", "args": {} }, + ])), + None, + "gemini-2.5-flash", + ); + assert_eq!(result["tools"], json!([{ "googleSearch": {} }])); + assert!(result.get("toolConfig").is_none()); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should add warnings for unsupported tools +#[test] +fn should_add_warnings_for_unsupported_tools() { + let result = prepare( + tools(json!([ + { "type": "provider", "id": "unsupported.tool", "name": "unsupported_tool", "args": {} }, + ])), + None, + "gemini-2.5-flash", + ); + assert!(result.get("tools").is_none()); + assert!(result.get("toolConfig").is_none()); + assert_eq!( + result["toolWarnings"], + json!([ + { "feature": "provider-defined tool unsupported.tool", "type": "unsupported" }, + ]) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should add warnings for file search on unsupported models +#[test] +fn should_add_warnings_for_file_search_on_unsupported_models() { + let result = prepare( + tools(json!([ + { + "type": "provider", + "id": "google.file_search", + "name": "file_search", + "args": { "fileSearchStoreNames": ["projects/foo/fileSearchStores/bar"] }, + }, + ])), + None, + "gemini-1.5-flash-8b", + ); + assert!(result.get("tools").is_none()); + assert_eq!( + result["toolWarnings"], + json!([ + { + "details": "The file search tool is only supported with Gemini 2.5 models and Gemini 3 models.", + "feature": "provider-defined tool google.file_search", + "type": "unsupported", + }, + ]) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should correctly prepare file search tool for gemini-2.5 models +#[test] +fn should_correctly_prepare_file_search_tool_for_gemini_2_5_models() { + let result = prepare( + tools(json!([ + { + "type": "provider", + "id": "google.file_search", + "name": "file_search", + "args": { + "fileSearchStoreNames": ["projects/foo/fileSearchStores/bar"], + "metadataFilter": "author=Robert Graves", + "topK": 5, + }, + }, + ])), + None, + "gemini-2.5-pro", + ); + assert_eq!( + result["tools"], + json!([ + { + "fileSearch": { + "fileSearchStoreNames": ["projects/foo/fileSearchStores/bar"], + "metadataFilter": "author=Robert Graves", + "topK": 5, + }, + }, + ]) + ); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should correctly prepare file search tool for gemini-3 models +#[test] +fn should_correctly_prepare_file_search_tool_for_gemini_3_models() { + let result = prepare( + tools(json!([ + { + "type": "provider", + "id": "google.file_search", + "name": "file_search", + "args": { + "fileSearchStoreNames": ["projects/foo/fileSearchStores/bar"], + "metadataFilter": "author=Robert Graves", + "topK": 5, + }, + }, + ])), + None, + "gemini-3.1-pro-preview", + ); + assert_eq!( + result["tools"], + json!([ + { + "fileSearch": { + "fileSearchStoreNames": ["projects/foo/fileSearchStores/bar"], + "metadataFilter": "author=Robert Graves", + "topK": 5, + }, + }, + ]) + ); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use newest tool support for an unknown future Gemini model +#[test] +fn should_use_newest_tool_support_for_an_unknown_future_gemini_model() { + let result = prepare( + tools(json!([ + { + "type": "function", + "name": "getWeather", + "description": "Get the weather", + "inputSchema": { "type": "object", "properties": { "location": { "type": "string" } } }, + }, + { "type": "provider", "id": "google.google_search", "name": "google_search", "args": {} }, + { + "type": "provider", + "id": "google.enterprise_web_search", + "name": "enterprise_web_search", + "args": {}, + }, + { "type": "provider", "id": "google.url_context", "name": "url_context", "args": {} }, + { "type": "provider", "id": "google.code_execution", "name": "code_execution", "args": {} }, + { + "type": "provider", + "id": "google.file_search", + "name": "file_search", + "args": { "fileSearchStoreNames": ["fileSearchStores/example-store"] }, + }, + ])), + None, + "gemini-99-pro-preview", + ); + + assert_eq!( + result, + json!({ + "tools": [ + { "googleSearch": {} }, + { "enterpriseWebSearch": {} }, + { "urlContext": {} }, + { "codeExecution": {} }, + { "fileSearch": { "fileSearchStoreNames": ["fileSearchStores/example-store"] } }, + { + "functionDeclarations": [ + { + "name": "getWeather", + "description": "Get the weather", + "parametersJsonSchema": { + "type": "object", + "properties": { "location": { "type": "string" } }, + }, + }, + ], + }, + ], + "toolConfig": { + "functionCallingConfig": { "mode": "VALIDATED" }, + "includeServerSideToolInvocations": true, + }, + "toolWarnings": [], + }) + ); +} + +fn test_function() -> Value { + json!({ "type": "function", "name": "testFunction", "description": "Test", "inputSchema": {} }) +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle tool choice "auto" +#[test] +fn should_handle_tool_choice_auto() { + let result = prepare( + tools(json!([test_function()])), + tool_choice(json!({ "type": "auto" })), + "gemini-2.5-flash", + ); + assert_eq!( + result["toolConfig"], + json!({ "functionCallingConfig": { "mode": "AUTO" } }) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle tool choice "required" +#[test] +fn should_handle_tool_choice_required() { + let result = prepare( + tools(json!([test_function()])), + tool_choice(json!({ "type": "required" })), + "gemini-2.5-flash", + ); + assert_eq!( + result["toolConfig"], + json!({ "functionCallingConfig": { "mode": "ANY" } }) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle tool choice "none" +#[test] +fn should_handle_tool_choice_none() { + let result = prepare( + tools(json!([test_function()])), + tool_choice(json!({ "type": "none" })), + "gemini-2.5-flash", + ); + assert_eq!( + result["tools"], + json!([ + { + "functionDeclarations": [ + { "name": "testFunction", "description": "Test", "parametersJsonSchema": {} }, + ], + }, + ]) + ); + assert_eq!( + result["toolConfig"], + json!({ "functionCallingConfig": { "mode": "NONE" } }) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle tool choice "tool" +#[test] +fn should_handle_tool_choice_tool() { + let result = prepare( + tools(json!([test_function()])), + tool_choice(json!({ "type": "tool", "toolName": "testFunction" })), + "gemini-2.5-flash", + ); + assert_eq!( + result["toolConfig"], + json!({ + "functionCallingConfig": { "mode": "ANY", "allowedFunctionNames": ["testFunction"] }, + }) + ); +} + +fn function_and_google_search() -> Option> { + tools(json!([ + { + "type": "function", + "name": "testFunction", + "description": "A test function", + "inputSchema": { "type": "object", "properties": {} }, + }, + { "type": "provider", "id": "google.google_search", "name": "google_search", "args": {} }, + ])) +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should warn when mixing function and provider-defined tools +#[test] +fn should_warn_when_mixing_function_and_provider_defined_tools() { + let result = prepare(function_and_google_search(), None, "gemini-2.5-flash"); + + assert_eq!(result["tools"], json!([{ "googleSearch": {} }])); + assert_eq!( + result["toolWarnings"], + json!([ + { "feature": "combination of function and provider-defined tools", "type": "unsupported" }, + ]) + ); + assert!(result.get("toolConfig").is_none()); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle tool choice with mixed tools (provider-defined tools only) +#[test] +fn should_handle_tool_choice_with_mixed_tools_provider_defined_tools_only() { + let result = prepare( + function_and_google_search(), + tool_choice(json!({ "type": "auto" })), + "gemini-2.5-flash", + ); + + assert_eq!(result["tools"], json!([{ "googleSearch": {} }])); + assert!(result.get("toolConfig").is_none()); + assert_eq!( + result["toolWarnings"], + json!([ + { "feature": "combination of function and provider-defined tools", "type": "unsupported" }, + ]) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should combine function and provider-defined tools on Gemini 3 models +#[test] +fn should_combine_function_and_provider_defined_tools_on_gemini_3_models() { + let result = prepare( + function_and_google_search(), + None, + "gemini-3.1-flash-lite-preview", + ); + + assert_eq!( + result["tools"], + json!([ + { "googleSearch": {} }, + { + "functionDeclarations": [ + { + "name": "testFunction", + "description": "A test function", + "parametersJsonSchema": { "type": "object", "properties": {} }, + }, + ], + }, + ]) + ); + assert_eq!( + result["toolConfig"], + json!({ + "functionCallingConfig": { "mode": "VALIDATED" }, + "includeServerSideToolInvocations": true, + }) + ); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should omit server-side tool invocation flag for Vertex Gemini 3 +#[test] +fn should_omit_server_side_tool_invocation_flag_for_vertex_gemini_3() { + let result = prepare_vertex(function_and_google_search(), None, "gemini-3-flash-preview"); + + assert_eq!( + result, + json!({ + "toolConfig": { "functionCallingConfig": { "mode": "VALIDATED" } }, + "toolWarnings": [], + "tools": [ + { "googleSearch": {} }, + { + "functionDeclarations": [ + { + "description": "A test function", + "name": "testFunction", + "parametersJsonSchema": { "properties": {}, "type": "object" }, + }, + ], + }, + ], + }) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should combine multiple provider tools with function tools on Gemini 3 +#[test] +fn should_combine_multiple_provider_tools_with_function_tools_on_gemini_3() { + let result = prepare( + tools(json!([ + { + "type": "function", + "name": "getWeather", + "description": "Get weather", + "inputSchema": { "type": "object", "properties": {} }, + }, + { + "type": "function", + "name": "bookVenue", + "description": "Book a venue", + "inputSchema": { "type": "object", "properties": {} }, + }, + { "type": "provider", "id": "google.google_search", "name": "google_search", "args": {} }, + { "type": "provider", "id": "google.google_maps", "name": "google_maps", "args": {} }, + ])), + None, + "gemini-3-flash-preview", + ); + + assert_eq!( + result["tools"], + json!([ + { "googleSearch": {} }, + { "googleMaps": {} }, + { + "functionDeclarations": [ + { + "name": "getWeather", + "description": "Get weather", + "parametersJsonSchema": { "type": "object", "properties": {} }, + }, + { + "name": "bookVenue", + "description": "Book a venue", + "parametersJsonSchema": { "type": "object", "properties": {} }, + }, + ], + }, + ]) + ); + assert_eq!( + result["toolConfig"], + json!({ + "functionCallingConfig": { "mode": "VALIDATED" }, + "includeServerSideToolInvocations": true, + }) + ); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use VALIDATED mode for combined tools with toolChoice auto on Gemini 3 +#[test] +fn should_use_validated_mode_for_combined_tools_with_tool_choice_auto_on_gemini_3() { + let result = prepare( + function_and_google_search(), + tool_choice(json!({ "type": "auto" })), + "gemini-3-flash-preview", + ); + + assert_eq!( + result["toolConfig"], + json!({ + "functionCallingConfig": { "mode": "VALIDATED" }, + "includeServerSideToolInvocations": true, + }) + ); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use ANY mode for combined tools with toolChoice required on Gemini 3 +#[test] +fn should_use_any_mode_for_combined_tools_with_tool_choice_required_on_gemini_3() { + let result = prepare( + function_and_google_search(), + tool_choice(json!({ "type": "required" })), + "gemini-3-flash-preview", + ); + + assert_eq!( + result["toolConfig"], + json!({ + "functionCallingConfig": { "mode": "ANY" }, + "includeServerSideToolInvocations": true, + }) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use NONE mode for combined tools with toolChoice none on Gemini 3 +#[test] +fn should_use_none_mode_for_combined_tools_with_tool_choice_none_on_gemini_3() { + let result = prepare( + function_and_google_search(), + tool_choice(json!({ "type": "none" })), + "gemini-3-flash-preview", + ); + + assert_eq!( + result["toolConfig"], + json!({ + "functionCallingConfig": { "mode": "NONE" }, + "includeServerSideToolInvocations": true, + }) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use ANY mode with allowedFunctionNames for combined tools with specific tool choice on Gemini 3 +#[test] +fn should_use_any_mode_with_allowed_function_names_for_combined_tools_on_gemini_3() { + let result = prepare( + function_and_google_search(), + tool_choice(json!({ "type": "tool", "toolName": "testFunction" })), + "gemini-3-flash-preview", + ); + + assert_eq!( + result["toolConfig"], + json!({ + "functionCallingConfig": { "mode": "ANY", "allowedFunctionNames": ["testFunction"] }, + "includeServerSideToolInvocations": true, + }) + ); +} + +fn single_provider_tool(id: &str, name: &str, args: Value) -> Option> { + tools(json!([{ "type": "provider", "id": id, "name": name, "args": args }])) +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle latest modelId for provider-defined tools correctly +#[test] +fn should_handle_latest_model_id_for_provider_defined_tools_correctly() { + let result = prepare( + single_provider_tool("google.google_search", "google_search", json!({})), + None, + "gemini-flash-latest", + ); + assert_eq!(result["tools"], json!([{ "googleSearch": {} }])); + assert!(result.get("toolConfig").is_none()); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle gemini-3 modelId for provider-defined tools correctly +#[test] +fn should_handle_gemini_3_model_id_for_provider_defined_tools_correctly() { + let result = prepare( + single_provider_tool("google.google_search", "google_search", json!({})), + None, + "gemini-3.1-pro-preview", + ); + assert_eq!(result["tools"], json!([{ "googleSearch": {} }])); + assert!(result.get("toolConfig").is_none()); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle code execution tool +#[test] +fn should_handle_code_execution_tool() { + let result = prepare( + single_provider_tool("google.code_execution", "code_execution", json!({})), + None, + "gemini-2.5-flash", + ); + assert_eq!(result["tools"], json!([{ "codeExecution": {} }])); + assert!(result.get("toolConfig").is_none()); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle url context tool alone +#[test] +fn should_handle_url_context_tool_alone() { + let result = prepare( + single_provider_tool("google.url_context", "url_context", json!({})), + None, + "gemini-2.5-flash", + ); + assert_eq!(result["tools"], json!([{ "urlContext": {} }])); + assert!(result.get("toolConfig").is_none()); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should handle google maps tool +#[test] +fn should_handle_google_maps_tool() { + let result = prepare( + single_provider_tool("google.google_maps", "google_maps", json!({})), + None, + "gemini-2.5-flash", + ); + assert_eq!(result["tools"], json!([{ "googleMaps": {} }])); + assert!(result.get("toolConfig").is_none()); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should pass searchTypes args through for google search +#[test] +fn should_pass_search_types_args_through_for_google_search() { + let result = prepare( + single_provider_tool( + "google.google_search", + "google_search", + json!({ "searchTypes": { "webSearch": {}, "imageSearch": {} } }), + ), + None, + "gemini-3.1-flash-image-preview", + ); + assert_eq!( + result["tools"], + json!([ + { "googleSearch": { "searchTypes": { "webSearch": {}, "imageSearch": {} } } }, + ]) + ); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should pass timeRangeFilter args through for google search +#[test] +fn should_pass_time_range_filter_args_through_for_google_search() { + let result = prepare( + single_provider_tool( + "google.google_search", + "google_search", + json!({ + "timeRangeFilter": { + "startTime": "2025-01-01T00:00:00Z", + "endTime": "2025-12-31T23:59:59Z", + }, + }), + ), + None, + "gemini-2.5-flash", + ); + assert_eq!( + result["tools"], + json!([ + { + "googleSearch": { + "timeRangeFilter": { + "startTime": "2025-01-01T00:00:00Z", + "endTime": "2025-12-31T23:59:59Z", + }, + }, + }, + ]) + ); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should add warnings for google search on unsupported models +#[test] +fn should_add_warnings_for_google_search_on_unsupported_models() { + let result = prepare( + single_provider_tool("google.google_search", "google_search", json!({})), + None, + "gemini-1.5-flash", + ); + assert!(result.get("tools").is_none()); + assert_eq!( + result["toolWarnings"], + json!([ + { + "details": "Google Search requires Gemini 2.0 or newer.", + "feature": "provider-defined tool google.google_search", + "type": "unsupported", + }, + ]) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should add warnings for google maps on unsupported models +#[test] +fn should_add_warnings_for_google_maps_on_unsupported_models() { + let result = prepare( + single_provider_tool("google.google_maps", "google_maps", json!({})), + None, + "gemini-1.5-flash", + ); + assert!(result.get("tools").is_none()); + assert_eq!( + result["toolWarnings"], + json!([ + { + "details": "The Google Maps grounding tool is not supported with Gemini models other than Gemini 2 or newer.", + "feature": "provider-defined tool google.google_maps", + "type": "unsupported", + }, + ]) + ); +} + +fn strict_get_weather() -> Value { + json!({ + "type": "function", + "name": "getWeather", + "description": "Get weather", + "inputSchema": { + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false, + }, + "strict": true, + }) +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use VALIDATED mode when any function tool has strict: true +#[test] +fn should_use_validated_mode_when_any_function_tool_has_strict_true() { + let result = prepare( + tools(json!([ + { + "type": "function", + "name": "createMeeting", + "description": "Create a meeting", + "inputSchema": { + "type": "object", + "properties": { "title": { "type": "string" } }, + "required": ["title"], + "additionalProperties": false, + }, + "strict": true, + }, + ])), + None, + "gemini-3-flash-preview", + ); + assert_eq!( + result["toolConfig"], + json!({ "functionCallingConfig": { "mode": "VALIDATED" } }) + ); + assert_eq!(result["toolWarnings"], json!([])); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use VALIDATED mode with toolChoice auto when strict: true +#[test] +fn should_use_validated_mode_with_tool_choice_auto_when_strict_true() { + let result = prepare( + tools(json!([strict_get_weather()])), + tool_choice(json!({ "type": "auto" })), + "gemini-3-flash-preview", + ); + assert_eq!( + result["toolConfig"], + json!({ "functionCallingConfig": { "mode": "VALIDATED" } }) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use ANY mode with toolChoice required when strict: true +#[test] +fn should_use_any_mode_with_tool_choice_required_when_strict_true() { + let result = prepare( + tools(json!([strict_get_weather()])), + tool_choice(json!({ "type": "required" })), + "gemini-3-flash-preview", + ); + assert_eq!( + result["toolConfig"], + json!({ "functionCallingConfig": { "mode": "ANY" } }) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use ANY mode with named toolChoice when another tool has strict: true +#[test] +fn should_use_any_mode_with_named_tool_choice_when_another_tool_has_strict_true() { + let result = prepare( + tools(json!([ + { + "type": "function", + "name": "createMeeting", + "description": "Create meeting", + "inputSchema": { + "type": "object", + "properties": { "title": { "type": "string" } }, + "required": ["title"], + "additionalProperties": false, + }, + }, + strict_get_weather(), + ])), + tool_choice(json!({ "type": "tool", "toolName": "createMeeting" })), + "gemini-3-flash-preview", + ); + assert_eq!( + result["toolConfig"], + json!({ + "functionCallingConfig": { "mode": "ANY", "allowedFunctionNames": ["createMeeting"] }, + }) + ); +} + +/// upstream: @ai-sdk/google src/google-prepare-tools.test.ts › should use AUTO mode when no tools have strict: true +#[test] +fn should_use_auto_mode_when_no_tools_have_strict_true() { + let result = prepare( + tools(json!([ + { + "type": "function", + "name": "getWeather", + "description": "Get weather", + "inputSchema": { + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false, + }, + }, + ])), + tool_choice(json!({ "type": "auto" })), + "gemini-3-flash-preview", + ); + assert_eq!( + result["toolConfig"], + json!({ "functionCallingConfig": { "mode": "AUTO" } }) + ); +} diff --git a/aimux-providers/src/google/tests/provider.rs b/aimux-providers/src/google/tests/provider.rs new file mode 100644 index 000000000..dadd7eec2 --- /dev/null +++ b/aimux-providers/src/google/tests/provider.rs @@ -0,0 +1,514 @@ +//! `createGoogle` (`google-provider.test.ts`). +//! +//! Upstream mocks the model constructors and inspects the config they were +//! given. aimux models keep their config private, so each case observes it +//! the way a caller does: `provider()` / `model_id()` / `supported_urls()` on +//! the returned model, and the base URL and headers on the one request the +//! model sends through a recording transport (`MockFetch` with no canned +//! response: the request is recorded, then the call fails). +//! +//! The user-agent is aimux's pinned `ai-sdk-google/4.0.87` (upstream mocks +//! `0.0.0-test`); on the wire the request also carries the provider-utils +//! part, so the full pinned value is compared. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::json; + +use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; +use aimux_core::image_model::{ImageCallOptions, ImageModel}; +use aimux_core::language_model::{LanguageModel, SupportedUrls}; +use aimux_core::options::CallOptions; +use aimux_core::provider::Provider; +use aimux_core::result::GenerateContent; +use aimux_core::speech_model::{SpeechCallOptions, SpeechModel}; +use aimux_core::tool::RawToolCall; +use aimux_core::video_model::{VideoCallOptions, VideoModel}; + +use crate::google::{GoogleImageSettings, GoogleProvider, GoogleProviderSettings, create_google}; +use crate::test_support::mock_fetch::{Canned, MockFetch, Seen}; + +const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; +const PINNED_USER_AGENT: &str = "ai-sdk-google/4.0.87 ai-sdk-provider-utils/5.0.53"; + +/// `createGoogle({ apiKey: 'test-api-key', ...settings })` over a recording +/// transport. +fn create(settings: GoogleProviderSettings) -> (GoogleProvider, Arc) { + create_answering(settings, Vec::new()) +} + +fn create_answering( + settings: GoogleProviderSettings, + canned: Vec, +) -> (GoogleProvider, Arc) { + let mock = MockFetch::new(canned); + let provider = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + fetch: Some(mock.transport()), + ..settings + }) + .expect("valid settings"); + (provider, mock) +} + +fn first_request(mock: &MockFetch) -> Seen { + mock.seen() + .into_iter() + .next() + .expect("the model sent a request") +} + +/// The request a language model sends for a one-message prompt. +async fn language_request(model: &dyn LanguageModel, mock: &MockFetch) -> Seen { + let _ = model + .do_generate(&CallOptions::new(vec![ + aimux_core::language_model_message::LanguageModelMessage::user_text("Hello"), + ])) + .await; + first_request(mock) +} + +async fn embedding_request(model: &dyn EmbeddingModel, mock: &MockFetch) -> Seen { + let _ = model.do_embed(&EmbeddingCallOptions::new("Hello")).await; + first_request(mock) +} + +async fn image_request(model: &dyn ImageModel, mock: &MockFetch) -> Seen { + let _ = model.do_generate(&ImageCallOptions::new("A cat")).await; + first_request(mock) +} + +async fn video_request(model: &dyn VideoModel, mock: &MockFetch) -> Seen { + let _ = model.do_start(&VideoCallOptions::new("A cat")).await; + first_request(mock) +} + +async fn speech_request(model: &dyn SpeechModel, mock: &MockFetch) -> Seen { + let _ = model.do_generate(&SpeechCallOptions::new("Hello")).await; + first_request(mock) +} + +fn patterns(supported_urls: &SupportedUrls) -> &[regex::Regex] { + supported_urls.0.get("*").expect("a '*' entry") +} + +mod google_provider { + use super::*; + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should create a language model with default settings + #[tokio::test] + async fn should_create_a_language_model_with_default_settings() { + let (provider, mock) = create(GoogleProviderSettings::default()); + let model = provider.call("gemini-pro"); + + assert_eq!(model.model_id(), "gemini-pro"); + assert_eq!(model.provider(), "google.generative-ai"); + assert!(!model.supported_urls().await.is_empty()); + let request = language_request(model.as_ref(), &mock).await; + assert_eq!( + request.url, + format!("{DEFAULT_BASE_URL}/models/gemini-pro:generateContent") + ); + assert_eq!(request.headers["x-goog-api-key"], "test-api-key"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should create an embedding model with correct settings + #[tokio::test] + async fn should_create_an_embedding_model_with_correct_settings() { + let (provider, mock) = create(GoogleProviderSettings::default()); + let model = Provider::embedding_model(&provider, "embedding-001").unwrap(); + + assert_eq!(model.model_id(), "embedding-001"); + assert_eq!(model.provider(), "google.generative-ai"); + let request = embedding_request(model.as_ref(), &mock).await; + assert!( + request + .url + .starts_with(&format!("{DEFAULT_BASE_URL}/models/embedding-001:")), + "{}", + request.url + ); + assert_eq!(request.headers["x-goog-api-key"], "test-api-key"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should pass custom headers to the model constructor + #[tokio::test] + async fn should_pass_custom_headers_to_the_model_constructor() { + let (provider, mock) = create(GoogleProviderSettings { + headers: Some(HashMap::from([( + "Custom-Header".to_string(), + Some("custom-value".to_string()), + )])), + ..Default::default() + }); + let model = provider.call("gemini-pro"); + + let request = language_request(model.as_ref(), &mock).await; + assert_eq!(request.headers["x-goog-api-key"], "test-api-key"); + assert_eq!(request.headers["custom-header"], "custom-value"); + assert_eq!(request.headers["user-agent"], PINNED_USER_AGENT); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should pass custom generateId function to the model constructor + #[tokio::test] + async fn should_pass_custom_generate_id_function_to_the_model_constructor() { + // The model calls `generateId` for a function call that has no id. + let (provider, _mock) = create_answering( + GoogleProviderSettings { + generate_id: Some(Arc::new(|| "custom-id".to_string())), + ..Default::default() + }, + vec![Canned::json(&json!({ + "candidates": [ + { + "content": { + "role": "model", + "parts": [{ "functionCall": { "name": "test-tool", "args": { "value": "x" } } }], + }, + "finishReason": "STOP", + }, + ], + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 1 }, + }))], + ); + let model = provider.call("gemini-pro"); + + let result = model + .do_generate(&CallOptions::new(vec![ + aimux_core::language_model_message::LanguageModelMessage::user_text("Hello"), + ])) + .await + .unwrap(); + let tool_call_id = result.content.iter().find_map(|part| match part { + GenerateContent::ToolCall(RawToolCall { tool_call_id, .. }) => { + Some(tool_call_id.as_str()) + } + _ => None, + }); + assert_eq!(tool_call_id, Some("custom-id")); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should use chat method to create a model + #[test] + fn should_use_chat_method_to_create_a_model() { + let (provider, _) = create(GoogleProviderSettings::default()); + let model = provider.chat("gemini-pro"); + + assert_eq!(model.model_id(), "gemini-pro"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should use custom baseURL when provided + #[tokio::test] + async fn should_use_custom_base_url_when_provided() { + let (provider, mock) = create(GoogleProviderSettings { + base_url: Some("https://custom-endpoint.example.com".into()), + ..Default::default() + }); + let model = provider.call("gemini-pro"); + + let request = language_request(model.as_ref(), &mock).await; + assert_eq!( + request.url, + "https://custom-endpoint.example.com/models/gemini-pro:generateContent" + ); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should create an image model with default settings + #[tokio::test] + async fn should_create_an_image_model_with_default_settings() { + let (provider, mock) = create(GoogleProviderSettings::default()); + let model = provider.image("gemini-2.5-flash-image"); + + assert_eq!(model.model_id(), "gemini-2.5-flash-image"); + assert_eq!(model.provider(), "google.generative-ai"); + let request = image_request(&model, &mock).await; + assert!( + request.url.starts_with(&format!("{DEFAULT_BASE_URL}/")), + "{}", + request.url + ); + assert_eq!(request.headers["x-goog-api-key"], "test-api-key"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should create an image model with custom maxImagesPerCall + #[tokio::test] + async fn should_create_an_image_model_with_custom_max_images_per_call() { + let (provider, mock) = create(GoogleProviderSettings::default()); + let model = provider.image_with_settings( + "gemini-2.5-flash-image", + GoogleImageSettings { + max_images_per_call: Some(3), + }, + ); + + assert_eq!(model.model_id(), "gemini-2.5-flash-image"); + assert_eq!(model.max_images_per_call(), Some(3)); + assert_eq!(model.provider(), "google.generative-ai"); + let request = image_request(&model, &mock).await; + assert!( + request.url.starts_with(&format!("{DEFAULT_BASE_URL}/")), + "{}", + request.url + ); + assert_eq!(request.headers["x-goog-api-key"], "test-api-key"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should support deprecated methods + #[test] + fn should_support_deprecated_methods() { + let (provider, _) = create(GoogleProviderSettings::default()); + + let language = provider.generative_ai("gemini-pro"); + let embedding = provider.embedding("embedding-001"); + let embedding_model = Provider::embedding_model(&provider, "embedding-001").unwrap(); + + assert_eq!(language.model_id(), "gemini-pro"); + assert_eq!(embedding.model_id(), "embedding-001"); + assert_eq!(embedding_model.model_id(), "embedding-001"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should include YouTube URLs in supportedUrls + #[tokio::test] + async fn should_include_youtube_urls_in_supported_urls() { + let (provider, _) = create(GoogleProviderSettings::default()); + let supported_urls = provider.call("gemini-pro").supported_urls().await; + let patterns = patterns(&supported_urls); + + let is_supported = |url: &str| patterns.iter().any(|pattern| pattern.is_match(url)); + let results = |urls: &[&str]| { + urls.iter() + .map(|url| json!({ "isSupported": is_supported(url), "url": url })) + .collect::>() + }; + let test_results = json!({ + "supportedUrls": results(&[ + "https://generativelanguage.googleapis.com/v1beta/files/test123", + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtu.be/dQw4w9WgXcQ", + ]), + "unsupportedUrls": results(&[ + "https://example.com", + "https://vimeo.com/123456789", + "https://youtube.com/channel/UCdQw4w9WgXcQ", + ]), + }); + + assert_eq!( + test_results, + json!({ + "supportedUrls": [ + { + "isSupported": true, + "url": "https://generativelanguage.googleapis.com/v1beta/files/test123", + }, + { "isSupported": true, "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }, + { "isSupported": true, "url": "https://youtube.com/watch?v=dQw4w9WgXcQ" }, + { "isSupported": true, "url": "https://youtu.be/dQw4w9WgXcQ" }, + ], + "unsupportedUrls": [ + { "isSupported": false, "url": "https://example.com" }, + { "isSupported": false, "url": "https://vimeo.com/123456789" }, + { "isSupported": false, "url": "https://youtube.com/channel/UCdQw4w9WgXcQ" }, + ], + }) + ); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should support default and configured Google file URLs with a custom baseURL + #[tokio::test] + async fn should_support_default_and_configured_google_file_urls_with_a_custom_base_url() { + let (provider, _) = create(GoogleProviderSettings { + base_url: Some("https://custom-endpoint.example.com/v1beta".into()), + ..Default::default() + }); + let supported_urls = provider.call("gemini-2.0-flash").supported_urls().await; + + for url in [ + "https://generativelanguage.googleapis.com/v1beta/files/google-file", + "https://custom-endpoint.example.com/v1beta/files/custom-file", + ] { + assert!(supported_urls.is_supported(url, "text/markdown"), "{url}"); + } + + assert!(!supported_urls.is_supported( + "https://example.com/files/unsupported-file", + "text/markdown" + )); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should support documented external HTTPS URLs for Gemini models that accept external URLs + #[tokio::test] + async fn should_support_documented_external_https_urls_for_gemini_models() { + let (provider, _) = create(GoogleProviderSettings::default()); + let supported_urls = provider.call("gemini-3.5-flash").supported_urls().await; + + for media_type in [ + "text/html", + "text/css", + "text/plain", + "text/xml", + "text/csv", + "text/rtf", + "text/javascript", + "application/json", + "application/pdf", + "image/bmp", + "image/jpeg", + "image/png", + "image/webp", + "video/mp4", + "video/mpeg", + "video/quicktime", + "video/avi", + "video/x-flv", + "video/mpg", + "video/webm", + "video/wmv", + "video/3gpp", + ] { + assert!( + supported_urls.is_supported("https://example.com/file", media_type), + "{media_type}" + ); + } + + assert!(!supported_urls.is_supported("http://example.com/file.txt", "text/plain")); + assert!(!supported_urls.is_supported("https://example.com/file.md", "text/markdown")); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google-provider › should not support external HTTPS URLs for Gemini 2.0 models + #[tokio::test] + async fn should_not_support_external_https_urls_for_gemini_2_0_models() { + let (provider, _) = create(GoogleProviderSettings::default()); + let supported_urls = provider.call("gemini-2.0-flash").supported_urls().await; + + assert!(!supported_urls.is_supported("https://example.com/file.txt", "text/plain")); + } +} + +mod google_provider_custom_provider_name { + use super::*; + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google provider - custom provider name › should use custom provider name when specified + #[test] + fn should_use_custom_provider_name_when_specified() { + let (provider, _) = create(GoogleProviderSettings { + name: Some("my-gemini-proxy".into()), + ..Default::default() + }); + let model = provider.call("gemini-pro"); + + assert_eq!(model.model_id(), "gemini-pro"); + assert_eq!(model.provider(), "my-gemini-proxy"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google provider - custom provider name › should default to google.generative-ai when name not specified + #[test] + fn should_default_to_google_generative_ai_when_name_not_specified() { + let (provider, _) = create(GoogleProviderSettings::default()); + let model = provider.call("gemini-pro"); + + assert_eq!(model.model_id(), "gemini-pro"); + assert_eq!(model.provider(), "google.generative-ai"); + } +} + +mod google_provider_video { + use super::*; + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google provider - video › should create a video model with default settings + #[tokio::test] + async fn should_create_a_video_model_with_default_settings() { + let (provider, mock) = create(GoogleProviderSettings::default()); + let model = provider.video("veo-3.1-generate-preview"); + + assert_eq!(model.model_id(), "veo-3.1-generate-preview"); + assert_eq!(model.provider(), "google.generative-ai"); + let request = video_request(&model, &mock).await; + assert!( + request.url.starts_with(&format!( + "{DEFAULT_BASE_URL}/models/veo-3.1-generate-preview:" + )), + "{}", + request.url + ); + assert_eq!(request.headers["x-goog-api-key"], "test-api-key"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google provider - video › should use custom baseURL for video model when provided + #[tokio::test] + async fn should_use_custom_base_url_for_video_model_when_provided() { + let (provider, mock) = create(GoogleProviderSettings { + base_url: Some("https://custom-endpoint.example.com".into()), + ..Default::default() + }); + let model = provider.video("veo-3.1-generate"); + + assert_eq!(model.model_id(), "veo-3.1-generate"); + let request = video_request(&model, &mock).await; + assert!( + request + .url + .starts_with("https://custom-endpoint.example.com/models/veo-3.1-generate:"), + "{}", + request.url + ); + } +} + +mod google_provider_speech { + use super::*; + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google provider - speech › should create a speech model with default settings + #[tokio::test] + async fn should_create_a_speech_model_with_default_settings() { + let (provider, mock) = create(GoogleProviderSettings::default()); + let model = provider.speech("gemini-2.5-flash-preview-tts"); + + assert_eq!(model.model_id(), "gemini-2.5-flash-preview-tts"); + assert_eq!(model.provider(), "google.generative-ai.speech"); + let request = speech_request(&model, &mock).await; + assert!( + request.url.starts_with(&format!( + "{DEFAULT_BASE_URL}/models/gemini-2.5-flash-preview-tts:" + )), + "{}", + request.url + ); + assert_eq!(request.headers["x-goog-api-key"], "test-api-key"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google provider - speech › should create a speech model via speechModel() + #[test] + fn should_create_a_speech_model_via_speech_model() { + let (provider, _) = create(GoogleProviderSettings::default()); + let model = Provider::speech_model(&provider, "gemini-2.5-pro-preview-tts") + .expect("the provider has speech models") + .unwrap(); + + assert_eq!(model.model_id(), "gemini-2.5-pro-preview-tts"); + assert_eq!(model.provider(), "google.generative-ai.speech"); + } + + /// upstream: @ai-sdk/google src/google-provider.test.ts › google provider - speech › should use custom baseURL for speech model when provided + #[tokio::test] + async fn should_use_custom_base_url_for_speech_model_when_provided() { + let (provider, mock) = create(GoogleProviderSettings { + base_url: Some("https://custom-endpoint.example.com".into()), + ..Default::default() + }); + let model = provider.speech("gemini-2.5-flash-preview-tts"); + + assert_eq!(model.model_id(), "gemini-2.5-flash-preview-tts"); + let request = speech_request(&model, &mock).await; + assert!( + request.url.starts_with( + "https://custom-endpoint.example.com/models/gemini-2.5-flash-preview-tts:" + ), + "{}", + request.url + ); + } +} diff --git a/aimux-providers/src/google/tests/sanitize_response_json_schema.rs b/aimux-providers/src/google/tests/sanitize_response_json_schema.rs new file mode 100644 index 000000000..4cb686e12 --- /dev/null +++ b/aimux-providers/src/google/tests/sanitize_response_json_schema.rs @@ -0,0 +1,68 @@ +use serde_json::json; + +use crate::google::convert::sanitize_response_json_schema; + +/// upstream: @ai-sdk/google src/sanitize-response-json-schema.test.ts › replaces const with enum while preserving JSON Schema +#[test] +fn replaces_const_with_enum_while_preserving_json_schema() { + let schema = json!({ + "type": "object", + "properties": { + "response": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "fruit" }, + }, + "required": ["type"], + "additionalProperties": false, + }, + ], + }, + }, + "required": ["response"], + "additionalProperties": false, + "$defs": { + "label": { "type": "string", "const": "produce" }, + }, + }); + + assert_eq!( + sanitize_response_json_schema(&schema), + json!({ + "type": "object", + "properties": { + "response": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["fruit"] }, + }, + "required": ["type"], + "additionalProperties": false, + }, + ], + }, + }, + "required": ["response"], + "additionalProperties": false, + "$defs": { + "label": { "type": "string", "enum": ["produce"] }, + }, + }) + ); + + // toMatchObject: the input schema is left unchanged. + assert_eq!( + schema["properties"]["response"]["oneOf"][0]["properties"]["type"], + json!({ "type": "string", "const": "fruit" }) + ); + assert_eq!( + schema["properties"]["response"]["oneOf"] + .as_array() + .map(Vec::len), + Some(1) + ); +} diff --git a/aimux-providers/src/google/tests/snapshots/do_generate_reasoning_gemini3_extract_reasoning.json b/aimux-providers/src/google/tests/snapshots/do_generate_reasoning_gemini3_extract_reasoning.json new file mode 100644 index 000000000..b635093e5 --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_generate_reasoning_gemini3_extract_reasoning.json @@ -0,0 +1,117 @@ +{ + "content": [ + { + "providerMetadata": { + "google": { + "thoughtSignature": "EswFCskFAb4+9vu5+eCedpmcjHHK+yjyrSAIGVBHj03GrObE+hduQ213f7FDz0UD+aLw3SDPDBkaK69ObgjN0U84Q1yKpmpLk6GnphX0+DrmRFYKnw2TJ8c7/DKa3z5x" + } + }, + "text": "There are **3** \"r\"s in strawberry.\n\nHere is the breakdown: st**r**awbe**rr**y.", + "type": "text" + } + ], + "finishReason": { + "raw": "STOP", + "unified": "stop" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 29, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 258, + "totalTokenCount": 296 + } + } + }, + "request": { + "body": { + "contents": [ + { + "parts": [ + { + "text": "Hello" + } + ], + "role": "user" + } + ], + "generationConfig": {} + } + }, + "response": { + "body": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "There are **3** \"r\"s in strawberry.\n\nHere is the breakdown: st**r**awbe**rr**y.", + "thoughtSignature": "EswFCskFAb4+9vu5+eCedpmcjHHK+yjyrSAIGVBHj03GrObE+hduQ213f7FDz0UD+aLw3SDPDBkaK69ObgjN0U84Q1yKpmpLk6GnphX0+DrmRFYKnw2TJ8c7/DKa3z5x" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "modelVersion": "gemini-3-pro-preview", + "responseId": "DniLab2dFPeSxN8PpqXY4Ag", + "usageMetadata": { + "candidatesTokenCount": 29, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 258, + "totalTokenCount": 296 + } + }, + "headers": { + "content-length": "580", + "content-type": "application/json" + }, + "id": "DniLab2dFPeSxN8PpqXY4Ag" + }, + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 9, + "total": 9 + }, + "outputTokens": { + "reasoning": 258, + "text": 29, + "total": 287 + }, + "raw": { + "candidatesTokenCount": 29, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 258, + "totalTokenCount": 296 + } + }, + "warnings": [] +} diff --git a/aimux-providers/src/google/tests/snapshots/do_generate_text_extract_text_response.json b/aimux-providers/src/google/tests/snapshots/do_generate_text_extract_text_response.json new file mode 100644 index 000000000..f0cc53aab --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_generate_text_extract_text_response.json @@ -0,0 +1,117 @@ +{ + "content": [ + { + "providerMetadata": { + "google": { + "thoughtSignature": "EtoFCtcFAb4+9vtfe4MXRxQjw48U1WKrR/7lYsgFkVi/bepqsSPjY0VU7HEzkeCBIfy1fu5t9aUZ4IZ65aWagqbBrV45fc97olcg" + } + }, + "text": "There are **3** r's in strawberry.\n\nHere is the breakdown: st**r**awbe**rr**y.", + "type": "text" + } + ], + "finishReason": { + "raw": "STOP", + "unified": "stop" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 28, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 244, + "totalTokenCount": 281 + } + } + }, + "request": { + "body": { + "contents": [ + { + "parts": [ + { + "text": "Hello" + } + ], + "role": "user" + } + ], + "generationConfig": {} + } + }, + "response": { + "body": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "There are **3** r's in strawberry.\n\nHere is the breakdown: st**r**awbe**rr**y.", + "thoughtSignature": "EtoFCtcFAb4+9vtfe4MXRxQjw48U1WKrR/7lYsgFkVi/bepqsSPjY0VU7HEzkeCBIfy1fu5t9aUZ4IZ65aWagqbBrV45fc97olcg" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "modelVersion": "gemini-3-pro-preview", + "responseId": "Un6LacrVMcjUxs0PmJfWoQc", + "usageMetadata": { + "candidatesTokenCount": 28, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 244, + "totalTokenCount": 281 + } + }, + "headers": { + "content-length": "549", + "content-type": "application/json" + }, + "id": "Un6LacrVMcjUxs0PmJfWoQc" + }, + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 9, + "total": 9 + }, + "outputTokens": { + "reasoning": 244, + "text": 28, + "total": 272 + }, + "raw": { + "candidatesTokenCount": 28, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 244, + "totalTokenCount": 281 + } + }, + "warnings": [] +} diff --git a/aimux-providers/src/google/tests/snapshots/do_generate_text_extract_usage.json b/aimux-providers/src/google/tests/snapshots/do_generate_text_extract_usage.json new file mode 100644 index 000000000..9ccd16557 --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_generate_text_extract_usage.json @@ -0,0 +1,24 @@ +{ + "inputTokens": { + "cacheRead": 0, + "noCache": 9, + "total": 9 + }, + "outputTokens": { + "reasoning": 244, + "text": 28, + "total": 272 + }, + "raw": { + "candidatesTokenCount": 28, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 244, + "totalTokenCount": 281 + } +} diff --git a/aimux-providers/src/google/tests/snapshots/do_generate_tool_call_extract_tool_calls.json b/aimux-providers/src/google/tests/snapshots/do_generate_tool_call_extract_tool_calls.json new file mode 100644 index 000000000..0dc42edf0 --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_generate_tool_call_extract_tool_calls.json @@ -0,0 +1,148 @@ +{ + "content": [ + { + "input": "{\"location\":\"San Francisco\"}", + "providerMetadata": { + "google": { + "thoughtSignature": "EskgCsYgAb4+9vtF7/499YQS2bjZs3xcQI+iAl+ILn29nK1j0Kg6su7QsUUUk3nrAAfnS2w5WiVvlcCqu9fAebJ2cvfaEyBahEt5" + } + }, + "toolCallId": "test-id", + "toolName": "weather", + "type": "tool-call" + } + ], + "finishReason": { + "raw": "STOP", + "unified": "tool-calls" + }, + "providerMetadata": { + "google": { + "finishMessage": "Model generated function call(s).", + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 893, + "totalTokenCount": 937 + } + } + }, + "request": { + "body": { + "contents": [ + { + "parts": [ + { + "text": "Hello" + } + ], + "role": "user" + } + ], + "generationConfig": {}, + "tools": [ + { + "functionDeclarations": [ + { + "description": "", + "name": "weather", + "parametersJsonSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + } + } + ] + } + ] + } + }, + "response": { + "body": { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "args": { + "location": "San Francisco" + }, + "name": "weather" + }, + "thoughtSignature": "EskgCsYgAb4+9vtF7/499YQS2bjZs3xcQI+iAl+ILn29nK1j0Kg6su7QsUUUk3nrAAfnS2w5WiVvlcCqu9fAebJ2cvfaEyBahEt5" + } + ], + "role": "model" + }, + "finishMessage": "Model generated function call(s).", + "finishReason": "STOP", + "index": 0 + } + ], + "modelVersion": "gemini-3-pro-preview", + "responseId": "m36LaZGyCLz1xs0PtNSB-QU", + "usageMetadata": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 893, + "totalTokenCount": 937 + } + }, + "headers": { + "content-length": "583", + "content-type": "application/json" + }, + "id": "m36LaZGyCLz1xs0PtNSB-QU" + }, + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 29, + "total": 29 + }, + "outputTokens": { + "reasoning": 893, + "text": 15, + "total": 908 + }, + "raw": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 893, + "totalTokenCount": 937 + } + }, + "warnings": [] +} diff --git a/aimux-providers/src/google/tests/snapshots/do_generate_tool_call_gemini3_extract_tool_call.json b/aimux-providers/src/google/tests/snapshots/do_generate_tool_call_gemini3_extract_tool_call.json new file mode 100644 index 000000000..435775e5f --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_generate_tool_call_gemini3_extract_tool_call.json @@ -0,0 +1,125 @@ +{ + "content": [ + { + "input": "{\"location\":\"San Francisco\"}", + "providerMetadata": { + "google": { + "thoughtSignature": "Eqo+Cqc+Ab4+9vtgONaaz6qwy6WXdp7gCd2w0X+Wz2gaBgY0Gv6A12JKo0y5vQwf9YQFyhMbKr1E9m17VT6HXd7jXzjaGYaE" + } + }, + "toolCallId": "test-id", + "toolName": "weather", + "type": "tool-call" + } + ], + "finishReason": { + "raw": "STOP", + "unified": "tool-calls" + }, + "providerMetadata": { + "google": { + "finishMessage": "Model generated function call(s).", + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 1801, + "totalTokenCount": 1845 + } + } + }, + "request": { + "body": { + "contents": [ + { + "parts": [ + { + "text": "Hello" + } + ], + "role": "user" + } + ], + "generationConfig": {} + } + }, + "response": { + "body": { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "args": { + "location": "San Francisco" + }, + "name": "weather" + }, + "thoughtSignature": "Eqo+Cqc+Ab4+9vtgONaaz6qwy6WXdp7gCd2w0X+Wz2gaBgY0Gv6A12JKo0y5vQwf9YQFyhMbKr1E9m17VT6HXd7jXzjaGYaE" + } + ], + "role": "model" + }, + "finishMessage": "Model generated function call(s).", + "finishReason": "STOP", + "index": 0 + } + ], + "modelVersion": "gemini-3-pro-preview", + "responseId": "JniLacKqGqH0xs0P0O776As", + "usageMetadata": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 1801, + "totalTokenCount": 1845 + } + }, + "headers": { + "content-length": "581", + "content-type": "application/json" + }, + "id": "JniLacKqGqH0xs0P0O776As" + }, + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 29, + "total": 29 + }, + "outputTokens": { + "reasoning": 1801, + "text": 15, + "total": 1816 + }, + "raw": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 1801, + "totalTokenCount": 1845 + } + }, + "warnings": [] +} diff --git a/aimux-providers/src/google/tests/snapshots/do_stream_reasoning.json b/aimux-providers/src/google/tests/snapshots/do_stream_reasoning.json new file mode 100644 index 000000000..2dded7df9 --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_stream_reasoning.json @@ -0,0 +1,91 @@ +[ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "dX6LadKVC7SZ28oPr9yJoQs", + "type": "response-metadata" + }, + { + "id": "0", + "type": "text-start" + }, + { + "delta": "There are **3** \"r\"s in", + "id": "0", + "type": "text-delta" + }, + { + "delta": " strawberry.\n\nHere is the breakdown: st**r**awbe**rr**y.", + "id": "0", + "type": "text-delta" + }, + { + "delta": "", + "id": "0", + "providerMetadata": { + "google": { + "thoughtSignature": "Eo0HCooHAb4+9vutXdtKMt+r7Z3gLh52ZQ3RKYDzp4A6PHh0dLuvI1KwnLxDS/qm8wn4Gm9NNNQ1nBS/rrCAvr4GUTqanTkyxQb0ctdgoMiQJ/BwIXliPqJ5C+YtnXZfktX+roGCDmCE4qimrCzgW4TytiSC4glx6CPNVCUT2S/vrK3WT1Dn8DpykEIddvSH5PRxAOXQHjPfwQ9BUcBVw3/6+dTv/IgIIDbSkt0Lmy/Vww80oAWSDph0YpK2OXdpuZnUjscFeKJFqMJeuc3OAuAMIeb1GSwD2l5bcufkMqBDSZb00QxjTqrQm7DHbauxrz7oNCJRtRFQlrGrrMx2ut2eXzeEamNGXf433pMBZBi0BxhFWBCuZemJQIzY/LHiHT7jTjaiZIISyAMFC7u/WTY5ONRa9wWQ76NJVmriCm2+Mo1GXVGAkwwD54qiKgGxvFEWeJqP+13Esuw02QwHEVWnIbQUhaEYDIvR//AGPvDJVpzZmDfv9mcJSa09z4/UG2sadoftByt44eW3gBINtmeUAGjn52wETOxUsq/2qz/zkxvySOpUcXFKAktjgLdA7tHMYRgrCUgYcfznZc1XlyCivb7imNJadsVg0O5SMcnBLAme/0cnnOUrZK1CW+7S5UM8408GE9soyHAXf92y8mZ3n9YoEPKhuf0OjMSYoI9MOqhkIZxj8IaOAuaH8PrAd2nI6Y82jCFF81ZxJN+YyL/NNL3iURvtPHJ2hGjQGdzYnm62CYEAG8t5bEZwix6agxhyY1uM/Atj1nvD/bpVvvBENrMPWFhI0O8IvJ2bdJh2AcKSLQveWZpqs8HQoUMZcOQPs61wn9se+NKh760oCvLDcfNxWSInIFpLuZdL8xzluDxu1FXm75LFPoTRnbCUAG9PcJJWyoM3SLgw3SaoRIjwkAbdOK47NwYhS1WSvgxQwbnMix75XKez8Ja17/o847oplrjiezqJVpQsMqs6mxRWnFIDl1mgw+BQSEl67wKTAaPC7nX6x2Px+H2PM20RqdWTd5RiwST6Gm0eQjekJG3G+Xtnhte1ubcnCu3bMMaza0uwsy5imuHj8KJaQbAEWtIEAhFJBNvnlZGnqFZPRWwMnJ28nSBe8VAdGOLOPP96hlbCe/3D0PBYEFGz1K7G108h7jr082IZ47P9Vk4CrOI7DdfmbfcnfiQKys8pHj6H981zw5xogUmwAj/uUJKN" + } + }, + "type": "text-delta" + }, + { + "id": "0", + "type": "text-end" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "stop" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 29, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 256, + "totalTokenCount": 294 + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 9, + "total": 9 + }, + "outputTokens": { + "reasoning": 256, + "text": 29, + "total": 285 + }, + "raw": { + "candidatesTokenCount": 29, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 256, + "totalTokenCount": 294 + } + } + } +] diff --git a/aimux-providers/src/google/tests/snapshots/do_stream_reasoning_gemini3.json b/aimux-providers/src/google/tests/snapshots/do_stream_reasoning_gemini3.json new file mode 100644 index 000000000..abf79ece2 --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_stream_reasoning_gemini3.json @@ -0,0 +1,91 @@ +[ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "M3iLaY-AI7zTxN8P3Piw4Qg", + "type": "response-metadata" + }, + { + "id": "0", + "type": "text-start" + }, + { + "delta": "There are **3** \"r\"s in strawberry.\n\n", + "id": "0", + "type": "text-delta" + }, + { + "delta": "St**r**awbe**rr**y", + "id": "0", + "type": "text-delta" + }, + { + "delta": "", + "id": "0", + "providerMetadata": { + "google": { + "thoughtSignature": "EpAICo0IAb4+9vuku3oDHR5EQSMfPm5IpmH06lgm9aGICNAJKKfpVXU3971qpSGSFiIVb60jKjMU182kVfRyl9bViwRtwI1o4nFI+BqC+kLUuUaG+hCfQZBYcCiq8rmQK+LQJxRey5s3kXIDNUcpyOk4I2Y/wFpbcnQC/8xzXHo8D0y7eZrayZwAsusgHwcww988h0JOA8zPXCaN/q7bDfnQqX4tHps3VezQBKlNQOGdbB/cb+Sr2qRPqoOYKrLhxJ+dWGqkbv4wYSF6rYLIXh8h95CkzmerYWkLg5O5fYuTyz0/cHZLnF0MJFCMZtwk8TWEc2+HVLDvS7lw/fFSqKmaIC/CBDSZA1N8VOPq9FZ+t1kJ2lwG5t6CfXcD2xz20eWpdf0p3G51XkTbF6Bghj1TZi0RiXJUgThyqB3gGSdE5nd0HAX2dU3YCVdwX31wNd6atyTeUx7itA39sP6EQteHbz8coTM77ueppUnU0bGUY85V7PCeQTFP47jIj0LuwCM8P3AE3Fj/QPstzMu42lW4kd95ngwaUKdo2QxFXB7/6mwFWWOLoDWgy1a1mgQYnk70GcLPnIcEwC+Yo+M3vb4F6VLIXtZhs+q9vubASDpCHxjcbFUmAoqdajCGh/q48VWFVBybiS5gYOPoR3dH+o+ugY3evyv1vO3fJqEeEj9lh7jgwJiy1CvjZlC+fzB75RGIeiI3R7gGeob6QjLjuSfvkSGbdZBlTShO3mQIxE7CKYyB7RNuYfaYW0E/4mTDEppuFsR6ub1JySylx+0klPfH2GP7pnVnWOZ1itHPXCexpAPfCoW5ulpgRxDUIvZUUvA2rRJoaCSWgRWx12OM9wzWEonLKEW122OPSUV3zEmHqjujJzHgkKoEMfGS/OAuIAusR8mv6VMKERLI2r5v4y/vqBXutwO0S7ztoU9qPJm60z4hDgkuSkjFCYh17U3Ah4LHUlBCO3BaOy/M68JtDHU3+hPmwMNjXByl68FacTWEOtjW4PoPoWFsWbUZ1uU4BtR6/aGsgARJPVQlZ9eRVke5JAQKk43jRVhzxHRkfunvRU7b1FAejU1J5byc2xes0saIwU0+tETySrKTe2ktkWvk/a4TgPnm8SGiV+pVZ91r3uTtjds6h+VA0lVMdqfY/vuYiNnYybR4J6r6U+DTpIAgNgyIx4r/N9zy+Z5nqNFFrwS85B+aWqVf8vaT/jW4u/vh8ZEuoJ1NhsGdefZjokyv+wm+G1N5ykmdo5qO5aR/oGbH/YSIFfALFAIgWK+6og1jjkjf2bVBVsmtns2/w+au7xvFpkjLT+klKwiuYjaTHMEBk4+Tb4Uq/VPO7ZbPPts9XwsWsv/P3FNrCXyy0/RCAUsr7SEP3u0kIsk9vG9i114=" + } + }, + "type": "text-delta" + }, + { + "id": "0", + "type": "text-end" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "stop" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 23, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 302, + "totalTokenCount": 334 + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 9, + "total": 9 + }, + "outputTokens": { + "reasoning": 302, + "text": 23, + "total": 325 + }, + "raw": { + "candidatesTokenCount": 23, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 302, + "totalTokenCount": 334 + } + } + } +] diff --git a/aimux-providers/src/google/tests/snapshots/do_stream_streaming_tool_call_arguments.json b/aimux-providers/src/google/tests/snapshots/do_stream_streaming_tool_call_arguments.json new file mode 100644 index 000000000..84e74b47c --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_stream_streaming_tool_call_arguments.json @@ -0,0 +1,186 @@ +[ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "dqHOab6xGLzWodAPkPuViA4", + "type": "response-metadata" + }, + { + "id": "test-id", + "providerMetadata": { + "googleVertex": { + "thoughtSignature": "CiMBjz1rX25KieIB4d4AwFn8/WbsHTRNHBXso88PtemwfsSfRAp6AY89a1/e2FrmrE2HtlOxvV7vy8Kn5Og9n6CepcBKYW9aBT5QdXtTD4PGv9pDDWLEDAfsd86Et6k7HDEV976M3kC0ahLS8FCrwRntUKr+GF5uiUfPvgpzqNl5m8d8EwYykfwo1VwuTStR7q3mr/TzDcNzepafpVLdZloKYQGPPWtfkohmdau0GqGi3DuPbFTUBe5Ac/UbCvUk+P7KFbCGb93vFmnjmmTV7228DcAddqtWfe4iVePtSumw3JptYE6PIeVao2r8q7FXZKazzEiA9c04nmq6Vv9lOtt5NsgKrwEBjz1rX1ajIx3yeG/QI78uO+MsKoqgwrCI7PAEaDRqbyDCW/NX/4Vfuz1wp96oT3h/ttJ5ejzQyGamOrYreUUFvJBR/0TvFBdWOXwwCOWtvJ0Jh7SinV9HNqBDM1WWH8e+tGWY0r7xU/o/M1Fkwd73bvyp3BmSm5orrQL4jUZ1TN6ECqw7T7wHEkIfuBXGMZhQwAPzaapGVCdn0LjFJwd1aVReWmt6pE4ut993/K+0CqsBAY89a1+ts0Nggt7GZxVw/dQhH2Gc6KxRbAZKFMTuYRcNufJ5CyTvq85rMWEhTPWQxlQe1V7NIOlsQzyDhlejiCVbq0chDVrwRY23BmLrhSLi+SfKukEc0M+V9LdFiqxxLgUBrVdCvxA17g11j6HGdmXiNze2WDKm7ZXgMYvEiVmglpRZOb4JeJzxNPFsxhv2k6VkKB+GRtO48XwNz/i2taQ8Nq+LO5tED6HhCp4BAY89a18wBKoSkBT2IRo530FeNq/lpVozY0+k+p+H//TAtKoWZBamKCwra81idaMm1TTaWZGlX85Pq0kBvx1sQLFEhILeQrbH0O3Iuk9JlKLKctAwnBj8BZUGJEGtbigTKaSgWcMBhW+jfKWi1xwSxyJLRkIcXmE8U3FE/XHCLeiBy0NRa0yKn3xh62JU9087Q2nOMClxy5Zy5VsZ0qQ=" + }, + "vertex": { + "thoughtSignature": "CiMBjz1rX25KieIB4d4AwFn8/WbsHTRNHBXso88PtemwfsSfRAp6AY89a1/e2FrmrE2HtlOxvV7vy8Kn5Og9n6CepcBKYW9aBT5QdXtTD4PGv9pDDWLEDAfsd86Et6k7HDEV976M3kC0ahLS8FCrwRntUKr+GF5uiUfPvgpzqNl5m8d8EwYykfwo1VwuTStR7q3mr/TzDcNzepafpVLdZloKYQGPPWtfkohmdau0GqGi3DuPbFTUBe5Ac/UbCvUk+P7KFbCGb93vFmnjmmTV7228DcAddqtWfe4iVePtSumw3JptYE6PIeVao2r8q7FXZKazzEiA9c04nmq6Vv9lOtt5NsgKrwEBjz1rX1ajIx3yeG/QI78uO+MsKoqgwrCI7PAEaDRqbyDCW/NX/4Vfuz1wp96oT3h/ttJ5ejzQyGamOrYreUUFvJBR/0TvFBdWOXwwCOWtvJ0Jh7SinV9HNqBDM1WWH8e+tGWY0r7xU/o/M1Fkwd73bvyp3BmSm5orrQL4jUZ1TN6ECqw7T7wHEkIfuBXGMZhQwAPzaapGVCdn0LjFJwd1aVReWmt6pE4ut993/K+0CqsBAY89a1+ts0Nggt7GZxVw/dQhH2Gc6KxRbAZKFMTuYRcNufJ5CyTvq85rMWEhTPWQxlQe1V7NIOlsQzyDhlejiCVbq0chDVrwRY23BmLrhSLi+SfKukEc0M+V9LdFiqxxLgUBrVdCvxA17g11j6HGdmXiNze2WDKm7ZXgMYvEiVmglpRZOb4JeJzxNPFsxhv2k6VkKB+GRtO48XwNz/i2taQ8Nq+LO5tED6HhCp4BAY89a18wBKoSkBT2IRo530FeNq/lpVozY0+k+p+H//TAtKoWZBamKCwra81idaMm1TTaWZGlX85Pq0kBvx1sQLFEhILeQrbH0O3Iuk9JlKLKctAwnBj8BZUGJEGtbigTKaSgWcMBhW+jfKWi1xwSxyJLRkIcXmE8U3FE/XHCLeiBy0NRa0yKn3xh62JU9087Q2nOMClxy5Zy5VsZ0qQ=" + } + }, + "toolName": "getWeather", + "type": "tool-input-start" + }, + { + "delta": "{\"location\":\"Boston", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"}", + "id": "test-id", + "providerMetadata": { + "googleVertex": { + "thoughtSignature": "CiMBjz1rX25KieIB4d4AwFn8/WbsHTRNHBXso88PtemwfsSfRAp6AY89a1/e2FrmrE2HtlOxvV7vy8Kn5Og9n6CepcBKYW9aBT5QdXtTD4PGv9pDDWLEDAfsd86Et6k7HDEV976M3kC0ahLS8FCrwRntUKr+GF5uiUfPvgpzqNl5m8d8EwYykfwo1VwuTStR7q3mr/TzDcNzepafpVLdZloKYQGPPWtfkohmdau0GqGi3DuPbFTUBe5Ac/UbCvUk+P7KFbCGb93vFmnjmmTV7228DcAddqtWfe4iVePtSumw3JptYE6PIeVao2r8q7FXZKazzEiA9c04nmq6Vv9lOtt5NsgKrwEBjz1rX1ajIx3yeG/QI78uO+MsKoqgwrCI7PAEaDRqbyDCW/NX/4Vfuz1wp96oT3h/ttJ5ejzQyGamOrYreUUFvJBR/0TvFBdWOXwwCOWtvJ0Jh7SinV9HNqBDM1WWH8e+tGWY0r7xU/o/M1Fkwd73bvyp3BmSm5orrQL4jUZ1TN6ECqw7T7wHEkIfuBXGMZhQwAPzaapGVCdn0LjFJwd1aVReWmt6pE4ut993/K+0CqsBAY89a1+ts0Nggt7GZxVw/dQhH2Gc6KxRbAZKFMTuYRcNufJ5CyTvq85rMWEhTPWQxlQe1V7NIOlsQzyDhlejiCVbq0chDVrwRY23BmLrhSLi+SfKukEc0M+V9LdFiqxxLgUBrVdCvxA17g11j6HGdmXiNze2WDKm7ZXgMYvEiVmglpRZOb4JeJzxNPFsxhv2k6VkKB+GRtO48XwNz/i2taQ8Nq+LO5tED6HhCp4BAY89a18wBKoSkBT2IRo530FeNq/lpVozY0+k+p+H//TAtKoWZBamKCwra81idaMm1TTaWZGlX85Pq0kBvx1sQLFEhILeQrbH0O3Iuk9JlKLKctAwnBj8BZUGJEGtbigTKaSgWcMBhW+jfKWi1xwSxyJLRkIcXmE8U3FE/XHCLeiBy0NRa0yKn3xh62JU9087Q2nOMClxy5Zy5VsZ0qQ=" + }, + "vertex": { + "thoughtSignature": "CiMBjz1rX25KieIB4d4AwFn8/WbsHTRNHBXso88PtemwfsSfRAp6AY89a1/e2FrmrE2HtlOxvV7vy8Kn5Og9n6CepcBKYW9aBT5QdXtTD4PGv9pDDWLEDAfsd86Et6k7HDEV976M3kC0ahLS8FCrwRntUKr+GF5uiUfPvgpzqNl5m8d8EwYykfwo1VwuTStR7q3mr/TzDcNzepafpVLdZloKYQGPPWtfkohmdau0GqGi3DuPbFTUBe5Ac/UbCvUk+P7KFbCGb93vFmnjmmTV7228DcAddqtWfe4iVePtSumw3JptYE6PIeVao2r8q7FXZKazzEiA9c04nmq6Vv9lOtt5NsgKrwEBjz1rX1ajIx3yeG/QI78uO+MsKoqgwrCI7PAEaDRqbyDCW/NX/4Vfuz1wp96oT3h/ttJ5ejzQyGamOrYreUUFvJBR/0TvFBdWOXwwCOWtvJ0Jh7SinV9HNqBDM1WWH8e+tGWY0r7xU/o/M1Fkwd73bvyp3BmSm5orrQL4jUZ1TN6ECqw7T7wHEkIfuBXGMZhQwAPzaapGVCdn0LjFJwd1aVReWmt6pE4ut993/K+0CqsBAY89a1+ts0Nggt7GZxVw/dQhH2Gc6KxRbAZKFMTuYRcNufJ5CyTvq85rMWEhTPWQxlQe1V7NIOlsQzyDhlejiCVbq0chDVrwRY23BmLrhSLi+SfKukEc0M+V9LdFiqxxLgUBrVdCvxA17g11j6HGdmXiNze2WDKm7ZXgMYvEiVmglpRZOb4JeJzxNPFsxhv2k6VkKB+GRtO48XwNz/i2taQ8Nq+LO5tED6HhCp4BAY89a18wBKoSkBT2IRo530FeNq/lpVozY0+k+p+H//TAtKoWZBamKCwra81idaMm1TTaWZGlX85Pq0kBvx1sQLFEhILeQrbH0O3Iuk9JlKLKctAwnBj8BZUGJEGtbigTKaSgWcMBhW+jfKWi1xwSxyJLRkIcXmE8U3FE/XHCLeiBy0NRa0yKn3xh62JU9087Q2nOMClxy5Zy5VsZ0qQ=" + } + }, + "type": "tool-input-delta" + }, + { + "id": "test-id", + "providerMetadata": { + "googleVertex": { + "thoughtSignature": "CiMBjz1rX25KieIB4d4AwFn8/WbsHTRNHBXso88PtemwfsSfRAp6AY89a1/e2FrmrE2HtlOxvV7vy8Kn5Og9n6CepcBKYW9aBT5QdXtTD4PGv9pDDWLEDAfsd86Et6k7HDEV976M3kC0ahLS8FCrwRntUKr+GF5uiUfPvgpzqNl5m8d8EwYykfwo1VwuTStR7q3mr/TzDcNzepafpVLdZloKYQGPPWtfkohmdau0GqGi3DuPbFTUBe5Ac/UbCvUk+P7KFbCGb93vFmnjmmTV7228DcAddqtWfe4iVePtSumw3JptYE6PIeVao2r8q7FXZKazzEiA9c04nmq6Vv9lOtt5NsgKrwEBjz1rX1ajIx3yeG/QI78uO+MsKoqgwrCI7PAEaDRqbyDCW/NX/4Vfuz1wp96oT3h/ttJ5ejzQyGamOrYreUUFvJBR/0TvFBdWOXwwCOWtvJ0Jh7SinV9HNqBDM1WWH8e+tGWY0r7xU/o/M1Fkwd73bvyp3BmSm5orrQL4jUZ1TN6ECqw7T7wHEkIfuBXGMZhQwAPzaapGVCdn0LjFJwd1aVReWmt6pE4ut993/K+0CqsBAY89a1+ts0Nggt7GZxVw/dQhH2Gc6KxRbAZKFMTuYRcNufJ5CyTvq85rMWEhTPWQxlQe1V7NIOlsQzyDhlejiCVbq0chDVrwRY23BmLrhSLi+SfKukEc0M+V9LdFiqxxLgUBrVdCvxA17g11j6HGdmXiNze2WDKm7ZXgMYvEiVmglpRZOb4JeJzxNPFsxhv2k6VkKB+GRtO48XwNz/i2taQ8Nq+LO5tED6HhCp4BAY89a18wBKoSkBT2IRo530FeNq/lpVozY0+k+p+H//TAtKoWZBamKCwra81idaMm1TTaWZGlX85Pq0kBvx1sQLFEhILeQrbH0O3Iuk9JlKLKctAwnBj8BZUGJEGtbigTKaSgWcMBhW+jfKWi1xwSxyJLRkIcXmE8U3FE/XHCLeiBy0NRa0yKn3xh62JU9087Q2nOMClxy5Zy5VsZ0qQ=" + }, + "vertex": { + "thoughtSignature": "CiMBjz1rX25KieIB4d4AwFn8/WbsHTRNHBXso88PtemwfsSfRAp6AY89a1/e2FrmrE2HtlOxvV7vy8Kn5Og9n6CepcBKYW9aBT5QdXtTD4PGv9pDDWLEDAfsd86Et6k7HDEV976M3kC0ahLS8FCrwRntUKr+GF5uiUfPvgpzqNl5m8d8EwYykfwo1VwuTStR7q3mr/TzDcNzepafpVLdZloKYQGPPWtfkohmdau0GqGi3DuPbFTUBe5Ac/UbCvUk+P7KFbCGb93vFmnjmmTV7228DcAddqtWfe4iVePtSumw3JptYE6PIeVao2r8q7FXZKazzEiA9c04nmq6Vv9lOtt5NsgKrwEBjz1rX1ajIx3yeG/QI78uO+MsKoqgwrCI7PAEaDRqbyDCW/NX/4Vfuz1wp96oT3h/ttJ5ejzQyGamOrYreUUFvJBR/0TvFBdWOXwwCOWtvJ0Jh7SinV9HNqBDM1WWH8e+tGWY0r7xU/o/M1Fkwd73bvyp3BmSm5orrQL4jUZ1TN6ECqw7T7wHEkIfuBXGMZhQwAPzaapGVCdn0LjFJwd1aVReWmt6pE4ut993/K+0CqsBAY89a1+ts0Nggt7GZxVw/dQhH2Gc6KxRbAZKFMTuYRcNufJ5CyTvq85rMWEhTPWQxlQe1V7NIOlsQzyDhlejiCVbq0chDVrwRY23BmLrhSLi+SfKukEc0M+V9LdFiqxxLgUBrVdCvxA17g11j6HGdmXiNze2WDKm7ZXgMYvEiVmglpRZOb4JeJzxNPFsxhv2k6VkKB+GRtO48XwNz/i2taQ8Nq+LO5tED6HhCp4BAY89a18wBKoSkBT2IRo530FeNq/lpVozY0+k+p+H//TAtKoWZBamKCwra81idaMm1TTaWZGlX85Pq0kBvx1sQLFEhILeQrbH0O3Iuk9JlKLKctAwnBj8BZUGJEGtbigTKaSgWcMBhW+jfKWi1xwSxyJLRkIcXmE8U3FE/XHCLeiBy0NRa0yKn3xh62JU9087Q2nOMClxy5Zy5VsZ0qQ=" + } + }, + "type": "tool-input-end" + }, + { + "input": "{\"location\":\"Boston\"}", + "providerMetadata": { + "googleVertex": { + "thoughtSignature": "CiMBjz1rX25KieIB4d4AwFn8/WbsHTRNHBXso88PtemwfsSfRAp6AY89a1/e2FrmrE2HtlOxvV7vy8Kn5Og9n6CepcBKYW9aBT5QdXtTD4PGv9pDDWLEDAfsd86Et6k7HDEV976M3kC0ahLS8FCrwRntUKr+GF5uiUfPvgpzqNl5m8d8EwYykfwo1VwuTStR7q3mr/TzDcNzepafpVLdZloKYQGPPWtfkohmdau0GqGi3DuPbFTUBe5Ac/UbCvUk+P7KFbCGb93vFmnjmmTV7228DcAddqtWfe4iVePtSumw3JptYE6PIeVao2r8q7FXZKazzEiA9c04nmq6Vv9lOtt5NsgKrwEBjz1rX1ajIx3yeG/QI78uO+MsKoqgwrCI7PAEaDRqbyDCW/NX/4Vfuz1wp96oT3h/ttJ5ejzQyGamOrYreUUFvJBR/0TvFBdWOXwwCOWtvJ0Jh7SinV9HNqBDM1WWH8e+tGWY0r7xU/o/M1Fkwd73bvyp3BmSm5orrQL4jUZ1TN6ECqw7T7wHEkIfuBXGMZhQwAPzaapGVCdn0LjFJwd1aVReWmt6pE4ut993/K+0CqsBAY89a1+ts0Nggt7GZxVw/dQhH2Gc6KxRbAZKFMTuYRcNufJ5CyTvq85rMWEhTPWQxlQe1V7NIOlsQzyDhlejiCVbq0chDVrwRY23BmLrhSLi+SfKukEc0M+V9LdFiqxxLgUBrVdCvxA17g11j6HGdmXiNze2WDKm7ZXgMYvEiVmglpRZOb4JeJzxNPFsxhv2k6VkKB+GRtO48XwNz/i2taQ8Nq+LO5tED6HhCp4BAY89a18wBKoSkBT2IRo530FeNq/lpVozY0+k+p+H//TAtKoWZBamKCwra81idaMm1TTaWZGlX85Pq0kBvx1sQLFEhILeQrbH0O3Iuk9JlKLKctAwnBj8BZUGJEGtbigTKaSgWcMBhW+jfKWi1xwSxyJLRkIcXmE8U3FE/XHCLeiBy0NRa0yKn3xh62JU9087Q2nOMClxy5Zy5VsZ0qQ=" + }, + "vertex": { + "thoughtSignature": "CiMBjz1rX25KieIB4d4AwFn8/WbsHTRNHBXso88PtemwfsSfRAp6AY89a1/e2FrmrE2HtlOxvV7vy8Kn5Og9n6CepcBKYW9aBT5QdXtTD4PGv9pDDWLEDAfsd86Et6k7HDEV976M3kC0ahLS8FCrwRntUKr+GF5uiUfPvgpzqNl5m8d8EwYykfwo1VwuTStR7q3mr/TzDcNzepafpVLdZloKYQGPPWtfkohmdau0GqGi3DuPbFTUBe5Ac/UbCvUk+P7KFbCGb93vFmnjmmTV7228DcAddqtWfe4iVePtSumw3JptYE6PIeVao2r8q7FXZKazzEiA9c04nmq6Vv9lOtt5NsgKrwEBjz1rX1ajIx3yeG/QI78uO+MsKoqgwrCI7PAEaDRqbyDCW/NX/4Vfuz1wp96oT3h/ttJ5ejzQyGamOrYreUUFvJBR/0TvFBdWOXwwCOWtvJ0Jh7SinV9HNqBDM1WWH8e+tGWY0r7xU/o/M1Fkwd73bvyp3BmSm5orrQL4jUZ1TN6ECqw7T7wHEkIfuBXGMZhQwAPzaapGVCdn0LjFJwd1aVReWmt6pE4ut993/K+0CqsBAY89a1+ts0Nggt7GZxVw/dQhH2Gc6KxRbAZKFMTuYRcNufJ5CyTvq85rMWEhTPWQxlQe1V7NIOlsQzyDhlejiCVbq0chDVrwRY23BmLrhSLi+SfKukEc0M+V9LdFiqxxLgUBrVdCvxA17g11j6HGdmXiNze2WDKm7ZXgMYvEiVmglpRZOb4JeJzxNPFsxhv2k6VkKB+GRtO48XwNz/i2taQ8Nq+LO5tED6HhCp4BAY89a18wBKoSkBT2IRo530FeNq/lpVozY0+k+p+H//TAtKoWZBamKCwra81idaMm1TTaWZGlX85Pq0kBvx1sQLFEhILeQrbH0O3Iuk9JlKLKctAwnBj8BZUGJEGtbigTKaSgWcMBhW+jfKWi1xwSxyJLRkIcXmE8U3FE/XHCLeiBy0NRa0yKn3xh62JU9087Q2nOMClxy5Zy5VsZ0qQ=" + } + }, + "toolCallId": "test-id", + "toolName": "getWeather", + "type": "tool-call" + }, + { + "id": "test-id", + "toolName": "getWeather", + "type": "tool-input-start" + }, + { + "delta": "{\"location\":\"San Francisco", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"}", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "id": "test-id", + "type": "tool-input-end" + }, + { + "input": "{\"location\":\"San Francisco\"}", + "toolCallId": "test-id", + "toolName": "getWeather", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "tool-calls" + }, + "providerMetadata": { + "googleVertex": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 23, + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 23 + } + ], + "promptTokenCount": 26, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 26 + } + ], + "thoughtsTokenCount": 132, + "totalTokenCount": 181, + "trafficType": "ON_DEMAND" + } + }, + "vertex": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 23, + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 23 + } + ], + "promptTokenCount": 26, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 26 + } + ], + "thoughtsTokenCount": 132, + "totalTokenCount": 181, + "trafficType": "ON_DEMAND" + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 26, + "total": 26 + }, + "outputTokens": { + "reasoning": 132, + "text": 23, + "total": 155 + }, + "raw": { + "candidatesTokenCount": 23, + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 23 + } + ], + "promptTokenCount": 26, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 26 + } + ], + "thoughtsTokenCount": 132, + "totalTokenCount": 181, + "trafficType": "ON_DEMAND" + } + } + } +] diff --git a/aimux-providers/src/google/tests/snapshots/do_stream_streaming_tool_call_arguments_nested.json b/aimux-providers/src/google/tests/snapshots/do_stream_streaming_tool_call_arguments_nested.json new file mode 100644 index 000000000..c2ff5ffa3 --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_stream_streaming_tool_call_arguments_nested.json @@ -0,0 +1,321 @@ +[ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "tjXVaYaxFISTq8YP_MWiyAo", + "type": "response-metadata" + }, + { + "id": "test-id", + "providerMetadata": { + "googleVertex": { + "thoughtSignature": "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" + }, + "vertex": { + "thoughtSignature": "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" + } + }, + "toolName": "cookRecipe", + "type": "tool-input-start" + }, + { + "delta": "{\"recipe\":{\"ingredients\":[{\"amount\":\"16 oz", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Lasagna noodles", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"},{\"amount\":\"1 lb", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Ground beef", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"},{\"amount\":\"15 oz", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Ricotta cheese", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"},{\"amount\":\"3 cups", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Mozzarella cheese", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"},{\"amount\":\"1/2 cup", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Parmesan cheese", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"},{\"amount\":\"24 oz", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Tomato sauce", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"},{\"amount\":\"1", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Egg", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"},{\"amount\":\"2 cloves", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Garlic", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"},{\"amount\":\"1 tsp", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Salt", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"},{\"amount\":\"1/2 tsp", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"name\":\"Pepper", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"}],\"name\":\"Lasagna", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"steps\":[\"Preheat oven to 375°F (190°C).", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"Cook lasagna noodles according to package directions, drain and set aside", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": ".", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"Brown ground beef with minced garlic in a skillet. Drain fat and stir in tomato sauce. Simmer for 10 minutes.", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"In a bowl, mix ricotta cheese, egg, salt, pepper, and Parmesan cheese.", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"In a 9x13 baking dish, spread a", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": " thin layer of meat sauce.", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"Layer noodles, ricotta mixture, mozzarella, and meat sauce. Repeat.", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"Top with remaining mozzarella cheese.", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"Cover with foil and bake for 25 minutes.", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"Remove foil and bake for another 25 minutes until golden.", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\",\"Let stand for 15 minutes before serving.", + "id": "test-id", + "type": "tool-input-delta" + }, + { + "delta": "\"]}}", + "id": "test-id", + "providerMetadata": { + "googleVertex": { + "thoughtSignature": "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" + }, + "vertex": { + "thoughtSignature": "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" + } + }, + "type": "tool-input-delta" + }, + { + "id": "test-id", + "providerMetadata": { + "googleVertex": { + "thoughtSignature": "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" + }, + "vertex": { + "thoughtSignature": "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" + } + }, + "type": "tool-input-end" + }, + { + "input": "{\"recipe\":{\"ingredients\":[{\"amount\":\"16 oz\",\"name\":\"Lasagna noodles\"},{\"amount\":\"1 lb\",\"name\":\"Ground beef\"},{\"amount\":\"15 oz\",\"name\":\"Ricotta cheese\"},{\"amount\":\"3 cups\",\"name\":\"Mozzarella cheese\"},{\"amount\":\"1/2 cup\",\"name\":\"Parmesan cheese\"},{\"amount\":\"24 oz\",\"name\":\"Tomato sauce\"},{\"amount\":\"1\",\"name\":\"Egg\"},{\"amount\":\"2 cloves\",\"name\":\"Garlic\"},{\"amount\":\"1 tsp\",\"name\":\"Salt\"},{\"amount\":\"1/2 tsp\",\"name\":\"Pepper\"}],\"name\":\"Lasagna\",\"steps\":[\"Preheat oven to 375°F (190°C).\",\"Cook lasagna noodles according to package directions, drain and set aside.\",\"Brown ground beef with minced garlic in a skillet. Drain fat and stir in tomato sauce. Simmer for 10 minutes.\",\"In a bowl, mix ricotta cheese, egg, salt, pepper, and Parmesan cheese.\",\"In a 9x13 baking dish, spread a thin layer of meat sauce.\",\"Layer noodles, ricotta mixture, mozzarella, and meat sauce. Repeat.\",\"Top with remaining mozzarella cheese.\",\"Cover with foil and bake for 25 minutes.\",\"Remove foil and bake for another 25 minutes until golden.\",\"Let stand for 15 minutes before serving.\"]}}", + "providerMetadata": { + "googleVertex": { + "thoughtSignature": "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" + }, + "vertex": { + "thoughtSignature": "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" + } + }, + "toolCallId": "test-id", + "toolName": "cookRecipe", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "tool-calls" + }, + "providerMetadata": { + "googleVertex": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 684, + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 684 + } + ], + "promptTokenCount": 31, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 31 + } + ], + "thoughtsTokenCount": 1026, + "totalTokenCount": 1741, + "trafficType": "ON_DEMAND" + } + }, + "vertex": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 684, + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 684 + } + ], + "promptTokenCount": 31, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 31 + } + ], + "thoughtsTokenCount": 1026, + "totalTokenCount": 1741, + "trafficType": "ON_DEMAND" + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 31, + "total": 31 + }, + "outputTokens": { + "reasoning": 1026, + "text": 684, + "total": 1710 + }, + "raw": { + "candidatesTokenCount": 684, + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 684 + } + ], + "promptTokenCount": 31, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 31 + } + ], + "thoughtsTokenCount": 1026, + "totalTokenCount": 1741, + "trafficType": "ON_DEMAND" + } + } + } +] diff --git a/aimux-providers/src/google/tests/snapshots/do_stream_text_raw_chunks.json b/aimux-providers/src/google/tests/snapshots/do_stream_text_raw_chunks.json new file mode 100644 index 000000000..d254f20dd --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_stream_text_raw_chunks.json @@ -0,0 +1,100 @@ +[ + { + "rawValue": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "There are **3**" + } + ], + "role": "model" + }, + "index": 0 + } + ], + "modelVersion": "gemini-3-pro-preview", + "responseId": "bH6LaZW8Fp_3nsEPqtaSwQ4", + "usageMetadata": { + "candidatesTokenCount": 5, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 185, + "totalTokenCount": 199 + } + }, + "type": "raw" + }, + { + "rawValue": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": " \"r\"s in strawberry.\n\nst**r**awbe**rr**y" + } + ], + "role": "model" + }, + "index": 0 + } + ], + "modelVersion": "gemini-3-pro-preview", + "responseId": "bH6LaZW8Fp_3nsEPqtaSwQ4", + "usageMetadata": { + "candidatesTokenCount": 23, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 185, + "totalTokenCount": 217 + } + }, + "type": "raw" + }, + { + "rawValue": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "", + "thoughtSignature": "EqsFCqgFAb4+9vvtAF5n87lB4OGDOoTRMOqp35jW65XsYXh6BySMwl9nvrbAvPcl2U0xITaYUyV4CmREEDB1z0ZPpCg7iEwiZcj40Eh1jXoL8Y/BbPqxdgZKvKxdBsJx92y2ML5ytajQHVFQb9ohEMMnjs9uNadLAhDEsOU1nC5tl3FQkx94uaGfWvg61bJT3Y9OxFdo/kbpm4RBngvYhVkBzHKkHBj72T2bUd8J4HPssi7ORC5iPosPRIOyH/CAVHEtMzFYMwb7OhRu+CW8Z9u7gDieME5iJjXtJtLrNGDxgR7XtWfRRyGjsj6uDS+KvjR3SUSWPdn5eeH6w+LXZm1X///Hvhhcx+NHxsuGjF3fGhyzTVAoIzk0lxyB4+/A9I4Xa0o/T4coVDiewMzGZDwmket//ig8x9UC8cyWr/hy1joZWUO7ooJlLncv8gy4Ng+y1JdievZokSFDNWfMMNAQr3kgUwJDucqDp44C1xMtgR3lhJ75IBBnprHCE/ThgvNXujmqNkwAjp5dS4PjVbrw8fqSylfE80tvU0g9dXqg4pEyG+hGIxbANLhsWjAKLqh69hyqvVLg2Ds3wppphf61IfC4VoeLWj85CjBZMf+k85NsUIJQ6+DQS9IPNbM29ZOzpUbHoWKJB6VzNCSJse7Pi07L+pd6skl77km00y4lJdHIGHfEgi8PaOonakBcxbRqKzGJAA/urlP0tiWya2fTWrvNZOybJHyyofNNSI4s5y76yKEjP1wnPqC7ujrQk6xb7eyCeqH9ekByy3vv0JfgERFptoSUoG2toIr9M3lS/LKpnwfCvZh+z3J0iMb83d4MaPKhGhE49J4660XUsEmjygAZNi9HnjfC3KtaU/07Sx4JCezMtpsLKUxBgy4xaNqwew3FwAG37eeWcow=" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "modelVersion": "gemini-3-pro-preview", + "responseId": "bH6LaZW8Fp_3nsEPqtaSwQ4", + "usageMetadata": { + "candidatesTokenCount": 23, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 185, + "totalTokenCount": 217 + } + }, + "type": "raw" + } +] diff --git a/aimux-providers/src/google/tests/snapshots/do_stream_text_stream_text_deltas.json b/aimux-providers/src/google/tests/snapshots/do_stream_text_stream_text_deltas.json new file mode 100644 index 000000000..686dce78e --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_stream_text_stream_text_deltas.json @@ -0,0 +1,91 @@ +[ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "bH6LaZW8Fp_3nsEPqtaSwQ4", + "type": "response-metadata" + }, + { + "id": "0", + "type": "text-start" + }, + { + "delta": "There are **3**", + "id": "0", + "type": "text-delta" + }, + { + "delta": " \"r\"s in strawberry.\n\nst**r**awbe**rr**y", + "id": "0", + "type": "text-delta" + }, + { + "delta": "", + "id": "0", + "providerMetadata": { + "google": { + "thoughtSignature": "EqsFCqgFAb4+9vvtAF5n87lB4OGDOoTRMOqp35jW65XsYXh6BySMwl9nvrbAvPcl2U0xITaYUyV4CmREEDB1z0ZPpCg7iEwiZcj40Eh1jXoL8Y/BbPqxdgZKvKxdBsJx92y2ML5ytajQHVFQb9ohEMMnjs9uNadLAhDEsOU1nC5tl3FQkx94uaGfWvg61bJT3Y9OxFdo/kbpm4RBngvYhVkBzHKkHBj72T2bUd8J4HPssi7ORC5iPosPRIOyH/CAVHEtMzFYMwb7OhRu+CW8Z9u7gDieME5iJjXtJtLrNGDxgR7XtWfRRyGjsj6uDS+KvjR3SUSWPdn5eeH6w+LXZm1X///Hvhhcx+NHxsuGjF3fGhyzTVAoIzk0lxyB4+/A9I4Xa0o/T4coVDiewMzGZDwmket//ig8x9UC8cyWr/hy1joZWUO7ooJlLncv8gy4Ng+y1JdievZokSFDNWfMMNAQr3kgUwJDucqDp44C1xMtgR3lhJ75IBBnprHCE/ThgvNXujmqNkwAjp5dS4PjVbrw8fqSylfE80tvU0g9dXqg4pEyG+hGIxbANLhsWjAKLqh69hyqvVLg2Ds3wppphf61IfC4VoeLWj85CjBZMf+k85NsUIJQ6+DQS9IPNbM29ZOzpUbHoWKJB6VzNCSJse7Pi07L+pd6skl77km00y4lJdHIGHfEgi8PaOonakBcxbRqKzGJAA/urlP0tiWya2fTWrvNZOybJHyyofNNSI4s5y76yKEjP1wnPqC7ujrQk6xb7eyCeqH9ekByy3vv0JfgERFptoSUoG2toIr9M3lS/LKpnwfCvZh+z3J0iMb83d4MaPKhGhE49J4660XUsEmjygAZNi9HnjfC3KtaU/07Sx4JCezMtpsLKUxBgy4xaNqwew3FwAG37eeWcow=" + } + }, + "type": "text-delta" + }, + { + "id": "0", + "type": "text-end" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "stop" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 23, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 185, + "totalTokenCount": 217 + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 9, + "total": 9 + }, + "outputTokens": { + "reasoning": 185, + "text": 23, + "total": 208 + }, + "raw": { + "candidatesTokenCount": 23, + "promptTokenCount": 9, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ], + "thoughtsTokenCount": 185, + "totalTokenCount": 217 + } + } + } +] diff --git a/aimux-providers/src/google/tests/snapshots/do_stream_tool_call.json b/aimux-providers/src/google/tests/snapshots/do_stream_tool_call.json new file mode 100644 index 000000000..ae1c27741 --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_stream_tool_call.json @@ -0,0 +1,103 @@ +[ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "b36LacjwM668nsEP2tbsgQQ", + "type": "response-metadata" + }, + { + "id": "test-id", + "providerMetadata": { + "google": { + "thoughtSignature": "EqUCCqICAb4+9vsh8Pd5taZVoPzSvjWWwzBrvhEQWBLCGa7IdY8FBMm7Z6dCKFU3Ft0la15gF7RaHe1NlPRygQec0bFwPDfMwGcUOMNiJiNIKxusCs4ejCZRuouNYQ4etEIt7CujEUHiILLfZXSJZYhs4UCrD2bLqPq0sE0lWgYJnzHkkKUOnMsA2hKffAhtF4DWn5INYj8pPssvch/2VpDFW2F9XSE04zLDzkIWF2eztJX50Y0lTehRZC3FW7fOrXCzGx+PwdataD6eXlF5O1zn+86XtmktOs2DEp4o1PMvXFFAXe8GGvPt8Idf3UtHMq7AsapwMW9sjiKj+FJk54m+9LMTSaj7C86smfvoQryYBEHTVazr1bEnpl4bPG5JUtm2yAMkHj4=" + } + }, + "toolName": "weather", + "type": "tool-input-start" + }, + { + "delta": "{\"location\":\"San Francisco\"}", + "id": "test-id", + "providerMetadata": { + "google": { + "thoughtSignature": "EqUCCqICAb4+9vsh8Pd5taZVoPzSvjWWwzBrvhEQWBLCGa7IdY8FBMm7Z6dCKFU3Ft0la15gF7RaHe1NlPRygQec0bFwPDfMwGcUOMNiJiNIKxusCs4ejCZRuouNYQ4etEIt7CujEUHiILLfZXSJZYhs4UCrD2bLqPq0sE0lWgYJnzHkkKUOnMsA2hKffAhtF4DWn5INYj8pPssvch/2VpDFW2F9XSE04zLDzkIWF2eztJX50Y0lTehRZC3FW7fOrXCzGx+PwdataD6eXlF5O1zn+86XtmktOs2DEp4o1PMvXFFAXe8GGvPt8Idf3UtHMq7AsapwMW9sjiKj+FJk54m+9LMTSaj7C86smfvoQryYBEHTVazr1bEnpl4bPG5JUtm2yAMkHj4=" + } + }, + "type": "tool-input-delta" + }, + { + "id": "test-id", + "providerMetadata": { + "google": { + "thoughtSignature": "EqUCCqICAb4+9vsh8Pd5taZVoPzSvjWWwzBrvhEQWBLCGa7IdY8FBMm7Z6dCKFU3Ft0la15gF7RaHe1NlPRygQec0bFwPDfMwGcUOMNiJiNIKxusCs4ejCZRuouNYQ4etEIt7CujEUHiILLfZXSJZYhs4UCrD2bLqPq0sE0lWgYJnzHkkKUOnMsA2hKffAhtF4DWn5INYj8pPssvch/2VpDFW2F9XSE04zLDzkIWF2eztJX50Y0lTehRZC3FW7fOrXCzGx+PwdataD6eXlF5O1zn+86XtmktOs2DEp4o1PMvXFFAXe8GGvPt8Idf3UtHMq7AsapwMW9sjiKj+FJk54m+9LMTSaj7C86smfvoQryYBEHTVazr1bEnpl4bPG5JUtm2yAMkHj4=" + } + }, + "type": "tool-input-end" + }, + { + "input": "{\"location\":\"San Francisco\"}", + "providerMetadata": { + "google": { + "thoughtSignature": "EqUCCqICAb4+9vsh8Pd5taZVoPzSvjWWwzBrvhEQWBLCGa7IdY8FBMm7Z6dCKFU3Ft0la15gF7RaHe1NlPRygQec0bFwPDfMwGcUOMNiJiNIKxusCs4ejCZRuouNYQ4etEIt7CujEUHiILLfZXSJZYhs4UCrD2bLqPq0sE0lWgYJnzHkkKUOnMsA2hKffAhtF4DWn5INYj8pPssvch/2VpDFW2F9XSE04zLDzkIWF2eztJX50Y0lTehRZC3FW7fOrXCzGx+PwdataD6eXlF5O1zn+86XtmktOs2DEp4o1PMvXFFAXe8GGvPt8Idf3UtHMq7AsapwMW9sjiKj+FJk54m+9LMTSaj7C86smfvoQryYBEHTVazr1bEnpl4bPG5JUtm2yAMkHj4=" + } + }, + "toolCallId": "test-id", + "toolName": "weather", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "tool-calls" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 45, + "totalTokenCount": 89 + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 29, + "total": 29 + }, + "outputTokens": { + "reasoning": 45, + "text": 15, + "total": 60 + }, + "raw": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 45, + "totalTokenCount": 89 + } + } + } +] diff --git a/aimux-providers/src/google/tests/snapshots/do_stream_tool_call_gemini3.json b/aimux-providers/src/google/tests/snapshots/do_stream_tool_call_gemini3.json new file mode 100644 index 000000000..919a64665 --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/do_stream_tool_call_gemini3.json @@ -0,0 +1,103 @@ +[ + { + "type": "stream-start", + "warnings": [] + }, + { + "id": "QHiLaa6LBrb8vdIPoNztsAg", + "type": "response-metadata" + }, + { + "id": "test-id", + "providerMetadata": { + "google": { + "thoughtSignature": "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" + } + }, + "toolName": "weather", + "type": "tool-input-start" + }, + { + "delta": "{\"location\":\"San Francisco\"}", + "id": "test-id", + "providerMetadata": { + "google": { + "thoughtSignature": "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" + } + }, + "type": "tool-input-delta" + }, + { + "id": "test-id", + "providerMetadata": { + "google": { + "thoughtSignature": "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" + } + }, + "type": "tool-input-end" + }, + { + "input": "{\"location\":\"San Francisco\"}", + "providerMetadata": { + "google": { + "thoughtSignature": "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" + } + }, + "toolCallId": "test-id", + "toolName": "weather", + "type": "tool-call" + }, + { + "finishReason": { + "raw": "STOP", + "unified": "tool-calls" + }, + "providerMetadata": { + "google": { + "finishMessage": null, + "groundingMetadata": null, + "promptFeedback": null, + "safetyRatings": null, + "serviceTier": null, + "urlContextMetadata": null, + "usageMetadata": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 804, + "totalTokenCount": 848 + } + } + }, + "type": "finish", + "usage": { + "inputTokens": { + "cacheRead": 0, + "noCache": 29, + "total": 29 + }, + "outputTokens": { + "reasoning": 804, + "text": 15, + "total": 819 + }, + "raw": { + "candidatesTokenCount": 15, + "promptTokenCount": 29, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 29 + } + ], + "thoughtsTokenCount": 804, + "totalTokenCount": 848 + } + } + } +] diff --git a/aimux-providers/src/google/tests/snapshots/embedding_expose_the_raw_response.json b/aimux-providers/src/google/tests/snapshots/embedding_expose_the_raw_response.json new file mode 100644 index 000000000..418f34ce7 --- /dev/null +++ b/aimux-providers/src/google/tests/snapshots/embedding_expose_the_raw_response.json @@ -0,0 +1,17 @@ +{ + "body": { + "embeddings": [ + { + "values": [0.1, 0.2, 0.3, 0.4, 0.5] + }, + { + "values": [0.6, 0.7, 0.8, 0.9, 1] + } + ] + }, + "headers": { + "content-length": "80", + "content-type": "application/json", + "test-header": "test-value" + } +} diff --git a/aimux-providers/src/google/tests/speech_input.rs b/aimux-providers/src/google/tests/speech_input.rs new file mode 100644 index 000000000..0403379d3 --- /dev/null +++ b/aimux-providers/src/google/tests/speech_input.rs @@ -0,0 +1,35 @@ +use serde_json::{Value, json}; + +use crate::google::speech::google_speech_input; + +mod get_google_speech_input { + use super::*; + + /// upstream: @ai-sdk/google src/google-speech-input.test.ts › getGoogleSpeechInput › does not infer custom voices from unrelated fields: %j + #[test] + fn does_not_infer_custom_voices_from_unrelated_fields_j() { + // `undefined` leaves the key out; the other rows are the value itself. + let configs = [ + None, + Some(Value::Null), + Some(json!("invalid")), + Some(json!({"speakerVoiceConfigs": null})), + Some(json!({"speakerVoiceConfigs": [null, {}, {"voiceConfig": null}]})), + Some(json!({"speakerVoiceConfigs": [ + {"speaker": "Alice", "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}, + ]})), + ]; + for config in configs { + let google = match &config { + None => json!({}), + Some(config) => json!({"multiSpeakerVoiceConfig": config}), + }; + let provider_options = json!({"google": google}); + assert!( + !google_speech_input("Hello", Some("Kore"), Some(&provider_options)) + .uses_custom_voice, + "{config:?}" + ); + } + } +} diff --git a/aimux-providers/src/google/tests/speech_model.rs b/aimux-providers/src/google/tests/speech_model.rs new file mode 100644 index 000000000..9114f3a85 --- /dev/null +++ b/aimux-providers/src/google/tests/speech_model.rs @@ -0,0 +1,302 @@ +use std::sync::Arc; + +use base64::Engine; +use serde_json::{Value, json}; + +use aimux_core::error::AiMuxError; +use aimux_core::shared::SharedProviderOptions; +use aimux_core::speech_model::{AudioData, SpeechCallOptions, SpeechModel, SpeechResult}; + +use crate::google::{GoogleProviderSettings, GoogleSpeechModel, create_google}; +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +/// The pinned user agent (upstream mocks `0.0.0-test`). +const PINNED_USER_AGENT: &str = "ai-sdk-google/4.0.87 ai-sdk-provider-utils/5.0.53"; + +// 8 bytes of raw PCM ([1..8]) base64-encoded. +const PCM_BASE64: &str = "AQIDBAUGBwg="; +const WAV_BASE64: &str = "UklGRiwAAABXQVZFZm10IBAAAAABAAEAwF0AAIC7AAACABAAZGF0YQgAAAABAgMEBQYHCA=="; +const MODERN_MODELS: [&str; 3] = [ + "gemini-3.8-flash-tts", + "gemini-3.8-flash-lite-tts", + "custom-tts-model", +]; + +fn convert_base64_to_bytes(data: &str) -> Vec { + base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap() +} + +/// A response with one inline audio part (upstream `prepareJsonResponse`). +fn audio_response(mime_type: &str, data: &str) -> Canned { + Canned::json(&json!({ + "candidates": [{"content": {"parts": [{"inlineData": {"mimeType": mime_type, "data": data}}]}}], + })) +} + +/// `createGoogle({ apiKey: 'test-api-key', fetch }).speech(modelId)`. +fn speech_model(mock: &Arc, model_id: &str) -> GoogleSpeechModel { + create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + fetch: Some(mock.transport()), + ..Default::default() + }) + .unwrap() + .speech(model_id) +} + +fn google_options(value: Value) -> Option { + let Value::Object(map) = value else { + panic!("options must be an object") + }; + Some([("google".to_string(), map)].into_iter().collect()) +} + +fn audio(result: &SpeechResult) -> Vec { + match &result.audio { + AudioData::Binary(bytes) => bytes.clone(), + AudioData::Base64(data) => convert_base64_to_bytes(data), + } +} + +/// `rejects.toMatchObject({ name: 'AI_InvalidArgumentError', argument })`: +/// aimux carries the argument in the message +/// (`Invalid argument for parameter : …`). +fn assert_invalid_argument(error: &AiMuxError, argument: &str) { + let wire = serde_json::to_value(error).unwrap(); + assert_eq!(wire["name"], "AI_InvalidArgumentError", "{error:?}"); + let message = wire["message"].as_str().unwrap_or_default(); + assert!( + message.starts_with(&format!("Invalid argument for parameter {argument}")), + "{message}" + ); +} + +fn multi_speaker_voice_config() -> Value { + json!({"speakerVoiceConfigs": [ + {"speaker": "Joe", "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}, + {"speaker": "Jane", "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Puck"}}}, + ]}) +} + +mod do_generate { + use super::*; + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › preserves legacy requests and PCM conversion for %s + #[tokio::test] + async fn preserves_legacy_requests_and_pcm_conversion_for_s() { + for model_id in [ + "gemini-2.5-flash-preview-tts", + "gemini-2.5-pro-preview-tts", + "gemini-2.5-flash-tts", + "gemini-3.1-flash-tts-preview", + "gemini-3.1-flash-tts", + ] { + let mock = MockFetch::new(vec![audio_response("audio/L16;rate=24000", PCM_BASE64)]); + let mut call = SpeechCallOptions::new("Hello."); + call.instructions = Some("Whisper".into()); + call.output_format = Some("wav".into()); + let result = speech_model(&mock, model_id) + .do_generate(&call) + .await + .unwrap(); + assert_eq!( + mock.seen()[0].json_body(), + json!({ + "contents": [{"role": "user", "parts": [{"text": "Whisper: Hello."}]}], + "generationConfig": { + "responseModalities": ["AUDIO"], + "speechConfig": { + "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}, + }, + }, + }), + "{model_id}" + ); + assert_eq!( + audio(&result), + convert_base64_to_bytes(WAV_BASE64), + "{model_id}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let mock = MockFetch::new(vec![audio_response("audio/L16;rate=24000", PCM_BASE64)]); + let provider = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + headers: Some( + [( + "Custom-Provider-Header".to_string(), + Some("provider-header-value".to_string()), + )] + .into_iter() + .collect(), + ), + fetch: Some(mock.transport()), + ..Default::default() + }) + .unwrap(); + + let mut call = SpeechCallOptions::new("Hello from the AI SDK!"); + call.headers = Some( + [( + "Custom-Request-Header".to_string(), + "request-header-value".to_string(), + )] + .into_iter() + .collect(), + ); + provider + .speech("gemini-2.5-flash-preview-tts") + .do_generate(&call) + .await + .unwrap(); + + let seen = &mock.seen()[0]; + for (name, value) in [ + ("content-type", "application/json"), + ("x-goog-api-key", "test-api-key"), + ("custom-provider-header", "provider-header-value"), + ("custom-request-header", "request-header-value"), + ] { + assert_eq!(seen.headers.get(name).map(String::as_str), Some(value)); + } + assert!( + seen.headers["user-agent"].contains(PINNED_USER_AGENT), + "{}", + seen.headers["user-agent"] + ); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should use the real date when no custom date provider is specified + #[tokio::test] + async fn should_use_the_real_date_when_no_custom_date_provider_is_specified() { + let mock = MockFetch::new(vec![audio_response("audio/L16;rate=24000", PCM_BASE64)]); + let model = speech_model(&mock, "gemini-2.5-flash-preview-tts"); + + let before_date = aimux_core::Timestamp::now(); + let result = model + .do_generate(&SpeechCallOptions::new("Hello from the AI SDK!")) + .await + .unwrap(); + let after_date = aimux_core::Timestamp::now(); + + let timestamp = result.response.timestamp.unwrap(); + assert!(timestamp.unix_millis() >= before_date.unix_millis()); + assert!(timestamp.unix_millis() <= after_date.unix_millis()); + assert_eq!( + result.response.model_id.as_deref(), + Some("gemini-2.5-flash-preview-tts") + ); + } +} + +mod modern_models { + use super::*; + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › rejects custom speaker voice configuration %j before fetching + #[tokio::test] + async fn rejects_custom_speaker_voice_configuration_j_before_fetching() { + for model_id in MODERN_MODELS { + for voice_config in [ + json!({"voice": "voice_custom"}), + json!({"voice": "voicekey_custom"}), + json!({"voice": "voice_custom", "prebuiltVoiceConfig": {"voiceName": "Kore"}}), + ] { + let mock = MockFetch::new(vec![]); + let mut call = SpeechCallOptions::new(""); + call.provider_options = google_options(json!({ + "turns": [{"text": "Hello", "speechMetadata": {"speaker": "Joe"}}], + "multiSpeakerVoiceConfig": { + "speakerVoiceConfigs": [{"speaker": "Joe", "voiceConfig": voice_config}], + }, + })); + let error = speech_model(&mock, model_id) + .do_generate(&call) + .await + .unwrap_err(); + assert_invalid_argument(&error, "providerOptions"); + assert!(mock.seen().is_empty()); + } + } + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › rejects missing or unconfigured speaker %s + #[tokio::test] + async fn rejects_missing_or_unconfigured_speaker_s() { + for model_id in MODERN_MODELS { + for speaker in [None, Some("Unknown")] { + let mock = MockFetch::new(vec![]); + let speech_metadata = match speaker { + None => json!({}), + Some(speaker) => json!({"speaker": speaker}), + }; + let mut call = SpeechCallOptions::new(""); + call.provider_options = google_options(json!({ + "multiSpeakerVoiceConfig": multi_speaker_voice_config(), + "turns": [{"text": "Hello", "speechMetadata": speech_metadata}], + })); + let error = speech_model(&mock, model_id) + .do_generate(&call) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("Every multi-speaker turn must specify"), + "{model_id} {speaker:?}: {error}" + ); + assert!(mock.seen().is_empty()); + } + } + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › rejects a labelled transcript without structured speakers + #[tokio::test] + async fn rejects_a_labelled_transcript_without_structured_speakers() { + for model_id in MODERN_MODELS { + let mock = MockFetch::new(vec![]); + let mut call = SpeechCallOptions::new("Joe: Hi. Jane: Hello."); + call.provider_options = + google_options(json!({"multiSpeakerVoiceConfig": multi_speaker_voice_config()})); + let error = speech_model(&mock, model_id) + .do_generate(&call) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("Every multi-speaker turn must specify"), + "{model_id}: {error}" + ); + assert!(mock.seen().is_empty()); + } + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › rejects conflicting global and per-turn metadata + #[tokio::test] + async fn rejects_conflicting_global_and_per_turn_metadata() { + for model_id in MODERN_MODELS { + let mock = MockFetch::new(vec![]); + let mut call = SpeechCallOptions::new(""); + call.provider_options = google_options(json!({ + "speechMetadata": {"style": "excited"}, + "turns": [{"text": "Hello"}], + })); + let error = speech_model(&mock, model_id) + .do_generate(&call) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("Set speechMetadata on each turn"), + "{model_id}: {error}" + ); + } + } +} diff --git a/aimux-providers/src/google/tests/supported_file_url.rs b/aimux-providers/src/google/tests/supported_file_url.rs new file mode 100644 index 000000000..02ba882c3 --- /dev/null +++ b/aimux-providers/src/google/tests/supported_file_url.rs @@ -0,0 +1,69 @@ +use crate::google::utils::is_supported_file_url; + +/// `new URL(text)`: the URL as the WHATWG parser serializes it (a bare origin +/// gains its `/` path), which is what upstream hands to `isSupportedFileUrl`. +fn new_url(text: &str) -> String { + url::Url::parse(text).expect("valid URL").to_string() +} + +/// upstream: @ai-sdk/google src/google-supported-file-url.test.ts › should return true for valid Google generative language file URLs +#[test] +fn should_return_true_for_valid_google_generative_language_file_urls() { + let valid_url = new_url( + "https://generativelanguage.googleapis.com/v1beta/files/00000000-00000000-00000000-00000000", + ); + assert!(is_supported_file_url(&valid_url)); + + let simple_valid_url = + new_url("https://generativelanguage.googleapis.com/v1beta/files/test123"); + assert!(is_supported_file_url(&simple_valid_url)); +} + +/// upstream: @ai-sdk/google src/google-supported-file-url.test.ts › should return true for valid YouTube URLs +#[test] +fn should_return_true_for_valid_you_tube_urls() { + let valid_you_tube_urls = [ + new_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), + new_url("https://youtube.com/watch?v=dQw4w9WgXcQ"), + new_url("https://youtu.be/dQw4w9WgXcQ"), + new_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=youtu.be"), + new_url("https://youtu.be/dQw4w9WgXcQ?t=42"), + ]; + + for url in &valid_you_tube_urls { + assert!(is_supported_file_url(url), "{url}"); + } +} + +/// upstream: @ai-sdk/google src/google-supported-file-url.test.ts › should return false for invalid YouTube URLs +#[test] +fn should_return_false_for_invalid_you_tube_urls() { + let invalid_you_tube_urls = [ + new_url("https://youtube.com/channel/UCdQw4w9WgXcQ"), + new_url("https://youtube.com/playlist?list=PLdQw4w9WgXcQ"), + new_url("https://m.youtube.com/watch?v=dQw4w9WgXcQ"), + new_url("http://youtube.com/watch?v=dQw4w9WgXcQ"), + new_url("https://vimeo.com/123456789"), + ]; + + for url in &invalid_you_tube_urls { + assert!(!is_supported_file_url(url), "{url}"); + } +} + +/// upstream: @ai-sdk/google src/google-supported-file-url.test.ts › should return false for non-Google generative language file URLs +#[test] +fn should_return_false_for_non_google_generative_language_file_urls() { + let test_cases = [ + new_url("https://example.com"), + new_url("https://example.com/foo/bar"), + new_url("https://generativelanguage.googleapis.com"), + new_url("https://generativelanguage.googleapis.com/v1/other"), + new_url("http://generativelanguage.googleapis.com/v1beta/files/test"), + new_url("https://api.googleapis.com/v1beta/files/test"), + ]; + + for url in &test_cases { + assert!(!is_supported_file_url(url), "{url}"); + } +} diff --git a/aimux-providers/src/google/tests/transcription/mod.rs b/aimux-providers/src/google/tests/transcription/mod.rs new file mode 100644 index 000000000..d28031630 --- /dev/null +++ b/aimux-providers/src/google/tests/transcription/mod.rs @@ -0,0 +1 @@ +mod transcription_model; diff --git a/aimux-providers/src/google/tests/transcription/transcription_model.rs b/aimux-providers/src/google/tests/transcription/transcription_model.rs new file mode 100644 index 000000000..11c4ac9a3 --- /dev/null +++ b/aimux-providers/src/google/tests/transcription/transcription_model.rs @@ -0,0 +1,425 @@ +use std::sync::Arc; +use std::time::Duration; + +use futures::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::io::DuplexStream; +use tokio::sync::mpsc; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; +use tokio_tungstenite::tungstenite::protocol::{CloseFrame, Role}; + +use aimux_core::error::AiMuxError; +use aimux_core::shared::SharedProviderOptions; +use aimux_core::transcription_model::{ + AudioChunk, AudioInput, InputAudioFormat, TranscriptionCallOptions, TranscriptionModel, + TranscriptionStreamOptions, TranscriptionStreamPart, +}; +use aimux_provider_utils::ws::{WebSocketRequest, WsConnection, WsConnector}; + +use crate::google::{GoogleProviderSettings, GoogleTranscriptionModel, create_google}; +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +/// The server end of one mocked WebSocket (upstream `MockWebSocket`): the +/// test reads what the model sent and plays the Gemini Live API. +type ServerSocket = WebSocketStream; + +/// Upstream `webSocket: MockWebSocket`: every connection is an in-memory +/// pipe whose server end, with the requested URL, goes to the test. +struct MockWebSocket { + instances: mpsc::UnboundedSender<(String, ServerSocket)>, +} + +#[async_trait::async_trait] +impl WsConnector for MockWebSocket { + async fn connect(&self, request: &WebSocketRequest) -> Result { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let client = WebSocketStream::from_raw_socket(client_io, Role::Client, None).await; + let server = WebSocketStream::from_raw_socket(server_io, Role::Server, None).await; + self.instances + .send((request.url.clone(), server)) + .expect("test holds the receiver"); + Ok(WsConnection::from_transport(Box::new(client), request)) + } +} + +/// Upstream `createModel`: the default base URL, `x-goog-api-key: +/// test-api-key`, the mock WebSocket and (for the unary cases) a mock fetch. +fn create_model( + model_id: &str, + fetch: Option<&Arc>, +) -> ( + GoogleTranscriptionModel, + mpsc::UnboundedReceiver<(String, ServerSocket)>, +) { + let (instances, receiver) = mpsc::unbounded_channel(); + let model = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".into()), + fetch: fetch.map(MockFetch::transport), + web_socket: Some(Arc::new(MockWebSocket { instances })), + ..Default::default() + }) + .unwrap() + .transcription(model_id); + (model, receiver) +} + +fn google_options(value: Value) -> Option { + let Value::Object(map) = value else { + panic!("options must be an object") + }; + Some([("google".to_string(), map)].into_iter().collect()) +} + +/// `providerOptions: {}`. +fn empty_options() -> Option { + Some(SharedProviderOptions::new()) +} + +fn wav_call(provider_options: Option) -> TranscriptionCallOptions { + let mut call = TranscriptionCallOptions::new(AudioInput::Binary(vec![1, 2, 3, 4]), "audio/wav"); + call.provider_options = provider_options; + call +} + +fn interactions_response() -> Value { + json!({ + "id": "interactions/test", + "status": "completed", + "steps": [{"type": "model_output", "content": [{"type": "text", "text": "Hello world."}]}], + "usage": {"total_tokens": 10, "total_input_tokens": 10, "total_output_tokens": 0}, + }) +} + +/// `convertArrayToReadableStream(chunks)`. +fn audio_stream( + chunks: Vec>, +) -> std::pin::Pin + Send>> { + Box::pin(futures::stream::iter( + chunks.into_iter().map(AudioChunk::Binary), + )) +} + +fn stream_options( + audio: std::pin::Pin + Send>>, + rate: u32, + provider_options: Option, +) -> TranscriptionStreamOptions { + TranscriptionStreamOptions { + audio, + input_audio_format: InputAudioFormat { + format_type: "audio/pcm".into(), + rate: Some(rate), + }, + provider_options, + abort_signal: None, + headers: None, + include_raw_chunks: false, + timeout: None, + } +} + +type Parts = Vec>; + +/// `convertReadableStreamToArray(result.stream)`, running while the test +/// plays the server. +async fn start_stream( + model: &GoogleTranscriptionModel, + options: TranscriptionStreamOptions, +) -> tokio::task::JoinHandle { + let result = model + .do_stream(options) + .expect("the model streams") + .await + .unwrap(); + tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(10), result.stream.collect::()) + .await + .expect("the stream ends") + }) +} + +async fn parts_of(handle: tokio::task::JoinHandle) -> Parts { + handle.await.unwrap() +} + +fn json_parts(parts: &Parts) -> Vec { + parts + .iter() + .map(|part| serde_json::to_value(part.as_ref().unwrap()).unwrap()) + .collect() +} + +/// The next text frame the model sent, parsed (`ws.send.mock.calls[i][0]`). +async fn sent(ws: &mut ServerSocket) -> Value { + loop { + match tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("the model sends a frame") + { + Some(Ok(Message::Text(text))) => return serde_json::from_str(&text).unwrap(), + Some(Ok(_)) => {} + other => panic!("expected a text frame, got {other:?}"), + } + } +} + +/// `ws.message(value)`. +async fn message(ws: &mut ServerSocket, value: Value) { + ws.send(Message::Text(value.to_string())).await.unwrap(); +} + +/// `ws.serverClose(code, reason)`. +async fn server_close(ws: &mut ServerSocket, code: u16, reason: &str) { + ws.send(Message::Close(Some(CloseFrame { + code: CloseCode::from(code), + reason: reason.to_string().into(), + }))) + .await + .unwrap(); +} + +/// Upstream's `await flush()` after `setupComplete`: the model has sent its +/// audio and `audioStreamEnd`. +async fn flush_audio(ws: &mut ServerSocket) { + while sent(ws).await["realtimeInput"]["audioStreamEnd"] != true {} +} + +mod do_generate { + use super::*; + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doGenerate › omits generation_config when no transcription options are set + #[tokio::test] + async fn omits_generation_config_when_no_transcription_options_are_set() { + let mock = MockFetch::new(vec![Canned::json(&interactions_response())]); + let (model, _) = create_model("gemini-3.5-transcribe", Some(&mock)); + + model.do_generate(&wav_call(empty_options())).await.unwrap(); + + assert!( + mock.seen()[0] + .json_body() + .get("generation_config") + .is_none() + ); + } +} + +mod do_stream { + use super::*; + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doStream › passes the SMART transcription mode into the live setup + #[tokio::test] + async fn passes_the_smart_transcription_mode_into_the_live_setup() { + let (model, mut instances) = create_model("gemini-3.5-transcribe-live", None); + + let parts = start_stream( + &model, + stream_options( + audio_stream(vec![vec![1, 2]]), + 16000, + google_options(json!({"mode": "SMART"})), + ), + ) + .await; + let (_, mut ws) = instances.recv().await.unwrap(); + + assert_eq!( + sent(&mut ws).await, + json!({ + "setup": { + "model": "models/gemini-3.5-transcribe-live", + "inputAudioTranscription": {"mode": "SMART"}, + }, + }) + ); + + message(&mut ws, json!({"setupComplete": {}})).await; + flush_audio(&mut ws).await; + message( + &mut ws, + json!({"serverContent": {"inputTranscription": {"text": "hi", "finished": true}}}), + ) + .await; + message( + &mut ws, + json!({"serverContent": {"interactionStatus": "IDLE"}}), + ) + .await; + + let parts = parts_of(parts).await; + assert!(parts.iter().all(Result::is_ok), "{parts:?}"); + } + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doStream › accepts the pre-launch REQUIRES_ACTION interaction status as idle + #[tokio::test] + async fn accepts_the_pre_launch_requires_action_interaction_status_as_idle() { + let (model, mut instances) = create_model("gemini-3.5-transcribe-live", None); + + let parts = start_stream( + &model, + stream_options(audio_stream(vec![vec![1, 2]]), 16000, empty_options()), + ) + .await; + let (_, mut ws) = instances.recv().await.unwrap(); + message(&mut ws, json!({"setupComplete": {}})).await; + flush_audio(&mut ws).await; + + message( + &mut ws, + json!({"serverContent": {"inputTranscription": {"text": "hi", "finished": true}}}), + ) + .await; + message( + &mut ws, + json!({"serverContent": {"interactionStatus": "REQUIRES_ACTION"}}), + ) + .await; + + let parts = json_parts(&parts_of(parts).await); + let last = parts.last().unwrap(); + assert_eq!(last["type"], "finish"); + assert_eq!(last["text"], "hi"); + } + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doStream › falls back to the latest interim partial when no final segment arrives + #[tokio::test] + async fn falls_back_to_the_latest_interim_partial_when_no_final_segment_arrives() { + let (model, mut instances) = create_model("gemini-3.5-transcribe-live", None); + + let parts = start_stream( + &model, + stream_options(audio_stream(vec![vec![1, 2]]), 16000, empty_options()), + ) + .await; + let (_, mut ws) = instances.recv().await.unwrap(); + message(&mut ws, json!({"setupComplete": {}})).await; + flush_audio(&mut ws).await; + + message( + &mut ws, + json!({"serverContent": {"interimInputTranscription": {"text": "hello wor"}}}), + ) + .await; + message( + &mut ws, + json!({"serverContent": {"interimInputTranscription": {"text": "hello world."}}}), + ) + .await; + message( + &mut ws, + json!({"serverContent": {"interactionStatus": "IDLE"}}), + ) + .await; + + let parts = json_parts(&parts_of(parts).await); + assert_eq!( + parts[parts.len() - 2], + json!({"type": "transcript-final", "id": "google-segment-0", "text": "hello world."}) + ); + let last = &parts[parts.len() - 1]; + assert_eq!(last["type"], "finish"); + assert_eq!(last["text"], "hello world."); + } + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doStream › finishes with accumulated text when the server closes after audio ended + #[tokio::test] + async fn finishes_with_accumulated_text_when_the_server_closes_after_audio_ended() { + let (model, mut instances) = create_model("gemini-3.5-transcribe-live", None); + + let parts = start_stream( + &model, + stream_options(audio_stream(vec![vec![1, 2]]), 16000, empty_options()), + ) + .await; + let (_, mut ws) = instances.recv().await.unwrap(); + message(&mut ws, json!({"setupComplete": {}})).await; + flush_audio(&mut ws).await; + + message( + &mut ws, + json!({"serverContent": {"inputTranscription": {"text": "partial words"}}}), + ) + .await; + server_close(&mut ws, 1000, "").await; + + let parts = json_parts(&parts_of(parts).await); + let last = parts.last().unwrap(); + assert_eq!(last["type"], "finish"); + assert_eq!(last["text"], "partial words"); + } + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doStream › errors when the socket closes before the audio ended + #[tokio::test] + async fn errors_when_the_socket_closes_before_the_audio_ended() { + let (model, mut instances) = create_model("gemini-3.5-transcribe-live", None); + + // a never-ending audio stream: the reader stays pending + let parts = start_stream( + &model, + stream_options(Box::pin(futures::stream::pending()), 16000, empty_options()), + ) + .await; + let (_, mut ws) = instances.recv().await.unwrap(); + message(&mut ws, json!({"setupComplete": {}})).await; + tokio::time::sleep(Duration::from_millis(20)).await; + + server_close(&mut ws, 1011, "internal error").await; + + let parts = parts_of(parts).await; + let error = parts.last().unwrap().as_ref().unwrap_err(); + assert!( + error.to_string().contains( + "closed unexpectedly before finishing (code 1011, reason: internal error)" + ), + "{error}" + ); + } + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doStream › surfaces server error messages + #[tokio::test] + async fn surfaces_server_error_messages() { + let (model, mut instances) = create_model("gemini-3.5-transcribe-live", None); + + let parts = start_stream( + &model, + stream_options(audio_stream(vec![vec![1]]), 16000, empty_options()), + ) + .await; + let (_, mut ws) = instances.recv().await.unwrap(); + message(&mut ws, json!({"setupComplete": {}})).await; + flush_audio(&mut ws).await; + message(&mut ws, json!({"error": {"message": "quota exceeded"}})).await; + + let parts = parts_of(parts).await; + let error = parts.last().unwrap().as_ref().unwrap_err(); + assert!(error.to_string().contains("quota exceeded"), "{error}"); + } + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doStream › emits raw chunks when includeRawChunks is set + #[tokio::test] + async fn emits_raw_chunks_when_include_raw_chunks_is_set() { + let (model, mut instances) = create_model("gemini-3.5-transcribe-live", None); + + let mut options = stream_options(audio_stream(vec![vec![1]]), 16000, empty_options()); + options.include_raw_chunks = true; + let parts = start_stream(&model, options).await; + let (_, mut ws) = instances.recv().await.unwrap(); + message(&mut ws, json!({"setupComplete": {}})).await; + flush_audio(&mut ws).await; + message( + &mut ws, + json!({"serverContent": {"inputTranscription": {"text": "ok", "finished": true}}}), + ) + .await; + message( + &mut ws, + json!({"serverContent": {"interactionStatus": "IDLE"}}), + ) + .await; + + let parts = json_parts(&parts_of(parts).await); + assert!(parts.iter().filter(|part| part["type"] == "raw").count() > 0); + } +} diff --git a/aimux-providers/src/google/tests/video_model.rs b/aimux-providers/src/google/tests/video_model.rs new file mode 100644 index 000000000..4d2c3d994 --- /dev/null +++ b/aimux-providers/src/google/tests/video_model.rs @@ -0,0 +1,601 @@ +use std::sync::Arc; + +use serde_json::{Value, json}; + +use aimux_core::error::AiMuxError; +use aimux_core::shared::{AspectRatio, Size, Warning}; +use aimux_core::video_model::{ + VideoCallOptions, VideoData, VideoFile, VideoFileData, VideoModel, VideoOperationStatus, +}; + +use crate::google::{GoogleProviderSettings, GoogleVideoModel, create_google}; +use crate::test_support::mock_fetch::{Canned, RouteFetch, Seen}; + +const PROMPT: &str = "A futuristic city with flying cars"; + +/// `defaultOptions`: the prompt, `n: 1`, everything else unset. +fn default_options() -> VideoCallOptions { + VideoCallOptions::new(PROMPT) +} + +/// The options of `createMockModel`. +struct MockModel { + model_id: &'static str, + operation_name: &'static str, + operation_done: bool, + operation_error: Option, + videos: Value, + api_key: &'static str, +} + +impl Default for MockModel { + fn default() -> Self { + Self { + model_id: "veo-3.1-generate-preview", + operation_name: "operations/test-operation-id", + operation_done: true, + operation_error: None, + videos: json!([{ + "video": {"uri": "https://generativelanguage.googleapis.com/files/video-123.mp4"}, + }]), + api_key: "test-api-key", + } + } +} + +/// `createMockModel`: `:predictLongRunning` answers the operation name, a +/// URL containing the operation name answers its status, anything else 404. +fn create_mock_model(options: MockModel) -> (GoogleVideoModel, Arc) { + let MockModel { + model_id, + operation_name, + operation_done, + operation_error, + videos, + api_key, + } = options; + let fetch = RouteFetch::new(move |seen: &Seen| { + if seen.url.contains(":predictLongRunning") { + return Canned::json(&json!({"name": operation_name, "done": false})); + } + if seen.url.contains(operation_name) { + let mut body = json!({"name": operation_name, "done": operation_done}); + match &operation_error { + Some(error) => body["error"] = error.clone(), + None => { + body["response"] = + json!({"generateVideoResponse": {"generatedSamples": videos}}); + } + } + return Canned::json(&body); + } + Canned { + status: 404, + headers: Vec::new(), + body: b"Not found".to_vec(), + } + }); + (model_with_fetch(model_id, api_key, &fetch), fetch) +} + +/// A model whose every request gets `body`. +fn model_answering(api_key: &'static str, body: Value) -> (GoogleVideoModel, Arc) { + let fetch = RouteFetch::new(move |_: &Seen| Canned::json(&body)); + ( + model_with_fetch("veo-3.1-generate-preview", api_key, &fetch), + fetch, + ) +} + +fn model_with_fetch(model_id: &str, api_key: &str, fetch: &Arc) -> GoogleVideoModel { + create_google(GoogleProviderSettings { + api_key: Some(api_key.into()), + fetch: Some(fetch.transport()), + ..Default::default() + }) + .unwrap() + .video(model_id) +} + +/// The body of the `:predictLongRunning` request (`capturedBody`). +fn start_body(fetch: &RouteFetch) -> Value { + fetch + .seen() + .iter() + .find(|seen| seen.url.contains(":predictLongRunning")) + .expect("a start request") + .json_body() +} + +fn google_options(value: Value) -> aimux_core::shared::SharedProviderOptions { + let Value::Object(map) = value else { + panic!("options must be an object") + }; + [("google".to_string(), map)].into_iter().collect() +} + +fn wire_message(error: &AiMuxError) -> String { + serde_json::to_value(error).unwrap()["message"] + .as_str() + .unwrap_or_default() + .to_string() +} + +mod google_video_model { + use super::*; + + mod constructor { + use super::*; + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › constructor › should expose correct provider and model information + #[test] + fn should_expose_correct_provider_and_model_information() { + let (model, _) = create_mock_model(MockModel::default()); + + assert_eq!(model.provider(), "google.generative-ai"); + assert_eq!(model.model_id(), "veo-3.1-generate-preview"); + assert_eq!(model.specification_version(), "v4"); + assert_eq!(model.max_videos_per_call(), Some(4)); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › constructor › should support different model IDs + #[test] + fn should_support_different_model_ids() { + let (model, _) = create_mock_model(MockModel { + model_id: "veo-3.1-generate", + ..Default::default() + }); + + assert_eq!(model.model_id(), "veo-3.1-generate"); + } + } + + mod do_start { + use super::*; + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should return operation with operationName + #[tokio::test] + async fn should_return_operation_with_operation_name() { + let (model, _) = create_mock_model(MockModel { + operation_name: "operations/start-test-op", + ..Default::default() + }); + + let result = model.do_start(&default_options()).await.unwrap(); + + assert_eq!( + result.operation, + json!({"operationName": "operations/start-test-op"}) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should pass correct request body + #[tokio::test] + async fn should_pass_correct_request_body() { + let (model, fetch) = create_mock_model(MockModel::default()); + + model.do_start(&default_options()).await.unwrap(); + + assert_eq!( + start_body(&fetch), + json!({"instances": [{"prompt": PROMPT}], "parameters": {"sampleCount": 1}}) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should pass seed when provided + #[tokio::test] + async fn should_pass_seed_when_provided() { + let (model, fetch) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.seed = Some(42); + model.do_start(&options).await.unwrap(); + + assert_eq!( + start_body(&fetch), + json!({ + "instances": [{"prompt": PROMPT}], + "parameters": {"sampleCount": 1, "seed": 42}, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should pass aspect ratio when provided + #[tokio::test] + async fn should_pass_aspect_ratio_when_provided() { + let (model, fetch) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.aspect_ratio = Some(AspectRatio::new(16, 9)); + model.do_start(&options).await.unwrap(); + + assert_eq!( + start_body(&fetch), + json!({ + "instances": [{"prompt": PROMPT}], + "parameters": {"sampleCount": 1, "aspectRatio": "16:9"}, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should convert resolution to Google format + #[tokio::test] + async fn should_convert_resolution_to_google_format() { + let (model, fetch) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.resolution = Some(Size::new(1920, 1080)); + model.do_start(&options).await.unwrap(); + + assert_eq!( + start_body(&fetch), + json!({ + "instances": [{"prompt": PROMPT}], + "parameters": {"sampleCount": 1, "resolution": "1080p"}, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should pass duration as durationSeconds + #[tokio::test] + async fn should_pass_duration_as_duration_seconds() { + let (model, fetch) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.duration = Some(5); + model.do_start(&options).await.unwrap(); + + assert_eq!( + start_body(&fetch), + json!({ + "instances": [{"prompt": PROMPT}], + "parameters": {"sampleCount": 1, "durationSeconds": 5}, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should pass n as sampleCount + #[tokio::test] + async fn should_pass_n_as_sample_count() { + let (model, fetch) = create_mock_model(MockModel { + videos: json!([ + {"video": {"uri": "https://example.com/video1.mp4"}}, + {"video": {"uri": "https://example.com/video2.mp4"}}, + ]), + ..Default::default() + }); + + let mut options = default_options(); + options.n = 2; + model.do_start(&options).await.unwrap(); + + assert_eq!( + start_body(&fetch), + json!({"instances": [{"prompt": PROMPT}], "parameters": {"sampleCount": 2}}) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should return warnings array + #[tokio::test] + async fn should_return_warnings_array() { + let (model, _) = create_mock_model(MockModel::default()); + + let result = model.do_start(&default_options()).await.unwrap(); + + assert!(result.warnings.is_empty()); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should include timestamp and modelId in response + #[tokio::test] + async fn should_include_timestamp_and_model_id_in_response() { + // No `currentDate` hook in Rust: the timestamp is the real time. + let (model, _) = create_mock_model(MockModel::default()); + + let before = aimux_core::Timestamp::now(); + let result = model.do_start(&default_options()).await.unwrap(); + let after = aimux_core::Timestamp::now(); + + let timestamp = result.response.timestamp.unwrap(); + assert!(before <= timestamp && timestamp <= after); + assert_eq!( + result.response.model_id.as_deref(), + Some("veo-3.1-generate-preview") + ); + assert!(result.response.headers.is_some()); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should pass headers + #[tokio::test] + async fn should_pass_headers() { + let (model, fetch) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.headers = Some( + [("x-custom-header".to_string(), "custom-value".to_string())] + .into_iter() + .collect(), + ); + model.do_start(&options).await.unwrap(); + + let seen = fetch.seen(); + let start = seen + .iter() + .find(|seen| seen.url.contains(":predictLongRunning")) + .unwrap(); + assert_eq!( + start.headers.get("x-custom-header").map(String::as_str), + Some("custom-value") + ); + assert_eq!( + start.headers.get("x-goog-api-key").map(String::as_str), + Some("test-api-key") + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should throw when no operation name returned + #[tokio::test] + async fn should_throw_when_no_operation_name_returned() { + let (model, _) = model_answering("test-key", json!({"done": false})); + + let error = model.do_start(&default_options()).await.unwrap_err(); + + assert_eq!(wire_message(&error), "No operation name returned from API"); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should send image as inlineData + #[tokio::test] + async fn should_send_image_as_inline_data() { + let (model, fetch) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.image = Some(VideoFile::File { + media_type: "image/png".into(), + data: VideoFileData::Base64("base64-image-data".into()), + }); + model.do_start(&options).await.unwrap(); + + assert_eq!( + start_body(&fetch)["instances"][0]["image"], + json!({"bytesBase64Encoded": "base64-image-data", "mimeType": "image/png"}) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should warn when URL-based image is provided + #[tokio::test] + async fn should_warn_when_url_based_image_is_provided() { + let (model, _) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.image = Some(VideoFile::Url { + url: "https://example.com/image.png".into(), + media_type: None, + }); + let result = model.do_start(&options).await.unwrap(); + + assert_eq!(result.warnings.len(), 1); + assert!( + matches!( + &result.warnings[0], + Warning::Unsupported { feature, .. } if feature == "URL-based image input" + ), + "{:?}", + result.warnings + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should pass personGeneration option + #[tokio::test] + async fn should_pass_person_generation_option() { + let (model, fetch) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.provider_options = google_options(json!({"personGeneration": "allow_adult"})); + model.do_start(&options).await.unwrap(); + + assert_eq!( + start_body(&fetch), + json!({ + "instances": [{"prompt": PROMPT}], + "parameters": {"sampleCount": 1, "personGeneration": "allow_adult"}, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should pass negativePrompt option + #[tokio::test] + async fn should_pass_negative_prompt_option() { + let (model, fetch) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.provider_options = + google_options(json!({"negativePrompt": "blurry, low quality"})); + model.do_start(&options).await.unwrap(); + + assert_eq!( + start_body(&fetch), + json!({ + "instances": [{"prompt": PROMPT}], + "parameters": {"sampleCount": 1, "negativePrompt": "blurry, low quality"}, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStart › should pass referenceImages option + #[tokio::test] + async fn should_pass_reference_images_option() { + let (model, fetch) = create_mock_model(MockModel::default()); + + let mut options = default_options(); + options.provider_options = google_options(json!({ + "referenceImages": [ + {"bytesBase64Encoded": "reference-image-data"}, + {"gcsUri": "gs://bucket/reference.png"}, + ], + })); + model.do_start(&options).await.unwrap(); + + assert_eq!( + start_body(&fetch)["instances"][0]["referenceImages"], + json!([ + { + "image": {"bytesBase64Encoded": "reference-image-data", "mimeType": "image/png"}, + "referenceType": "asset", + }, + { + "image": {"gcsUri": "gs://bucket/reference.png", "mimeType": "image/png"}, + "referenceType": "asset", + }, + ]) + ); + } + } + + mod do_status { + use super::*; + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStatus › should return completed with video data when done + #[tokio::test] + async fn should_return_completed_with_video_data_when_done() { + let (model, _) = create_mock_model(MockModel { + operation_name: "operations/status-test-op", + videos: json!([{ + "video": {"uri": "https://generativelanguage.googleapis.com/files/video-456.mp4"}, + }]), + ..Default::default() + }); + + let result = model + .do_status( + &json!({"operationName": "operations/status-test-op"}), + &default_options(), + ) + .await + .unwrap(); + + let VideoOperationStatus::Completed(result) = result else { + panic!("expected completed, got {result:?}") + }; + assert_eq!(result.videos.len(), 1); + let VideoData::Url { url, media_type } = &result.videos[0] else { + panic!("expected a URL video, got {:?}", result.videos[0]) + }; + assert_eq!( + url, + "https://generativelanguage.googleapis.com/files/video-456.mp4?key=test-api-key" + ); + assert_eq!(media_type, "video/mp4"); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStatus › should return pending when not done + #[tokio::test] + async fn should_return_pending_when_not_done() { + let (model, _) = model_answering( + "test-key", + json!({"name": "operations/pending-op", "done": false}), + ); + + let result = model + .do_status( + &json!({"operationName": "operations/pending-op"}), + &default_options(), + ) + .await + .unwrap(); + + // aimux's `Pending` carries no response: upstream's + // `response.timestamp` / `response.modelId` have no Rust field. + assert!( + matches!(result, VideoOperationStatus::Pending), + "{result:?}" + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStatus › should return error status on operation error + #[tokio::test] + async fn should_return_error_status_on_operation_error() { + let (model, _) = model_answering( + "test-key", + json!({ + "name": "operations/error-op", + "done": true, + "error": { + "code": 400, + "message": "Content policy violation", + "status": "FAILED_PRECONDITION", + }, + }), + ); + + // aimux maps the `error` status to `Err` (see `VideoOperationStatus`). + let error = model + .do_status( + &json!({"operationName": "operations/error-op"}), + &default_options(), + ) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("Content policy violation"), + "{error}" + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStatus › should throw when no videos in response + #[tokio::test] + async fn should_throw_when_no_videos_in_response() { + let (model, _) = model_answering( + "test-key", + json!({ + "name": "operations/empty-op", + "done": true, + "response": {"generateVideoResponse": {"generatedSamples": []}}, + }), + ); + + let error = model + .do_status( + &json!({"operationName": "operations/empty-op"}), + &default_options(), + ) + .await + .unwrap_err(); + + assert!( + wire_message(&error).contains("No videos in response"), + "{error}" + ); + } + + /// upstream: @ai-sdk/google src/google-video-model.test.ts › GoogleVideoModel › doStatus › should pass headers to status request + #[tokio::test] + async fn should_pass_headers_to_status_request() { + let (model, fetch) = model_answering( + "test-key", + json!({"name": "operations/headers-test", "done": false}), + ); + + let mut options = default_options(); + options.headers = Some( + [("x-custom".to_string(), "value".to_string())] + .into_iter() + .collect(), + ); + model + .do_status( + &json!({"operationName": "operations/headers-test"}), + &options, + ) + .await + .unwrap(); + + let seen = fetch.seen(); + let captured = &seen.last().unwrap().headers; + assert_eq!(captured.get("x-custom").map(String::as_str), Some("value")); + assert_eq!( + captured.get("x-goog-api-key").map(String::as_str), + Some("test-key") + ); + } + } +} diff --git a/aimux-providers/src/google/utils.rs b/aimux-providers/src/google/utils.rs index 55782b22f..45381aa16 100644 --- a/aimux-providers/src/google/utils.rs +++ b/aimux-providers/src/google/utils.rs @@ -546,6 +546,7 @@ fn parent_must_be(seg: &Segment) -> AiMuxError { mod tests { use super::*; + /// partial: `should accumulate a complete string arg (no willContinue)`; full port in src/google/tests/json_accumulator.rs. #[test] fn smoke() { let mut acc = GoogleJsonAccumulator::new(); diff --git a/aimux-providers/tests/google_embedding_test.rs b/aimux-providers/tests/google_embedding_test.rs index d2b56c574..58c5cb1fa 100644 --- a/aimux-providers/tests/google_embedding_test.rs +++ b/aimux-providers/tests/google_embedding_test.rs @@ -48,32 +48,48 @@ fn default_options(values: Vec) -> EmbeddingCallOptions { } } -/// TS: "should extract embedding" (batch endpoint) -#[tokio::test] -async fn should_extract_embedding() { - 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 provider = create_google(GoogleProviderSettings { - api_key: Some("test-api-key".to_string()), - base_url: Some(server.uri()), - ..Default::default() - }) - .unwrap(); - let model = provider.embedding_model("gemini-embedding-001").unwrap(); +mod google_embedding_model { + use super::*; + + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should extract embedding + #[tokio::test] + async fn should_extract_embedding() { + 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 provider = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".to_string()), + base_url: Some(server.uri()), + ..Default::default() + }) + .unwrap(); + let model = provider.embedding_model("gemini-embedding-001").unwrap(); + + let result = model + .do_embed(&default_options(test_values())) + .await + .expect("should succeed"); + assert_eq!(result.embeddings, dummy_embeddings()); + } - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - assert_eq!(result.embeddings, dummy_embeddings()); + /// upstream: @ai-sdk/google src/google-embedding-model.test.ts › GoogleEmbeddingModel › should expose the Google batch embedding API limit + #[test] + fn should_expose_max_embeddings_per_call() { + let provider = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".to_string()), + ..Default::default() + }) + .unwrap(); + let model = provider.embedding_model("gemini-embedding-001").unwrap(); + assert_eq!(model.max_embeddings_per_call(), Some(100)); + } } -/// TS: "should expose the raw response" +/// partial: `should expose the raw response`; full port in src/google/tests/embedding_model.rs. #[tokio::test] async fn should_expose_raw_response() { let server = MockServer::start().await; @@ -110,7 +126,7 @@ async fn should_expose_raw_response() { ); } -/// TS: "should pass the model and the values" +/// partial: `should pass the model and the values`; full port in src/google/tests/embedding_model.rs. #[tokio::test] async fn should_pass_model_and_values() { let server = MockServer::start().await; @@ -149,7 +165,7 @@ async fn should_pass_model_and_values() { ); } -/// TS: "should pass headers" +/// partial: `should pass headers`; full port in src/google/tests/embedding_model.rs. #[tokio::test] async fn should_pass_headers() { let server = MockServer::start().await; @@ -197,7 +213,7 @@ async fn should_pass_headers() { ); } -/// TS: "should use the batch embeddings endpoint" +/// partial: `should use the batch embeddings endpoint`; full port in src/google/tests/embedding_model.rs. #[tokio::test] async fn should_use_batch_endpoint() { let server = MockServer::start().await; @@ -224,7 +240,7 @@ async fn should_use_batch_endpoint() { assert!(requests[0].url.as_str().ends_with(":batchEmbedContents")); } -/// TS: "should use the single embeddings endpoint" +/// partial: `should use the single embeddings endpoint`; full port in src/google/tests/embedding_model.rs. #[tokio::test] async fn should_use_single_endpoint() { let server = MockServer::start().await; @@ -254,18 +270,6 @@ async fn should_use_single_endpoint() { assert!(requests[0].url.as_str().ends_with(":embedContent")); } -/// TS: "should expose the Google batch embedding API limit" -#[test] -fn should_expose_max_embeddings_per_call() { - let provider = create_google(GoogleProviderSettings { - api_key: Some("test-api-key".to_string()), - ..Default::default() - }) - .unwrap(); - let model = provider.embedding_model("gemini-embedding-001").unwrap(); - assert_eq!(model.max_embeddings_per_call(), Some(100)); -} - // Remaining untranslated cases (multimodal `content` provider option): // - "should merge multimodal content for single embedding" // - "should merge per-value multimodal content for batch embedding" diff --git a/aimux-providers/tests/google_evaluation_test.rs b/aimux-providers/tests/google_evaluation_test.rs index 1208874a4..798162773 100644 --- a/aimux-providers/tests/google_evaluation_test.rs +++ b/aimux-providers/tests/google_evaluation_test.rs @@ -104,7 +104,7 @@ fn parts(text: &str) -> Value { json!({ "content": { "role": "model", "parts": [{ "text": text }] } }) } -/// TS: uses Gemini structured output and forwards thinking options and cancellation +/// partial: `uses Gemini structured output and forwards thinking options and cancellation`; full port in src/google/tests/evaluation.rs. #[tokio::test] async fn uses_structured_output_and_forwards_thinking_options() { let (model, mock) = setup(&fixture()); @@ -174,7 +174,7 @@ async fn uses_structured_output_and_forwards_thinking_options() { assert!(schema["properties"]["q1"].get("maximum").is_none()); } -/// TS: defaults to the minimum thinking level through the provider reasoning mapping +/// upstream: @ai-sdk/google src/google-evaluation.test.ts › defaults to the minimum thinking level through the provider reasoning mapping #[tokio::test] async fn defaults_to_the_minimum_thinking_level() { let (model, mock) = setup(&fixture()); @@ -185,7 +185,7 @@ async fn defaults_to_the_minimum_thinking_level() { ); } -/// TS: ignores thought text while counting reasoning tokens in usage +/// upstream: @ai-sdk/google src/google-evaluation.test.ts › ignores thought text while counting reasoning tokens in usage #[tokio::test] async fn ignores_thought_text_while_counting_reasoning_tokens() { let mut body = fixture(); @@ -205,7 +205,7 @@ async fn ignores_thought_text_while_counting_reasoning_tokens() { assert_eq!(result.usage.unwrap().output_tokens, Some(24 + 10)); } -/// TS: rejects %s even with valid JSON +/// upstream: @ai-sdk/google src/google-evaluation.test.ts › rejects %s even with valid JSON #[tokio::test] async fn rejects_unfinished_generations_even_with_valid_json() { for finish_reason in ["SAFETY", "MAX_TOKENS"] { @@ -221,7 +221,7 @@ async fn rejects_unfinished_generations_even_with_valid_json() { } } -/// TS: validates score bounds locally +/// upstream: @ai-sdk/google src/google-evaluation.test.ts › validates score bounds locally #[tokio::test] async fn validates_score_bounds_locally() { let (model, _) = setup(&with_candidate(fixture(), parts(r#"{"q0":"c1","q1":3}"#))); @@ -232,7 +232,7 @@ async fn validates_score_bounds_locally() { ); } -/// TS: evaluates Boolean alongside Choice and Score in one Gemini request +/// partial: `evaluates Boolean alongside Choice and Score in one Gemini request`; full port in src/google/tests/evaluation.rs. #[tokio::test] async fn evaluates_boolean_alongside_choice_and_score_in_one_request() { let (model, mock) = setup(&with_candidate( @@ -266,7 +266,7 @@ async fn evaluates_boolean_alongside_choice_and_score_in_one_request() { assert_eq!(schema["required"], json!(["q0", "q1", "q2"])); } -/// TS: rejects an aborted evaluation without a request +/// upstream: @ai-sdk/google src/google-evaluation.test.ts › rejects an aborted evaluation without a request #[tokio::test] async fn rejects_an_aborted_evaluation_without_a_request() { let (model, mock) = setup(&fixture()); diff --git a/aimux-providers/tests/google_files_test.rs b/aimux-providers/tests/google_files_test.rs index 3e8d28235..7214e20c8 100644 --- a/aimux-providers/tests/google_files_test.rs +++ b/aimux-providers/tests/google_files_test.rs @@ -77,96 +77,9 @@ async fn mount_success_mocks(server: &MockServer) { .await; } -// -- constructor tests ------------------------------------------------------- - -#[tokio::test] -async fn should_expose_correct_provider_and_specification_version() { - let provider = create_google(GoogleProviderSettings { - api_key: Some("test-api-key".to_string()), - ..Default::default() - }) - .unwrap(); - let files = Provider::files(&provider).unwrap(); - assert_eq!(files.provider(), "google.generative-ai"); - assert_eq!(files.specification_version(), "v4"); -} - -// -- upload initiation tests ------------------------------------------------- - -#[tokio::test] -async fn should_send_correct_headers_for_resumable_upload_initiation() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - let opts = UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Binary(vec![1, 2, 3]), - }, - media_type: "application/pdf".to_string(), - abort_signal: None, - ..upload_options() - }; - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - // First request should be the init request. - let init_req = &requests[0]; - assert_eq!(init_req.method.as_str(), "POST"); - let headers = &init_req.headers; - assert_eq!( - headers - .get("x-goog-upload-protocol") - .and_then(|v| v.to_str().ok()), - Some("resumable") - ); - assert_eq!( - headers - .get("x-goog-upload-command") - .and_then(|v| v.to_str().ok()), - Some("start") - ); - assert_eq!( - headers - .get("x-goog-upload-header-content-length") - .and_then(|v| v.to_str().ok()), - Some("3") - ); - assert_eq!( - headers - .get("x-goog-upload-header-content-type") - .and_then(|v| v.to_str().ok()), - Some("application/pdf") - ); - assert_eq!( - headers.get("content-type").and_then(|v| v.to_str().ok()), - Some("application/json") - ); - assert_eq!( - headers.get("x-goog-api-key").and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); -} - -#[tokio::test] -async fn should_omit_display_name_from_body_when_not_provided() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - files.upload_file(&upload_options()).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": {} })); -} - // -- upload data tests ------------------------------------------------------- +/// partial: `should send ArrayBuffer-backed data and let fetch derive its content length`; full port in src/google/tests/files.rs. #[tokio::test] async fn should_send_file_data_to_upload_url_with_correct_headers() { let server = MockServer::start().await; @@ -210,35 +123,7 @@ async fn should_send_file_data_to_upload_url_with_correct_headers() { // -- result tests ------------------------------------------------------------ -#[tokio::test] -async fn should_return_provider_reference_with_google_key_set_to_file_uri() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - let result = files.upload_file(&upload_options()).await.unwrap(); - - assert_eq!( - result.provider_reference.get("google"), - Some(&"https://generativelanguage.googleapis.com/v1beta/files/abc123".to_string()) - ); -} - -#[tokio::test] -async fn should_return_empty_warnings_when_filename_not_provided() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - let result = files.upload_file(&upload_options()).await.unwrap(); - - assert!(result.warnings.is_empty()); -} - +/// partial: `should return unsupported warning when filename is provided`; full port in src/google/tests/files.rs. #[tokio::test] async fn should_return_unsupported_warning_when_filename_provided() { let server = MockServer::start().await; @@ -260,6 +145,7 @@ async fn should_return_unsupported_warning_when_filename_provided() { } } +/// partial: `should return providerMetadata with file details`; full port in src/google/tests/files.rs. #[tokio::test] async fn should_return_provider_metadata_with_file_details() { let server = MockServer::start().await; @@ -287,31 +173,6 @@ async fn should_return_provider_metadata_with_file_details() { assert_eq!(google["sha256Hash"], "abc123hash"); } -#[tokio::test] -async fn should_handle_base64_string_data() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - // "hello" encoded as base64. - let opts = UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Base64("aGVsbG8=".to_string()), - }, - abort_signal: None, - ..upload_options() - }; - let result = files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - // The upload request body should be the decoded bytes. - assert_eq!(&requests[1].body, b"hello"); - - assert!(result.provider_reference.contains_key("google")); -} - /// google-files.ts:84-92 (init) and :118-126 (upload) call the provider's /// plain `fetch`, not `postToApi`, so neither request carries the /// `ai-sdk-provider-utils/` part (post-to-api.ts:100-104). The init sends @@ -343,159 +204,9 @@ async fn upload_requests_carry_no_provider_utils_user_agent_part() { assert_eq!(requests[1].headers.get("user-agent"), None); } -// -- polling tests ----------------------------------------------------------- - -#[tokio::test] -async fn should_not_poll_when_file_is_immediately_active() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - files.upload_file(&upload_options()).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - // Only 2 requests: init + upload. No poll requests. - assert_eq!(requests.len(), 2); -} - -#[tokio::test] -async fn should_throw_when_file_state_is_failed() { - let server = MockServer::start().await; - let upload_url = format!("{}/resume", server.uri()); - - Mock::given(method("POST")) - .and(path("/upload/v1beta/files")) - .respond_with(ResponseTemplate::new(200).insert_header("x-goog-upload-url", &upload_url)) - .mount(&server) - .await; - - let failed_resource = { - let mut r = default_file_resource(); - r["state"] = json!("FAILED"); - r - }; - Mock::given(method("POST")) - .and(path("/resume")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "file": failed_resource - }))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - let result = files.upload_file(&upload_options()).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.to_string().contains("File processing failed"), - "error should mention 'File processing failed': {err}" - ); -} - -// -- error handling tests ---------------------------------------------------- - -#[tokio::test] -async fn should_throw_when_initiation_request_fails() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/upload/v1beta/files")) - .respond_with(ResponseTemplate::new(500).set_body_string("Init error")) - .mount(&server) - .await; - - // Only the message text matters here; skip the retries so it stays fast. - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - let result = files.upload_file(&upload_options()).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.to_string() - .contains("Failed to initiate resumable upload"), - "error should mention 'Failed to initiate': {err}" - ); -} - -#[tokio::test] -async fn should_throw_when_no_upload_url_returned() { - let server = MockServer::start().await; - - // Return 200 but without the x-goog-upload-url header. - Mock::given(method("POST")) - .and(path("/upload/v1beta/files")) - .respond_with(ResponseTemplate::new(200)) - .mount(&server) - .await; - - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - let result = files.upload_file(&upload_options()).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.to_string().contains("No upload URL"), - "error should mention 'No upload URL': {err}" - ); -} - -#[tokio::test] -async fn should_throw_when_upload_request_fails() { - let server = MockServer::start().await; - let upload_url = format!("{}/resume", server.uri()); - - Mock::given(method("POST")) - .and(path("/upload/v1beta/files")) - .respond_with(ResponseTemplate::new(200).insert_header("x-goog-upload-url", &upload_url)) - .mount(&server) - .await; - - Mock::given(method("POST")) - .and(path("/resume")) - .respond_with(ResponseTemplate::new(500).set_body_string("Upload error")) - .mount(&server) - .await; - - // Only the message text matters here; skip the retries so it stays fast. - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - let result = files.upload_file(&upload_options()).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.to_string().contains("Failed to upload file data"), - "error should mention 'Failed to upload file data': {err}" - ); -} - -// -- provider options tests -------------------------------------------------- - -#[tokio::test] -async fn should_work_without_provider_options() { - let server = MockServer::start().await; - mount_success_mocks(&server).await; - - let provider = provider(&server); - let files = Provider::files(&provider).unwrap(); - - let result = files.upload_file(&upload_options()).await.unwrap(); - - assert!(result.provider_reference.contains_key("google")); -} - // -- response metadata tests ------------------------------------------------- +/// partial: `should omit optional fields from metadata when not present`; full port in src/google/tests/files.rs. #[tokio::test] async fn should_omit_optional_fields_from_metadata_when_not_present() { let server = MockServer::start().await; @@ -540,3 +251,326 @@ async fn should_omit_optional_fields_from_metadata_when_not_present() { assert!(google.get("expirationTime").is_none()); assert!(google.get("sha256Hash").is_none()); } + +mod google_files { + use super::*; + + mod constructor { + use super::*; + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › constructor › should expose correct provider and specification version + #[tokio::test] + async fn should_expose_correct_provider_and_specification_version() { + let provider = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".to_string()), + ..Default::default() + }) + .unwrap(); + let files = Provider::files(&provider).unwrap(); + assert_eq!(files.provider(), "google.generative-ai"); + assert_eq!(files.specification_version(), "v4"); + } + } + + mod upload_file { + use super::*; + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › should send correct headers for resumable upload initiation + #[tokio::test] + async fn should_send_correct_headers_for_resumable_upload_initiation() { + let server = MockServer::start().await; + mount_success_mocks(&server).await; + + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + let opts = UploadFileCallOptions { + data: UploadFileData::Data { + data: FileBytes::Binary(vec![1, 2, 3]), + }, + media_type: "application/pdf".to_string(), + abort_signal: None, + ..upload_options() + }; + files.upload_file(&opts).await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + // First request should be the init request. + let init_req = &requests[0]; + assert_eq!(init_req.method.as_str(), "POST"); + let headers = &init_req.headers; + assert_eq!( + headers + .get("x-goog-upload-protocol") + .and_then(|v| v.to_str().ok()), + Some("resumable") + ); + assert_eq!( + headers + .get("x-goog-upload-command") + .and_then(|v| v.to_str().ok()), + Some("start") + ); + assert_eq!( + headers + .get("x-goog-upload-header-content-length") + .and_then(|v| v.to_str().ok()), + Some("3") + ); + assert_eq!( + headers + .get("x-goog-upload-header-content-type") + .and_then(|v| v.to_str().ok()), + Some("application/pdf") + ); + assert_eq!( + headers.get("content-type").and_then(|v| v.to_str().ok()), + Some("application/json") + ); + assert_eq!( + headers.get("x-goog-api-key").and_then(|v| v.to_str().ok()), + Some("test-api-key") + ); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › should omit displayName from body when not provided + #[tokio::test] + async fn should_omit_display_name_from_body_when_not_provided() { + let server = MockServer::start().await; + mount_success_mocks(&server).await; + + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + files.upload_file(&upload_options()).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": {} })); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › should return providerReference with google key set to file URI + #[tokio::test] + async fn should_return_provider_reference_with_google_key_set_to_file_uri() { + let server = MockServer::start().await; + mount_success_mocks(&server).await; + + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + let result = files.upload_file(&upload_options()).await.unwrap(); + + assert_eq!( + result.provider_reference.get("google"), + Some(&"https://generativelanguage.googleapis.com/v1beta/files/abc123".to_string()) + ); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › should return empty warnings when filename is not provided + #[tokio::test] + async fn should_return_empty_warnings_when_filename_not_provided() { + let server = MockServer::start().await; + mount_success_mocks(&server).await; + + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + let result = files.upload_file(&upload_options()).await.unwrap(); + + assert!(result.warnings.is_empty()); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › should handle base64 string data + #[tokio::test] + async fn should_handle_base64_string_data() { + let server = MockServer::start().await; + mount_success_mocks(&server).await; + + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + // "hello" encoded as base64. + let opts = UploadFileCallOptions { + data: UploadFileData::Data { + data: FileBytes::Base64("aGVsbG8=".to_string()), + }, + abort_signal: None, + ..upload_options() + }; + let result = files.upload_file(&opts).await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + // The upload request body should be the decoded bytes. + assert_eq!(&requests[1].body, b"hello"); + + assert!(result.provider_reference.contains_key("google")); + } + + mod polling { + use super::*; + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › polling › should not poll when file is immediately ACTIVE + #[tokio::test] + async fn should_not_poll_when_file_is_immediately_active() { + let server = MockServer::start().await; + mount_success_mocks(&server).await; + + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + files.upload_file(&upload_options()).await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + // Only 2 requests: init + upload. No poll requests. + assert_eq!(requests.len(), 2); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › polling › should throw when file state is FAILED + #[tokio::test] + async fn should_throw_when_file_state_is_failed() { + let server = MockServer::start().await; + let upload_url = format!("{}/resume", server.uri()); + + Mock::given(method("POST")) + .and(path("/upload/v1beta/files")) + .respond_with( + ResponseTemplate::new(200).insert_header("x-goog-upload-url", &upload_url), + ) + .mount(&server) + .await; + + let failed_resource = { + let mut r = default_file_resource(); + r["state"] = json!("FAILED"); + r + }; + Mock::given(method("POST")) + .and(path("/resume")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "file": failed_resource + }))) + .mount(&server) + .await; + + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + let result = files.upload_file(&upload_options()).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("File processing failed"), + "error should mention 'File processing failed': {err}" + ); + } + } + + mod error_handling { + use super::*; + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › error handling › should throw when initiation request fails + #[tokio::test] + async fn should_throw_when_initiation_request_fails() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/upload/v1beta/files")) + .respond_with(ResponseTemplate::new(500).set_body_string("Init error")) + .mount(&server) + .await; + + // Only the message text matters here; skip the retries so it stays fast. + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + let result = files.upload_file(&upload_options()).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.to_string() + .contains("Failed to initiate resumable upload"), + "error should mention 'Failed to initiate': {err}" + ); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › error handling › should throw when no upload URL is returned + #[tokio::test] + async fn should_throw_when_no_upload_url_returned() { + let server = MockServer::start().await; + + // Return 200 but without the x-goog-upload-url header. + Mock::given(method("POST")) + .and(path("/upload/v1beta/files")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + let result = files.upload_file(&upload_options()).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("No upload URL"), + "error should mention 'No upload URL': {err}" + ); + } + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › error handling › should throw when upload request fails + #[tokio::test] + async fn should_throw_when_upload_request_fails() { + let server = MockServer::start().await; + let upload_url = format!("{}/resume", server.uri()); + + Mock::given(method("POST")) + .and(path("/upload/v1beta/files")) + .respond_with( + ResponseTemplate::new(200).insert_header("x-goog-upload-url", &upload_url), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/resume")) + .respond_with(ResponseTemplate::new(500).set_body_string("Upload error")) + .mount(&server) + .await; + + // Only the message text matters here; skip the retries so it stays fast. + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + let result = files.upload_file(&upload_options()).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("Failed to upload file data"), + "error should mention 'Failed to upload file data': {err}" + ); + } + } + + mod provider_options { + use super::*; + + /// upstream: @ai-sdk/google src/google-files.test.ts › GoogleFiles › uploadFile › provider options › should work without provider options + #[tokio::test] + async fn should_work_without_provider_options() { + let server = MockServer::start().await; + mount_success_mocks(&server).await; + + let provider = provider(&server); + let files = Provider::files(&provider).unwrap(); + + let result = files.upload_file(&upload_options()).await.unwrap(); + + assert!(result.provider_reference.contains_key("google")); + } + } + } +} diff --git a/aimux-providers/tests/google_image_test.rs b/aimux-providers/tests/google_image_test.rs index c553f1738..cfeba6605 100644 --- a/aimux-providers/tests/google_image_test.rs +++ b/aimux-providers/tests/google_image_test.rs @@ -77,7 +77,7 @@ fn base64_file(media_type: &str, b64: &str) -> ImageFile { // Image model tests // ════════════════════════════════════════════════════════════════════════════ -/// TS: "should pass headers" +/// partial: `should send response modalities, aspect ratio, seed, and headers`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn should_pass_headers() { let server = MockServer::start().await; @@ -132,19 +132,44 @@ async fn should_respect_max_images_per_call_setting() { assert_eq!(model.max_images_per_call(), Some(2)); } -/// TS: "should use default maxImagesPerCall when not specified" -#[tokio::test] -async fn should_use_default_max_images_per_call_when_not_specified() { - let provider = create_google(GoogleProviderSettings { - api_key: Some("test-api-key".to_string()), - ..Default::default() - }) - .unwrap(); - let model = provider.image_model("gemini-2.5-flash-image").unwrap(); - assert_eq!(model.max_images_per_call(), Some(1)); +mod google_image_model { + use super::*; + + mod max_images_per_call { + use super::*; + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › maxImagesPerCall › should default to a supported per-call limit + #[tokio::test] + async fn should_use_default_max_images_per_call_when_not_specified() { + let provider = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".to_string()), + ..Default::default() + }) + .unwrap(); + let model = provider.image_model("gemini-2.5-flash-image").unwrap(); + assert_eq!(model.max_images_per_call(), Some(1)); + } + + /// upstream: @ai-sdk/google src/google-image-model.test.ts › GoogleImageModel › maxImagesPerCall › should respect a custom setting + #[tokio::test] + async fn gemini_should_respect_custom_max_images_per_call() { + let provider = create_google(GoogleProviderSettings { + api_key: Some("test-api-key".to_string()), + ..Default::default() + }) + .unwrap(); + let model = provider.image_with_settings( + "gemini-2.5-flash-image", + GoogleImageSettings { + max_images_per_call: Some(5), + }, + ); + assert_eq!(model.max_images_per_call(), Some(5)); + } + } } -/// TS: "should extract the generated images" +/// partial: `should use the language model endpoint and extract generated images`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn should_extract_the_generated_images() { let server = MockServer::start().await; @@ -235,7 +260,7 @@ async fn should_return_warnings_for_unsupported_settings() { } } -/// TS: "should include response data with timestamp, modelId and headers" +/// partial: `should include usage and response metadata`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn should_include_response_data_with_timestamp_model_id_and_headers() { let server = MockServer::start().await; @@ -280,7 +305,7 @@ async fn should_include_response_data_with_timestamp_model_id_and_headers() { ); } -/// TS: "should throw error when mask is provided" +/// partial: `should reject unsupported URL editing input and masks`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn should_throw_error_when_mask_is_provided() { let server = MockServer::start().await; @@ -320,24 +345,7 @@ async fn gemini_should_return_10_for_max_images_per_call() { assert_eq!(model.max_images_per_call(), Some(1)); } -/// TS: "should respect custom maxImagesPerCall setting" -#[tokio::test] -async fn gemini_should_respect_custom_max_images_per_call() { - let provider = create_google(GoogleProviderSettings { - api_key: Some("test-api-key".to_string()), - ..Default::default() - }) - .unwrap(); - let model = provider.image_with_settings( - "gemini-2.5-flash-image", - GoogleImageSettings { - max_images_per_call: Some(5), - }, - ); - assert_eq!(model.max_images_per_call(), Some(5)); -} - -/// TS: "should extract the generated image" +/// partial: `should use the language model endpoint and extract generated images`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn gemini_should_extract_the_generated_image() { let server = MockServer::start().await; @@ -364,7 +372,7 @@ async fn gemini_should_extract_the_generated_image() { } } -/// TS: "should send correct request body with responseModalities" +/// partial: `should send response modalities, aspect ratio, seed, and headers`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn gemini_should_send_correct_request_body() { let server = MockServer::start().await; @@ -423,7 +431,7 @@ async fn gemini_should_pass_aspect_ratio_via_image_config() { ); } -/// TS: "should support Gemini-only aspect ratios like 21:9" +/// partial: `should send response modalities, aspect ratio, seed, and headers`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn gemini_should_support_21_9_aspect_ratio() { let server = MockServer::start().await; @@ -451,7 +459,7 @@ async fn gemini_should_support_21_9_aspect_ratio() { ); } -/// TS: "should pass seed in generationConfig" +/// partial: `should send response modalities, aspect ratio, seed, and headers`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn gemini_should_pass_seed_in_generation_config() { let server = MockServer::start().await; @@ -476,7 +484,7 @@ async fn gemini_should_pass_seed_in_generation_config() { assert_eq!(body["generationConfig"]["seed"], 12345); } -/// TS: "should include usage in response" +/// partial: `should include usage and response metadata`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn gemini_should_include_usage_in_response() { let server = MockServer::start().await; @@ -523,7 +531,7 @@ async fn gemini_should_include_usage_in_response() { assert_eq!(usage.total_tokens, Some(220)); } -/// TS: "should return warning for unsupported size option" +/// partial: `should return a warning for the unsupported size option`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn gemini_should_return_warning_for_size() { let server = MockServer::start().await; @@ -549,7 +557,7 @@ async fn gemini_should_return_warning_for_size() { })); } -/// TS: "should include input images in request for editing" +/// partial: `should include input images for editing`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn gemini_should_include_input_images_for_editing() { let server = MockServer::start().await; @@ -580,7 +588,7 @@ async fn gemini_should_include_input_images_for_editing() { ); } -/// TS: "should throw error when mask is provided" +/// partial: `should reject unsupported URL editing input and masks`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn gemini_should_throw_error_when_mask_is_provided() { let server = MockServer::start().await; diff --git a/aimux-providers/tests/google_speech_test.rs b/aimux-providers/tests/google_speech_test.rs index 0a7a5cb98..0ef51c6f5 100644 --- a/aimux-providers/tests/google_speech_test.rs +++ b/aimux-providers/tests/google_speech_test.rs @@ -116,39 +116,351 @@ fn multi_speaker() -> Value { // ── doGenerate ────────────────────────────────────────────────────────────── -/// TS: preserves raw usage including cached input tokens for %s -#[tokio::test] -async fn preserves_raw_response_body() { - for model_id in [LEGACY_MODEL, "gemini-3.1-flash-tts-preview"] - .into_iter() - .chain(MODERN_MODELS) - { +mod do_generate { + use super::*; + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › preserves raw usage including cached input tokens for %s + #[tokio::test] + async fn preserves_raw_response_body() { + for model_id in [LEGACY_MODEL, "gemini-3.1-flash-tts-preview"] + .into_iter() + .chain(MODERN_MODELS) + { + let server = MockServer::start().await; + let body = json!({ + "candidates": [{"content": {"parts": [{"inlineData": {"data": WAV_BASE64, "mimeType": "audio/wav"}}]}}], + "usageMetadata": { + "promptTokenCount": 6, "cachedContentTokenCount": 5, "candidatesTokenCount": 67, + "totalTokenCount": 73, + "candidatesTokensDetails": [{"modality": "AUDIO", "tokenCount": 67}], + }, + }); + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(body.clone())) + .mount(&server) + .await; + let result = model(&server, model_id) + .do_generate(&options("Hello.")) + .await + .unwrap(); + assert_eq!(result.response.body, Some(body.clone()), "{model_id}"); + // The provider's usage object, passed through unchanged + // (`@ai-sdk/google@4.0.87 src/google-speech-model.ts:356-358`). + assert_eq!( + result.usage.map(Value::Object), + Some(body["usageMetadata"].clone()), + "{model_id}" + ); + } + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › rejects an empty transcript before fetching for %s + #[tokio::test] + async fn rejects_an_empty_transcript_before_fetching() { + for model_id in [LEGACY_MODEL, "gemini-3.1-flash-tts-preview"] + .into_iter() + .chain(MODERN_MODELS) + { + let server = MockServer::start().await; + let mut call = options(""); + call.instructions = Some("Whisper".into()); + let error = model(&server, model_id) + .do_generate(&call) + .await + .unwrap_err(); + assert!( + invalid_argument(error).contains("parameter text"), + "{model_id}" + ); + assert!(server.received_requests().await.unwrap().is_empty()); + } + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › rejects empty turns even with top-level text for %s + #[tokio::test] + async fn rejects_empty_turns_even_with_top_level_text() { + for model_id in MODERN_MODELS { + let server = MockServer::start().await; + let mut call = options("Ignored"); + call.provider_options = google_options(json!({"turns": [{"text": ""}]})); + let error = model(&server, model_id) + .do_generate(&call) + .await + .unwrap_err(); + assert!( + invalid_argument(error).contains("parameter text"), + "{model_id}" + ); + assert!(server.received_requests().await.unwrap().is_empty()); + } + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should send the text and the default voice + #[tokio::test] + async fn sends_the_text_and_the_default_voice() { let server = MockServer::start().await; - let body = json!({ - "candidates": [{"content": {"parts": [{"inlineData": {"data": WAV_BASE64, "mimeType": "audio/wav"}}]}}], - "usageMetadata": { - "promptTokenCount": 6, "cachedContentTokenCount": 5, "candidatesTokenCount": 67, - "totalTokenCount": 73, - "candidatesTokensDetails": [{"modality": "AUDIO", "tokenCount": 67}], - }, - }); - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200).set_body_json(body.clone())) - .mount(&server) - .await; - let result = model(&server, model_id) - .do_generate(&options("Hello.")) + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + model(&server, LEGACY_MODEL) + .do_generate(&options("Hello from the AI SDK!")) .await .unwrap(); - assert_eq!(result.response.body, Some(body.clone()), "{model_id}"); - // The provider's usage object, passed through unchanged - // (`@ai-sdk/google@4.0.87 src/google-speech-model.ts:356-358`). assert_eq!( - result.usage.map(Value::Object), - Some(body["usageMetadata"].clone()), - "{model_id}" + request_body(&server, 0).await, + json!({ + "contents": [{"role": "user", "parts": [{"text": "Hello from the AI SDK!"}]}], + "generationConfig": { + "responseModalities": ["AUDIO"], + "speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}, + }, + }) ); } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should use the provided voice + #[tokio::test] + async fn uses_the_provided_voice() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let mut call = options("Hello from the AI SDK!"); + call.voice = Some("Puck".into()); + model(&server, LEGACY_MODEL) + .do_generate(&call) + .await + .unwrap(); + assert_eq!( + request_body(&server, 0).await["generationConfig"]["speechConfig"], + json!({"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Puck"}}}) + ); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should wrap PCM audio in a WAV container by default + #[tokio::test] + async fn wraps_pcm_audio_in_a_wav_container_by_default() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let result = model(&server, LEGACY_MODEL) + .do_generate(&options("Hello from the AI SDK!")) + .await + .unwrap(); + let audio = audio(&result); + // 44-byte WAV header + 8 bytes PCM payload. + assert_eq!(audio.len(), 52); + assert_eq!(&audio[..4], b"RIFF"); + assert_eq!(&audio[8..12], b"WAVE"); + assert_eq!(u32::from_le_bytes(audio[24..28].try_into().unwrap()), 24000); + assert_eq!(u16::from_le_bytes(audio[22..24].try_into().unwrap()), 1); + assert_eq!(u16::from_le_bytes(audio[34..36].try_into().unwrap()), 16); + assert_eq!(&audio[44..], PCM_BYTES); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should preserve WAV responses even from older models + #[tokio::test] + async fn preserves_wav_responses_from_older_models() { + let server = MockServer::start().await; + mount(&server, "audio/wav", WAV_BASE64).await; + let result = model(&server, LEGACY_MODEL) + .do_generate(&options("Hello")) + .await + .unwrap(); + assert_eq!(audio(&result), wav_bytes()); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should derive the WAV sample rate from the response mime type + #[tokio::test] + async fn derives_the_wav_sample_rate_from_the_mime_type() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=16000", PCM_BASE64).await; + let result = model(&server, LEGACY_MODEL) + .do_generate(&options("Hello from the AI SDK!")) + .await + .unwrap(); + let audio = audio(&result); + assert_eq!(u32::from_le_bytes(audio[24..28].try_into().unwrap()), 16000); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should return raw PCM and warn for outputFormat "pcm" + #[tokio::test] + async fn returns_raw_pcm_and_warns_for_pcm_output_format() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let mut call = options("Hello from the AI SDK!"); + call.output_format = Some("pcm".into()); + let result = model(&server, LEGACY_MODEL) + .do_generate(&call) + .await + .unwrap(); + assert_eq!(audio(&result), PCM_BYTES); + assert!(has_warning(&result, "outputFormat")); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should warn for unsupported speed and language options + #[tokio::test] + async fn warns_for_unsupported_speed_and_language() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let mut call = options("Hello from the AI SDK!"); + call.speed = Some(1.5); + call.language = Some("en".into()); + let result = model(&server, LEGACY_MODEL) + .do_generate(&call) + .await + .unwrap(); + assert!(has_warning(&result, "speed")); + assert!(has_warning(&result, "language")); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should prepend instructions to the prompt text + #[tokio::test] + async fn prepends_instructions_to_the_prompt_text() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let mut call = options("Hello there"); + call.instructions = Some("Say cheerfully".into()); + model(&server, LEGACY_MODEL) + .do_generate(&call) + .await + .unwrap(); + assert_eq!( + request_body(&server, 0).await["contents"], + json!([{"role": "user", "parts": [{"text": "Say cheerfully: Hello there"}]}]) + ); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should map multi-speaker provider options into speechConfig + #[tokio::test] + async fn maps_multi_speaker_options_into_speech_config() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let mut call = options("Joe: Hi. Jane: Hello."); + call.provider_options = google_options(json!({"multiSpeakerVoiceConfig": multi_speaker()})); + model(&server, LEGACY_MODEL) + .do_generate(&call) + .await + .unwrap(); + // Strict equality proves the single-voice `voiceConfig` is absent. + assert_eq!( + request_body(&server, 0).await, + json!({ + "contents": [{"role": "user", "parts": [{"text": "Joe: Hi. Jane: Hello."}]}], + "generationConfig": { + "responseModalities": ["AUDIO"], + "speechConfig": {"multiSpeakerVoiceConfig": multi_speaker()}, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should read provider options under `googleVertex` for a Vertex provider + #[tokio::test] + async fn reads_provider_options_under_google_vertex_for_a_vertex_provider() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let vertex = create_google_vertex(VertexProviderSettings { + api_key: Some(Resolvable::Value("test-api-key".to_string())), + base_url: Some(server.uri()), + ..Default::default() + }) + .unwrap(); + let model = vertex.speech_model(LEGACY_MODEL).unwrap().unwrap(); + assert_eq!(model.provider(), "google.vertex.speech"); + let mut call = options("Joe: Hi. Jane: Hello."); + call.provider_options = provider_options( + "googleVertex", + json!({"multiSpeakerVoiceConfig": multi_speaker()}), + ); + model.do_generate(&call).await.unwrap(); + assert_eq!( + request_body(&server, 0).await, + json!({ + "contents": [{"role": "user", "parts": [{"text": "Joe: Hi. Jane: Hello."}]}], + "generationConfig": { + "responseModalities": ["AUDIO"], + "speechConfig": {"multiSpeakerVoiceConfig": multi_speaker()}, + }, + }) + ); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should ignore instructions (with a warning) when multi-speaker is set + #[tokio::test] + async fn ignores_instructions_with_a_warning_when_multi_speaker_is_set() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let mut call = options("Joe: Hi. Jane: Hello."); + call.instructions = Some("Say cheerfully".into()); + call.provider_options = google_options(json!({"multiSpeakerVoiceConfig": multi_speaker()})); + let result = model(&server, LEGACY_MODEL) + .do_generate(&call) + .await + .unwrap(); + // instructions are NOT prepended to the multi-speaker transcript. + assert_eq!( + request_body(&server, 0).await["contents"], + json!([{"role": "user", "parts": [{"text": "Joe: Hi. Jane: Hello."}]}]) + ); + assert!(has_warning(&result, "instructions")); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should expose sample rate and mime type in provider metadata + #[tokio::test] + async fn exposes_sample_rate_and_mime_type_in_provider_metadata() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let result = model(&server, LEGACY_MODEL) + .do_generate(&options("Hello from the AI SDK!")) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(result.provider_metadata.unwrap()).unwrap(), + json!({"google": {"sampleRate": 24000, "mimeType": "audio/L16;rate=24000"}}) + ); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should return empty audio when no inline data is present + #[tokio::test] + async fn returns_empty_audio_when_no_inline_data_is_present() { + let server = MockServer::start().await; + mount_parts(&server, json!([{"text": "no audio here"}])).await; + let result = model(&server, LEGACY_MODEL) + .do_generate(&options("Hello from the AI SDK!")) + .await + .unwrap(); + assert!(audio(&result).is_empty()); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should include response data with timestamp, modelId and headers + #[tokio::test] + async fn includes_response_data_with_timestamp_model_id_and_headers() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let result = model(&server, LEGACY_MODEL) + .do_generate(&options("Hello from the AI SDK!")) + .await + .unwrap(); + assert!(result.response.timestamp.is_some()); + assert_eq!(result.response.model_id.as_deref(), Some(LEGACY_MODEL)); + assert_eq!( + result + .response + .headers + .unwrap() + .get("x-request-id") + .map(String::as_str), + Some("test-request-id") + ); + } + + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › doGenerate › should have no warnings on the happy path + #[tokio::test] + async fn has_no_warnings_on_the_happy_path() { + let server = MockServer::start().await; + mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; + let result = model(&server, LEGACY_MODEL) + .do_generate(&options("Hello from the AI SDK!")) + .await + .unwrap(); + assert!(result.warnings.is_empty()); + } } /// A response without usage (or with `usageMetadata: null`, which @@ -177,7 +489,7 @@ async fn omits_usage_the_response_does_not_report() { } } -/// TS: preserves legacy requests and PCM conversion for %s +/// partial: `preserves legacy requests and PCM conversion for %s`; full port in src/google/tests/speech_model.rs. #[tokio::test] async fn preserves_legacy_requests_and_pcm_conversion() { for model_id in [ @@ -210,86 +522,7 @@ async fn preserves_legacy_requests_and_pcm_conversion() { } } -/// TS: rejects an empty transcript before fetching for %s -#[tokio::test] -async fn rejects_an_empty_transcript_before_fetching() { - for model_id in [LEGACY_MODEL, "gemini-3.1-flash-tts-preview"] - .into_iter() - .chain(MODERN_MODELS) - { - let server = MockServer::start().await; - let mut call = options(""); - call.instructions = Some("Whisper".into()); - let error = model(&server, model_id) - .do_generate(&call) - .await - .unwrap_err(); - assert!( - invalid_argument(error).contains("parameter text"), - "{model_id}" - ); - assert!(server.received_requests().await.unwrap().is_empty()); - } -} - -/// TS: rejects empty turns even with top-level text for %s -#[tokio::test] -async fn rejects_empty_turns_even_with_top_level_text() { - for model_id in MODERN_MODELS { - let server = MockServer::start().await; - let mut call = options("Ignored"); - call.provider_options = google_options(json!({"turns": [{"text": ""}]})); - let error = model(&server, model_id) - .do_generate(&call) - .await - .unwrap_err(); - assert!( - invalid_argument(error).contains("parameter text"), - "{model_id}" - ); - assert!(server.received_requests().await.unwrap().is_empty()); - } -} - -/// TS: should send the text and the default voice -#[tokio::test] -async fn sends_the_text_and_the_default_voice() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - model(&server, LEGACY_MODEL) - .do_generate(&options("Hello from the AI SDK!")) - .await - .unwrap(); - assert_eq!( - request_body(&server, 0).await, - json!({ - "contents": [{"role": "user", "parts": [{"text": "Hello from the AI SDK!"}]}], - "generationConfig": { - "responseModalities": ["AUDIO"], - "speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}, - }, - }) - ); -} - -/// TS: should use the provided voice -#[tokio::test] -async fn uses_the_provided_voice() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let mut call = options("Hello from the AI SDK!"); - call.voice = Some("Puck".into()); - model(&server, LEGACY_MODEL) - .do_generate(&call) - .await - .unwrap(); - assert_eq!( - request_body(&server, 0).await["generationConfig"]["speechConfig"], - json!({"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Puck"}}}) - ); -} - -/// TS: should pass headers +/// partial: `should pass headers`; full port in src/google/tests/speech_model.rs. #[tokio::test] async fn passes_headers() { let server = MockServer::start().await; @@ -339,302 +572,196 @@ async fn passes_headers() { assert!(header("user-agent").contains("ai-sdk-google/")); } -/// TS: should wrap PCM audio in a WAV container by default -#[tokio::test] -async fn wraps_pcm_audio_in_a_wav_container_by_default() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let result = model(&server, LEGACY_MODEL) - .do_generate(&options("Hello from the AI SDK!")) - .await - .unwrap(); - let audio = audio(&result); - // 44-byte WAV header + 8 bytes PCM payload. - assert_eq!(audio.len(), 52); - assert_eq!(&audio[..4], b"RIFF"); - assert_eq!(&audio[8..12], b"WAVE"); - assert_eq!(u32::from_le_bytes(audio[24..28].try_into().unwrap()), 24000); - assert_eq!(u16::from_le_bytes(audio[22..24].try_into().unwrap()), 1); - assert_eq!(u16::from_le_bytes(audio[34..36].try_into().unwrap()), 16); - assert_eq!(&audio[44..], PCM_BYTES); -} - -/// TS: should preserve WAV responses even from older models -#[tokio::test] -async fn preserves_wav_responses_from_older_models() { - let server = MockServer::start().await; - mount(&server, "audio/wav", WAV_BASE64).await; - let result = model(&server, LEGACY_MODEL) - .do_generate(&options("Hello")) - .await - .unwrap(); - assert_eq!(audio(&result), wav_bytes()); -} - -/// TS: should derive the WAV sample rate from the response mime type -#[tokio::test] -async fn derives_the_wav_sample_rate_from_the_mime_type() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=16000", PCM_BASE64).await; - let result = model(&server, LEGACY_MODEL) - .do_generate(&options("Hello from the AI SDK!")) - .await - .unwrap(); - let audio = audio(&result); - assert_eq!(u32::from_le_bytes(audio[24..28].try_into().unwrap()), 16000); -} - -/// TS: should return raw PCM and warn for outputFormat "pcm" -#[tokio::test] -async fn returns_raw_pcm_and_warns_for_pcm_output_format() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let mut call = options("Hello from the AI SDK!"); - call.output_format = Some("pcm".into()); - let result = model(&server, LEGACY_MODEL) - .do_generate(&call) - .await - .unwrap(); - assert_eq!(audio(&result), PCM_BYTES); - assert!(has_warning(&result, "outputFormat")); -} - -/// TS: should warn for unsupported speed and language options -#[tokio::test] -async fn warns_for_unsupported_speed_and_language() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let mut call = options("Hello from the AI SDK!"); - call.speed = Some(1.5); - call.language = Some("en".into()); - let result = model(&server, LEGACY_MODEL) - .do_generate(&call) - .await - .unwrap(); - assert!(has_warning(&result, "speed")); - assert!(has_warning(&result, "language")); -} - -/// TS: should prepend instructions to the prompt text -#[tokio::test] -async fn prepends_instructions_to_the_prompt_text() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let mut call = options("Hello there"); - call.instructions = Some("Say cheerfully".into()); - model(&server, LEGACY_MODEL) - .do_generate(&call) - .await - .unwrap(); - assert_eq!( - request_body(&server, 0).await["contents"], - json!([{"role": "user", "parts": [{"text": "Say cheerfully: Hello there"}]}]) - ); -} - -/// TS: should map multi-speaker provider options into speechConfig -#[tokio::test] -async fn maps_multi_speaker_options_into_speech_config() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let mut call = options("Joe: Hi. Jane: Hello."); - call.provider_options = google_options(json!({"multiSpeakerVoiceConfig": multi_speaker()})); - model(&server, LEGACY_MODEL) - .do_generate(&call) - .await - .unwrap(); - // Strict equality proves the single-voice `voiceConfig` is absent. - assert_eq!( - request_body(&server, 0).await, - json!({ - "contents": [{"role": "user", "parts": [{"text": "Joe: Hi. Jane: Hello."}]}], - "generationConfig": { - "responseModalities": ["AUDIO"], - "speechConfig": {"multiSpeakerVoiceConfig": multi_speaker()}, - }, - }) - ); -} - -/// TS: should read provider options under `googleVertex` for a Vertex provider -#[tokio::test] -async fn reads_provider_options_under_google_vertex_for_a_vertex_provider() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let vertex = create_google_vertex(VertexProviderSettings { - api_key: Some(Resolvable::Value("test-api-key".to_string())), - base_url: Some(server.uri()), - ..Default::default() - }) - .unwrap(); - let model = vertex.speech_model(LEGACY_MODEL).unwrap().unwrap(); - assert_eq!(model.provider(), "google.vertex.speech"); - let mut call = options("Joe: Hi. Jane: Hello."); - call.provider_options = provider_options( - "googleVertex", - json!({"multiSpeakerVoiceConfig": multi_speaker()}), - ); - model.do_generate(&call).await.unwrap(); - assert_eq!( - request_body(&server, 0).await, - json!({ - "contents": [{"role": "user", "parts": [{"text": "Joe: Hi. Jane: Hello."}]}], - "generationConfig": { - "responseModalities": ["AUDIO"], - "speechConfig": {"multiSpeakerVoiceConfig": multi_speaker()}, - }, - }) - ); -} +// ── modern models (describe.each) ─────────────────────────────────────────── -/// TS: should ignore instructions (with a warning) when multi-speaker is set -#[tokio::test] -async fn ignores_instructions_with_a_warning_when_multi_speaker_is_set() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let mut call = options("Joe: Hi. Jane: Hello."); - call.instructions = Some("Say cheerfully".into()); - call.provider_options = google_options(json!({"multiSpeakerVoiceConfig": multi_speaker()})); - let result = model(&server, LEGACY_MODEL) - .do_generate(&call) - .await - .unwrap(); - // instructions are NOT prepended to the multi-speaker transcript. - assert_eq!( - request_body(&server, 0).await["contents"], - json!([{"role": "user", "parts": [{"text": "Joe: Hi. Jane: Hello."}]}]) - ); - assert!(has_warning(&result, "instructions")); -} +mod modern_models { + use super::*; -/// TS: should expose sample rate and mime type in provider metadata -#[tokio::test] -async fn exposes_sample_rate_and_mime_type_in_provider_metadata() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let result = model(&server, LEGACY_MODEL) - .do_generate(&options("Hello from the AI SDK!")) - .await - .unwrap(); - assert_eq!( - serde_json::to_value(result.provider_metadata.unwrap()).unwrap(), - json!({"google": {"sampleRate": 24000, "mimeType": "audio/L16;rate=24000"}}) - ); -} + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › preserves the transcript and maps instructions to speech metadata + #[tokio::test] + async fn modern_maps_instructions_to_speech_metadata() { + for model_id in MODERN_MODELS { + let server = MockServer::start().await; + mount(&server, "audio/wav", WAV_BASE64).await; + let mut call = options("Hello. How are you? "); + call.instructions = Some("whispering".into()); + let result = model(&server, model_id).do_generate(&call).await.unwrap(); + assert_eq!( + request_body(&server, 0).await, + json!({ + "contents": [{"role": "user", "parts": [{ + "text": "Hello. How are you? ", + "speechMetadata": {"style": "whispering"}, + }]}], + "generationConfig": { + "responseModalities": ["AUDIO"], + "speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}, + }, + }), + "{model_id}" + ); + assert_eq!(audio(&result), wav_bytes()); + assert!(result.warnings.is_empty()); + } + } -/// TS: should return empty audio when no inline data is present -#[tokio::test] -async fn returns_empty_audio_when_no_inline_data_is_present() { - let server = MockServer::start().await; - mount_parts(&server, json!([{"text": "no audio here"}])).await; - let result = model(&server, LEGACY_MODEL) - .do_generate(&options("Hello from the AI SDK!")) - .await - .unwrap(); - assert!(audio(&result).is_empty()); -} + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › allows an empty style to override instructions + #[tokio::test] + async fn modern_allows_an_empty_style_to_override_instructions() { + for model_id in MODERN_MODELS { + let server = MockServer::start().await; + mount(&server, "audio/wav", WAV_BASE64).await; + let mut call = options("Hello"); + call.instructions = Some("excited".into()); + call.provider_options = google_options(json!({"speechMetadata": {"style": ""}})); + model(&server, model_id).do_generate(&call).await.unwrap(); + assert_eq!( + request_body(&server, 0).await["contents"][0]["parts"], + json!([{"text": "Hello", "speechMetadata": {"style": ""}}]), + "{model_id}" + ); + } + } -/// TS: should include response data with timestamp, modelId and headers -#[tokio::test] -async fn includes_response_data_with_timestamp_model_id_and_headers() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let result = model(&server, LEGACY_MODEL) - .do_generate(&options("Hello from the AI SDK!")) - .await - .unwrap(); - assert!(result.response.timestamp.is_some()); - assert_eq!(result.response.model_id.as_deref(), Some(LEGACY_MODEL)); - assert_eq!( - result - .response - .headers - .unwrap() - .get("x-request-id") - .map(String::as_str), - Some("test-request-id") - ); -} + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › rejects custom voice %s before fetching + #[tokio::test] + async fn modern_rejects_custom_voice_before_fetching() { + for model_id in MODERN_MODELS { + for voice in ["voice_custom", "voicekey_custom"] { + let server = MockServer::start().await; + let mut call = options("Hello"); + call.voice = Some(voice.into()); + let error = model(&server, model_id) + .do_generate(&call) + .await + .unwrap_err(); + assert!(invalid_argument(error).contains("parameter voice")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + } + } -/// TS: should have no warnings on the happy path -#[tokio::test] -async fn has_no_warnings_on_the_happy_path() { - let server = MockServer::start().await; - mount(&server, "audio/L16;rate=24000", PCM_BASE64).await; - let result = model(&server, LEGACY_MODEL) - .do_generate(&options("Hello from the AI SDK!")) - .await - .unwrap(); - assert!(result.warnings.is_empty()); -} + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › sends separate turns with explicit speakers and per-turn styles + #[tokio::test] + async fn modern_sends_separate_turns_with_speakers_and_styles() { + for model_id in MODERN_MODELS { + let server = MockServer::start().await; + mount(&server, "audio/wav", WAV_BASE64).await; + let mut call = options(""); + call.instructions = Some("speaking slowly".into()); + call.provider_options = google_options(json!({ + "multiSpeakerVoiceConfig": multi_speaker(), + "turns": [ + {"text": "Hi.", "speechMetadata": {"speaker": "Joe"}}, + {"text": " Hello.", "speechMetadata": {"speaker": "Jane", "style": ""}}, + ], + })); + let result = model(&server, model_id).do_generate(&call).await.unwrap(); + let body = request_body(&server, 0).await; + assert_eq!( + body["contents"][0]["parts"], + json!([ + {"text": "Hi.", "speechMetadata": {"speaker": "Joe", "style": "speaking slowly"}}, + {"text": " Hello.", "speechMetadata": {"speaker": "Jane", "style": ""}}, + ]), + "{model_id}" + ); + assert_eq!( + body["generationConfig"]["speechConfig"], + json!({"multiSpeakerVoiceConfig": multi_speaker()}) + ); + assert!(result.warnings.is_empty()); + } + } -// ── modern models (describe.each) ─────────────────────────────────────────── + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › warns when turns replace nonempty top-level text + #[tokio::test] + async fn modern_warns_when_turns_replace_nonempty_top_level_text() { + for model_id in MODERN_MODELS { + let server = MockServer::start().await; + mount(&server, "audio/wav", WAV_BASE64).await; + let mut call = options("Ignored"); + call.provider_options = google_options(json!({"turns": [{"text": "Spoken"}]})); + let result = model(&server, model_id).do_generate(&call).await.unwrap(); + assert!(has_warning(&result, "text")); + assert_eq!( + request_body(&server, 0).await["contents"][0]["parts"], + json!([{"text": "Spoken"}]) + ); + } + } -/// TS: preserves the transcript and maps instructions to speech metadata -#[tokio::test] -async fn modern_maps_instructions_to_speech_metadata() { - for model_id in MODERN_MODELS { - let server = MockServer::start().await; - mount(&server, "audio/wav", WAV_BASE64).await; - let mut call = options("Hello. How are you? "); - call.instructions = Some("whispering".into()); - let result = model(&server, model_id).do_generate(&call).await.unwrap(); - assert_eq!( - request_body(&server, 0).await, - json!({ - "contents": [{"role": "user", "parts": [{ - "text": "Hello. How are you? ", - "speechMetadata": {"style": "whispering"}, - }]}], - "generationConfig": { - "responseModalities": ["AUDIO"], - "speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}, - }, - }), - "{model_id}" - ); - assert_eq!(audio(&result), wav_bytes()); - assert!(result.warnings.is_empty()); + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › requests %s and preserves raw bytes + #[tokio::test] + async fn modern_requests_raw_formats_and_preserves_bytes() { + for (output_format, mime_type, response_mime_type) in [ + ("pcm", "AUDIO_L16", "audio/l16"), + ("audio/l16", "AUDIO_L16", "audio/l16"), + ("mulaw", "AUDIO_MULAW", "audio/mulaw"), + ("audio/mulaw", "AUDIO_MULAW", "audio/mulaw"), + ("alaw", "AUDIO_ALAW", "audio/alaw"), + ("audio/alaw", "AUDIO_ALAW", "audio/alaw"), + ] { + for model_id in MODERN_MODELS { + let server = MockServer::start().await; + mount( + &server, + &format!("{response_mime_type}; rate=24000; channels=1"), + PCM_BASE64, + ) + .await; + let mut call = options("Hello"); + call.output_format = Some(output_format.into()); + let result = model(&server, model_id).do_generate(&call).await.unwrap(); + assert_eq!( + request_body(&server, 0).await["generationConfig"]["responseFormat"], + json!({"audio": {"mimeType": mime_type}}) + ); + assert_eq!(audio(&result), PCM_BYTES); + assert!(result.warnings.is_empty()); + assert_eq!( + serde_json::to_value(result.provider_metadata.unwrap()).unwrap()["google"]["sampleRate"], + 24000 + ); + } + } } -} -/// TS: allows an empty style to override instructions -#[tokio::test] -async fn modern_allows_an_empty_style_to_override_instructions() { - for model_id in MODERN_MODELS { - let server = MockServer::start().await; - mount(&server, "audio/wav", WAV_BASE64).await; - let mut call = options("Hello"); - call.instructions = Some("excited".into()); - call.provider_options = google_options(json!({"speechMetadata": {"style": ""}})); - model(&server, model_id).do_generate(&call).await.unwrap(); - assert_eq!( - request_body(&server, 0).await["contents"][0]["parts"], - json!([{"text": "Hello", "speechMetadata": {"style": ""}}]), - "{model_id}" - ); + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › requests %s without adding another header + #[tokio::test] + async fn modern_requests_wav_without_adding_another_header() { + for output_format in ["wav", "audio/wav"] { + for model_id in MODERN_MODELS { + let server = MockServer::start().await; + mount(&server, "audio/wav", WAV_BASE64).await; + let mut call = options("Hello"); + call.output_format = Some(output_format.into()); + let result = model(&server, model_id).do_generate(&call).await.unwrap(); + assert_eq!( + request_body(&server, 0).await["generationConfig"]["responseFormat"], + json!({"audio": {"mimeType": "AUDIO_WAV"}}) + ); + assert_eq!(audio(&result), wav_bytes()); + } + } } -} -/// TS: rejects custom voice %s before fetching -#[tokio::test] -async fn modern_rejects_custom_voice_before_fetching() { - for model_id in MODERN_MODELS { - for voice in ["voice_custom", "voicekey_custom"] { + /// upstream: @ai-sdk/google src/google-speech-model.test.ts › %s › returns empty audio without a WAV header + #[tokio::test] + async fn modern_returns_empty_audio_without_a_wav_header() { + for model_id in MODERN_MODELS { let server = MockServer::start().await; - let mut call = options("Hello"); - call.voice = Some(voice.into()); - let error = model(&server, model_id) - .do_generate(&call) + mount(&server, "audio/wav", "").await; + let result = model(&server, model_id) + .do_generate(&options("Hello")) .await - .unwrap_err(); - assert!(invalid_argument(error).contains("parameter voice")); - assert!(server.received_requests().await.unwrap().is_empty()); + .unwrap(); + assert!(audio(&result).is_empty()); } } } -/// TS: rejects custom speaker voice configuration %j before fetching +/// partial: `rejects custom speaker voice configuration %j before fetching`; full port in src/google/tests/speech_model.rs. #[tokio::test] async fn modern_rejects_custom_speaker_voice_configuration_before_fetching() { for voice_config in [ @@ -657,40 +784,7 @@ async fn modern_rejects_custom_speaker_voice_configuration_before_fetching() { } } -/// TS: sends separate turns with explicit speakers and per-turn styles -#[tokio::test] -async fn modern_sends_separate_turns_with_speakers_and_styles() { - for model_id in MODERN_MODELS { - let server = MockServer::start().await; - mount(&server, "audio/wav", WAV_BASE64).await; - let mut call = options(""); - call.instructions = Some("speaking slowly".into()); - call.provider_options = google_options(json!({ - "multiSpeakerVoiceConfig": multi_speaker(), - "turns": [ - {"text": "Hi.", "speechMetadata": {"speaker": "Joe"}}, - {"text": " Hello.", "speechMetadata": {"speaker": "Jane", "style": ""}}, - ], - })); - let result = model(&server, model_id).do_generate(&call).await.unwrap(); - let body = request_body(&server, 0).await; - assert_eq!( - body["contents"][0]["parts"], - json!([ - {"text": "Hi.", "speechMetadata": {"speaker": "Joe", "style": "speaking slowly"}}, - {"text": " Hello.", "speechMetadata": {"speaker": "Jane", "style": ""}}, - ]), - "{model_id}" - ); - assert_eq!( - body["generationConfig"]["speechConfig"], - json!({"multiSpeakerVoiceConfig": multi_speaker()}) - ); - assert!(result.warnings.is_empty()); - } -} - -/// TS: rejects missing or unconfigured speaker %s +/// partial: `rejects missing or unconfigured speaker %s`; full port in src/google/tests/speech_model.rs. #[tokio::test] async fn modern_rejects_missing_or_unconfigured_speaker() { for speaker_metadata in [json!({}), json!({"speaker": "Unknown"})] { @@ -709,7 +803,7 @@ async fn modern_rejects_missing_or_unconfigured_speaker() { } } -/// TS: rejects a labelled transcript without structured speakers +/// partial: `rejects a labelled transcript without structured speakers`; full port in src/google/tests/speech_model.rs. #[tokio::test] async fn modern_rejects_a_labelled_transcript_without_structured_speakers() { let server = MockServer::start().await; @@ -723,7 +817,7 @@ async fn modern_rejects_a_labelled_transcript_without_structured_speakers() { assert!(server.received_requests().await.unwrap().is_empty()); } -/// TS: rejects conflicting global and per-turn metadata +/// partial: `rejects conflicting global and per-turn metadata`; full port in src/google/tests/speech_model.rs. #[tokio::test] async fn modern_rejects_conflicting_global_and_per_turn_metadata() { let server = MockServer::start().await; @@ -739,177 +833,95 @@ async fn modern_rejects_conflicting_global_and_per_turn_metadata() { assert!(invalid_argument(error).contains("Set speechMetadata on each turn")); } -/// TS: warns when turns replace nonempty top-level text -#[tokio::test] -async fn modern_warns_when_turns_replace_nonempty_top_level_text() { - for model_id in MODERN_MODELS { - let server = MockServer::start().await; - mount(&server, "audio/wav", WAV_BASE64).await; - let mut call = options("Ignored"); - call.provider_options = google_options(json!({"turns": [{"text": "Spoken"}]})); - let result = model(&server, model_id).do_generate(&call).await.unwrap(); - assert!(has_warning(&result, "text")); - assert_eq!( - request_body(&server, 0).await["contents"][0]["parts"], - json!([{"text": "Spoken"}]) +// ── getGoogleSpeechInput ──────────────────────────────────────────────────── + +mod get_google_speech_input { + use super::*; + + /// upstream: @ai-sdk/google src/google-speech-input.test.ts › getGoogleSpeechInput › extracts only transcript text, preserving Unicode and inline tags + #[test] + fn input_extracts_only_transcript_text() { + let input = google_speech_input( + "Ignored top-level text", + None, + Some(&json!({"google": {"turns": [ + {"text": "Hello ", "speechMetadata": {"speaker": "Alice", "style": "whispering"}}, + {"text": "世界 👋", "speechMetadata": {"speaker": "Bob"}}, + ]}})), ); + assert_eq!(input.text, "Hello 世界 👋"); + assert!(!input.uses_custom_voice); } -} -/// TS: requests %s and preserves raw bytes -#[tokio::test] -async fn modern_requests_raw_formats_and_preserves_bytes() { - for (output_format, mime_type, response_mime_type) in [ - ("pcm", "AUDIO_L16", "audio/l16"), - ("audio/l16", "AUDIO_L16", "audio/l16"), - ("mulaw", "AUDIO_MULAW", "audio/mulaw"), - ("audio/mulaw", "AUDIO_MULAW", "audio/mulaw"), - ("alaw", "AUDIO_ALAW", "audio/alaw"), - ("audio/alaw", "AUDIO_ALAW", "audio/alaw"), - ] { - for model_id in MODERN_MODELS { - let server = MockServer::start().await; - mount( - &server, - &format!("{response_mime_type}; rate=24000; channels=1"), - PCM_BASE64, - ) - .await; - let mut call = options("Hello"); - call.output_format = Some(output_format.into()); - let result = model(&server, model_id).do_generate(&call).await.unwrap(); - assert_eq!( - request_body(&server, 0).await["generationConfig"]["responseFormat"], - json!({"audio": {"mimeType": mime_type}}) - ); - assert_eq!(audio(&result), PCM_BYTES); - assert!(result.warnings.is_empty()); - assert_eq!( - serde_json::to_value(result.provider_metadata.unwrap()).unwrap()["google"]["sampleRate"], - 24000 - ); - } + /// upstream: @ai-sdk/google src/google-speech-input.test.ts › getGoogleSpeechInput › preserves an empty structured transcript instead of using top-level text + #[test] + fn input_preserves_an_empty_structured_transcript() { + let input = google_speech_input( + "Ignored", + None, + Some(&json!({"google": {"turns": [{"text": ""}]}})), + ); + assert_eq!(input.text, ""); } -} -/// TS: requests %s without adding another header -#[tokio::test] -async fn modern_requests_wav_without_adding_another_header() { - for output_format in ["wav", "audio/wav"] { - for model_id in MODERN_MODELS { - let server = MockServer::start().await; - mount(&server, "audio/wav", WAV_BASE64).await; - let mut call = options("Hello"); - call.output_format = Some(output_format.into()); - let result = model(&server, model_id).do_generate(&call).await.unwrap(); - assert_eq!( - request_body(&server, 0).await["generationConfig"]["responseFormat"], - json!({"audio": {"mimeType": "AUDIO_WAV"}}) - ); - assert_eq!(audio(&result), wav_bytes()); + /// upstream: @ai-sdk/google src/google-speech-input.test.ts › getGoogleSpeechInput › leaves malformed options to provider validation: %j + #[test] + fn input_leaves_malformed_options_to_provider_validation() { + let cases = [ + None, + Some(json!({})), + Some(json!({"google": null})), + Some(json!({"google": "invalid"})), + Some(json!({"google": {"turns": []}})), + Some(json!({"google": {"turns": "Hello"}})), + Some(json!({"google": {"turns": [null]}})), + Some(json!({"google": {"turns": [{"text": 123}]}})), + Some(json!({"google": {"turns": [{"text": "Partial"}, {}]}})), + Some(json!({"openai": {"turns": [{"text": "Other provider"}]}})), + ]; + for options in cases { + let input = google_speech_input("Hello", None, options.as_ref()); + assert_eq!(input.text, "Hello", "{options:?}"); + assert!(!input.uses_custom_voice, "{options:?}"); } } -} -/// TS: returns empty audio without a WAV header -#[tokio::test] -async fn modern_returns_empty_audio_without_a_wav_header() { - for model_id in MODERN_MODELS { - let server = MockServer::start().await; - mount(&server, "audio/wav", "").await; - let result = model(&server, model_id) - .do_generate(&options("Hello")) - .await - .unwrap(); - assert!(audio(&result).is_empty()); - } -} - -// ── getGoogleSpeechInput ──────────────────────────────────────────────────── - -/// TS: extracts only transcript text, preserving Unicode and inline tags -#[test] -fn input_extracts_only_transcript_text() { - let input = google_speech_input( - "Ignored top-level text", - None, - Some(&json!({"google": {"turns": [ - {"text": "Hello ", "speechMetadata": {"speaker": "Alice", "style": "whispering"}}, - {"text": "世界 👋", "speechMetadata": {"speaker": "Bob"}}, - ]}})), - ); - assert_eq!(input.text, "Hello 世界 👋"); - assert!(!input.uses_custom_voice); -} - -/// TS: preserves an empty structured transcript instead of using top-level text -#[test] -fn input_preserves_an_empty_structured_transcript() { - let input = google_speech_input( - "Ignored", - None, - Some(&json!({"google": {"turns": [{"text": ""}]}})), - ); - assert_eq!(input.text, ""); -} - -/// TS: leaves malformed options to provider validation: %j -#[test] -fn input_leaves_malformed_options_to_provider_validation() { - let cases = [ - None, - Some(json!({})), - Some(json!({"google": null})), - Some(json!({"google": "invalid"})), - Some(json!({"google": {"turns": []}})), - Some(json!({"google": {"turns": "Hello"}})), - Some(json!({"google": {"turns": [null]}})), - Some(json!({"google": {"turns": [{"text": 123}]}})), - Some(json!({"google": {"turns": [{"text": "Partial"}, {}]}})), - Some(json!({"openai": {"turns": [{"text": "Other provider"}]}})), - ]; - for options in cases { - let input = google_speech_input("Hello", None, options.as_ref()); - assert_eq!(input.text, "Hello", "{options:?}"); - assert!(!input.uses_custom_voice, "{options:?}"); + /// upstream: @ai-sdk/google src/google-speech-input.test.ts › getGoogleSpeechInput › identifies a top-level custom voice: %s + #[test] + fn input_identifies_a_top_level_custom_voice() { + for voice in ["voice_test", "voicekey_test"] { + assert!(google_speech_input("Hello", Some(voice), None).uses_custom_voice); + } } -} -/// TS: identifies a top-level custom voice: %s -#[test] -fn input_identifies_a_top_level_custom_voice() { - for voice in ["voice_test", "voicekey_test"] { - assert!(google_speech_input("Hello", Some(voice), None).uses_custom_voice); + /// upstream: @ai-sdk/google src/google-speech-input.test.ts › getGoogleSpeechInput › identifies explicit custom voice fields regardless of their value: %j + #[test] + fn input_identifies_explicit_custom_voice_fields_regardless_of_value() { + for voice in [ + json!("voice_test"), + json!("voicekey_test"), + json!("unprefixed-id"), + json!(""), + Value::Null, + ] { + let options = json!({"google": {"multiSpeakerVoiceConfig": {"speakerVoiceConfigs": [ + {"speaker": "Alice", "voiceConfig": {"voice": voice}}, + ]}}}); + assert!(google_speech_input("Hello", None, Some(&options)).uses_custom_voice); + } } -} -/// TS: identifies explicit custom voice fields regardless of their value: %j -#[test] -fn input_identifies_explicit_custom_voice_fields_regardless_of_value() { - for voice in [ - json!("voice_test"), - json!("voicekey_test"), - json!("unprefixed-id"), - json!(""), - Value::Null, - ] { + /// upstream: @ai-sdk/google src/google-speech-input.test.ts › getGoogleSpeechInput › recognizes custom voices even when multi-speaker configuration overrides voice + #[test] + fn input_recognizes_custom_voices_when_multi_speaker_overrides_voice() { let options = json!({"google": {"multiSpeakerVoiceConfig": {"speakerVoiceConfigs": [ - {"speaker": "Alice", "voiceConfig": {"voice": voice}}, + {"speaker": "Alice", "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}, ]}}}); - assert!(google_speech_input("Hello", None, Some(&options)).uses_custom_voice); + assert!(google_speech_input("Hello", Some("voice_test"), Some(&options)).uses_custom_voice); } } -/// TS: recognizes custom voices even when multi-speaker configuration overrides voice -#[test] -fn input_recognizes_custom_voices_when_multi_speaker_overrides_voice() { - let options = json!({"google": {"multiSpeakerVoiceConfig": {"speakerVoiceConfigs": [ - {"speaker": "Alice", "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}, - ]}}}); - assert!(google_speech_input("Hello", Some("voice_test"), Some(&options)).uses_custom_voice); -} - -/// TS: does not infer custom voices from unrelated fields: %j +/// partial: `does not infer custom voices from unrelated fields: %j`; full port in src/google/tests/speech_input.rs. #[test] fn input_does_not_infer_custom_voices_from_unrelated_fields() { let configs = [ diff --git a/aimux-providers/tests/google_transcription_test.rs b/aimux-providers/tests/google_transcription_test.rs index 40f16681c..878f37dbd 100644 --- a/aimux-providers/tests/google_transcription_test.rs +++ b/aimux-providers/tests/google_transcription_test.rs @@ -72,7 +72,7 @@ fn wav_call(provider_options: Option) -> TranscriptionCal call } -/// TS: transcribes audio via the Interactions API with transcription_config +/// partial: `transcribes audio via the Interactions API with transcription_config`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn transcribes_audio_via_the_interactions_api_with_transcription_config() { let server = interactions_server(hello_world()).await; @@ -108,30 +108,89 @@ async fn transcribes_audio_via_the_interactions_api_with_transcription_config() ); } -/// TS: maps diarization and word timestamps into the mode object -#[tokio::test] -async fn maps_diarization_and_word_timestamps_into_the_mode_object() { - let server = interactions_server(hello_world()).await; - model(&server.uri(), "gemini-3.5-transcribe") - .do_generate(&wav_call(google_options( - json!({"diarization": true, "wordTimestamp": true}), - ))) - .await - .unwrap(); - let body: Value = server.received_requests().await.unwrap()[0] - .body_json() - .unwrap(); - assert_eq!( - body["generation_config"]["transcription_config"], - json!({"mode": { - "type": "verbatim", - "diarization_mode": "speaker", - "timestamp_granularities": ["word"], - }}) - ); +mod do_generate { + use super::*; + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doGenerate › maps diarization and word timestamps into the mode object + #[tokio::test] + async fn maps_diarization_and_word_timestamps_into_the_mode_object() { + let server = interactions_server(hello_world()).await; + model(&server.uri(), "gemini-3.5-transcribe") + .do_generate(&wav_call(google_options( + json!({"diarization": true, "wordTimestamp": true}), + ))) + .await + .unwrap(); + let body: Value = server.received_requests().await.unwrap()[0] + .body_json() + .unwrap(); + assert_eq!( + body["generation_config"]["transcription_config"], + json!({"mode": { + "type": "verbatim", + "diarization_mode": "speaker", + "timestamp_granularities": ["word"], + }}) + ); + } + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doGenerate › extracts word segments from word_info annotations + #[tokio::test] + async fn extracts_word_segments_from_word_info_annotations() { + let word = |text: &str, start: &str, end: &str| json!({"type": "word_info", "text": text, "speaker": "spk:0", "start_offset": start, "end_offset": end}); + let server = interactions_server(json!({ + "id": "interactions/test", + "status": "completed", + "steps": [{"type": "model_output", "content": [{ + "type": "text", + "text": "The quick brown fox.", + "annotations": [ + word("The", "0.100s", "0.100s"), + word("quick", "0.100s", "0.400s"), + word("brown", "0.400s", "0.700s"), + word("fox.", "0.700s", "1s"), + ], + }]}], + "usage": {"total_input_tokens": 64}, + })) + .await; + let result = model(&server.uri(), "gemini-3.5-transcribe") + .do_generate(&wav_call(google_options(json!({})))) + .await + .unwrap(); + assert_eq!(result.text, "The quick brown fox."); + let segments: Vec<(String, f64, f64)> = result + .segments + .into_iter() + .map(|s| (s.text, s.start_second, s.end_second)) + .collect(); + assert_eq!( + segments, + [ + ("The".to_string(), 0.1, 0.1), + ("quick".to_string(), 0.1, 0.4), + ("brown".to_string(), 0.4, 0.7), + ("fox.".to_string(), 0.7, 1.0), + ] + ); + } + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doGenerate › rejects unary transcription on live model ids + #[tokio::test] + async fn rejects_unary_transcription_on_live_model_ids() { + let error = model("http://127.0.0.1:1", "gemini-3.5-transcribe-live") + .do_generate(&wav_call(google_options(json!({})))) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("only supports streaming transcription") + ); + } } -/// TS: omits generation_config when no transcription options are set +/// partial: `omits generation_config when no transcription options are set`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn omits_generation_config_when_no_transcription_options_are_set() { let server = interactions_server(hello_world()).await; @@ -145,61 +204,6 @@ async fn omits_generation_config_when_no_transcription_options_are_set() { assert!(body.get("generation_config").is_none()); } -/// TS: extracts word segments from word_info annotations -#[tokio::test] -async fn extracts_word_segments_from_word_info_annotations() { - let word = |text: &str, start: &str, end: &str| json!({"type": "word_info", "text": text, "speaker": "spk:0", "start_offset": start, "end_offset": end}); - let server = interactions_server(json!({ - "id": "interactions/test", - "status": "completed", - "steps": [{"type": "model_output", "content": [{ - "type": "text", - "text": "The quick brown fox.", - "annotations": [ - word("The", "0.100s", "0.100s"), - word("quick", "0.100s", "0.400s"), - word("brown", "0.400s", "0.700s"), - word("fox.", "0.700s", "1s"), - ], - }]}], - "usage": {"total_input_tokens": 64}, - })) - .await; - let result = model(&server.uri(), "gemini-3.5-transcribe") - .do_generate(&wav_call(google_options(json!({})))) - .await - .unwrap(); - assert_eq!(result.text, "The quick brown fox."); - let segments: Vec<(String, f64, f64)> = result - .segments - .into_iter() - .map(|s| (s.text, s.start_second, s.end_second)) - .collect(); - assert_eq!( - segments, - [ - ("The".to_string(), 0.1, 0.1), - ("quick".to_string(), 0.1, 0.4), - ("brown".to_string(), 0.4, 0.7), - ("fox.".to_string(), 0.7, 1.0), - ] - ); -} - -/// TS: rejects unary transcription on live model ids -#[tokio::test] -async fn rejects_unary_transcription_on_live_model_ids() { - let error = model("http://127.0.0.1:1", "gemini-3.5-transcribe-live") - .do_generate(&wav_call(google_options(json!({})))) - .await - .unwrap_err(); - assert!( - error - .to_string() - .contains("only supports streaming transcription") - ); -} - // ── doStream ──────────────────────────────────────────────────────────────── #[derive(Default)] @@ -350,7 +354,7 @@ fn finished(text: &str) -> Value { json!({"serverContent": {"inputTranscription": {"text": text, "finished": true}}}) } -/// TS: streams transcription over the Gemini Live API WebSocket +/// partial: `streams transcription over the Gemini Live API WebSocket`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn streams_transcription_over_the_gemini_live_api_websocket() { let (base_url, captured) = live_server(Script { @@ -417,7 +421,7 @@ async fn streams_transcription_over_the_gemini_live_api_websocket() { ); } -/// TS: passes the SMART transcription mode into the live setup +/// partial: `passes the SMART transcription mode into the live setup`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn passes_the_smart_transcription_mode_into_the_live_setup() { let (base_url, captured) = live_server(Script { @@ -446,7 +450,7 @@ async fn passes_the_smart_transcription_mode_into_the_live_setup() { ); } -/// TS: accepts the pre-launch REQUIRES_ACTION interaction status as idle +/// partial: `accepts the pre-launch REQUIRES_ACTION interaction status as idle`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn accepts_the_pre_launch_requires_action_interaction_status_as_idle() { let (base_url, _) = live_server(Script { @@ -467,7 +471,7 @@ async fn accepts_the_pre_launch_requires_action_interaction_status_as_idle() { assert_eq!(described(&parts).last().unwrap(), "finish:hi:-"); } -/// TS: falls back to the latest interim partial when no final segment arrives +/// partial: `falls back to the latest interim partial when no final segment arrives`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn falls_back_to_the_latest_interim_partial_when_no_final_segment_arrives() { let (base_url, _) = live_server(Script { @@ -494,7 +498,7 @@ async fn falls_back_to_the_latest_interim_partial_when_no_final_segment_arrives( assert_eq!(described[described.len() - 1], "finish:hello world.:-"); } -/// TS: finishes with accumulated text when the server closes after audio ended +/// partial: `finishes with accumulated text when the server closes after audio ended`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn finishes_with_accumulated_text_when_the_server_closes_after_audio_ended() { let (base_url, _) = live_server(Script { @@ -512,7 +516,7 @@ async fn finishes_with_accumulated_text_when_the_server_closes_after_audio_ended assert_eq!(described(&parts).last().unwrap(), "finish:partial words:-"); } -/// TS: errors when the socket closes before the audio ended +/// partial: `errors when the socket closes before the audio ended`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn errors_when_the_socket_closes_before_the_audio_ended() { let (base_url, _) = live_server(Script { @@ -537,7 +541,7 @@ async fn errors_when_the_socket_closes_before_the_audio_ended() { ); } -/// TS: surfaces server error messages +/// partial: `surfaces server error messages`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn surfaces_server_error_messages() { let (base_url, _) = live_server(Script { @@ -556,37 +560,41 @@ async fn surfaces_server_error_messages() { assert!(error.to_string().contains("quota exceeded")); } -/// TS: rejects streaming on unary model ids -#[tokio::test] -async fn rejects_streaming_on_unary_model_ids() { - let error = model("http://127.0.0.1:1", "gemini-3.5-transcribe") - .do_stream(stream_options(one_chunk(), Some(16000), None, false)) - .expect("the model has do_stream") - .await - .unwrap_err(); - assert!( - error - .to_string() - .contains("does not support streaming transcription") - ); -} +mod do_stream { + use super::*; -/// TS: rejects non-16kHz PCM input audio -#[tokio::test] -async fn rejects_non_16khz_pcm_input_audio() { - let error = model("http://127.0.0.1:1", "gemini-3.5-transcribe-live") - .do_stream(stream_options(one_chunk(), Some(24000), None, false)) - .expect("the model has do_stream") - .await - .unwrap_err(); - assert!( - error - .to_string() - .contains("only supports 16kHz 16-bit PCM input audio") - ); + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doStream › rejects streaming on unary model ids + #[tokio::test] + async fn rejects_streaming_on_unary_model_ids() { + let error = model("http://127.0.0.1:1", "gemini-3.5-transcribe") + .do_stream(stream_options(one_chunk(), Some(16000), None, false)) + .expect("the model has do_stream") + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("does not support streaming transcription") + ); + } + + /// upstream: @ai-sdk/google src/transcription/google-transcription-model.test.ts › doStream › rejects non-16kHz PCM input audio + #[tokio::test] + async fn rejects_non_16khz_pcm_input_audio() { + let error = model("http://127.0.0.1:1", "gemini-3.5-transcribe-live") + .do_stream(stream_options(one_chunk(), Some(24000), None, false)) + .expect("the model has do_stream") + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("only supports 16kHz 16-bit PCM input audio") + ); + } } -/// TS: emits raw chunks when includeRawChunks is set +/// partial: `emits raw chunks when includeRawChunks is set`; full port in src/google/tests/transcription/transcription_model.rs. #[tokio::test] async fn emits_raw_chunks_when_include_raw_chunks_is_set() { let (base_url, _) = live_server(Script { diff --git a/aimux-providers/tests/google_upstream_e2e_test.rs b/aimux-providers/tests/google_upstream_e2e_test.rs index 83ea2eb0a..f5ed00d66 100644 --- a/aimux-providers/tests/google_upstream_e2e_test.rs +++ b/aimux-providers/tests/google_upstream_e2e_test.rs @@ -39,7 +39,7 @@ fn basic_request() -> Value { json!({"contents":[{"role":"user","parts":[{"text":"Hello"}]}],"generationConfig":{}}) } -/// TS: "should preserve complete raw usage metadata" (google/src/google-language-model.test.ts) +/// partial: `should preserve complete raw usage metadata`; full port in src/google/tests/language_model.rs. #[tokio::test] async fn generate_preserves_usage_and_metadata() { let usage: Value = serde_json::from_str(r##"{"promptTokenCount": 12, "cachedContentTokenCount": 4, "candidatesTokenCount": 71, "toolUsePromptTokenCount": 65, "thoughtsTokenCount": 89, "totalTokenCount": 237, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 12, "nestedSentinel": "prompt"}], "cacheTokensDetails": [{"modality": "TEXT", "tokenCount": 4, "nestedSentinel": "cache"}], "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 71, "nestedSentinel": "candidate"}], "toolUsePromptTokensDetails": [{"modality": "TEXT", "tokenCount": 65, "nestedSentinel": "tool"}], "serviceTier": "standard", "topLevelSentinel": "preserve-me"}"##).unwrap(); @@ -76,7 +76,7 @@ async fn generate_preserves_usage_and_metadata() { assert_eq!(metadata["google"]["serviceTier"], "standard"); } -/// TS: "should stream text deltas" (google/src/google-language-model.test.ts) +/// partial: `should stream text deltas`; full port in src/google/tests/language_model.rs. #[tokio::test] async fn stream_text_and_finish_usage() { let chunks = r##"{"candidates":[{"content":{"parts":[{"text":"There are **3**"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":5,"totalTokenCount":199,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9}],"thoughtsTokenCount":185},"modelVersion":"gemini-3-pro-preview","responseId":"bH6LaZW8Fp_3nsEPqtaSwQ4"} @@ -169,7 +169,7 @@ async fn stream_text_and_finish_usage() { ); } -/// TS: "should extract tool calls" (google/src/google-language-model.test.ts) +/// partial: `should extract tool calls`; full port in src/google/tests/language_model.rs. #[tokio::test] async fn generate_function_call_with_signature() { let body: Value = serde_json::from_str(r##"{ @@ -241,7 +241,7 @@ async fn generate_function_call_with_signature() { assert_eq!(result.usage.output_tokens.total, Some(908)); } -/// TS: "preserves google.rpc.RetryInfo in APICallError.data" (google/src/google-error.test.ts) +/// partial: `preserves google.rpc.RetryInfo in APICallError.data`; full port in src/google/tests/error.rs. #[tokio::test] async fn error_preserves_retry_info() { let body = json!({"error":{"code":429,"message":"You exceeded your current quota, please check your plan.","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"quotaId":"GenerateRequestsPerMinutePerProjectPerModel-FreeTier"}]},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"34.4s"}]}}); @@ -267,8 +267,8 @@ async fn error_preserves_retry_info() { assert_eq!(error.data, Some(body)); } -/// TS: "should extract embedding" (google/src/google-embedding-model.test.ts) -/// TS: "should pass the model and the values" (google/src/google-embedding-model.test.ts) +/// partial: `should extract embedding`; full port in src/google/tests/embedding_model.rs. +/// partial: `should pass the model and the values`; full port in src/google/tests/embedding_model.rs. #[tokio::test] async fn embed_batch_request_and_vectors() { let body = @@ -299,7 +299,7 @@ async fn embed_batch_request_and_vectors() { assert_eq!(result.response.unwrap().body, Some(body)); } -/// TS: "should use the language model endpoint and extract generated images" (google/src/google-image-model.test.ts) +/// partial: `should use the language model endpoint and extract generated images`; full port in src/google/tests/image_model.rs. #[tokio::test] async fn image_request_and_metadata() { let usage = json!({"promptTokenCount":10,"candidatesTokenCount":100,"totalTokenCount":110}); @@ -334,9 +334,9 @@ async fn image_request_and_metadata() { assert_eq!(usage.total_tokens, Some(110)); } -/// TS: "should return operation with operationName" (google/src/google-video-model.test.ts) -/// TS: "should pass correct request body" (google/src/google-video-model.test.ts) -/// TS: "should return completed with video data when done" (google/src/google-video-model.test.ts) +/// partial: `should return operation with operationName`; full port in src/google/tests/video_model.rs. +/// partial: `should pass correct request body`; full port in src/google/tests/video_model.rs. +/// partial: `should return completed with video data when done`; full port in src/google/tests/video_model.rs. #[tokio::test] async fn video_start_and_completed_status() { let mock = MockFetch::new(vec![ @@ -380,7 +380,7 @@ async fn video_start_and_completed_status() { ); } -/// TS: "should throw when no videos in response" (google/src/google-video-model.test.ts) +/// partial: `should throw when no videos in response`; full port in src/google/tests/video_model.rs. #[tokio::test] async fn video_completed_response_without_videos_errors() { let mock = MockFetch::new(vec![Canned::json( diff --git a/aimux-providers/tests/google_video_test.rs b/aimux-providers/tests/google_video_test.rs index 23afeae57..228e51f98 100644 --- a/aimux-providers/tests/google_video_test.rs +++ b/aimux-providers/tests/google_video_test.rs @@ -59,6 +59,7 @@ async fn mock_predict_and_poll(server: &MockServer, result: &Value) { .await; } +/// partial: `should return completed with video data when done`; full port in src/google/tests/video_model.rs. #[tokio::test] async fn should_generate_video() { let server = MockServer::start().await; @@ -96,6 +97,7 @@ async fn should_generate_video() { } } +/// partial: `should pass correct request body`; full port in src/google/tests/video_model.rs. #[tokio::test] async fn should_pass_prompt() { let server = MockServer::start().await; @@ -119,6 +121,8 @@ async fn should_pass_prompt() { assert_eq!(body["instances"][0]["prompt"], "A cat"); } +/// partial: `should pass aspect ratio when provided`; full port in src/google/tests/video_model.rs. +/// partial: `should pass duration as durationSeconds`; full port in src/google/tests/video_model.rs. #[tokio::test] async fn should_pass_aspect_ratio_and_duration() { let server = MockServer::start().await; @@ -144,6 +148,7 @@ async fn should_pass_aspect_ratio_and_duration() { assert_eq!(body["parameters"]["durationSeconds"], 5); } +/// partial: `should include timestamp and modelId in response`; full port in src/google/tests/video_model.rs. #[tokio::test] async fn should_include_response_data() { let server = MockServer::start().await;