mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
fix(gemini): accept signature-only reasoning exhaustion
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user