修复 Gemini 空输出按候选重试处理

This commit is contained in:
MMEXA
2026-07-09 23:10:30 +08:00
parent 7bc7d0f8d8
commit d2ea437c1c
5 changed files with 385 additions and 30 deletions
@@ -510,6 +510,11 @@ fn maybe_build_standard_same_format_sync_body(
if is_error_like_sync_body(body_json) {
return None;
}
if api_format_is_gemini_generate_content(expected_api_format)
&& !gemini_generate_content_body_has_visible_output(body_json)
{
return None;
}
let mut body_json = body_json.clone();
if expected_api_format == "claude:messages" {
@@ -578,6 +583,11 @@ fn maybe_build_standard_same_format_stream_sync_body(
else {
return Ok(None);
};
if api_format_is_gemini_generate_content(&provider_stream_event_api_format)
&& !gemini_generate_content_body_has_visible_output(&body)
{
return Ok(None);
}
let Some(client_body) = convert_aggregated_stream_body_to_client_sync_response(
report_kind,
body,
@@ -752,6 +762,14 @@ fn is_error_like_sync_body(value: &Value) -> bool {
})
}
fn gemini_generate_content_body_has_visible_output(value: &Value) -> bool {
crate::formats::gemini::generate_content::response::from_raw(value).is_some()
}
fn api_format_is_gemini_generate_content(api_format: &str) -> bool {
aether_ai_formats::normalize_api_format_alias(api_format) == "gemini:generate_content"
}
pub fn maybe_build_standard_cross_format_sync_product(
report_kind: &str,
provider_api_format: &str,
@@ -5415,6 +5433,34 @@ mod tests {
assert_eq!(body_json["choices"][0]["message"]["content"], "pong");
}
#[test]
fn standard_same_format_gemini_finalize_rejects_usage_only_body() {
let report_context = json!({
"provider_api_format": "gemini:generate_content",
"client_api_format": "gemini:generate_content",
"needs_conversion": false,
});
let provider_body_json = json!({
"responseId": "resp-empty-visible-output",
"modelVersion": "gemini-3-flash-preview",
"usageMetadata": {
"promptTokenCount": 8,
"totalTokenCount": 8
}
});
let product = maybe_build_standard_sync_finalize_product_from_normalized_payload(
"gemini_chat_sync_finalize",
200,
Some(&report_context),
Some(&provider_body_json),
None,
)
.expect("dispatch should succeed");
assert_eq!(product, None);
}
#[test]
fn standard_cross_format_finalize_uses_explicit_stream_event_format_for_provider_body() {
let body = concat!(
@@ -101,19 +101,7 @@ pub fn normalize_provider_private_response_value(
}
}
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME) => {
if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
let mut unwrapped = response.clone();
if let Some(response_id) = data.get("responseId").cloned() {
unwrapped.insert("_v1internal_response_id".to_string(), response_id);
}
Value::Object(unwrapped)
} else {
data
}
normalize_antigravity_sync_response_value(data, provider_api_format)
}
Some(WINDSURF_ENVELOPE_NAME) => normalize_windsurf_sync_response_value(data)?,
_ => return None,
@@ -122,6 +110,59 @@ pub fn normalize_provider_private_response_value(
Some(unwrapped)
}
fn normalize_antigravity_sync_response_value(data: Value, provider_api_format: &str) -> Value {
if crate::normalize_api_format_alias(provider_api_format) == "gemini:generate_content" {
if let Some(aggregated) = aggregate_antigravity_gemini_chunks(&data) {
return aggregated;
}
}
if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
let mut unwrapped = response.clone();
if let Some(response_id) = data.get("responseId").cloned() {
unwrapped.insert("_v1internal_response_id".to_string(), response_id);
}
Value::Object(unwrapped)
} else {
data
}
}
fn aggregate_antigravity_gemini_chunks(data: &Value) -> Option<Value> {
let chunks = data.get("chunks").and_then(Value::as_array)?;
if chunks.is_empty() {
return None;
}
let body = serde_json::to_vec(chunks).ok()?;
let mut aggregated =
crate::formats::shared::sync_products::aggregate_gemini_stream_sync_response(&body)?;
if let Some(response_id) = antigravity_chunks_response_id(chunks) {
if let Some(object) = aggregated.as_object_mut() {
if object.get("responseId").is_none_or(|value| value.is_null()) {
object.insert("responseId".to_string(), response_id);
}
}
}
Some(aggregated)
}
fn antigravity_chunks_response_id(chunks: &[Value]) -> Option<Value> {
chunks.iter().find_map(|chunk| {
chunk
.get("responseId")
.or_else(|| {
chunk
.get("response")
.and_then(|response| response.get("responseId"))
})
.cloned()
.filter(|value| !value.is_null())
})
}
pub fn transform_provider_private_stream_line(
report_context: &Value,
line: Vec<u8>,
@@ -914,6 +955,69 @@ mod tests {
);
}
#[test]
fn unwraps_antigravity_chunks_sync_response() {
let report_context = json!({
"has_envelope": true,
"provider_api_format": "gemini:generate_content",
"envelope_name": "antigravity:v1internal",
"mapped_model": "gemini-3-flash-agent",
});
let body = json!({
"chunks": [{
"response": {
"responseId": "resp_antigravity_chunks_123",
"candidates": [{
"content": {
"parts": [{"text": "Hello "}],
"role": "model"
},
"index": 0
}],
"modelVersion": "gemini-3-flash-agent"
},
"traceId": "trace-antigravity-chunks"
}, {
"response": {
"responseId": "resp_antigravity_chunks_123",
"candidates": [{
"content": {
"parts": [{"text": "Gemini"}],
"role": "model"
},
"finishReason": "STOP",
"index": 0
}],
"modelVersion": "gemini-3-flash-agent",
"usageMetadata": {
"promptTokenCount": 2,
"candidatesTokenCount": 2,
"totalTokenCount": 4
}
},
"traceId": "trace-antigravity-chunks"
}],
"metadata": {
"stream": true,
"stored_chunks": 2,
"total_chunks": 2
}
});
let normalized = normalize_provider_private_response_value(body, &report_context)
.expect("chunks should normalize");
assert_eq!(
normalized["candidates"][0]["content"]["parts"][0]["text"],
json!("Hello Gemini")
);
assert_eq!(
normalized["_v1internal_response_id"],
json!("resp_antigravity_chunks_123")
);
assert_eq!(normalized["usageMetadata"]["totalTokenCount"], json!(4));
}
#[test]
fn unwraps_antigravity_stream_line_and_injects_ids() {
let report_context = json!({