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:
@@ -3774,6 +3774,33 @@ mod tests {
|
|||||||
assert!(message.is_none());
|
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]
|
#[test]
|
||||||
fn invalid_gemini_provider_success_error_is_retryable_candidate_failure() {
|
fn invalid_gemini_provider_success_error_is_retryable_candidate_failure() {
|
||||||
let error = invalid_gemini_provider_success_execution_error(
|
let error = invalid_gemini_provider_success_execution_error(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let candidates = body.get("candidates")?.as_array()?;
|
let candidates = body.get("candidates")?.as_array()?;
|
||||||
|
let usage = gemini_usage_to_canonical(body.get("usageMetadata"));
|
||||||
let mut outputs = Vec::new();
|
let mut outputs = Vec::new();
|
||||||
for (fallback_index, candidate) in candidates.iter().enumerate() {
|
for (fallback_index, candidate) in candidates.iter().enumerate() {
|
||||||
let candidate_object = candidate.as_object()?;
|
let candidate_object = candidate.as_object()?;
|
||||||
@@ -79,7 +80,10 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
|||||||
extensions,
|
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() {
|
if outputs.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -106,7 +110,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
|||||||
outputs,
|
outputs,
|
||||||
content,
|
content,
|
||||||
stop_reason,
|
stop_reason,
|
||||||
usage: gemini_usage_to_canonical(body.get("usageMetadata")),
|
usage,
|
||||||
extensions: gemini_extensions(
|
extensions: gemini_extensions(
|
||||||
body,
|
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> {
|
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value) -> Option<Value> {
|
||||||
let mut response = canonical_to_gemini_response(canonical, report_context)?;
|
let mut response = canonical_to_gemini_response(canonical, report_context)?;
|
||||||
if let Some(object) = response.as_object_mut() {
|
if let Some(object) = response.as_object_mut() {
|
||||||
@@ -463,6 +485,77 @@ mod tests {
|
|||||||
assert_eq!(openai["choices"][0]["finish_reason"], "length");
|
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]
|
#[test]
|
||||||
fn gemini_response_with_function_call_is_visible_output() {
|
fn gemini_response_with_function_call_is_visible_output() {
|
||||||
let body = json!({
|
let body = json!({
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ impl GeminiProviderState {
|
|||||||
let is_reasoning = part_object
|
let is_reasoning = part_object
|
||||||
.get("thought")
|
.get("thought")
|
||||||
.and_then(Value::as_bool)
|
.and_then(Value::as_bool)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false)
|
||||||
|
|| (text.trim().is_empty() && reasoning_signature.is_some());
|
||||||
let previous = if is_reasoning {
|
let previous = if is_reasoning {
|
||||||
self.reasoning_parts.entry(index).or_default()
|
self.reasoning_parts.entry(index).or_default()
|
||||||
} else {
|
} 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]
|
#[test]
|
||||||
fn gemini_provider_state_parses_function_response_as_tool_result() {
|
fn gemini_provider_state_parses_function_response_as_tool_result() {
|
||||||
let mut state = GeminiProviderState::default();
|
let mut state = GeminiProviderState::default();
|
||||||
|
|||||||
@@ -4257,6 +4257,57 @@ mod tests {
|
|||||||
assert_eq!(aggregated["usageMetadata"]["totalTokenCount"], 5);
|
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]
|
#[test]
|
||||||
fn gemini_stream_aggregation_rejects_unknown_parts() {
|
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";
|
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> {
|
) -> Option<CanonicalContentBlock> {
|
||||||
let part_object = part.as_object()?;
|
let part_object = part.as_object()?;
|
||||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
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")
|
.get("thought")
|
||||||
.and_then(Value::as_bool)
|
.and_then(Value::as_bool)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
|| (text.trim().is_empty() && thought_signature.is_some());
|
||||||
|
if is_thinking {
|
||||||
return Some(CanonicalContentBlock::Thinking {
|
return Some(CanonicalContentBlock::Thinking {
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
signature: part_object
|
signature: thought_signature,
|
||||||
.get("thoughtSignature")
|
|
||||||
.or_else(|| part_object.get("thought_signature"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
encrypted_content: None,
|
encrypted_content: None,
|
||||||
extensions: gemini_extensions(
|
extensions: gemini_extensions(
|
||||||
part_object,
|
part_object,
|
||||||
|
|||||||
Reference in New Issue
Block a user