fix(gemini): accept signature-only reasoning exhaustion

This commit is contained in:
ZheFox
2026-09-03 17:57:40 +08:00
parent f8b4382a54
commit f6884eb8c4
5 changed files with 235 additions and 11 deletions
@@ -26,6 +26,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
}
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<CanonicalResponse> {
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<CanonicalResponse> {
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<Value> {
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!({
@@ -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();
@@ -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";
@@ -1249,19 +1249,21 @@ pub(crate) fn gemini_part_to_canonical_block(
) -> Option<CanonicalContentBlock> {
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,