Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/coverage-report/src/requests_expected_differences.json
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,14 @@
{ "pattern": "messages[*].content", "reason": "Empty assistant content (no parts) from truncated Gemini response gains a placeholder empty text part when roundtripped through other providers" },
{ "pattern": "messages.length", "reason": "Empty assistant content from truncated Gemini response may change message count when roundtripped through providers that split/merge messages" }
]
},
{
"testCase": "exclusiveMinimumToolParam",
"source": "ChatCompletions",
"target": "Google",
"fields": [
{ "pattern": "params.tools[0].parameters.properties.max_tokens.exclusiveMinimum", "reason": "Gemini's Schema proto does not support JSON Schema Draft-07 keywords such as exclusiveMinimum; they are stripped during conversion to avoid a 400 INVALID_ARGUMENT error" }
]
}
]
}
54 changes: 53 additions & 1 deletion crates/lingua/src/providers/google/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,8 @@ impl ProviderAdapter for GoogleAdapter {
);
}

// Add tools if present
// Add tools if present. FunctionDeclaration::try_from strips unsupported JSON Schema
// keywords (e.g. `exclusiveMinimum`) from parametersJsonSchema during conversion.
if let Some(tools) = &req.params.tools {
let google_tools = <Vec<GoogleTool> as TryFromLLM<Vec<_>>>::try_from(tools.clone())
.map_err(|e| TransformError::FromUniversalFailed(e.to_string()))?;
Expand Down Expand Up @@ -769,6 +770,7 @@ fn fill_tool_names_from_context(messages: &mut [Message]) {
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::google::GenerateContentRequest;
use crate::serde_json::json;
use crate::universal::request::ToolChoiceMode;

Expand Down Expand Up @@ -1023,6 +1025,56 @@ mod tests {
assert_eq!(parts.len(), 1);
}

#[test]
fn test_exclusive_minimum_stripped_from_google_request() {
let adapter = GoogleAdapter;
let req = UniversalRequest {
model: None,
messages: vec![Message::User {
content: UserContent::String("Hello".into()),
}],
params: UniversalParams {
tools: Some(vec![crate::universal::tools::UniversalTool::function(
"my_tool",
Some("A tool".to_string()),
Some(json!({
"type": "object",
"properties": {
"count": {
"type": "integer",
"exclusiveMinimum": 0
}
}
})),
None,
)]),
..Default::default()
},
};

let payload = adapter.request_from_universal(&req).unwrap();

// Deserialize into the typed request struct so we navigate via struct
// fields rather than raw Value map access.
let typed: GenerateContentRequest = serde_json::from_value(payload).unwrap();
let decl = &typed.tools.as_ref().unwrap()[0]
.function_declarations
.as_ref()
.unwrap()[0];
let schema = decl
.parameters_json_schema
.as_ref()
.expect("parametersJsonSchema should be present");

// parameters_json_schema is an opaque JSON value; verify absence of the
// unsupported keyword by checking the serialized form.
let schema_str = serde_json::to_string(schema).unwrap();
assert!(
!schema_str.contains("exclusiveMinimum"),
"exclusiveMinimum must be stripped from Google request"
);
}

#[test]
fn test_google_stream_from_universal_usage_only_emits_placeholder_candidate() {
let adapter = GoogleAdapter;
Expand Down
44 changes: 38 additions & 6 deletions crates/lingua/src/providers/google/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,17 +810,49 @@ fn normalize_google_schema_types(mut value: Value) -> Value {
value
}

/// Recursively strip `exclusiveMinimum` fields from a JSON schema value in-place.
///
/// Google's `Schema` proto does not recognise `exclusiveMinimum` (a JSON Schema Draft 6+
/// keyword) and returns a 400 INVALID_ARGUMENT error when it is present. We strip it
/// here — at the conversion boundary — so that callers never have to remember to do so
/// themselves. The original schema is preserved in the caller's `UniversalTool.parameters`
/// field and can be round-tripped via the adapter's extras mechanism.
fn strip_exclusive_minimum(value: &mut Value) {
match value {
Value::Object(map) => {
map.remove("exclusiveMinimum");
for v in map.values_mut() {
strip_exclusive_minimum(v);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
strip_exclusive_minimum(v);
}
}
_ => {}
}
}

impl TryFrom<&UniversalTool> for FunctionDeclaration {
type Error = ConvertError;

fn try_from(tool: &UniversalTool) -> Result<Self, Self::Error> {
match &tool.tool_type {
UniversalToolType::Function => Ok(FunctionDeclaration {
name: Some(tool.name.clone()),
description: tool.description.clone(),
parameters_json_schema: tool.parameters.clone(),
..Default::default()
}),
UniversalToolType::Function => {
// Strip JSON Schema keywords unsupported by Google's Schema proto (e.g.
// `exclusiveMinimum`) before embedding the schema in the declaration.
let parameters_json_schema = tool.parameters.clone().map(|mut p| {
strip_exclusive_minimum(&mut p);
p
});
Ok(FunctionDeclaration {
name: Some(tool.name.clone()),
description: tool.description.clone(),
parameters_json_schema,
..Default::default()
})
}
UniversalToolType::Custom { .. } => Err(ConvertError::UnsupportedToolType {
tool_name: tool.name.clone(),
tool_type: "custom".to_string(),
Expand Down
85 changes: 85 additions & 0 deletions payloads/cases/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
OPENAI_NON_REASONING_MODEL,
ANTHROPIC_MODEL,
ANTHROPIC_OPUS_MODEL,
GOOGLE_MODEL,
GOOGLE_GEMINI_3_MODEL,
GOOGLE_IMAGE_MODEL,
GOOGLE_TTS_MODEL,
Expand Down Expand Up @@ -2105,4 +2106,88 @@ export const paramsCases: TestCaseCollection = {
},
bedrock: null,
},

exclusiveMinimumToolParam: {
"chat-completions": {
model: OPENAI_CHAT_COMPLETIONS_MODEL,
messages: [{ role: "user", content: "Configure the LLM." }],
tools: [
{
type: "function",
function: {
name: "configure_llm",
description: "Configure LLM generation parameters",
parameters: {
type: "object",
properties: {
max_tokens: {
type: "number",
exclusiveMinimum: 0,
description: "Maximum number of tokens to generate",
},
},
required: ["max_tokens"],
additionalProperties: false,
},
},
},
],
},
responses: {
model: OPENAI_RESPONSES_MODEL,
input: [{ role: "user", content: "Configure the LLM." }],
tools: [
{
type: "function",
name: "configure_llm",
description: "Configure LLM generation parameters",
parameters: {
type: "object",
properties: {
max_tokens: {
type: "number",
exclusiveMinimum: 0,
description: "Maximum number of tokens to generate",
},
},
required: ["max_tokens"],
additionalProperties: false,
},
strict: false,
},
],
},
anthropic: null,
google: (() => {
// Assigned to a variable first so TypeScript applies structural (not
// excess-property) checking when it lands in Record<string, Schema>.
// exclusiveMinimum is not in Gemini's Schema type but IS passed here
// deliberately to capture the resulting 400 INVALID_ARGUMENT error.
const maxTokensSchema = {
type: Type.NUMBER,
exclusiveMinimum: 0,
description: "Maximum number of tokens to generate",
};
return {
model: GOOGLE_MODEL,
contents: [{ role: "user", parts: [{ text: "Configure the LLM." }] }],
tools: [
{
functionDeclarations: [
{
name: "configure_llm",
description: "Configure LLM generation parameters",
parameters: {
type: Type.OBJECT,
properties: { max_tokens: maxTokensSchema },
required: ["max_tokens"],
},
},
],
},
],
};
})(),
bedrock: null,
},
};
Loading
Loading