diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index a988224..cca62d9 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -39,6 +39,11 @@ impl GeminiAuth { ) } + /// Whether this credential is an OAuth token from Gemini CLI. + fn is_oauth(&self) -> bool { + matches!(self, GeminiAuth::OAuthToken(_)) + } + /// The raw credential string. fn credential(&self) -> &str { match self { @@ -63,6 +68,18 @@ struct GenerateContentRequest { generation_config: GenerationConfig, } +/// Request envelope for the internal cloudcode-pa API. +/// OAuth tokens from Gemini CLI are scoped for this endpoint. +#[derive(Debug, Serialize)] +struct InternalGenerateContentRequest { + model: String, + #[serde(rename = "generationConfig")] + generation_config: GenerationConfig, + contents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + system_instruction: Option, +} + #[derive(Debug, Serialize)] struct Content { #[serde(skip_serializing_if = "Option::is_none")] @@ -75,7 +92,7 @@ struct Part { text: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Clone)] struct GenerationConfig { temperature: f64, #[serde(rename = "maxOutputTokens")] @@ -119,6 +136,13 @@ struct GeminiCliOAuthCreds { expiry: Option, } +/// Internal API endpoint used by Gemini CLI for OAuth users. +/// See: https://github.com/google-gemini/gemini-cli/issues/19200 +const CLOUDCODE_PA_ENDPOINT: &str = "https://cloudcode-pa.googleapis.com/v1internal"; + +/// Public API endpoint for API key users. +const PUBLIC_API_ENDPOINT: &str = "https://generativelanguage.googleapis.com/v1beta"; + impl GeminiProvider { /// Create a new Gemini provider. /// @@ -225,16 +249,35 @@ impl GeminiProvider { } } + /// Build the API URL based on auth type. + /// + /// - API key users → public `generativelanguage.googleapis.com/v1beta` + /// - OAuth users → internal `cloudcode-pa.googleapis.com/v1internal` + /// + /// The Gemini CLI OAuth tokens are scoped for the internal Code Assist API, + /// not the public API. Sending them to the public endpoint results in + /// "400 Bad Request: API key not valid" errors. + /// See: https://github.com/google-gemini/gemini-cli/issues/19200 fn build_generate_content_url(model: &str, auth: &GeminiAuth) -> String { - let model_name = Self::format_model_name(model); - let base_url = format!( - "https://generativelanguage.googleapis.com/v1beta/{model_name}:generateContent" - ); + match auth { + GeminiAuth::OAuthToken(_) => { + // OAuth tokens from Gemini CLI are scoped for the internal + // Code Assist API. The model is passed in the request body, + // not the URL path. + format!("{CLOUDCODE_PA_ENDPOINT}:generateContent") + } + _ => { + let model_name = Self::format_model_name(model); + let base_url = format!( + "{PUBLIC_API_ENDPOINT}/{model_name}:generateContent" + ); - if auth.is_api_key() { - format!("{base_url}?key={}", auth.credential()) - } else { - base_url + if auth.is_api_key() { + format!("{base_url}?key={}", auth.credential()) + } else { + base_url + } + } } } @@ -243,11 +286,26 @@ impl GeminiProvider { auth: &GeminiAuth, url: &str, request: &GenerateContentRequest, + model: &str, ) -> reqwest::RequestBuilder { - let req = self.client.post(url).json(request); match auth { - GeminiAuth::OAuthToken(token) => req.bearer_auth(token), - _ => req, + 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(), + }), + }; + self.client.post(url).json(&internal_request).bearer_auth(token) + } + _ => self.client.post(url).json(request), } } } @@ -296,7 +354,7 @@ impl Provider for GeminiProvider { let url = Self::build_generate_content_url(model, auth); let response = self - .build_generate_content_request(auth, &url, &request) + .build_generate_content_request(auth, &url, &request, model) .send() .await?; @@ -417,13 +475,23 @@ mod tests { } #[test] - fn oauth_url_omits_key_query_param() { + fn oauth_url_uses_internal_endpoint() { let auth = GeminiAuth::OAuthToken("ya29.test-token".into()); let url = GeminiProvider::build_generate_content_url("gemini-2.0-flash", &auth); + assert!(url.starts_with("https://cloudcode-pa.googleapis.com/v1internal")); assert!(url.ends_with(":generateContent")); + assert!(!url.contains("generativelanguage.googleapis.com")); assert!(!url.contains("?key=")); } + #[test] + fn api_key_url_uses_public_endpoint() { + let auth = GeminiAuth::ExplicitKey("api-key-123".into()); + let url = GeminiProvider::build_generate_content_url("gemini-2.0-flash", &auth); + assert!(url.contains("generativelanguage.googleapis.com/v1beta")); + assert!(url.contains("models/gemini-2.0-flash")); + } + #[test] fn oauth_request_uses_bearer_auth_header() { let provider = GeminiProvider { @@ -447,7 +515,7 @@ mod tests { }; let request = provider - .build_generate_content_request(&auth, &url, &body) + .build_generate_content_request(&auth, &url, &body, "gemini-2.0-flash") .build() .unwrap(); @@ -483,7 +551,7 @@ mod tests { }; let request = provider - .build_generate_content_request(&auth, &url, &body) + .build_generate_content_request(&auth, &url, &body, "gemini-2.0-flash") .build() .unwrap(); @@ -518,6 +586,29 @@ mod tests { 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(), + }], + }], + system_instruction: None, + }; + + 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")); + } + #[test] fn response_deserialization() { let json = r#"{