mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
fix(formats): repair live cross-format conversion gaps
Verified against a live Antigravity + xAI deployment: - Gemini and Claude clients calling a forced-stream Responses upstream (xAI, Codex) without streaming always failed: the aggregated body echoes request metadata (parallel_tool_calls, tools, encrypted reasoning) that the strict cross-format check refuses, and the gateway then wrapped the raw SSE capture in a client error body sent with HTTP 200. Project the validated aggregate to every client format, as the Chat path already does, and return 502 instead of raw provider bytes when a successful cross-format response still cannot be converted. - Gemini stream decoding keyed tool calls by part position, so parallel calls arriving in separate chunks (all at parts[0]) merged into one call with concatenated arguments. Key them by arrival order; ids cannot be used because they are optional and the Antigravity envelope synthesizes per-chunk ids that repeat across chunks. Generated call_auto_N ids now follow arrival order. - Non-stream Responses output reported truncated or filtered cross-format answers as completed; derive incomplete + incomplete_details from the canonical stop reason. - Gemini request parsing ignored parametersJsonSchema and responseJsonSchema and passed OpenAPI upper-case type names (OBJECT, STRING) through to JSON Schema targets, which xAI rejects. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d30268f80f
commit
75bc32cfe9
@@ -323,6 +323,12 @@ impl GeminiProviderState {
|
||||
self.observed_tool_calls = true;
|
||||
continue;
|
||||
}
|
||||
// Gemini streams are incremental and every functionCall part is a
|
||||
// complete call, so parallel calls arriving in separate chunks all
|
||||
// sit at parts[0]. Key calls by arrival order, not part position.
|
||||
// Ids cannot disambiguate: they are optional, and the Antigravity
|
||||
// envelope synthesizes per-chunk ids that repeat across chunks.
|
||||
let index = self.tool_calls.len();
|
||||
let tool_state = self.tool_calls.entry(index).or_default();
|
||||
tool_state.call_id = function_call
|
||||
.get("id")
|
||||
@@ -1526,6 +1532,80 @@ mod tests {
|
||||
assert!(signature_index < call_index);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_provider_state_keeps_parallel_function_calls_from_separate_chunks() {
|
||||
let mut state = GeminiProviderState::default();
|
||||
let report_context = json!({});
|
||||
let chunk = |call: Value| {
|
||||
data_line(json!({
|
||||
"responseId": "resp_parallel_123",
|
||||
"modelVersion": "gemini-3.8-flash",
|
||||
"candidates": [{
|
||||
"index": 0,
|
||||
"content": {"role": "model", "parts": [{"functionCall": call}]}
|
||||
}]
|
||||
}))
|
||||
};
|
||||
let mut frames = Vec::new();
|
||||
for call in [
|
||||
json!({"id": "call_a", "name": "get_weather", "args": {"city": "Paris"}}),
|
||||
json!({"id": "call_b", "name": "get_weather", "args": {"city": "Tokyo"}}),
|
||||
json!({"name": "get_time", "args": {"city": "Paris"}}),
|
||||
json!({"name": "get_time", "args": {"city": "Tokyo"}}),
|
||||
] {
|
||||
frames.extend(
|
||||
state
|
||||
.push_line(&report_context, chunk(call))
|
||||
.expect("function call chunk should parse"),
|
||||
);
|
||||
}
|
||||
|
||||
let starts = frames
|
||||
.iter()
|
||||
.filter_map(|frame| match &frame.event {
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => Some((*index, call_id.clone(), name.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(starts.len(), 4);
|
||||
assert_eq!(
|
||||
starts
|
||||
.iter()
|
||||
.map(|(index, _, _)| *index)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![0, 1, 2, 3]
|
||||
);
|
||||
assert_eq!(starts[0].1, "call_a");
|
||||
assert_eq!(starts[1].1, "call_b");
|
||||
assert_eq!(
|
||||
starts
|
||||
.iter()
|
||||
.map(|(_, _, name)| name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["get_weather", "get_weather", "get_time", "get_time"]
|
||||
);
|
||||
assert_ne!(starts[2].1, starts[3].1);
|
||||
|
||||
let mut arguments = BTreeMap::<usize, String>::new();
|
||||
for frame in &frames {
|
||||
if let CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: delta,
|
||||
} = &frame.event
|
||||
{
|
||||
arguments.entry(*index).or_default().push_str(delta);
|
||||
}
|
||||
}
|
||||
assert_eq!(arguments[&0], "{\"city\":\"Paris\"}");
|
||||
assert_eq!(arguments[&1], "{\"city\":\"Tokyo\"}");
|
||||
assert_eq!(arguments[&2], "{\"city\":\"Paris\"}");
|
||||
assert_eq!(arguments[&3], "{\"city\":\"Tokyo\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_client_emitter_marks_reasoning_parts_as_thoughts() {
|
||||
let mut emitter = GeminiClientEmitter::default();
|
||||
|
||||
@@ -114,6 +114,14 @@ fn openai_responses_incomplete_stop_reason(body: &Map<String, Value>) -> Canonic
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_incomplete_reason(canonical: &CanonicalResponse) -> Option<&'static str> {
|
||||
match canonical.stop_reason.as_ref()? {
|
||||
CanonicalStopReason::MaxTokens => Some("max_output_tokens"),
|
||||
CanonicalStopReason::ContentFiltered => Some("content_filter"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bool) -> Value {
|
||||
let namespace_tool_aliases = NamespaceToolAliases::from_report_context(report_context);
|
||||
let mut response = Map::new();
|
||||
@@ -143,6 +151,17 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
|
||||
.cloned()
|
||||
{
|
||||
response.insert("status".to_string(), raw_status);
|
||||
} else if let Some(reason) = canonical_incomplete_reason(canonical) {
|
||||
// Cross-format sources carry no Responses status of their own; a
|
||||
// truncated or filtered answer must not be reported as completed.
|
||||
response.insert(
|
||||
"status".to_string(),
|
||||
Value::String("incomplete".to_string()),
|
||||
);
|
||||
response.insert(
|
||||
"incomplete_details".to_string(),
|
||||
json!({ "reason": reason }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,4 +950,73 @@ mod tests {
|
||||
}) if id == "call_ws_1" && name == "web_search" && input["query"] == "today tech")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_builder_reports_cross_format_truncation_as_incomplete() {
|
||||
let response = |stop_reason| CanonicalResponse {
|
||||
id: "gemini-resp".to_string(),
|
||||
model: "gemini-3.8-flash".to_string(),
|
||||
content: vec![CanonicalContentBlock::Text {
|
||||
text: "partial".to_string(),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(stop_reason),
|
||||
usage: None,
|
||||
extensions: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let truncated = to_raw(&response(CanonicalStopReason::MaxTokens), &json!({}), false);
|
||||
assert_eq!(truncated["status"], "incomplete");
|
||||
assert_eq!(
|
||||
truncated["incomplete_details"],
|
||||
json!({"reason": "max_output_tokens"})
|
||||
);
|
||||
|
||||
let filtered = to_raw(
|
||||
&response(CanonicalStopReason::ContentFiltered),
|
||||
&json!({}),
|
||||
false,
|
||||
);
|
||||
assert_eq!(filtered["status"], "incomplete");
|
||||
assert_eq!(
|
||||
filtered["incomplete_details"],
|
||||
json!({"reason": "content_filter"})
|
||||
);
|
||||
|
||||
let finished = to_raw(&response(CanonicalStopReason::EndTurn), &json!({}), false);
|
||||
assert_eq!(finished["status"], "completed");
|
||||
assert!(finished.get("incomplete_details").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_max_tokens_response_converts_to_incomplete_responses_body() {
|
||||
let gemini = json!({
|
||||
"responseId": "gemini-trunc-123",
|
||||
"modelVersion": "gemini-3.8-flash",
|
||||
"candidates": [{
|
||||
"content": {"role": "model", "parts": [{"text": "The printing press"}]},
|
||||
"finishReason": "MAX_TOKENS"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 19,
|
||||
"candidatesTokenCount": 256,
|
||||
"totalTokenCount": 275
|
||||
}
|
||||
});
|
||||
|
||||
let body = crate::formats::registry::convert_response(
|
||||
"gemini:generate_content",
|
||||
"openai:responses",
|
||||
&gemini,
|
||||
&FormatContext::default(),
|
||||
)
|
||||
.expect("gemini response should convert");
|
||||
|
||||
assert_eq!(body["status"], "incomplete");
|
||||
assert_eq!(
|
||||
body["incomplete_details"],
|
||||
json!({"reason": "max_output_tokens"})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +115,26 @@ pub fn maybe_build_standard_cross_format_sync_product_from_normalized_payload(
|
||||
.as_deref()
|
||||
.unwrap_or(provider_api_format);
|
||||
|
||||
let aggregated_from_stream = aggregated_stream_body.is_some();
|
||||
let Some(provider_body_json) = aggregated_stream_body.or_else(|| body_json.cloned()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let projection_fallback_body = aggregated_from_stream.then(|| provider_body_json.clone());
|
||||
|
||||
Ok(maybe_build_standard_cross_format_sync_product(
|
||||
let product = maybe_build_standard_cross_format_sync_product(
|
||||
report_kind,
|
||||
provider_body_api_format,
|
||||
client_api_format,
|
||||
report_context,
|
||||
provider_body_json,
|
||||
);
|
||||
if product.is_some() {
|
||||
return Ok(product);
|
||||
}
|
||||
let Some(provider_body_json) = projection_fallback_body else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(project_validated_openai_responses_stream_sync_product(
|
||||
report_kind,
|
||||
provider_body_api_format,
|
||||
client_api_format,
|
||||
@@ -128,6 +143,46 @@ pub fn maybe_build_standard_cross_format_sync_product_from_normalized_payload(
|
||||
))
|
||||
}
|
||||
|
||||
/// Forced-stream Responses upstreams (Codex, xAI) echo request metadata such as
|
||||
/// `parallel_tool_calls`, `tools` and encrypted reasoning back in the aggregated
|
||||
/// body, which the strict cross-format response check refuses. The aggregated
|
||||
/// body stays the provider body, so the client projection may drop those
|
||||
/// provider-only fields — mirroring the OpenAI Chat client path.
|
||||
fn project_validated_openai_responses_stream_sync_product(
|
||||
report_kind: &str,
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
report_context: &Value,
|
||||
provider_body_json: Value,
|
||||
) -> Option<StandardCrossFormatSyncProduct> {
|
||||
let provider_api_format = normalize_openai_responses_family_api_format(provider_api_format);
|
||||
if !matches!(
|
||||
provider_api_format.as_str(),
|
||||
"openai:responses" | "openai:responses:compact"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
if is_standard_chat_finalize_kind(report_kind) {
|
||||
sync_chat_response_conversion_kind(&provider_api_format, &client_api_format)?;
|
||||
} else if is_standard_cli_finalize_kind(report_kind) {
|
||||
sync_cli_response_conversion_kind(&provider_api_format, &client_api_format)?;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
let client_body_json = project_validated_openai_responses_stream_to_client(
|
||||
&provider_body_json,
|
||||
&client_api_format,
|
||||
report_context,
|
||||
)?;
|
||||
let client_body_json =
|
||||
client_body_with_report_context_model(client_body_json, report_context, &client_api_format);
|
||||
Some(StandardCrossFormatSyncProduct {
|
||||
client_body_json,
|
||||
provider_body_json,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn maybe_build_standard_same_format_sync_body_from_normalized_payload(
|
||||
report_kind: &str,
|
||||
status_code: u16,
|
||||
@@ -1474,6 +1529,14 @@ fn convert_openai_chat_canonical_response_to_openai_chat(
|
||||
fn project_validated_openai_responses_stream_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
project_validated_openai_responses_stream_to_client(body_json, "openai:chat", report_context)
|
||||
}
|
||||
|
||||
fn project_validated_openai_responses_stream_to_client(
|
||||
body_json: &Value,
|
||||
client_api_format: &str,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
// The caller retains body_json as provider_body_json. This projection is therefore allowed
|
||||
// to omit provider-only response metadata, but never unknown canonical output blocks.
|
||||
@@ -1490,7 +1553,12 @@ fn project_validated_openai_responses_stream_to_openai_chat(
|
||||
}
|
||||
|
||||
apply_report_context_model_fallback(&mut canonical.model, report_context);
|
||||
Some(canonical_to_openai_chat_response(&canonical))
|
||||
match client_api_format {
|
||||
"openai:chat" => Some(canonical_to_openai_chat_response(&canonical)),
|
||||
"claude:messages" => Some(canonical_to_claude_response(&canonical)),
|
||||
"gemini:generate_content" => canonical_to_gemini_response(&canonical, report_context),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_chat_response_can_use_single_response_canonical(body_json: &Value) -> bool {
|
||||
@@ -7143,6 +7211,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_sync_finalize_projects_forced_responses_stream_to_gemini_and_claude_clients() {
|
||||
// Shape of a forced-stream xAI / Codex upstream: the terminal response
|
||||
// echoes request metadata and carries encrypted reasoning, which the
|
||||
// strict cross-format check refuses.
|
||||
let stream_body = concat!(
|
||||
"data: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"resp_forced_123\",\"object\":\"response\",\"status\":\"in_progress\",\"model\":\"grok-4.7-build\",\"output\":[],\"parallel_tool_calls\":true,\"tool_choice\":\"auto\",\"tools\":[],\"temperature\":0.7}}\n\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"sequence_number\":1,\"output_index\":0,\"item\":{\"id\":\"rs_forced_123\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"greet briefly\"}],\"encrypted_content\":\"opaque-xai-reasoning\"}}\n\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"sequence_number\":2,\"item_id\":\"msg_forced_123\",\"output_index\":1,\"content_index\":0,\"delta\":\"Hello there friend\"}\n\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"sequence_number\":3,\"output_index\":1,\"item\":{\"id\":\"msg_forced_123\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello there friend\",\"annotations\":[]}]}}\n\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"sequence_number\":4,\"output_index\":2,\"item\":{\"id\":\"fc_forced_123\",\"type\":\"function_call\",\"status\":\"completed\",\"call_id\":\"call_forced_123\",\"name\":\"search\",\"arguments\":\"{\\\"q\\\":\\\"aether\\\"}\"}}\n\n",
|
||||
"data: {\"type\":\"response.completed\",\"sequence_number\":5,\"response\":{\"id\":\"resp_forced_123\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"grok-4.7-build\",\"output\":[],\"parallel_tool_calls\":true,\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"name\":\"search\",\"parameters\":{\"type\":\"object\"}}],\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":{\"effort\":null,\"summary\":null},\"temperature\":0.7,\"top_p\":0.95,\"store\":false,\"usage\":{\"input_tokens\":1249,\"input_tokens_details\":{\"cached_tokens\":1152},\"output_tokens\":40,\"output_tokens_details\":{\"reasoning_tokens\":31},\"total_tokens\":1289}}}\n\n",
|
||||
);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(stream_body);
|
||||
|
||||
for (report_kind, client_api_format) in [
|
||||
("gemini_chat_sync_finalize", "gemini:generate_content"),
|
||||
("gemini_cli_sync_finalize", "gemini:generate_content"),
|
||||
("claude_chat_sync_finalize", "claude:messages"),
|
||||
("claude_cli_sync_finalize", "claude:messages"),
|
||||
] {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:responses",
|
||||
"provider_stream_event_api_format": "openai:responses",
|
||||
"client_api_format": client_api_format,
|
||||
"model": "grok-4.7",
|
||||
"mapped_model": "grok-4.7",
|
||||
"needs_conversion": true,
|
||||
});
|
||||
let product = maybe_build_standard_sync_finalize_product_from_normalized_payload(
|
||||
report_kind,
|
||||
200,
|
||||
Some(&report_context),
|
||||
None,
|
||||
Some(&encoded),
|
||||
)
|
||||
.expect("forced Responses stream should aggregate")
|
||||
.unwrap_or_else(|| panic!("{report_kind} should receive a projection"));
|
||||
let StandardSyncFinalizeNormalizedProduct::CrossFormat(product) = product else {
|
||||
panic!("{report_kind}: Responses stream should stay a cross-format product")
|
||||
};
|
||||
assert_eq!(product.provider_body_json["parallel_tool_calls"], true);
|
||||
let client = product.client_body_json.to_string();
|
||||
assert!(
|
||||
!client.contains("opaque-xai-reasoning") && !client.contains("response.created"),
|
||||
"{report_kind}: provider-only data leaked into the client body: {client}"
|
||||
);
|
||||
if client_api_format == "gemini:generate_content" {
|
||||
let parts = product.client_body_json["candidates"][0]["content"]["parts"]
|
||||
.as_array()
|
||||
.expect("gemini parts");
|
||||
assert!(parts
|
||||
.iter()
|
||||
.any(|part| part["text"] == "Hello there friend"
|
||||
&& part.get("thought").is_none()));
|
||||
assert!(parts
|
||||
.iter()
|
||||
.any(|part| part["functionCall"]["name"] == "search"
|
||||
&& part["functionCall"]["args"]["q"] == "aether"));
|
||||
assert_eq!(
|
||||
product.client_body_json["usageMetadata"]["promptTokenCount"],
|
||||
1249
|
||||
);
|
||||
} else {
|
||||
let content = product.client_body_json["content"]
|
||||
.as_array()
|
||||
.expect("claude content");
|
||||
assert!(content
|
||||
.iter()
|
||||
.any(|block| block["type"] == "text" && block["text"] == "Hello there friend"));
|
||||
assert!(content.iter().any(|block| block["type"] == "tool_use"
|
||||
&& block["name"] == "search"
|
||||
&& block["input"]["q"] == "aether"));
|
||||
assert_eq!(product.client_body_json["stop_reason"], "tool_use");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_sync_finalize_projects_authoritative_incomplete_responses_stream() {
|
||||
let report_context = json!({
|
||||
|
||||
@@ -4935,13 +4935,22 @@ pub(crate) fn gemini_response_format_to_canonical(
|
||||
if response_mime_type != "application/json" {
|
||||
return None;
|
||||
}
|
||||
let json_schema = gemini_value_by_case(generation_config, "responseSchema", "response_schema")
|
||||
.map(|schema| {
|
||||
json!({
|
||||
"name": "response_schema",
|
||||
"schema": schema,
|
||||
})
|
||||
});
|
||||
let json_schema = gemini_value_by_case(
|
||||
generation_config,
|
||||
"responseJsonSchema",
|
||||
"response_json_schema",
|
||||
)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
gemini_value_by_case(generation_config, "responseSchema", "response_schema")
|
||||
.map(gemini_openapi_schema_to_json_schema)
|
||||
})
|
||||
.map(|schema| {
|
||||
json!({
|
||||
"name": "response_schema",
|
||||
"schema": schema,
|
||||
})
|
||||
});
|
||||
Some(CanonicalResponseFormat {
|
||||
format_type: if json_schema.is_some() {
|
||||
"json_schema".to_string()
|
||||
@@ -4953,6 +4962,68 @@ pub(crate) fn gemini_response_format_to_canonical(
|
||||
})
|
||||
}
|
||||
|
||||
/// `parametersJsonSchema` is already standard JSON Schema; the legacy
|
||||
/// `parameters` field is Gemini's OpenAPI subset with upper-case type names.
|
||||
fn gemini_declaration_parameters_to_json_schema(declaration: &Map<String, Value>) -> Option<Value> {
|
||||
gemini_value_by_case(
|
||||
declaration,
|
||||
"parametersJsonSchema",
|
||||
"parameters_json_schema",
|
||||
)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
declaration
|
||||
.get("parameters")
|
||||
.map(gemini_openapi_schema_to_json_schema)
|
||||
})
|
||||
}
|
||||
|
||||
/// Gemini's OpenAPI-style `Schema` spells types in upper case (`OBJECT`,
|
||||
/// `STRING`, ...); other protocols expect JSON Schema's lower-case names.
|
||||
pub(crate) fn gemini_openapi_schema_to_json_schema(schema: &Value) -> Value {
|
||||
fn normalize(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, child) in object.iter_mut() {
|
||||
if key == "type" {
|
||||
match child {
|
||||
Value::String(type_name) => lowercase_schema_type(type_name),
|
||||
Value::Array(type_names) => {
|
||||
for type_name in type_names.iter_mut() {
|
||||
if let Value::String(type_name) = type_name {
|
||||
lowercase_schema_type(type_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
other => normalize(other),
|
||||
}
|
||||
} else if key != "enum"
|
||||
&& key != "const"
|
||||
&& key != "default"
|
||||
&& key != "example"
|
||||
{
|
||||
normalize(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(normalize),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn lowercase_schema_type(type_name: &mut String) {
|
||||
if matches!(
|
||||
type_name.as_str(),
|
||||
"OBJECT" | "STRING" | "INTEGER" | "NUMBER" | "BOOLEAN" | "ARRAY" | "NULL"
|
||||
) {
|
||||
*type_name = type_name.to_ascii_lowercase();
|
||||
}
|
||||
}
|
||||
|
||||
let mut schema = schema.clone();
|
||||
normalize(&mut schema);
|
||||
schema
|
||||
}
|
||||
|
||||
pub(crate) type GeminiCanonicalTools = (
|
||||
Vec<CanonicalToolDefinition>,
|
||||
Vec<Value>,
|
||||
@@ -5126,12 +5197,18 @@ pub(crate) fn gemini_tools_to_canonical(value: Option<&Value>) -> Option<GeminiC
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
parameters: declaration_object.get("parameters").cloned(),
|
||||
parameters: gemini_declaration_parameters_to_json_schema(declaration_object),
|
||||
strict: None,
|
||||
extensions: {
|
||||
let mut extensions = gemini_extensions(
|
||||
declaration_object,
|
||||
&["name", "description", "parameters"],
|
||||
&[
|
||||
"name",
|
||||
"description",
|
||||
"parameters",
|
||||
"parametersJsonSchema",
|
||||
"parameters_json_schema",
|
||||
],
|
||||
);
|
||||
if let Some(parameters) = declaration_object.get("parameters").cloned() {
|
||||
canonical_extension_object_mut(&mut extensions, "gemini")
|
||||
@@ -7133,6 +7210,8 @@ const GEMINI_MAPPED_GENERATION_CONFIG_KEYS: &[&str] = &[
|
||||
"response_mime_type",
|
||||
"responseSchema",
|
||||
"response_schema",
|
||||
"responseJsonSchema",
|
||||
"response_json_schema",
|
||||
"responseModalities",
|
||||
"response_modalities",
|
||||
];
|
||||
@@ -9360,6 +9439,80 @@ mod tests {
|
||||
assert_eq!(rebuilt["toolConfig"], request["toolConfig"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_request_adapter_reads_json_schema_fields_and_lowercases_openapi_types() {
|
||||
let request = json!({
|
||||
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
"tools": [{"functionDeclarations": [
|
||||
{
|
||||
"name": "search",
|
||||
"parametersJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
"required": ["q"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "legacy",
|
||||
"parameters": {
|
||||
"type": "OBJECT",
|
||||
"properties": {
|
||||
"type": {"type": "STRING", "enum": ["OBJECT", "STRING"]},
|
||||
"tags": {"type": "ARRAY", "items": {"type": "STRING"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}],
|
||||
"generationConfig": {
|
||||
"responseMimeType": "application/json",
|
||||
"responseJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let canonical =
|
||||
from_gemini_to_canonical_request(&request, "/v1beta/models/grok-4.7:generateContent")
|
||||
.expect("canonical request");
|
||||
|
||||
assert_eq!(
|
||||
canonical.tools[0].parameters,
|
||||
Some(json!({
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
"required": ["q"]
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
canonical.tools[1].parameters,
|
||||
Some(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"type": "string", "enum": ["OBJECT", "STRING"]},
|
||||
"tags": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}))
|
||||
);
|
||||
let response_format = canonical.response_format.as_ref().expect("response format");
|
||||
assert_eq!(response_format.format_type, "json_schema");
|
||||
assert_eq!(
|
||||
response_format.json_schema.as_ref().expect("schema")["schema"]["required"],
|
||||
json!(["name"])
|
||||
);
|
||||
|
||||
let legacy_schema = super::gemini_response_format_to_canonical(Some(&json!({
|
||||
"responseMimeType": "application/json",
|
||||
"responseSchema": {"type": "OBJECT", "properties": {"n": {"type": "INTEGER"}}}
|
||||
})))
|
||||
.expect("legacy response format");
|
||||
assert_eq!(
|
||||
legacy_schema.json_schema.expect("legacy schema")["schema"],
|
||||
json!({"type": "object", "properties": {"n": {"type": "integer"}}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_request_adapter_normalizes_google_search_grounding_aliases() {
|
||||
let cases = [
|
||||
|
||||
Reference in New Issue
Block a user