fix(gemini): correct Gemini CLI OAuth cloudcode payload/response handling (#1040)

* fix(gemini): align OAuth cloudcode payload and response parsing

* docs(gemini): document OAuth vs API key endpoint behavior
This commit is contained in:
Chummy 2026-02-20 12:27:00 +08:00 committed by GitHub
parent db2d9acd22
commit 178bb108da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 176 additions and 59 deletions

View file

@ -56,6 +56,13 @@ credential is not reused for fallback providers.
| `lmstudio` | `lm-studio` | Yes | (optional; local by default) |
| `nvidia` | `nvidia-nim`, `build.nvidia.com` | No | `NVIDIA_API_KEY` |
### Gemini Notes
- Provider ID: `gemini` (aliases: `google`, `google-gemini`)
- Auth can come from `GEMINI_API_KEY`, `GOOGLE_API_KEY`, or Gemini CLI OAuth cache (`~/.gemini/oauth_creds.json`)
- API key requests use `generativelanguage.googleapis.com/v1beta`
- Gemini CLI OAuth requests use `cloudcode-pa.googleapis.com/v1internal` with Code Assist request envelope semantics
### Ollama Vision Notes
- Provider ID: `ollama`

View file

@ -58,10 +58,10 @@ impl GeminiAuth {
// API REQUEST/RESPONSE TYPES
// ══════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Clone)]
struct GenerateContentRequest {
contents: Vec<Content>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "systemInstruction", skip_serializing_if = "Option::is_none")]
system_instruction: Option<Content>,
#[serde(rename = "generationConfig")]
generation_config: GenerationConfig,
@ -70,23 +70,33 @@ struct GenerateContentRequest {
/// Request envelope for the internal cloudcode-pa API.
/// OAuth tokens from Gemini CLI are scoped for this endpoint.
#[derive(Debug, Serialize)]
struct InternalGenerateContentRequest {
struct InternalGenerateContentEnvelope {
model: String,
#[serde(rename = "generationConfig")]
generation_config: GenerationConfig,
contents: Vec<Content>,
#[serde(skip_serializing_if = "Option::is_none")]
system_instruction: Option<Content>,
project: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
user_prompt_id: Option<String>,
request: InternalGenerateContentRequest,
}
/// Nested request payload for cloudcode-pa's code assist APIs.
#[derive(Debug, Serialize)]
struct InternalGenerateContentRequest {
contents: Vec<Content>,
#[serde(rename = "systemInstruction", skip_serializing_if = "Option::is_none")]
system_instruction: Option<Content>,
#[serde(rename = "generationConfig")]
generation_config: GenerationConfig,
}
#[derive(Debug, Serialize, Clone)]
struct Content {
#[serde(skip_serializing_if = "Option::is_none")]
role: Option<String>,
parts: Vec<Part>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Clone)]
struct Part {
text: String,
}
@ -102,6 +112,8 @@ struct GenerationConfig {
struct GenerateContentResponse {
candidates: Option<Vec<Candidate>>,
error: Option<ApiError>,
#[serde(default)]
response: Option<Box<GenerateContentResponse>>,
}
#[derive(Debug, Deserialize)]
@ -124,6 +136,19 @@ struct ApiError {
message: String,
}
impl GenerateContentResponse {
/// cloudcode-pa wraps the actual response under `response`.
fn into_effective_response(self) -> Self {
match self {
Self {
response: Some(inner),
..
} => *inner,
other => other,
}
}
}
// ══════════════════════════════════════════════════════════════════════════════
// GEMINI CLI TOKEN STRUCTURES
// ══════════════════════════════════════════════════════════════════════════════
@ -243,6 +268,10 @@ impl GeminiProvider {
}
}
fn format_internal_model_name(model: &str) -> String {
model.strip_prefix("models/").unwrap_or(model).to_string()
}
/// Build the API URL based on auth type.
///
/// - API key users → public `generativelanguage.googleapis.com/v1beta`
@ -287,34 +316,16 @@ impl GeminiProvider {
let req = self.http_client().post(url).json(request);
match auth {
GeminiAuth::OAuthToken(token) => {
// Internal API expects the model in the request body envelope
let internal_request = InternalGenerateContentRequest {
model: Self::format_model_name(model),
generation_config: request.generation_config.clone(),
contents: request
.contents
.iter()
.map(|c| Content {
role: c.role.clone(),
parts: c
.parts
.iter()
.map(|p| Part {
text: p.text.clone(),
})
.collect(),
})
.collect(),
system_instruction: request.system_instruction.as_ref().map(|si| Content {
role: si.role.clone(),
parts: si
.parts
.iter()
.map(|p| Part {
text: p.text.clone(),
})
.collect(),
}),
// cloudcode-pa expects an outer envelope with `request`.
let internal_request = InternalGenerateContentEnvelope {
model: Self::format_internal_model_name(model),
project: None,
user_prompt_id: None,
request: InternalGenerateContentRequest {
contents: request.contents.clone(),
system_instruction: request.system_instruction.clone(),
generation_config: request.generation_config.clone(),
},
};
self.http_client()
.post(url)
@ -367,7 +378,10 @@ impl GeminiProvider {
}
let result: GenerateContentResponse = response.json().await?;
if let Some(err) = &result.error {
anyhow::bail!("Gemini API error: {}", err.message);
}
let result = result.into_effective_response();
if let Some(err) = result.error {
anyhow::bail!("Gemini API error: {}", err.message);
}
@ -460,6 +474,12 @@ impl Provider for GeminiProvider {
async fn warmup(&self) -> anyhow::Result<()> {
if let Some(auth) = self.auth.as_ref() {
// cloudcode-pa does not expose a lightweight model-list probe like the public API.
// Avoid false negatives for valid Gemini CLI OAuth credentials.
if auth.is_oauth() {
return Ok(());
}
let url = if auth.is_api_key() {
format!(
"https://generativelanguage.googleapis.com/v1beta/models?key={}",
@ -469,12 +489,11 @@ impl Provider for GeminiProvider {
"https://generativelanguage.googleapis.com/v1beta/models".to_string()
};
let mut request = self.http_client().get(&url);
if let GeminiAuth::OAuthToken(token) = auth {
request = request.bearer_auth(token);
}
request.send().await?.error_for_status()?;
self.http_client()
.get(&url)
.send()
.await?
.error_for_status()?;
}
Ok(())
}
@ -559,6 +578,14 @@ mod tests {
GeminiProvider::format_model_name("models/gemini-1.5-pro"),
"models/gemini-1.5-pro"
);
assert_eq!(
GeminiProvider::format_internal_model_name("models/gemini-2.5-flash"),
"gemini-2.5-flash"
);
assert_eq!(
GeminiProvider::format_internal_model_name("gemini-2.5-flash"),
"gemini-2.5-flash"
);
}
#[test]
@ -621,6 +648,44 @@ mod tests {
);
}
#[test]
fn oauth_request_wraps_payload_in_request_envelope() {
let provider = GeminiProvider {
auth: Some(GeminiAuth::OAuthToken("ya29.mock-token".into())),
};
let auth = GeminiAuth::OAuthToken("ya29.mock-token".into());
let url = GeminiProvider::build_generate_content_url("gemini-2.0-flash", &auth);
let body = GenerateContentRequest {
contents: vec![Content {
role: Some("user".into()),
parts: vec![Part {
text: "hello".into(),
}],
}],
system_instruction: None,
generation_config: GenerationConfig {
temperature: 0.7,
max_output_tokens: 8192,
},
};
let request = provider
.build_generate_content_request(&auth, &url, &body, "models/gemini-2.0-flash")
.build()
.unwrap();
let payload = request
.body()
.and_then(|b| b.as_bytes())
.expect("json request body should be bytes");
let json: serde_json::Value = serde_json::from_slice(payload).unwrap();
assert_eq!(json["model"], "gemini-2.0-flash");
assert!(json.get("generationConfig").is_none());
assert!(json.get("request").is_some());
assert!(json["request"].get("generationConfig").is_some());
}
#[test]
fn api_key_request_does_not_set_bearer_header() {
let provider = GeminiProvider {
@ -674,31 +739,38 @@ mod tests {
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"role\":\"user\""));
assert!(json.contains("\"text\":\"Hello\""));
assert!(json.contains("\"systemInstruction\""));
assert!(!json.contains("\"system_instruction\""));
assert!(json.contains("\"temperature\":0.7"));
assert!(json.contains("\"maxOutputTokens\":8192"));
}
#[test]
fn internal_request_includes_model() {
let request = InternalGenerateContentRequest {
model: "models/gemini-3-pro-preview".to_string(),
generation_config: GenerationConfig {
temperature: 0.7,
max_output_tokens: 8192,
},
contents: vec![Content {
role: Some("user".to_string()),
parts: vec![Part {
text: "Hello".to_string(),
let request = InternalGenerateContentEnvelope {
model: "gemini-test-model".to_string(),
project: None,
user_prompt_id: None,
request: InternalGenerateContentRequest {
contents: vec![Content {
role: Some("user".to_string()),
parts: vec![Part {
text: "Hello".to_string(),
}],
}],
}],
system_instruction: None,
system_instruction: None,
generation_config: GenerationConfig {
temperature: 0.7,
max_output_tokens: 8192,
},
},
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"model\":\"models/gemini-3-pro-preview\""));
assert!(json.contains("\"role\":\"user\""));
assert!(json.contains("\"temperature\":0.7"));
let json: serde_json::Value = serde_json::to_value(&request).unwrap();
assert_eq!(json["model"], "gemini-test-model");
assert!(json.get("generationConfig").is_none());
assert!(json["request"].get("generationConfig").is_some());
assert_eq!(json["request"]["contents"][0]["role"], "user");
}
#[test]
@ -741,10 +813,48 @@ mod tests {
assert_eq!(response.error.unwrap().message, "Invalid API key");
}
#[test]
fn internal_response_deserialization() {
let json = r#"{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "Hello from internal"}]
}
}]
}
}"#;
let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
let text = response
.into_effective_response()
.candidates
.unwrap()
.into_iter()
.next()
.unwrap()
.content
.parts
.into_iter()
.next()
.unwrap()
.text;
assert_eq!(text, Some("Hello from internal".to_string()));
}
#[tokio::test]
async fn warmup_without_key_is_noop() {
let provider = GeminiProvider { auth: None };
let result = provider.warmup().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn warmup_oauth_is_noop() {
let provider = GeminiProvider {
auth: Some(GeminiAuth::OAuthToken("ya29.mock-token".into())),
};
let result = provider.warmup().await;
assert!(result.is_ok());
}
}