From f6884eb8c4b8351b804bff2c47d313ccf86c1793 Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:57:40 +0800 Subject: [PATCH] fix(gemini): accept signature-only reasoning exhaustion --- .../src/execution_runtime/sync/execution.rs | 27 ++++++ .../gemini/generate_content/response.rs | 97 ++++++++++++++++++- .../formats/gemini/generate_content/stream.rs | 53 +++++++++- .../src/formats/shared/sync_products.rs | 51 ++++++++++ .../formats/src/protocol/canonical.rs | 18 ++-- 5 files changed, 235 insertions(+), 11 deletions(-) diff --git a/apps/aether-gateway/src/execution_runtime/sync/execution.rs b/apps/aether-gateway/src/execution_runtime/sync/execution.rs index 8ee2742ae..e81c006e2 100644 --- a/apps/aether-gateway/src/execution_runtime/sync/execution.rs +++ b/apps/aether-gateway/src/execution_runtime/sync/execution.rs @@ -3774,6 +3774,33 @@ mod tests { assert!(message.is_none()); } + #[test] + fn invalid_gemini_provider_stream_success_accepts_signature_only_reasoning_exhaustion() { + let plan = test_gemini_chat_plan(); + let report_context = json!({ + "has_envelope": true, + "envelope_name": "antigravity:v1internal", + "provider_api_format": "gemini:generate_content", + }); + let body = concat!( + "data: {\"response\":{\"responseId\":\"resp_signature_only_123\",\"modelVersion\":\"gemini-3.7-flash-tiered\",", + "\"candidates\":[{\"index\":0,\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"\",\"thoughtSignature\":\"opaque-thought-signature\"}]},\"finishReason\":\"MAX_TOKENS\"}],", + "\"usageMetadata\":{\"promptTokenCount\":22,\"thoughtsTokenCount\":29,\"totalTokenCount\":51}},", + "\"traceId\":\"trace-signature-only\"}\n\n", + ); + + let message = invalid_gemini_provider_stream_success_message( + &plan, + Some(&report_context), + StatusCode::OK.as_u16(), + None, + body.as_bytes(), + true, + ); + + assert!(message.is_none()); + } + #[test] fn invalid_gemini_provider_success_error_is_retryable_candidate_failure() { let error = invalid_gemini_provider_success_execution_error( diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/response.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/response.rs index 1ca77b729..5b4208272 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/response.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/response.rs @@ -26,6 +26,7 @@ pub fn from_raw(body_json: &Value) -> Option { } let candidates = body.get("candidates")?.as_array()?; + let usage = gemini_usage_to_canonical(body.get("usageMetadata")); let mut outputs = Vec::new(); for (fallback_index, candidate) in candidates.iter().enumerate() { let candidate_object = candidate.as_object()?; @@ -79,7 +80,10 @@ pub fn from_raw(body_json: &Value) -> Option { extensions, }); } - outputs.retain(gemini_response_output_has_visible_content); + outputs.retain(|output| { + gemini_response_output_has_visible_content(output) + || gemini_response_output_is_reasoning_exhausted_terminal(output, usage.as_ref()) + }); if outputs.is_empty() { return None; } @@ -106,7 +110,7 @@ pub fn from_raw(body_json: &Value) -> Option { outputs, content, stop_reason, - usage: gemini_usage_to_canonical(body.get("usageMetadata")), + usage, extensions: gemini_extensions( body, &[ @@ -139,6 +143,24 @@ fn gemini_response_output_has_visible_content(output: &CanonicalResponseOutput) }) } +fn gemini_response_output_is_reasoning_exhausted_terminal( + output: &CanonicalResponseOutput, + usage: Option<&CanonicalUsage>, +) -> bool { + matches!(output.stop_reason, Some(CanonicalStopReason::MaxTokens)) + && usage.is_some_and(|usage| usage.reasoning_tokens > 0) + && output.content.iter().any(|block| { + matches!( + block, + CanonicalContentBlock::Thinking { + text, + signature: Some(signature), + .. + } if text.trim().is_empty() && !signature.trim().is_empty() + ) + }) +} + pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value) -> Option { let mut response = canonical_to_gemini_response(canonical, report_context)?; if let Some(object) = response.as_object_mut() { @@ -463,6 +485,77 @@ mod tests { assert_eq!(openai["choices"][0]["finish_reason"], "length"); } + #[test] + fn gemini_response_with_signature_only_reasoning_exhaustion_is_success() { + let body = json!({ + "candidates": [{ + "content": { + "role": "model", + "parts": [{ + "text": "", + "thoughtSignature": "opaque-thought-signature" + }] + }, + "finishReason": "MAX_TOKENS" + }], + "usageMetadata": { + "promptTokenCount": 22, + "thoughtsTokenCount": 29, + "totalTokenCount": 51 + }, + "modelVersion": "gemini-3.7-flash-tiered", + "responseId": "resp-signature-only" + }); + + let canonical = from_raw(&body).expect("reasoning exhaustion is a valid terminal"); + assert!(matches!( + canonical.content.first(), + Some(CanonicalContentBlock::Thinking { + text, + signature: Some(signature), + .. + }) if text.is_empty() && signature == "opaque-thought-signature" + )); + assert!(matches!( + canonical.stop_reason, + Some(CanonicalStopReason::MaxTokens) + )); + assert_eq!( + canonical.usage.as_ref().map(|usage| usage.reasoning_tokens), + Some(29) + ); + + let openai = crate::canonical_to_openai_chat_response(&canonical); + assert_eq!(openai["choices"][0]["finish_reason"], "length"); + assert_eq!( + openai["usage"]["completion_tokens_details"]["reasoning_tokens"], + 29 + ); + } + + #[test] + fn gemini_signature_only_terminal_without_reasoning_usage_is_not_success() { + let body = json!({ + "candidates": [{ + "content": { + "role": "model", + "parts": [{ + "text": "", + "thoughtSignature": "opaque-thought-signature" + }] + }, + "finishReason": "MAX_TOKENS" + }], + "usageMetadata": { + "promptTokenCount": 22, + "thoughtsTokenCount": 0, + "totalTokenCount": 22 + } + }); + + assert!(from_raw(&body).is_none()); + } + #[test] fn gemini_response_with_function_call_is_visible_output() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs index 8b890cacb..80d04e4af 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs @@ -129,7 +129,8 @@ impl GeminiProviderState { let is_reasoning = part_object .get("thought") .and_then(Value::as_bool) - .unwrap_or(false); + .unwrap_or(false) + || (text.trim().is_empty() && reasoning_signature.is_some()); let previous = if is_reasoning { self.reasoning_parts.entry(index).or_default() } else { @@ -960,6 +961,56 @@ mod tests { ))); } + #[test] + fn gemini_provider_state_preserves_signature_only_reasoning_terminal() { + let mut state = GeminiProviderState::default(); + let report_context = json!({}); + let frames = state + .push_line( + &report_context, + data_line(json!({ + "response": { + "responseId": "resp_signature_only_123", + "modelVersion": "gemini-3.7-flash-tiered", + "candidates": [{ + "index": 0, + "finishReason": "MAX_TOKENS", + "content": { + "role": "model", + "parts": [{ + "text": "", + "thoughtSignature": "opaque-thought-signature" + }] + } + }], + "usageMetadata": { + "promptTokenCount": 22, + "thoughtsTokenCount": 29, + "totalTokenCount": 51 + } + }, + "traceId": "trace-signature-only" + })), + ) + .expect("signature-only reasoning terminal should parse"); + + assert!(frames.iter().any(|frame| matches!( + frame.event, + CanonicalStreamEvent::ReasoningSignature(ref signature) + if signature == "opaque-thought-signature" + ))); + assert!(frames.iter().any(|frame| matches!( + frame.event, + CanonicalStreamEvent::Finish { + ref finish_reason, + usage: Some(CanonicalUsage { + reasoning_tokens: 29, + .. + }), + } if finish_reason.as_deref() == Some("length") + ))); + } + #[test] fn gemini_provider_state_parses_function_response_as_tool_result() { let mut state = GeminiProviderState::default(); diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index 8424884fa..5ad5c21ea 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -4257,6 +4257,57 @@ mod tests { assert_eq!(aggregated["usageMetadata"]["totalTokenCount"], 5); } + #[test] + fn aggregates_antigravity_signature_only_reasoning_exhaustion() { + let body = concat!( + "data: {\"response\":{\"responseId\":\"resp_signature_only_123\",\"modelVersion\":\"gemini-3.7-flash-tiered\",", + "\"candidates\":[{\"index\":0,\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"\",\"thoughtSignature\":\"opaque-thought-signature\"}]},\"finishReason\":\"MAX_TOKENS\"}],", + "\"usageMetadata\":{\"promptTokenCount\":22,\"thoughtsTokenCount\":29,\"totalTokenCount\":51}},", + "\"traceId\":\"trace-signature-only\"}\n\n", + ); + + let aggregated = aggregate_gemini_stream_sync_response(body.as_bytes()) + .expect("signature-only reasoning terminal should aggregate"); + + assert_eq!( + aggregated["candidates"][0]["content"]["parts"][0]["thought"], + true + ); + assert_eq!( + aggregated["candidates"][0]["content"]["parts"][0]["thoughtSignature"], + "opaque-thought-signature" + ); + assert_eq!(aggregated["candidates"][0]["finishReason"], "MAX_TOKENS"); + assert_eq!(aggregated["usageMetadata"]["thoughtsTokenCount"], 29); + assert!( + crate::formats::gemini::generate_content::response::from_raw(&aggregated).is_some() + ); + + let report_context = json!({ + "provider_api_format": "gemini:generate_content", + "client_api_format": "openai:chat", + "mapped_model": "gemini-3.7-flash-tiered", + }); + let product = maybe_build_standard_cross_format_sync_product_from_normalized_payload( + "openai_chat_sync_finalize", + 200, + Some(&report_context), + None, + Some(&base64::engine::general_purpose::STANDARD.encode(body)), + ) + .expect("signature-only reasoning terminal should convert") + .expect("cross-format product should exist"); + + assert_eq!( + product.client_body_json["choices"][0]["finish_reason"], + "length" + ); + assert_eq!( + product.client_body_json["usage"]["completion_tokens_details"]["reasoning_tokens"], + 29 + ); + } + #[test] fn gemini_stream_aggregation_rejects_unknown_parts() { let body = "data: {\"responseId\":\"resp_gem_unknown_123\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"index\":0,\"content\":{\"role\":\"model\",\"parts\":[{\"futurePart\":{\"kept\":true}}]}}]}\n\n"; diff --git a/crates/aether-ai/formats/src/protocol/canonical.rs b/crates/aether-ai/formats/src/protocol/canonical.rs index bf4db8aab..f9fe69512 100644 --- a/crates/aether-ai/formats/src/protocol/canonical.rs +++ b/crates/aether-ai/formats/src/protocol/canonical.rs @@ -1249,19 +1249,21 @@ pub(crate) fn gemini_part_to_canonical_block( ) -> Option { let part_object = part.as_object()?; if let Some(text) = part_object.get("text").and_then(Value::as_str) { - if part_object + let thought_signature = part_object + .get("thoughtSignature") + .or_else(|| part_object.get("thought_signature")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let is_thinking = part_object .get("thought") .and_then(Value::as_bool) .unwrap_or(false) - { + || (text.trim().is_empty() && thought_signature.is_some()); + if is_thinking { return Some(CanonicalContentBlock::Thinking { text: text.to_string(), - signature: part_object - .get("thoughtSignature") - .or_else(|| part_object.get("thought_signature")) - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned), + signature: thought_signature, encrypted_content: None, extensions: gemini_extensions( part_object,