Skip to content

Commit c77d1aa

Browse files
committed
fix: resolve version gate and SSE parser misparse of CloudCodeResponse
Update upstream client version from CARGO_PKG_VERSION to dedicated UPSTREAM_VERSION constant (1.16.5) to pass Google's version gate check. Fix SSE parser silently misinterpreting CloudCodeResponse-wrapped JSON as bare GenerateContentResponse when deserialization fails (e.g. missing 'role' field). The fallback parse would produce all-None fields, causing a misleading 'no candidates' error. Now detects the wrapper key and extracts text via JSON pointer for a meaningful error message. Add promptFeedback/blockReason parsing for prompt-level blocking. Add raw_data to 'no candidates' diagnostic logging. Fix clippy warnings (collapsible_match, collapsible_if, filter_map).
1 parent 88a6200 commit c77d1aa

File tree

9 files changed

+194
-73
lines changed

9 files changed

+194
-73
lines changed

src/auth/mod.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,12 @@ impl HttpClient {
106106
) -> Result<Vec<u8>, String> {
107107
let os = std::env::consts::OS;
108108
let arch = std::env::consts::ARCH;
109-
let user_agent = format!("antigravity/{} {}/{}", env!("CARGO_PKG_VERSION"), os, arch);
109+
let user_agent = format!(
110+
"antigravity/{} {}/{}",
111+
crate::cloudcode::request::UPSTREAM_VERSION,
112+
os,
113+
arch
114+
);
110115

111116
let client_metadata = r#"{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}"#;
112117

@@ -151,7 +156,12 @@ impl HttpClient {
151156
) -> Result<Vec<u8>, String> {
152157
let os = std::env::consts::OS;
153158
let arch = std::env::consts::ARCH;
154-
let user_agent = format!("antigravity/{} {}/{}", env!("CARGO_PKG_VERSION"), os, arch);
159+
let user_agent = format!(
160+
"antigravity/{} {}/{}",
161+
crate::cloudcode::request::UPSTREAM_VERSION,
162+
os,
163+
arch
164+
);
155165

156166
let client_metadata = r#"{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}"#;
157167

src/cloudcode/request.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::borrow::Cow;
33
use std::sync::LazyLock;
44

55
use crate::format::google::CloudCodeRequest;
6-
use crate::format::{convert_request, MessagesRequest};
6+
use crate::format::{MessagesRequest, convert_request};
77
use crate::models::{get_model_family, is_thinking_model};
88

99
const SYSTEM_INSTRUCTION: &str = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**";
@@ -18,10 +18,15 @@ static SYSTEM_INSTRUCTION_IGNORE: LazyLock<String> = LazyLock::new(|| {
1818
)
1919
});
2020

21+
/// The upstream Antigravity client version to impersonate.
22+
/// This must be kept up to date with the latest Antigravity release
23+
/// to avoid Google's version gate ("This version of Antigravity is no longer supported").
24+
pub const UPSTREAM_VERSION: &str = "1.16.5";
25+
2126
static USER_AGENT: LazyLock<String> = LazyLock::new(|| {
2227
let os = std::env::consts::OS;
2328
let arch = std::env::consts::ARCH;
24-
format!("antigravity/{} {}/{}", env!("CARGO_PKG_VERSION"), os, arch)
29+
format!("antigravity/{} {}/{}", UPSTREAM_VERSION, os, arch)
2530
});
2631

2732
pub fn build_headers(

src/cloudcode/sse.rs

Lines changed: 104 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub struct SseParser {
1818
output_tokens: u32,
1919
cache_read_tokens: u32,
2020
stop_reason: Option<String>,
21+
last_raw_data: String,
2122
}
2223

2324
#[derive(Clone, Copy, PartialEq)]
@@ -41,6 +42,7 @@ impl SseParser {
4142
output_tokens: 0,
4243
cache_read_tokens: 0,
4344
stop_reason: None,
45+
last_raw_data: String::new(),
4446
}
4547
}
4648

@@ -93,18 +95,55 @@ impl SseParser {
9395
return Some(vec![create_message_stop()]);
9496
}
9597

98+
// Store raw data for diagnostic logging
99+
self.last_raw_data.clear();
100+
self.last_raw_data
101+
.push_str(&data.chars().take(500).collect::<String>());
102+
96103
// Parse JSON - try CloudCodeResponse wrapper first, then direct GenerateContentResponse
97104
let response: GenerateContentResponse =
98105
match serde_json::from_str::<CloudCodeResponse>(data) {
99106
Ok(wrapper) => wrapper.response,
100-
Err(_) => match serde_json::from_str(data) {
101-
Ok(r) => r,
102-
Err(_) => {
103-
// Try to detect error responses from Google API
104-
// Google may return {"error": {"code": 404, "message": "...", "status": "NOT_FOUND"}}
105-
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(data)
106-
&& let Some(error_obj) = raw.get("error")
107-
{
107+
Err(wrapper_err) => {
108+
// Before falling through, check if the JSON has a "response" key.
109+
// If it does, this IS a CloudCodeResponse wrapper but with unexpected
110+
// structure (e.g. missing "role" on content). Falling through to parse
111+
// as bare GenerateContentResponse would silently produce all-None fields
112+
// since serde ignores unknown keys.
113+
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(data) {
114+
if raw.get("response").is_some() {
115+
// Extract any text from the response for a useful error message
116+
let text = raw
117+
.pointer("/response/candidates/0/content/parts/0/text")
118+
.and_then(|v| v.as_str());
119+
120+
let message = if let Some(msg) = text {
121+
msg.to_string()
122+
} else {
123+
format!(
124+
"Failed to parse CloudCodeResponse ({}). Raw: {}",
125+
wrapper_err,
126+
data.chars().take(300).collect::<String>()
127+
)
128+
};
129+
130+
tracing::warn!(
131+
model = %self.model,
132+
message = %message,
133+
"Unparseable CloudCodeResponse wrapper"
134+
);
135+
136+
return Some(vec![StreamEvent::Error {
137+
error: ErrorData {
138+
error_type: "api_error".to_string(),
139+
message,
140+
},
141+
}]);
142+
}
143+
144+
// Check for top-level error responses from Google API
145+
// e.g. {"error": {"code": 404, "message": "...", "status": "NOT_FOUND"}}
146+
if let Some(error_obj) = raw.get("error") {
108147
let code = error_obj.get("code").and_then(|c| c.as_i64()).unwrap_or(0);
109148
let message = error_obj
110149
.get("message")
@@ -130,13 +169,20 @@ impl SseParser {
130169
},
131170
}]);
132171
}
133-
tracing::debug!(
134-
data = %data.chars().take(200).collect::<String>(),
135-
"Failed to parse SSE data"
136-
);
137-
return None;
138172
}
139-
},
173+
174+
// Try direct GenerateContentResponse parse (for non-wrapper responses)
175+
match serde_json::from_str(data) {
176+
Ok(r) => r,
177+
Err(_) => {
178+
tracing::debug!(
179+
data = %data.chars().take(200).collect::<String>(),
180+
"Failed to parse SSE data"
181+
);
182+
return None;
183+
}
184+
}
185+
}
140186
};
141187

142188
// Check for error in the parsed response
@@ -199,10 +245,28 @@ impl SseParser {
199245
}
200246
}
201247

248+
// Check for prompt-level blocking (promptFeedback with blockReason)
249+
if let Some(feedback) = &response.prompt_feedback
250+
&& let Some(reason) = &feedback.block_reason
251+
{
252+
tracing::warn!(
253+
model = %self.model,
254+
block_reason = %reason,
255+
"Prompt blocked by Google API"
256+
);
257+
return vec![StreamEvent::Error {
258+
error: ErrorData {
259+
error_type: "invalid_request_error".to_string(),
260+
message: format!("Prompt blocked by Google API (reason: {})", reason),
261+
},
262+
}];
263+
}
264+
202265
// Check for no candidates at all (model unavailable or empty response)
203266
if first_candidate.is_none() && !self.has_emitted_start {
204267
tracing::warn!(
205268
model = %self.model,
269+
raw_data = %self.last_raw_data,
206270
"Google API returned response with no candidates"
207271
);
208272
return vec![StreamEvent::Error {
@@ -636,4 +700,30 @@ mod tests {
636700
_ => panic!("Expected Error event, got {:?}", events[0]),
637701
}
638702
}
703+
704+
#[test]
705+
fn test_sse_parser_version_gate_response() {
706+
// Reproduce the exact response Google returns when client version is outdated.
707+
// The response has candidates with content but no "role" field on the content object,
708+
// causing CloudCodeResponse parsing to fail. We should extract the text and return
709+
// it as an error instead of silently misreporting "no candidates."
710+
let mut parser = SseParser::new("claude-opus-4-6-thinking");
711+
712+
let data = "data: {\"response\": {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"This version of Antigravity is no longer supported. Please update to receive the latest features!\"}]}}]}}\n\n";
713+
714+
let events = parser.feed(data);
715+
716+
assert_eq!(events.len(), 1);
717+
match &events[0] {
718+
StreamEvent::Error { error } => {
719+
assert_eq!(error.error_type, "api_error");
720+
assert!(
721+
error.message.contains("no longer supported"),
722+
"Error message should contain the version gate text, got: {}",
723+
error.message
724+
);
725+
}
726+
_ => panic!("Expected Error event, got {:?}", events[0]),
727+
}
728+
}
639729
}

src/format/google.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ pub struct GenerateContentResponse {
159159
pub usage_metadata: Option<UsageMetadata>,
160160
#[serde(skip_serializing_if = "Option::is_none")]
161161
pub error: Option<GoogleError>,
162+
#[serde(skip_serializing_if = "Option::is_none")]
163+
pub prompt_feedback: Option<PromptFeedback>,
164+
}
165+
166+
#[derive(Debug, Clone, Serialize, Deserialize)]
167+
#[serde(rename_all = "camelCase")]
168+
pub struct PromptFeedback {
169+
#[serde(skip_serializing_if = "Option::is_none")]
170+
pub block_reason: Option<String>,
171+
#[serde(skip_serializing_if = "Option::is_none")]
172+
pub safety_ratings: Option<Vec<serde_json::Value>>,
162173
}
163174

164175
#[derive(Debug, Clone, Serialize, Deserialize)]

src/format/openai_convert.rs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,12 @@ pub fn openai_to_anthropic(request: &ChatCompletionRequest) -> MessagesRequest {
146146
serde_json::Value::Object(obj) => {
147147
// {"type": "function", "function": {"name": "..."}}
148148
if let Some(func) = obj.get("function")
149-
&& let Some(name) = func.get("name").and_then(|n| n.as_str()) {
150-
return Some(crate::format::anthropic::ToolChoice::Tool {
151-
name: name.to_string(),
152-
});
153-
}
149+
&& let Some(name) = func.get("name").and_then(|n| n.as_str())
150+
{
151+
return Some(crate::format::anthropic::ToolChoice::Tool {
152+
name: name.to_string(),
153+
});
154+
}
154155
None
155156
}
156157
_ => None,
@@ -163,9 +164,10 @@ pub fn openai_to_anthropic(request: &ChatCompletionRequest) -> MessagesRequest {
163164
let json_instruction =
164165
"You must respond with valid JSON. Output only JSON, no other text.";
165166
match system {
166-
Some(SystemPrompt::Text(existing)) => {
167-
Some(SystemPrompt::Text(format!("{}\n\n{}", existing, json_instruction)))
168-
}
167+
Some(SystemPrompt::Text(existing)) => Some(SystemPrompt::Text(format!(
168+
"{}\n\n{}",
169+
existing, json_instruction
170+
))),
169171
None => Some(SystemPrompt::Text(json_instruction.to_string())),
170172
other => other,
171173
}
@@ -290,12 +292,10 @@ fn convert_chat_content(content: &ChatContent) -> MessageContent {
290292
let blocks: Vec<ContentBlock> = parts
291293
.iter()
292294
.map(|p| match p {
293-
crate::format::openai::ChatContentPart::Text { text } => {
294-
ContentBlock::Text {
295-
text: text.clone(),
296-
cache_control: None,
297-
}
298-
}
295+
crate::format::openai::ChatContentPart::Text { text } => ContentBlock::Text {
296+
text: text.clone(),
297+
cache_control: None,
298+
},
299299
crate::format::openai::ChatContentPart::ImageUrl { image_url } => {
300300
// Try to parse data URL
301301
if let Some(data) = parse_data_url(&image_url.url) {

src/format/responses_convert.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,11 @@ pub fn responses_to_anthropic(request: &ResponsesRequest) -> MessagesRequest {
7979
// Try to append to last assistant message
8080
if let Some(last) = messages.last_mut()
8181
&& matches!(last.role, Role::Assistant)
82-
&& let MessageContent::Blocks(blocks) = &mut last.content {
83-
blocks.push(block);
84-
continue;
85-
}
82+
&& let MessageContent::Blocks(blocks) = &mut last.content
83+
{
84+
blocks.push(block);
85+
continue;
86+
}
8687

8788
messages.push(Message {
8889
role: Role::Assistant,
@@ -102,10 +103,11 @@ pub fn responses_to_anthropic(request: &ResponsesRequest) -> MessagesRequest {
102103
// Try to append to last user message
103104
if let Some(last) = messages.last_mut()
104105
&& matches!(last.role, Role::User)
105-
&& let MessageContent::Blocks(blocks) = &mut last.content {
106-
blocks.push(block);
107-
continue;
108-
}
106+
&& let MessageContent::Blocks(blocks) = &mut last.content
107+
{
108+
blocks.push(block);
109+
continue;
110+
}
109111

110112
messages.push(Message {
111113
role: Role::User,

src/format/to_anthropic.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ mod tests {
172172
cached_content_token_count: 0,
173173
}),
174174
error: None,
175+
prompt_feedback: None,
175176
}
176177
}
177178

@@ -226,6 +227,7 @@ mod tests {
226227
cached_content_token_count: 800,
227228
}),
228229
error: None,
230+
prompt_feedback: None,
229231
};
230232

231233
let result = convert_response(&response, "test", "req_cache");
@@ -242,6 +244,7 @@ mod tests {
242244
candidates: None,
243245
usage_metadata: None,
244246
error: None,
247+
prompt_feedback: None,
245248
};
246249

247250
let result = convert_response(&response, "test", "req_empty");

src/format/to_google.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ use crate::format::google::{
77
InlineData, InlineDataPart, Part, TextPart, ThinkingConfig, ThoughtPart, ToolConfig,
88
};
99
use crate::format::signature_cache::{
10-
get_cached_tool_signature, is_signature_compatible, ModelFamily, GEMINI_SKIP_SIGNATURE,
11-
MIN_SIGNATURE_LENGTH,
10+
GEMINI_SKIP_SIGNATURE, MIN_SIGNATURE_LENGTH, ModelFamily, get_cached_tool_signature,
11+
is_signature_compatible,
1212
};
1313
use crate::models::{get_model_family, is_thinking_model};
1414

0 commit comments

Comments
 (0)