diff --git a/crates/coverage-report/src/compact.rs b/crates/coverage-report/src/compact.rs index 77e11a1..0134f66 100644 --- a/crates/coverage-report/src/compact.rs +++ b/crates/coverage-report/src/compact.rs @@ -43,7 +43,7 @@ pub struct PatternGroup { /// Truncate a string to a maximum number of characters, adding "..." if truncated. /// Uses character count, not byte count, to avoid UTF-8 panics. -fn truncate_str(s: &str, max_chars: usize) -> String { +pub fn truncate_str(s: &str, max_chars: usize) -> String { let char_count = s.chars().count(); if char_count > max_chars { let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect(); diff --git a/crates/coverage-report/src/report.rs b/crates/coverage-report/src/report.rs index cad7750..146c684 100644 --- a/crates/coverage-report/src/report.rs +++ b/crates/coverage-report/src/report.rs @@ -482,7 +482,7 @@ fn generate_markdown_report( report.push_str(&format!( " - `{}` - {}{}\n", test_case, - error, + compact::truncate_str(&error, 200), format_diff(&diff) )); } @@ -532,7 +532,7 @@ fn generate_markdown_report( report.push_str(&format!( " - `{}` - {}{}\n", test_case, - error, + compact::truncate_str(&error, 200), format_diff(&diff) )); } @@ -582,7 +582,7 @@ fn generate_markdown_report( report.push_str(&format!( " - `{}` - {}{}\n", test_case, - error, + compact::truncate_str(&error, 200), format_diff(&diff) )); } diff --git a/crates/coverage-report/src/requests_expected_differences.json b/crates/coverage-report/src/requests_expected_differences.json index b45c30f..a517bb4 100644 --- a/crates/coverage-report/src/requests_expected_differences.json +++ b/crates/coverage-report/src/requests_expected_differences.json @@ -39,7 +39,8 @@ ], "errors": [ { "pattern": "is not supported by OpenAI Chat Completions", "reason": "Provider-specific built-in tool has no OpenAI equivalent" }, - { "pattern": "Unsupported input type: UserContentPart variant: File", "reason": "Anthropic document blocks not supported in OpenAI" } + { "pattern": "UserContentPart::File", "reason": "OpenAI doesn't support File content parts" }, + { "pattern": "AssistantContentPart::File", "reason": "OpenAI doesn't support File content in assistant messages" } ] }, { @@ -59,7 +60,8 @@ ], "errors": [ { "pattern": "is not supported by OpenAI Responses API", "reason": "Provider-specific built-in tool has no OpenAI equivalent" }, - { "pattern": "Unsupported input type: UserContentPart variant: File", "reason": "Anthropic document blocks not supported in OpenAI" }, + { "pattern": "UserContentPart::File", "reason": "OpenAI doesn't support File content parts" }, + { "pattern": "AssistantContentPart::File", "reason": "OpenAI doesn't support File content in assistant messages" }, { "pattern": "ToolResult { tool_name: \"web_search\"", "reason": "Anthropic web_search encrypted results cannot be transformed to OpenAI" } ] }, @@ -108,7 +110,62 @@ { "pattern": "params.stream", "reason": "Google uses endpoint-based streaming" }, { "pattern": "params.metadata", "reason": "Google doesn't support metadata" }, { "pattern": "params.reasoning.effort", "reason": "Cross-canonical conversion (effort→budget_tokens) may quantize when max_tokens is very small" }, - { "pattern": "params.reasoning.summary", "reason": "Google doesn't support reasoning summary" } + { "pattern": "params.reasoning.summary", "reason": "Google doesn't support reasoning summary" }, + { "pattern": "params.parallel_tool_calls", "reason": "Google doesn't support parallel_tool_calls parameter" }, + { "pattern": "params.store", "reason": "Google doesn't support store parameter" }, + { "pattern": "params.frequency_penalty", "reason": "Google doesn't support frequency_penalty" }, + { "pattern": "params.presence_penalty", "reason": "Google doesn't support presence_penalty" }, + { "pattern": "params.seed", "reason": "Google doesn't support seed" }, + { "pattern": "params.logprobs", "reason": "Google doesn't support logprobs" }, + { "pattern": "params.top_logprobs", "reason": "Google doesn't support top_logprobs" }, + { "pattern": "params.tools[*].strict", "reason": "Google doesn't support strict mode for tools" }, + { "pattern": "params.tools[*].parameters.additionalProperties", "reason": "Google doesn't preserve additionalProperties in tool schemas" }, + { "pattern": "params.response_format.json_schema.schema.additionalProperties", "reason": "Google doesn't preserve additionalProperties in response schemas" }, + { "pattern": "params.response_format.json_schema.strict", "reason": "Google doesn't support strict mode for response schemas" }, + { "pattern": "params.tool_choice.disable_parallel", "reason": "Google doesn't support disable_parallel in tool_choice" }, + { "pattern": "params.temperature", "reason": "Google uses f32 for temperature, causing precision loss (0.7 → 0.699999988079071)" }, + { "pattern": "params.top_p", "reason": "Google uses f32 for top_p, causing precision loss (0.9 → 0.8999999761581421)" }, + { "pattern": "messages[*].content[*].media_type", "reason": "Google requires mime_type for images; null defaults to image/jpeg" }, + { "pattern": "messages[*].content[*].provider_options", "reason": "Google doesn't support provider_options on content parts" }, + { "pattern": "messages.length", "reason": "Google moves system messages to systemInstruction and merges consecutive same-role messages" } + ], + "errors": [ + { "pattern": "is not supported by Google", "reason": "Provider-specific built-in tool has no Google equivalent" } + ] + }, + { + "source": "Responses", + "target": "Google", + "fields": [ + { "pattern": "params.tools", "reason": "OpenAI built-in tools (code_interpreter, web_search) not supported in Google" } + ] + }, + { + "source": "Anthropic", + "target": "Google", + "fields": [ + { "pattern": "params.tools", "reason": "Anthropic built-in tools (bash, text_editor, web_search) not supported in Google" } + ] + }, + { + "source": "Google", + "target": "*", + "fields": [ + { "pattern": "messages[*].content[*].encrypted_content", "reason": "Google thoughtSignature doesn't roundtrip through other providers" } + ] + }, + { + "source": "Google", + "target": "ChatCompletions", + "fields": [ + { "pattern": "messages[*].content[*].tool_name", "reason": "ChatCompletions tool messages don't include tool_name, only tool_call_id" } + ] + }, + { + "source": "Google", + "target": "Anthropic", + "fields": [ + { "pattern": "messages[*].content[*].tool_name", "reason": "Anthropic tool_result doesn't include tool name, only tool_use_id" } ] } ], @@ -252,6 +309,100 @@ "target": "ChatCompletions", "skip": true, "reason": "Anthropic document blocks not supported in OpenAI ChatCompletions" + }, + { + "testCase": "reasoningEffortLowParam", + "source": "Google", + "target": "Responses", + "fields": [ + { "pattern": "params.reasoning.budget_tokens", "reason": "OpenAI uses effort levels, thinkingBudget gets quantized" } + ] + }, + { + "testCase": "reasoningEffortLowParam", + "source": "Google", + "target": "ChatCompletions", + "fields": [ + { "pattern": "params.reasoning.budget_tokens", "reason": "OpenAI uses effort levels, thinkingBudget gets quantized" } + ] + }, + { + "testCase": "reasoningSummaryParam", + "source": "Google", + "target": "Responses", + "fields": [ + { "pattern": "params.reasoning.budget_tokens", "reason": "OpenAI uses effort levels, thinkingBudget gets quantized" } + ] + }, + { + "testCase": "reasoningSummaryParam", + "source": "Google", + "target": "ChatCompletions", + "fields": [ + { "pattern": "params.reasoning.budget_tokens", "reason": "OpenAI uses effort levels, thinkingBudget gets quantized" } + ] + }, + { + "testCase": "thinkingLevelParam", + "source": "Google", + "target": "ChatCompletions", + "fields": [ + { "pattern": "messages[*].content.length", "reason": "ChatCompletions doesn't support thinking/reasoning content blocks" } + ] + }, + { + "testCase": "imageConfigParam", + "source": "Google", + "target": "ChatCompletions", + "fields": [ + { "pattern": "messages[*].content[*].type", "reason": "Google assistant image content not supported in ChatCompletions" }, + { "pattern": "messages[*].content[*].media_type", "reason": "Google assistant image content not supported in ChatCompletions" }, + { "pattern": "messages[*].content[*].data", "reason": "Google assistant image content not supported in ChatCompletions" }, + { "pattern": "messages[*].content[*].text", "reason": "Google assistant image content converted to text fallback in ChatCompletions" } + ] + }, + { + "testCase": "imageConfigParam", + "source": "Google", + "target": "Anthropic", + "fields": [ + { "pattern": "messages[*].content[*].type", "reason": "Google assistant image content not supported in Anthropic" }, + { "pattern": "messages[*].content[*].media_type", "reason": "Google assistant image content not supported in Anthropic" }, + { "pattern": "messages[*].content[*].data", "reason": "Google assistant image content not supported in Anthropic" }, + { "pattern": "messages[*].content[*].text", "reason": "Google assistant image content converted to text fallback in Anthropic" } + ] + }, + { + "testCase": "webSearchToolParam", + "source": "Anthropic", + "target": "Google", + "fields": [ + { "pattern": "messages[*].content.length", "reason": "Anthropic web search content blocks don't map 1:1 to Google structure" } + ] + }, + { + "testCase": "webSearchToolAdvancedParam", + "source": "Anthropic", + "target": "Google", + "fields": [ + { "pattern": "messages[*].content.length", "reason": "Anthropic web search content blocks don't map 1:1 to Google structure" } + ] + }, + { + "testCase": "documentContentParam", + "source": "Anthropic", + "target": "Google", + "fields": [ + { "pattern": "messages[*].content.length", "reason": "Anthropic document blocks not supported in Google" } + ] + }, + { + "testCase": "reasoningSummaryParam", + "source": "Google", + "target": "Responses", + "fields": [ + { "pattern": "messages.length", "reason": "Google reasoning summary creates additional messages in Responses format" } + ] } ] } diff --git a/crates/coverage-report/src/responses_expected_differences.json b/crates/coverage-report/src/responses_expected_differences.json index 8471714..746457a 100644 --- a/crates/coverage-report/src/responses_expected_differences.json +++ b/crates/coverage-report/src/responses_expected_differences.json @@ -8,6 +8,21 @@ { "pattern": "params.service_tier", "reason": "OpenAI-specific billing tier not universal" } ] }, + { + "source": "Responses", + "target": "Google", + "fields": [ + { "pattern": "messages[*].content[*].provider_options", "reason": "Responses API annotations/logprobs have no Google equivalent" }, + { "pattern": "messages[*].content[*].provider_executed", "reason": "Responses API provider_executed flag has no Google equivalent" } + ] + }, + { + "source": "Google", + "target": "*", + "fields": [ + { "pattern": "messages[*].content[*].encrypted_content", "reason": "Google thoughtSignature doesn't roundtrip through other providers" } + ] + }, { "source": "Anthropic", "target": "*", @@ -152,6 +167,100 @@ "fields": [ { "pattern": "messages.length", "reason": "Responses API doesn't support n>1 (multiple completions)" } ] + }, + { + "testCase": "webSearchToolParam", + "source": "Anthropic", + "target": "Google", + "fields": [ + { "pattern": "messages[*].content.length", "reason": "Anthropic web search content blocks don't map 1:1 to Google structure" } + ] + }, + { + "testCase": "webSearchToolAdvancedParam", + "source": "Anthropic", + "target": "Google", + "fields": [ + { "pattern": "messages[*].content.length", "reason": "Anthropic web search content blocks don't map 1:1 to Google structure" } + ] + }, + { + "testCase": "thinkingLevelParam", + "source": "Google", + "target": "ChatCompletions", + "fields": [ + { "pattern": "messages[*].content.length", "reason": "ChatCompletions doesn't support thinking/reasoning content blocks" } + ] + }, + { + "testCase": "imageConfigParam", + "source": "Google", + "target": "ChatCompletions", + "fields": [ + { "pattern": "messages[*].content[*].type", "reason": "Google assistant image content not supported in ChatCompletions" }, + { "pattern": "messages[*].content[*].media_type", "reason": "Google assistant image content not supported in ChatCompletions" }, + { "pattern": "messages[*].content[*].data", "reason": "Google assistant image content not supported in ChatCompletions" }, + { "pattern": "messages[*].content[*].text", "reason": "Google assistant image content converted to text fallback in ChatCompletions" } + ] + }, + { + "testCase": "imageConfigParam", + "source": "Google", + "target": "Anthropic", + "fields": [ + { "pattern": "messages[*].content[*].type", "reason": "Google assistant image content not supported in Anthropic" }, + { "pattern": "messages[*].content[*].media_type", "reason": "Google assistant image content not supported in Anthropic" }, + { "pattern": "messages[*].content[*].data", "reason": "Google assistant image content not supported in Anthropic" }, + { "pattern": "messages[*].content[*].text", "reason": "Google assistant image content converted to text fallback in Anthropic" } + ] + }, + { + "testCase": "imageConfigParam", + "source": "Google", + "target": "Responses", + "fields": [ + { "pattern": "messages.length", "reason": "Google assistant image content not supported in Responses" } + ] + }, + { + "testCase": "webSearchToolParam", + "source": "Anthropic", + "target": "Google", + "fields": [ + { "pattern": "finish_reason", "reason": "Google infers finish_reason from content - presence of tool calls yields ToolCalls" } + ] + }, + { + "testCase": "webSearchToolAdvancedParam", + "source": "Anthropic", + "target": "Google", + "fields": [ + { "pattern": "finish_reason", "reason": "Google infers finish_reason from content - presence of tool calls yields ToolCalls" } + ] + }, + { + "testCase": "codeInterpreterToolParam", + "source": "Responses", + "target": "Google", + "fields": [ + { "pattern": "finish_reason", "reason": "Google infers finish_reason from content - presence of tool calls yields ToolCalls" } + ] + }, + { + "testCase": "webSearchToolParam", + "source": "Responses", + "target": "Google", + "fields": [ + { "pattern": "finish_reason", "reason": "Google infers finish_reason from content - presence of tool calls yields ToolCalls" } + ] + }, + { + "testCase": "reasoningSummaryParam", + "source": "Google", + "target": "Responses", + "fields": [ + { "pattern": "messages.length", "reason": "Google reasoning summary creates additional messages in Responses format" } + ] } ] } diff --git a/crates/coverage-report/src/runner.rs b/crates/coverage-report/src/runner.rs index ed6311a..3b15fbd 100644 --- a/crates/coverage-report/src/runner.rs +++ b/crates/coverage-report/src/runner.rs @@ -99,7 +99,10 @@ pub fn test_request_transformation( Err(e) => { return TransformResult { level: ValidationLevel::Fail, - error: Some(format!("Conversion to universal format failed: {}", e)), + error: Some(truncate_error( + format!("Conversion to universal format failed: {}", e), + 500, + )), diff: None, limitation_reason: None, }; @@ -223,7 +226,10 @@ pub fn test_response_transformation( Err(e) => { return TransformResult { level: ValidationLevel::Fail, - error: Some(format!("Conversion to universal format failed: {}", e)), + error: Some(truncate_error( + format!("Conversion to universal format failed: {}", e), + 500, + )), diff: None, limitation_reason: None, }; @@ -388,7 +394,10 @@ fn test_single_stream_event( Err(e) => { return TransformResult { level: ValidationLevel::Fail, - error: Some(format!("Conversion to universal format failed: {}", e)), + error: Some(truncate_error( + format!("Conversion to universal format failed: {}", e), + 500, + )), diff: None, limitation_reason: None, } @@ -685,6 +694,16 @@ pub fn run_all_tests(adapters: &[Box], filter: &TestFilter) use crate::expected::{is_expected_error, is_expected_field, is_expected_test_case}; use crate::types::{RoundtripDiff, RoundtripResult}; +use lingua::util::testutil::truncate_large_values; + +/// Truncate an error message to avoid massive output from debug representations +pub fn truncate_error(msg: String, max_len: usize) -> String { + if msg.len() <= max_len { + msg + } else { + format!("{}...", &msg[..max_len]) + } +} use std::collections::HashSet; /// Context for value comparison, carrying provider names for expected-difference filtering. @@ -850,8 +869,11 @@ fn compare_recursive( }; // Track expected differences as limitations if let Some(reason) = context.and_then(|ctx| ctx.is_expected(&field_path)) { - let before = lingua::serde_json::to_string(&orig[*key]) - .unwrap_or_else(|_| "?".to_string()); + let before = lingua::serde_json::to_string(&truncate_large_values( + orig[*key].clone(), + 200, + )) + .unwrap_or_else(|_| "?".to_string()); diff.expected_diffs.push(( field_path, before, @@ -872,8 +894,11 @@ fn compare_recursive( }; // Track expected differences as limitations if let Some(reason) = context.and_then(|ctx| ctx.is_expected(&field_path)) { - let after = lingua::serde_json::to_string(&round[*key]) - .unwrap_or_else(|_| "?".to_string()); + let after = lingua::serde_json::to_string(&truncate_large_values( + round[*key].clone(), + 200, + )) + .unwrap_or_else(|_| "?".to_string()); diff.expected_diffs.push(( field_path, "(missing)".to_string(), @@ -930,9 +955,11 @@ fn compare_recursive( _ => { // Values differ - track expected differences as limitations let before = - lingua::serde_json::to_string(original).unwrap_or_else(|_| "?".to_string()); + lingua::serde_json::to_string(&truncate_large_values(original.clone(), 200)) + .unwrap_or_else(|_| "?".to_string()); let after = - lingua::serde_json::to_string(roundtripped).unwrap_or_else(|_| "?".to_string()); + lingua::serde_json::to_string(&truncate_large_values(roundtripped.clone(), 200)) + .unwrap_or_else(|_| "?".to_string()); if let Some(reason) = context.and_then(|ctx| ctx.is_expected(path)) { diff.expected_diffs .push((path.to_string(), before, after, reason.to_string())); diff --git a/crates/coverage-report/src/streaming_expected_differences.json b/crates/coverage-report/src/streaming_expected_differences.json index 0a8ba15..aa79d28 100644 --- a/crates/coverage-report/src/streaming_expected_differences.json +++ b/crates/coverage-report/src/streaming_expected_differences.json @@ -10,6 +10,14 @@ { "pattern": "messages[*].id", "reason": "Message/response IDs vary by provider and represent different concepts" } ] }, + { + "source": "*", + "target": "Google", + "fields": [ + { "pattern": "choices[*].delta.role", "reason": "Google streaming emits role/content on final events that other providers don't" }, + { "pattern": "choices[*].delta.content", "reason": "Google streaming emits role/content on final events that other providers don't" } + ] + }, { "source": "Anthropic", "target": "*", @@ -54,6 +62,19 @@ { "pattern": "id", "reason": "Responses API response IDs don't map to Anthropic streaming format" }, { "pattern": "model", "reason": "Responses API model field isn't preserved in Anthropic streaming events" } ] + }, + { + "source": "Google", + "target": "*", + "fields": [ + { "pattern": "id", "reason": "Google streams responseId once; other providers expect it on every/specific events" }, + { "pattern": "model", "reason": "Google streams modelVersion once; other providers repeat model per event" }, + { "pattern": "usage", "reason": "Google combines all usage in single event; other providers distribute across events" }, + { "pattern": "usage.completion_reasoning_tokens", "reason": "Google thoughtsTokenCount only in single streaming event; lost when converting to multi-event formats" }, + { "pattern": "choices[*].delta.role", "reason": "Google sends role with content in single event; Anthropic content_block_delta doesn't include role" }, + { "pattern": "choices[*].finish_reason", "reason": "Google sends finish_reason with content in single event; Anthropic separates into message_delta" }, + { "pattern": "choices[*].delta.content", "reason": "Google sends content with metadata in single event; content normalization differs across providers" } + ] } ], "perTestCase": [ diff --git a/crates/coverage-report/tests/cross_provider_test.rs b/crates/coverage-report/tests/cross_provider_test.rs index 168203d..7b6f534 100644 --- a/crates/coverage-report/tests/cross_provider_test.rs +++ b/crates/coverage-report/tests/cross_provider_test.rs @@ -6,7 +6,7 @@ unexpected failures. Known limitations (documented in expected_differences.json) are allowed, but regressions will cause this test to fail. */ -use coverage_report::runner::run_all_tests; +use coverage_report::runner::{run_all_tests, truncate_error}; use coverage_report::types::TestFilter; use lingua::capabilities::ProviderFormat; use lingua::processing::adapters::adapters; @@ -17,6 +17,7 @@ const REQUIRED_PROVIDERS: &[ProviderFormat] = &[ ProviderFormat::Responses, ProviderFormat::OpenAI, // ChatCompletions ProviderFormat::Anthropic, + ProviderFormat::Google, ]; #[test] @@ -45,8 +46,10 @@ fn cross_provider_transformations_have_no_unexpected_failures() { // Collect detailed failure messages for (test_case, error, _diff) in &pair_result.failures { failures.push(format!( - " [{category}] {:?} -> {:?}: {test_case}\n Error: {error}", - src_format, tgt_format + " [{category}] {:?} -> {:?}: {test_case}\n Error: {}", + src_format, + tgt_format, + truncate_error(error.clone(), 1000) )); } } diff --git a/crates/lingua/src/providers/anthropic/convert.rs b/crates/lingua/src/providers/anthropic/convert.rs index d5ee7cf..9944db1 100644 --- a/crates/lingua/src/providers/anthropic/convert.rs +++ b/crates/lingua/src/providers/anthropic/convert.rs @@ -48,9 +48,8 @@ impl TryFromLLM for Message { (block.tool_use_id, block.content) { let output = match content { - generated::Content::String(s) => { - serde_json::Value::String(s) - } + generated::Content::String(s) => serde_json::from_str(&s) + .unwrap_or_else(|_| serde_json::Value::String(s)), generated::Content::BlockArray(blocks) => { serde_json::to_value(blocks).map_err(|e| { ConvertError::JsonSerializationFailed { diff --git a/crates/lingua/src/providers/google/adapter.rs b/crates/lingua/src/providers/google/adapter.rs index c48402b..512dcb6 100644 --- a/crates/lingua/src/providers/google/adapter.rs +++ b/crates/lingua/src/providers/google/adapter.rs @@ -12,6 +12,9 @@ use crate::capabilities::ProviderFormat; use crate::error::ConvertError; use crate::processing::adapters::ProviderAdapter; use crate::processing::transform::TransformError; +use crate::providers::google::convert::{ + denormalize_json_schema_types, normalize_json_schema_types, +}; use crate::providers::google::detect::try_parse_google; use crate::providers::google::generated::{ Content as GoogleContent, GenerationConfig, ThinkingConfig, @@ -19,12 +22,12 @@ use crate::providers::google::generated::{ use crate::providers::google::params::GoogleParams; use crate::serde_json::{self, Map, Value}; use crate::universal::convert::TryFromLLM; -use crate::universal::message::Message; +use crate::universal::message::{AssistantContent, AssistantContentPart, Message}; use crate::universal::tools::{UniversalTool, UniversalToolType}; use crate::universal::{ - extract_system_messages, flatten_consecutive_messages, FinishReason, UniversalParams, - UniversalRequest, UniversalResponse, UniversalStreamChoice, UniversalStreamChunk, - UniversalUsage, UserContent, + extract_system_messages, flatten_consecutive_messages, FinishReason, ToolChoiceConfig, + ToolChoiceMode, UniversalParams, UniversalRequest, UniversalResponse, UniversalStreamChoice, + UniversalStreamChunk, UniversalUsage, UserContent, }; /// Adapter for Google AI GenerateContent API. @@ -63,17 +66,33 @@ impl ProviderAdapter for GoogleAdapter { .map_err(|e| TransformError::ToUniversalFailed(e.to_string()))?; // Extract params from generationConfig (now typed in params struct) - let (temperature, top_p, top_k, max_tokens, stop, reasoning) = - if let Some(config) = &typed_params.generation_config { - let max_tokens = config.max_output_tokens.map(|t| t as i64); - // Convert Google's thinkingConfig to ReasoningConfig - // thinkingBudget: 0 means disabled - let reasoning = config.thinking_config.as_ref().map(|tc| { + let (temperature, top_p, top_k, max_tokens, stop, reasoning) = if let Some(config) = + &typed_params.generation_config + { + let max_tokens = config.max_output_tokens.map(|t| t as i64); + // Convert Google's thinkingConfig to ReasoningConfig + // thinkingLevel: Gemini 3 (effort-based) + // thinkingBudget: Gemini 2.5 (budget-based), 0 means disabled + let reasoning = config.thinking_config.as_ref().map(|tc| { + use crate::providers::google::capabilities::thinking_level_to_effort; + + if let Some(level) = tc.thinking_level { + // Gemini 3 style: thinkingLevel is canonical (effort-based) + let effort = thinking_level_to_effort(level); + let budget = crate::universal::reasoning::effort_to_budget(effort, max_tokens); + crate::universal::ReasoningConfig { + enabled: Some(true), + effort: Some(effort), + budget_tokens: Some(budget), + canonical: Some(crate::universal::ReasoningCanonical::Effort), + ..Default::default() + } + } else { + // Gemini 2.5 style: thinkingBudget is canonical (budget-based) let is_disabled = tc.thinking_budget == Some(0); let budget_tokens = tc.thinking_budget.map(|b| b as i64); - // Derive effort from budget_tokens let effort = budget_tokens - .map(|b| crate::universal::reasoning::budget_to_effort(b, None)); + .map(|b| crate::universal::reasoning::budget_to_effort(b, max_tokens)); crate::universal::ReasoningConfig { enabled: Some(!is_disabled), effort, @@ -81,24 +100,25 @@ impl ProviderAdapter for GoogleAdapter { canonical: Some(crate::universal::ReasoningCanonical::BudgetTokens), ..Default::default() } - }); - // Generated type has stop_sequences as Vec, convert to Option - let stop = if config.stop_sequences.is_empty() { - None - } else { - Some(config.stop_sequences.clone()) - }; - ( - config.temperature.map(|t| t as f64), - config.top_p.map(|p| p as f64), - config.top_k.map(|k| k as i64), - max_tokens, - stop, - reasoning, - ) + } + }); + // Generated type has stop_sequences as Vec, convert to Option + let stop = if config.stop_sequences.is_empty() { + None } else { - (None, None, None, None, None, None) + Some(config.stop_sequences.clone()) }; + ( + config.temperature.map(|t| t as f64), + config.top_p.map(|p| p as f64), + config.top_k.map(|k| k as i64), + max_tokens, + stop, + reasoning, + ) + } else { + (None, None, None, None, None, None) + }; let mut params = UniversalParams { temperature, @@ -122,7 +142,10 @@ impl ProviderAdapter for GoogleAdapter { .get("description") .and_then(|v| v.as_str()) .map(String::from); - let parameters = decl.get("parameters").cloned(); + let parameters = decl.get("parameters").cloned().map(|mut p| { + denormalize_json_schema_types(&mut p); + p + }); universal_tools.push(UniversalTool::function( name, @@ -147,8 +170,101 @@ impl ProviderAdapter for GoogleAdapter { Some(universal_tools) } }), - tool_choice: None, // Google uses different mechanism - response_format: None, + tool_choice: typed_params.tool_config.as_ref().and_then(|tc| { + let fcc = tc.get("functionCallingConfig")?; + let mode = fcc.get("mode").and_then(|m| m.as_str())?; + + // Extract allowed function names if present + let allowed_names: Vec = fcc + .get("allowedFunctionNames") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // If mode is ANY with exactly one allowed function, treat as specific tool choice + let (tool_mode, tool_name) = match mode { + "AUTO" => (ToolChoiceMode::Auto, None), + "ANY" if allowed_names.len() == 1 => ( + ToolChoiceMode::Tool, + Some(allowed_names.into_iter().next().unwrap()), + ), + "ANY" => (ToolChoiceMode::Required, None), + "NONE" => (ToolChoiceMode::None, None), + _ => return None, + }; + + Some(ToolChoiceConfig { + mode: Some(tool_mode), + tool_name, + disable_parallel: None, + }) + }), + response_format: typed_params.generation_config.as_ref().and_then(|gc| { + use crate::universal::request::{ + JsonSchemaConfig, ResponseFormatConfig, ResponseFormatType, + }; + + let mime_type = gc.response_mime_type.as_str(); + if mime_type.is_empty() { + return None; + } + + match mime_type { + "text/plain" => Some(ResponseFormatConfig { + format_type: Some(ResponseFormatType::Text), + json_schema: None, + }), + "application/json" => { + let schema = gc + .response_json_schema + .as_ref() + .map(|s| serde_json::to_value(s).ok()) + .flatten() + .or_else(|| { + gc.response_schema + .as_ref() + .map(|s| serde_json::to_value(s).ok()) + .flatten() + }); + + if let Some(mut schema) = schema { + // Denormalize schema types from Google's uppercase to standard lowercase + denormalize_json_schema_types(&mut schema); + + // Try to extract name from schema's title field + let name = schema + .get("title") + .and_then(Value::as_str) + .map(String::from) + .unwrap_or_else(|| "response".to_string()); + + if let Some(obj) = schema.as_object_mut() { + obj.remove("title"); + } + + Some(ResponseFormatConfig { + format_type: Some(ResponseFormatType::JsonSchema), + json_schema: Some(JsonSchemaConfig { + name, + schema, + strict: None, + description: None, + }), + }) + } else { + Some(ResponseFormatConfig { + format_type: Some(ResponseFormatType::JsonObject), + json_schema: None, + }) + } + } + _ => None, + } + }), seed: None, // Google doesn't support seed presence_penalty: None, frequency_penalty: None, @@ -241,28 +357,56 @@ impl ProviderAdapter for GoogleAdapter { .as_ref() .map(|r| !r.is_effectively_disabled()) .unwrap_or(false); + let has_response_format = req + .params + .response_format + .as_ref() + .map(|rf| rf.format_type.is_some()) + .unwrap_or(false); let has_params = req.params.temperature.is_some() || req.params.top_p.is_some() || req.params.top_k.is_some() || req.params.max_tokens.is_some() || req.params.stop.is_some() - || has_reasoning; + || has_reasoning + || has_response_format; if has_params { // Convert ReasoningConfig to Google's thinkingConfig + // Use capabilities to determine whether to use thinkingLevel (Gemini 3) or thinkingBudget (Gemini 2.5) let thinking_config = req.params.reasoning.as_ref().and_then(|r| { + use crate::providers::google::capabilities::{ + effort_to_thinking_level, GoogleCapabilities, GoogleThinkingStyle, + }; + if r.is_effectively_disabled() { return None; } - // Use budget_tokens or default minimum - let budget = r - .budget_tokens - .unwrap_or(crate::universal::reasoning::MIN_THINKING_BUDGET); - Some(ThinkingConfig { - include_thoughts: Some(true), - thinking_budget: Some(budget as i32), - thinking_level: None, - }) + + let caps = GoogleCapabilities::detect(req.model.as_deref()); + + match caps.thinking_style { + GoogleThinkingStyle::ThinkingLevel => { + // Gemini 3: use thinkingLevel (effort-based) + let level = r.effort.map(effort_to_thinking_level).unwrap_or(3); // Default to HIGH + Some(ThinkingConfig { + include_thoughts: Some(true), + thinking_budget: None, + thinking_level: Some(level), + }) + } + GoogleThinkingStyle::ThinkingBudget | GoogleThinkingStyle::None => { + // Gemini 2.5: use thinkingBudget (budget-based) + let budget = r + .budget_tokens + .unwrap_or(crate::universal::reasoning::MIN_THINKING_BUDGET); + Some(ThinkingConfig { + include_thoughts: Some(true), + thinking_budget: Some(budget as i32), + thinking_level: None, + }) + } + } }); // Generated type has stop_sequences as Vec, not Option @@ -278,11 +422,23 @@ impl ProviderAdapter for GoogleAdapter { ..Default::default() }; - obj.insert( - "generationConfig".into(), - serde_json::to_value(config) - .map_err(|e| TransformError::SerializationFailed(e.to_string()))?, - ); + let mut config_value = serde_json::to_value(config) + .map_err(|e| TransformError::SerializationFailed(e.to_string()))?; + + if let Some(ref rf) = req.params.response_format { + if let Ok(Some(rf_value)) = rf.to_provider(ProviderFormat::Google) { + if let Some(config_obj) = config_value.as_object_mut() { + if let Some(mime) = rf_value.get("responseMimeType") { + config_obj.insert("responseMimeType".into(), mime.clone()); + } + if let Some(schema) = rf_value.get("responseSchema") { + config_obj.insert("responseSchema".into(), schema.clone()); + } + } + } + } + + obj.insert("generationConfig".into(), config_value); } // Add tools if present @@ -311,10 +467,15 @@ impl ProviderAdapter for GoogleAdapter { .iter() .filter_map(|tool| { if tool.is_function() { + let mut parameters = + tool.parameters.clone().unwrap_or(serde_json::json!({})); + // Normalize schema type values for Google (lowercase -> UPPERCASE) + normalize_json_schema_types(&mut parameters); + Some(serde_json::json!({ "name": tool.name, "description": tool.description, - "parameters": tool.parameters.clone().unwrap_or(serde_json::json!({})) + "parameters": parameters })) } else { None @@ -331,6 +492,36 @@ impl ProviderAdapter for GoogleAdapter { } } + if let Some(tc) = &req.params.tool_choice { + if let Some(mode) = &tc.mode { + let google_mode = match mode { + ToolChoiceMode::Auto => "AUTO", + ToolChoiceMode::None => "NONE", + ToolChoiceMode::Required => "ANY", + ToolChoiceMode::Tool => "ANY", + }; + + // Build functionCallingConfig with optional allowedFunctionNames + let fcc = if *mode == ToolChoiceMode::Tool { + if let Some(tool_name) = &tc.tool_name { + serde_json::json!({ + "mode": google_mode, + "allowedFunctionNames": [tool_name] + }) + } else { + serde_json::json!({ "mode": google_mode }) + } + } else { + serde_json::json!({ "mode": google_mode }) + }; + + obj.insert( + "toolConfig".into(), + serde_json::json!({ "functionCallingConfig": fcc }), + ); + } + } + // Merge back provider-specific extras (only for Google) if let Some(extras) = req.params.extras.get(&ProviderFormat::Google) { for (k, v) in extras { @@ -345,11 +536,11 @@ impl ProviderAdapter for GoogleAdapter { } fn detect_response(&self, payload: &Value) -> bool { - // Google response has candidates[].content structure + // Google response has candidates array (content may be missing for NO_IMAGE etc) payload .get("candidates") .and_then(Value::as_array) - .is_some_and(|arr| arr.first().and_then(|c| c.get("content")).is_some()) + .is_some_and(|arr| !arr.is_empty()) } fn response_to_universal(&self, payload: Value) -> Result { @@ -382,6 +573,26 @@ impl ProviderAdapter for GoogleAdapter { } } + let has_tool_calls = messages.iter().any(|m| { + if let Message::Assistant { + content: AssistantContent::Array(parts), + .. + } = m + { + parts + .iter() + .any(|p| matches!(p, AssistantContentPart::ToolCall { .. })) + } else { + false + } + }); + + let finish_reason = if has_tool_calls { + Some(FinishReason::ToolCalls) + } else { + finish_reason + }; + let usage = UniversalUsage::extract_from_response(&payload, self.format()); Ok(UniversalResponse { @@ -424,6 +635,10 @@ impl ProviderAdapter for GoogleAdapter { let mut map = serde_json::Map::new(); map.insert("candidates".into(), Value::Array(candidates)); + if let Some(model) = &resp.model { + map.insert("modelVersion".into(), Value::String(model.clone())); + } + if let Some(usage) = &resp.usage { map.insert( "usageMetadata".into(), @@ -632,4 +847,77 @@ mod tests { let reconstructed = adapter.request_from_universal(&universal).unwrap(); assert!(reconstructed.get("contents").is_some()); } + + #[test] + fn test_google_tool_choice_to_universal() { + let adapter = GoogleAdapter; + let payload = json!({ + "contents": [{ + "role": "user", + "parts": [{"text": "Hello"}] + }], + "toolConfig": { + "functionCallingConfig": { + "mode": "AUTO" + } + } + }); + + let universal = adapter.request_to_universal(payload).unwrap(); + let tool_choice = universal.params.tool_choice.unwrap(); + assert_eq!(tool_choice.mode, Some(ToolChoiceMode::Auto)); + } + + #[test] + fn test_google_tool_choice_from_universal() { + let adapter = GoogleAdapter; + let req = UniversalRequest { + model: None, + messages: vec![Message::User { + content: UserContent::String("Hello".into()), + }], + params: UniversalParams { + tool_choice: Some(ToolChoiceConfig { + mode: Some(ToolChoiceMode::Required), + tool_name: None, + disable_parallel: None, + }), + ..Default::default() + }, + }; + + let payload = adapter.request_from_universal(&req).unwrap(); + assert_eq!( + payload + .get("toolConfig") + .and_then(|tc| tc.get("functionCallingConfig")) + .and_then(|fcc| fcc.get("mode")) + .and_then(|m| m.as_str()), + Some("ANY") + ); + } + + #[test] + fn test_google_response_model_version_roundtrip() { + let adapter = GoogleAdapter; + let payload = json!({ + "modelVersion": "gemini-1.5", + "candidates": [{ + "content": { + "role": "model", + "parts": [{"text": "Hi"}] + }, + "finishReason": "STOP" + }] + }); + + let universal = adapter.response_to_universal(payload).unwrap(); + assert_eq!(universal.model, Some("gemini-1.5".into())); + + let back = adapter.response_from_universal(&universal).unwrap(); + assert_eq!( + back.get("modelVersion").and_then(|v| v.as_str()), + Some("gemini-1.5") + ); + } } diff --git a/crates/lingua/src/providers/google/capabilities.rs b/crates/lingua/src/providers/google/capabilities.rs new file mode 100644 index 0000000..d501058 --- /dev/null +++ b/crates/lingua/src/providers/google/capabilities.rs @@ -0,0 +1,162 @@ +//! Google model-specific capability detection. +//! +//! This module provides capability detection for Google Gemini models, +//! following the same pattern as OpenAI's capabilities.rs. +//! +//! ## Thinking configuration +//! +//! Google has two different ways to configure thinking/reasoning: +//! - **Gemini 3+**: Uses `thinkingLevel` (LOW/MEDIUM/HIGH/MINIMAL) - effort-based +//! - **Gemini 2.5**: Uses `thinkingBudget` (integer token count) - budget-based +//! +//! Using `thinkingBudget` with Gemini 3 Pro may result in suboptimal performance. + +use crate::universal::ReasoningEffort; + +/// Google model thinking capability tier +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoogleThinkingStyle { + /// Gemini 3+ models: use thinkingLevel (effort-based) + ThinkingLevel, + /// Gemini 2.5 models: use thinkingBudget (token-based) + ThinkingBudget, + /// Models without thinking support + None, +} + +/// Model prefixes that use thinkingLevel (Gemini 3+) +const THINKING_LEVEL_PREFIXES: &[&str] = &["gemini-3"]; + +/// Model prefixes that use thinkingBudget (Gemini 2.5) +const THINKING_BUDGET_PREFIXES: &[&str] = &["gemini-2.5", "gemini-2.0"]; + +/// Google model capabilities +pub struct GoogleCapabilities { + pub thinking_style: GoogleThinkingStyle, +} + +impl GoogleCapabilities { + /// Detect capabilities from model name + pub fn detect(model: Option<&str>) -> Self { + let thinking_style = model + .map(|m| { + let m_lower = m.to_ascii_lowercase(); + if THINKING_LEVEL_PREFIXES + .iter() + .any(|p| m_lower.starts_with(p)) + { + GoogleThinkingStyle::ThinkingLevel + } else if THINKING_BUDGET_PREFIXES + .iter() + .any(|p| m_lower.starts_with(p)) + { + GoogleThinkingStyle::ThinkingBudget + } else { + // Default to ThinkingBudget for unknown models (safer/backwards compatible) + GoogleThinkingStyle::ThinkingBudget + } + }) + .unwrap_or(GoogleThinkingStyle::ThinkingBudget); + + Self { thinking_style } + } +} + +pub fn thinking_level_to_effort(level: i32) -> ReasoningEffort { + match level { + 1 => ReasoningEffort::Low, // LOW + 2 => ReasoningEffort::Medium, // MEDIUM + 3 => ReasoningEffort::High, // HIGH + 4 => ReasoningEffort::Low, // MINIMAL → Low (closest approximation) + _ => ReasoningEffort::High, // UNSPECIFIED or unknown → High (Google's default) + } +} + +/// Convert ReasoningEffort to Google ThinkingLevel enum value +pub fn effort_to_thinking_level(effort: ReasoningEffort) -> i32 { + match effort { + ReasoningEffort::Low => 1, // LOW + ReasoningEffort::Medium => 2, // MEDIUM + ReasoningEffort::High => 3, // HIGH + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_gemini_3_models() { + assert_eq!( + GoogleCapabilities::detect(Some("gemini-3-flash-preview")).thinking_style, + GoogleThinkingStyle::ThinkingLevel + ); + assert_eq!( + GoogleCapabilities::detect(Some("gemini-3-pro")).thinking_style, + GoogleThinkingStyle::ThinkingLevel + ); + assert_eq!( + GoogleCapabilities::detect(Some("Gemini-3-Flash")).thinking_style, + GoogleThinkingStyle::ThinkingLevel + ); + } + + #[test] + fn test_detect_gemini_25_models() { + assert_eq!( + GoogleCapabilities::detect(Some("gemini-2.5-flash")).thinking_style, + GoogleThinkingStyle::ThinkingBudget + ); + assert_eq!( + GoogleCapabilities::detect(Some("gemini-2.5-pro")).thinking_style, + GoogleThinkingStyle::ThinkingBudget + ); + assert_eq!( + GoogleCapabilities::detect(Some("gemini-2.0-flash")).thinking_style, + GoogleThinkingStyle::ThinkingBudget + ); + } + + #[test] + fn test_detect_unknown_models() { + // Unknown models default to ThinkingBudget for backwards compatibility + assert_eq!( + GoogleCapabilities::detect(Some("gemini-1.5-pro")).thinking_style, + GoogleThinkingStyle::ThinkingBudget + ); + assert_eq!( + GoogleCapabilities::detect(Some("some-other-model")).thinking_style, + GoogleThinkingStyle::ThinkingBudget + ); + assert_eq!( + GoogleCapabilities::detect(None).thinking_style, + GoogleThinkingStyle::ThinkingBudget + ); + } + + #[test] + fn test_thinking_level_to_effort() { + assert_eq!(thinking_level_to_effort(1), ReasoningEffort::Low); + assert_eq!(thinking_level_to_effort(2), ReasoningEffort::Medium); + assert_eq!(thinking_level_to_effort(3), ReasoningEffort::High); + assert_eq!(thinking_level_to_effort(4), ReasoningEffort::Low); // MINIMAL + assert_eq!(thinking_level_to_effort(0), ReasoningEffort::High); // UNSPECIFIED + } + + #[test] + fn test_effort_to_thinking_level() { + assert_eq!(effort_to_thinking_level(ReasoningEffort::Low), 1); + assert_eq!(effort_to_thinking_level(ReasoningEffort::Medium), 2); + assert_eq!(effort_to_thinking_level(ReasoningEffort::High), 3); + } + + #[test] + fn test_roundtrip_effort() { + // Test that effort roundtrips correctly (except MINIMAL which maps to Low) + for level in [1, 2, 3] { + let effort = thinking_level_to_effort(level); + let back = effort_to_thinking_level(effort); + assert_eq!(back, level); + } + } +} diff --git a/crates/lingua/src/providers/google/convert.rs b/crates/lingua/src/providers/google/convert.rs index cfdc663..feb3f8c 100644 --- a/crates/lingua/src/providers/google/convert.rs +++ b/crates/lingua/src/providers/google/convert.rs @@ -7,8 +7,9 @@ Google's GenerateContent API format and Lingua's universal message format. use crate::error::ConvertError; use crate::providers::google::generated::{ - part, Blob as GoogleBlob, Content as GoogleContent, FunctionCall as GoogleFunctionCall, - FunctionResponse as GoogleFunctionResponse, GenerateContentRequest, Part as GooglePart, + part, Blob as GoogleBlob, Content as GoogleContent, FileData as GoogleFileData, + FunctionCall as GoogleFunctionCall, FunctionResponse as GoogleFunctionResponse, + GenerateContentRequest, Part as GooglePart, }; use crate::serde_json::{self, Value}; use crate::universal::convert::TryFromLLM; @@ -56,6 +57,109 @@ fn struct_to_json(value: &Struct) -> Value { serde_json::to_value(value).unwrap_or(Value::Null) } +/// Normalizes JSON Schema "type" fields from lowercase to uppercase for Google's API. +/// Google expects: STRING, NUMBER, INTEGER, BOOLEAN, ARRAY, OBJECT, NULL +/// Standard JSON Schema uses: string, number, integer, boolean, array, object, null +pub fn normalize_json_schema_types(schema: &mut Value) { + match schema { + Value::Object(map) => { + if let Some(type_value) = map.get_mut("type") { + if let Value::String(type_str) = type_value { + *type_value = Value::String( + match type_str.as_str() { + "string" => "STRING", + "number" => "NUMBER", + "integer" => "INTEGER", + "boolean" => "BOOLEAN", + "array" => "ARRAY", + "object" => "OBJECT", + "null" => "NULL", + _ => type_str, + } + .to_string(), + ); + } + } + + if let Some(Value::Object(props)) = map.get_mut("properties") { + for prop_schema in props.values_mut() { + normalize_json_schema_types(prop_schema); + } + } + + if let Some(items) = map.get_mut("items") { + normalize_json_schema_types(items); + } + + map.remove("additionalProperties"); + + for key in &["allOf", "anyOf", "oneOf"] { + if let Some(Value::Array(schemas)) = map.get_mut(*key) { + for schema in schemas { + normalize_json_schema_types(schema); + } + } + } + } + Value::Array(arr) => { + for item in arr { + normalize_json_schema_types(item); + } + } + _ => {} + } +} + +/// Denormalizes JSON Schema "type" fields from uppercase to lowercase. +/// Reverses Google's uppercase format back to standard JSON Schema format. +pub fn denormalize_json_schema_types(schema: &mut Value) { + match schema { + Value::Object(map) => { + if let Some(type_value) = map.get_mut("type") { + if let Value::String(type_str) = type_value { + *type_value = Value::String( + match type_str.as_str() { + "STRING" => "string", + "NUMBER" => "number", + "INTEGER" => "integer", + "BOOLEAN" => "boolean", + "ARRAY" => "array", + "OBJECT" => "object", + "NULL" => "null", + _ => type_str, + } + .to_string(), + ); + } + } + + if let Some(Value::Object(props)) = map.get_mut("properties") { + for prop_schema in props.values_mut() { + denormalize_json_schema_types(prop_schema); + } + } + + if let Some(items) = map.get_mut("items") { + denormalize_json_schema_types(items); + } + + for key in &["allOf", "anyOf", "oneOf"] { + if let Some(Value::Array(schemas)) = map.get_mut(*key) { + for schema in schemas { + denormalize_json_schema_types(schema); + } + } + } + } + Value::Array(arr) => { + for item in arr { + denormalize_json_schema_types(item); + } + } + _ => {} + } +} + impl TryFromLLM for Message { type Error = ConvertError; @@ -145,12 +249,35 @@ impl TryFromLLM for Message { provider_options: None, }); } + part::Data::FileData(file_data) => { + user_parts.push(UserContentPart::Image { + image: Value::String(file_data.file_uri.clone()), + media_type: if file_data.mime_type.is_empty() { + None + } else { + Some(file_data.mime_type.clone()) + }, + provider_options: None, + }); + } part::Data::FunctionResponse(fr) => { - let output = fr + let raw_output = fr .response .as_ref() .map(struct_to_json) .unwrap_or(Value::Null); + + // Unwrap {"output": value} wrapper if present + // (added by Universal→Google conversion for non-object values) + let output = match &raw_output { + Value::Object(map) + if map.len() == 1 && map.contains_key("output") => + { + map.get("output").cloned().unwrap_or(raw_output) + } + _ => raw_output, + }; + tool_parts.push(ToolContentPart::ToolResult( ToolResultContentPart { tool_call_id: fr.id.clone(), @@ -233,31 +360,40 @@ impl TryFromLLM for GoogleContent { media_type, .. } => { - let mut inferred_media_type = None; - let base64_data = - if let Some(block) = parse_base64_data_url(&data) { - inferred_media_type = Some(block.media_type); - block.data - } else { - data - }; - - let mime_type = media_type - .or(inferred_media_type) - .unwrap_or_else(|| DEFAULT_MIME_TYPE.to_string()); - let bytes = - STANDARD.decode(base64_data.as_bytes()).map_err(|e| { - ConvertError::ContentConversionFailed { + if data.starts_with("http://") || data.starts_with("https://") { + let mime_type = media_type + .unwrap_or_else(|| DEFAULT_MIME_TYPE.to_string()); + converted.push(part_from_data(part::Data::FileData( + GoogleFileData { + mime_type, + file_uri: data, + }, + ))); + } else { + // Handle base64 data (with or without data URL prefix) + let (base64_data, inferred_media_type) = + if let Some(block) = parse_base64_data_url(&data) { + (block.data, Some(block.media_type)) + } else { + (data, None) + }; + + let mime_type = media_type + .or(inferred_media_type) + .unwrap_or_else(|| DEFAULT_MIME_TYPE.to_string()); + let bytes = STANDARD + .decode(base64_data.as_bytes()) + .map_err(|e| ConvertError::ContentConversionFailed { reason: format!("Invalid base64 inline image: {e}"), - } - })?; - - converted.push(part_from_data(part::Data::InlineData( - GoogleBlob { - mime_type, - data: bytes, - }, - ))); + })?; + + converted.push(part_from_data(part::Data::InlineData( + GoogleBlob { + mime_type, + data: bytes, + }, + ))); + } } _ => {} } @@ -600,4 +736,90 @@ mod tests { assert_eq!(arr[0]["role"], "user"); assert_eq!(arr[1]["role"], "model"); } + + #[test] + fn test_normalize_json_schema_types_simple() { + let mut schema = json!({ + "type": "string" + }); + normalize_json_schema_types(&mut schema); + assert_eq!(schema["type"], "STRING"); + } + + #[test] + fn test_normalize_json_schema_types_nested() { + let mut schema = json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "number"}, + "active": {"type": "boolean"} + } + }); + normalize_json_schema_types(&mut schema); + assert_eq!(schema["type"], "OBJECT"); + assert_eq!(schema["properties"]["name"]["type"], "STRING"); + assert_eq!(schema["properties"]["age"]["type"], "NUMBER"); + assert_eq!(schema["properties"]["active"]["type"], "BOOLEAN"); + } + + #[test] + fn test_normalize_json_schema_types_array() { + let mut schema = json!({ + "type": "array", + "items": {"type": "integer"} + }); + normalize_json_schema_types(&mut schema); + assert_eq!(schema["type"], "ARRAY"); + assert_eq!(schema["items"]["type"], "INTEGER"); + } + + #[test] + fn test_normalize_json_schema_types_already_uppercase() { + let mut schema = json!({ + "type": "STRING" + }); + normalize_json_schema_types(&mut schema); + assert_eq!(schema["type"], "STRING"); + } + + #[test] + fn test_normalize_json_schema_types_null_type() { + let mut schema = json!({ + "type": "null" + }); + normalize_json_schema_types(&mut schema); + assert_eq!(schema["type"], "NULL"); + } + + #[test] + fn test_normalize_json_schema_types_deeply_nested() { + let mut schema = json!({ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "address": { + "type": "object", + "properties": { + "city": {"type": "string"} + } + } + } + } + } + }); + normalize_json_schema_types(&mut schema); + assert_eq!(schema["type"], "OBJECT"); + assert_eq!(schema["properties"]["user"]["type"], "OBJECT"); + assert_eq!( + schema["properties"]["user"]["properties"]["address"]["type"], + "OBJECT" + ); + assert_eq!( + schema["properties"]["user"]["properties"]["address"]["properties"]["city"]["type"], + "STRING" + ); + } } diff --git a/crates/lingua/src/providers/google/mod.rs b/crates/lingua/src/providers/google/mod.rs index 4835915..b4fd758 100644 --- a/crates/lingua/src/providers/google/mod.rs +++ b/crates/lingua/src/providers/google/mod.rs @@ -2,6 +2,7 @@ // Generated from official protobuf files pub mod adapter; +pub mod capabilities; pub mod convert; pub mod detect; pub mod generated; @@ -13,6 +14,9 @@ pub mod test_google; // Re-export adapter pub use adapter::GoogleAdapter; +// Re-export capabilities +pub use capabilities::{GoogleCapabilities, GoogleThinkingStyle}; + // Re-export detection functions pub use detect::{try_parse_google, DetectionError}; diff --git a/crates/lingua/src/providers/openai/convert.rs b/crates/lingua/src/providers/openai/convert.rs index 32d7a05..bea13ef 100644 --- a/crates/lingua/src/providers/openai/convert.rs +++ b/crates/lingua/src/providers/openai/convert.rs @@ -314,11 +314,12 @@ impl TryFromLLM> for Vec { let output = input.output.unwrap_or_else(|| "".to_string()); - let output_value = serde_json::Value::String(output); + let output_value = serde_json::from_str(&output) + .unwrap_or_else(|_| serde_json::Value::String(output)); let tool_result = ToolResultContentPart { tool_call_id, - tool_name: String::new(), // OpenAI doesn't provide tool name in output + tool_name: input.name.clone().unwrap_or_default(), output: output_value, provider_options: None, }; @@ -548,9 +549,12 @@ impl TryFromLLM for openai::InputContent { ..Default::default() } } - _ => { + UserContentPart::File { media_type, .. } => { return Err(ConvertError::UnsupportedInputType { - type_info: format!("UserContentPart variant: {:?}", part), + type_info: format!( + "UserContentPart::File (media_type: {}) is not supported by OpenAI", + media_type + ), }) } }) @@ -675,9 +679,12 @@ impl TryFromLLM for openai::InputContent { }); } } - _ => { + AssistantContentPart::File { media_type, .. } => { return Err(ConvertError::UnsupportedInputType { - type_info: format!("AssistantContentPart variant: {:?}", part), + type_info: format!( + "AssistantContentPart::File (media_type: {}) is not supported by OpenAI", + media_type + ), }) } }) @@ -1092,6 +1099,11 @@ impl TryFromLLM for openai::InputItem { input_item_type: Some(openai::InputItemType::FunctionCallOutput), call_id: Some(tool_result.tool_call_id.clone()), output: Some(output_string), + name: if tool_result.tool_name.is_empty() { + None + } else { + Some(tool_result.tool_name.clone()) + }, ..Default::default() }); } @@ -1309,6 +1321,11 @@ pub fn universal_to_responses_input( input_item_type: Some(openai::InputItemType::FunctionCallOutput), call_id: Some(tool_result.tool_call_id.clone()), output: Some(output_string), + name: if tool_result.tool_name.is_empty() { + None + } else { + Some(tool_result.tool_name.clone()) + }, ..Default::default() }); } @@ -1558,7 +1575,7 @@ impl TryFromLLM for openai::InputItem { action: output_item.action, pending_safety_checks: output_item.pending_safety_checks, acknowledged_safety_checks: None, - output: None, + output: output_item.output, encrypted_content: output_item.encrypted_content, result: output_item.result, code: output_item.code, @@ -2529,10 +2546,14 @@ impl TryFromLLM for Message { })?; // Convert to universal Tool message format + // Try to parse as JSON, fall back to string if parsing fails + let output_value = serde_json::from_str(&content_text) + .unwrap_or_else(|_| serde_json::Value::String(content_text)); + let tool_result = ToolResultContentPart { tool_call_id: tool_call_id.clone(), tool_name: String::new(), // OpenAI doesn't provide tool name in tool messages - output: serde_json::Value::String(content_text), + output: output_value, provider_options: None, }; diff --git a/crates/lingua/src/providers/openai/responses_adapter.rs b/crates/lingua/src/providers/openai/responses_adapter.rs index b394c87..00db361 100644 --- a/crates/lingua/src/providers/openai/responses_adapter.rs +++ b/crates/lingua/src/providers/openai/responses_adapter.rs @@ -327,7 +327,7 @@ impl ProviderAdapter for ResponsesAdapter { let messages: Vec = TryFromLLM::try_from(output_items) .map_err(|e: ConvertError| TransformError::ToUniversalFailed(e.to_string()))?; - let has_tool_calls = messages.iter().any(|m| { + let has_actionable_tool_calls = messages.iter().any(|m| { if let Message::Assistant { content: AssistantContent::Array(parts), .. @@ -336,7 +336,10 @@ impl ProviderAdapter for ResponsesAdapter { parts.iter().any(|p| { matches!( p, - crate::universal::message::AssistantContentPart::ToolCall { .. } + crate::universal::message::AssistantContentPart::ToolCall { + provider_executed, + .. + } if *provider_executed != Some(true) ) }) } else { @@ -344,7 +347,7 @@ impl ProviderAdapter for ResponsesAdapter { } }); - let finish_reason = if has_tool_calls { + let finish_reason = if has_actionable_tool_calls { Some(FinishReason::ToolCalls) } else { match payload.get("status").and_then(Value::as_str) { diff --git a/crates/lingua/src/universal/response.rs b/crates/lingua/src/universal/response.rs index 3391ae3..2fc7408 100644 --- a/crates/lingua/src/universal/response.rs +++ b/crates/lingua/src/universal/response.rs @@ -324,14 +324,18 @@ impl UniversalUsage { serde_json::json!(prompt + completion), ); - map.insert( - "input_tokens_details".into(), - serde_json::json!({ "cached_tokens": self.prompt_cached_tokens.unwrap_or(0) }), - ); - map.insert( - "output_tokens_details".into(), - serde_json::json!({ "reasoning_tokens": self.completion_reasoning_tokens.unwrap_or(0) }), - ); + if let Some(cached) = self.prompt_cached_tokens { + map.insert( + "input_tokens_details".into(), + serde_json::json!({ "cached_tokens": cached }), + ); + } + if let Some(reasoning) = self.completion_reasoning_tokens { + map.insert( + "output_tokens_details".into(), + serde_json::json!({ "reasoning_tokens": reasoning }), + ); + } Value::Object(map) } @@ -360,11 +364,39 @@ impl UniversalUsage { "inputTokens": prompt, "outputTokens": completion }), - ProviderFormat::Google => serde_json::json!({ - "promptTokenCount": prompt, - "candidatesTokenCount": completion, - "totalTokenCount": prompt + completion - }), + ProviderFormat::Google => { + let mut map = serde_json::Map::new(); + + if let Some(p) = self.prompt_tokens { + map.insert("promptTokenCount".into(), serde_json::json!(p)); + } + if let Some(c) = self.completion_tokens { + map.insert("candidatesTokenCount".into(), serde_json::json!(c)); + } + + if self.prompt_tokens.is_some() || self.completion_tokens.is_some() { + map.insert( + "totalTokenCount".into(), + serde_json::json!(prompt + completion), + ); + } + + if let Some(cached_tokens) = self.prompt_cached_tokens { + map.insert( + "cachedContentTokenCount".into(), + serde_json::json!(cached_tokens), + ); + } + + if let Some(reasoning_tokens) = self.completion_reasoning_tokens { + map.insert( + "thoughtsTokenCount".into(), + serde_json::json!(reasoning_tokens), + ); + } + + Value::Object(map) + } } } } diff --git a/crates/lingua/src/universal/response_format.rs b/crates/lingua/src/universal/response_format.rs index 656a174..5f47a68 100644 --- a/crates/lingua/src/universal/response_format.rs +++ b/crates/lingua/src/universal/response_format.rs @@ -34,6 +34,7 @@ use std::convert::TryFrom; use crate::capabilities::ProviderFormat; use crate::error::ConvertError; use crate::processing::transform::TransformError; +use crate::providers::google::convert::denormalize_json_schema_types; use crate::serde_json::{json, Map, Value}; use crate::universal::request::{JsonSchemaConfig, ResponseFormatConfig, ResponseFormatType}; @@ -49,6 +50,7 @@ impl<'a> TryFrom<(ProviderFormat, &'a Value)> for ResponseFormatConfig { ProviderFormat::OpenAI => Ok(from_openai_chat(value)?), ProviderFormat::Responses => Ok(from_openai_responses(value)?), ProviderFormat::Anthropic => Ok(from_anthropic(value)?), + ProviderFormat::Google => Ok(from_google(value)?), _ => Ok(Self::default()), } } @@ -73,6 +75,7 @@ impl ResponseFormatConfig { ProviderFormat::OpenAI => Ok(to_openai_chat(self)), ProviderFormat::Responses => Ok(to_openai_responses_text(self)), ProviderFormat::Anthropic => Ok(to_anthropic(self)), + ProviderFormat::Google => Ok(to_google(self)), _ => Ok(None), } } @@ -161,6 +164,54 @@ fn from_anthropic(value: &Value) -> Result { }) } +/// Parse Google `generationConfig` response format fields into ResponseFormatConfig. +/// +/// Handles: +/// - `responseMimeType: "text/plain"` → Text +/// - `responseMimeType: "application/json"` → JsonObject or JsonSchema (if responseSchema present) +/// - `responseSchema: {...}` → JsonSchema +/// - No responseMimeType → None (no response format specified) +fn from_google(value: &Value) -> Result { + let mime_type = match value.get("responseMimeType").and_then(Value::as_str) { + Some(mt) if !mt.is_empty() => mt, + _ => return Ok(ResponseFormatConfig::default()), + }; + + let response_schema = value.get("responseSchema").cloned(); + + let (format_type, json_schema) = match (mime_type, response_schema) { + ("application/json", Some(mut schema)) => { + // Denormalize schema types from Google's uppercase to standard lowercase + denormalize_json_schema_types(&mut schema); + + // Try to extract name from schema's title field + let name = schema + .get("title") + .and_then(Value::as_str) + .map(String::from) + .unwrap_or_else(|| "response".to_string()); + + ( + Some(ResponseFormatType::JsonSchema), + Some(JsonSchemaConfig { + name, + schema, + strict: None, + description: None, + }), + ) + } + ("application/json", None) => (Some(ResponseFormatType::JsonObject), None), + ("text/plain", _) => (Some(ResponseFormatType::Text), None), + _ => return Ok(ResponseFormatConfig::default()), // Unknown MIME type + }; + + Ok(ResponseFormatConfig { + format_type, + json_schema, + }) +} + /// Parse OpenAI Responses API `text.format` into ResponseFormatConfig. /// /// Handles the flattened structure: @@ -296,6 +347,85 @@ fn to_anthropic(config: &ResponseFormatConfig) -> Option { } } +/// Normalizes a JSON schema for Google compatibility: +/// - Removes additionalProperties (not supported by Google's Schema type) +/// - Converts lowercase type values to uppercase (Google expects STRING, NUMBER, etc.) +fn normalize_schema_for_google(schema: &mut Value) { + if let Value::Object(map) = schema { + map.remove("additionalProperties"); + + if let Some(type_value) = map.get_mut("type") { + if let Value::String(type_str) = type_value { + *type_value = Value::String( + match type_str.as_str() { + "string" => "STRING", + "number" => "NUMBER", + "integer" => "INTEGER", + "boolean" => "BOOLEAN", + "array" => "ARRAY", + "object" => "OBJECT", + "null" => "NULL", + _ => type_str, + } + .to_string(), + ); + } + } + + if let Some(Value::Object(props)) = map.get_mut("properties") { + for prop_schema in props.values_mut() { + normalize_schema_for_google(prop_schema); + } + } + if let Some(items) = map.get_mut("items") { + normalize_schema_for_google(items); + } + for key in &["allOf", "anyOf", "oneOf"] { + if let Some(Value::Array(schemas)) = map.get_mut(*key) { + for s in schemas { + normalize_schema_for_google(s); + } + } + } + } +} + +/// Convert ResponseFormatConfig to Google generationConfig fields. +/// +/// Output format (as partial generationConfig object): +/// - Text → `{ responseMimeType: "text/plain" }` +/// - JsonObject → `{ responseMimeType: "application/json" }` +/// - JsonSchema → `{ responseMimeType: "application/json", responseSchema: {...} }` +/// +/// Note: This returns fields to merge into generationConfig, not a standalone value. +fn to_google(config: &ResponseFormatConfig) -> Option { + let format_type = config.format_type?; + + match format_type { + // Explicitly output text/plain to preserve roundtrip + ResponseFormatType::Text => Some(json!({ + "responseMimeType": "text/plain" + })), + ResponseFormatType::JsonObject => Some(json!({ + "responseMimeType": "application/json" + })), + ResponseFormatType::JsonSchema => { + let js = config.json_schema.as_ref()?; + // Normalize schema for Google (uppercase types, no additionalProperties) + let mut schema = js.schema.clone(); + normalize_schema_for_google(&mut schema); + // Store name in schema's title field for roundtrip preservation + if let Some(obj) = schema.as_object_mut() { + obj.insert("title".to_string(), Value::String(js.name.clone())); + } + Some(json!({ + "responseMimeType": "application/json", + "responseSchema": schema + })) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -426,4 +556,88 @@ mod tests { // Anthropic format doesn't have nested json_schema wrapper assert!(anthropic_format.get("json_schema").is_none()); } + + #[test] + fn test_from_google_json_schema() { + let value = json!({ + "responseMimeType": "application/json", + "responseSchema": { + "type": "object", + "properties": { + "name": { "type": "string" } + } + } + }); + let config: ResponseFormatConfig = (ProviderFormat::Google, &value).try_into().unwrap(); + assert_eq!(config.format_type, Some(ResponseFormatType::JsonSchema)); + let js = config.json_schema.unwrap(); + assert_eq!(js.name, "response"); + assert_eq!(js.schema, value.get("responseSchema").unwrap().clone()); + } + + #[test] + fn test_from_google_json_object() { + let value = json!({ "responseMimeType": "application/json" }); + let config: ResponseFormatConfig = (ProviderFormat::Google, &value).try_into().unwrap(); + assert_eq!(config.format_type, Some(ResponseFormatType::JsonObject)); + assert!(config.json_schema.is_none()); + } + + #[test] + fn test_from_google_text() { + let value = json!({ "responseMimeType": "text/plain" }); + let config: ResponseFormatConfig = (ProviderFormat::Google, &value).try_into().unwrap(); + assert_eq!(config.format_type, Some(ResponseFormatType::Text)); + assert!(config.json_schema.is_none()); + } + + #[test] + fn test_to_google_formats() { + let text = ResponseFormatConfig { + format_type: Some(ResponseFormatType::Text), + json_schema: None, + }; + let text_value = text.to_provider(ProviderFormat::Google).unwrap().unwrap(); + assert_eq!( + text_value + .get("responseMimeType") + .unwrap() + .as_str() + .unwrap(), + "text/plain" + ); + + let json_object = ResponseFormatConfig { + format_type: Some(ResponseFormatType::JsonObject), + json_schema: None, + }; + let value = json_object + .to_provider(ProviderFormat::Google) + .unwrap() + .unwrap(); + assert_eq!( + value.get("responseMimeType").unwrap().as_str().unwrap(), + "application/json" + ); + assert!(value.get("responseSchema").is_none()); + + let json_schema = ResponseFormatConfig { + format_type: Some(ResponseFormatType::JsonSchema), + json_schema: Some(JsonSchemaConfig { + name: "ignored".into(), + schema: json!({ "type": "object" }), + strict: None, + description: None, + }), + }; + let value = json_schema + .to_provider(ProviderFormat::Google) + .unwrap() + .unwrap(); + assert_eq!( + value.get("responseMimeType").unwrap().as_str().unwrap(), + "application/json" + ); + assert!(value.get("responseSchema").is_some()); + } }