From 178bb108dab9cfd5adba7bbb7959a4ec05074886 Mon Sep 17 00:00:00 2001 From: Chummy Date: Fri, 20 Feb 2026 12:27:00 +0800 Subject: [PATCH] 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 --- docs/providers-reference.md | 7 ++ src/providers/gemini.rs | 228 ++++++++++++++++++++++++++---------- 2 files changed, 176 insertions(+), 59 deletions(-) diff --git a/docs/providers-reference.md b/docs/providers-reference.md index bc91382..f9c7726 100644 --- a/docs/providers-reference.md +++ b/docs/providers-reference.md @@ -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` diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index c415f13..b3b7110 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -58,10 +58,10 @@ impl GeminiAuth { // API REQUEST/RESPONSE TYPES // ══════════════════════════════════════════════════════════════════════════════ -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Clone)] struct GenerateContentRequest { contents: Vec, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "systemInstruction", skip_serializing_if = "Option::is_none")] system_instruction: Option, #[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, #[serde(skip_serializing_if = "Option::is_none")] - system_instruction: Option, + project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_prompt_id: Option, + request: InternalGenerateContentRequest, } +/// Nested request payload for cloudcode-pa's code assist APIs. #[derive(Debug, Serialize)] +struct InternalGenerateContentRequest { + contents: Vec, + #[serde(rename = "systemInstruction", skip_serializing_if = "Option::is_none")] + system_instruction: Option, + #[serde(rename = "generationConfig")] + generation_config: GenerationConfig, +} + +#[derive(Debug, Serialize, Clone)] struct Content { #[serde(skip_serializing_if = "Option::is_none")] role: Option, parts: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Clone)] struct Part { text: String, } @@ -102,6 +112,8 @@ struct GenerationConfig { struct GenerateContentResponse { candidates: Option>, error: Option, + #[serde(default)] + response: Option>, } #[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()); + } }