fix(gemini): route OAuth tokens to cloudcode-pa.googleapis.com

Gemini CLI OAuth tokens are scoped for Google's internal Code Assist
API at cloudcode-pa.googleapis.com/v1internal, not the public
generativelanguage.googleapis.com/v1beta endpoint.

This commit:
- Routes OAuth requests to the correct internal endpoint
- Wraps the request payload with model metadata (internal API format)
- Keeps API key auth unchanged on the public endpoint

Fixes #578
This commit is contained in:
KNIGHTABDO 2026-02-17 18:07:43 +00:00 committed by Chummy
parent 36062fb1c2
commit 1d8e57d388

View file

@ -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. /// The raw credential string.
fn credential(&self) -> &str { fn credential(&self) -> &str {
match self { match self {
@ -63,6 +68,18 @@ struct GenerateContentRequest {
generation_config: GenerationConfig, 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<Content>,
#[serde(skip_serializing_if = "Option::is_none")]
system_instruction: Option<Content>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct Content { struct Content {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -75,7 +92,7 @@ struct Part {
text: String, text: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, Clone)]
struct GenerationConfig { struct GenerationConfig {
temperature: f64, temperature: f64,
#[serde(rename = "maxOutputTokens")] #[serde(rename = "maxOutputTokens")]
@ -119,6 +136,13 @@ struct GeminiCliOAuthCreds {
expiry: Option<String>, expiry: Option<String>,
} }
/// 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 { impl GeminiProvider {
/// Create a new Gemini provider. /// 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 { fn build_generate_content_url(model: &str, auth: &GeminiAuth) -> String {
let model_name = Self::format_model_name(model); match auth {
let base_url = format!( GeminiAuth::OAuthToken(_) => {
"https://generativelanguage.googleapis.com/v1beta/{model_name}:generateContent" // 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() { if auth.is_api_key() {
format!("{base_url}?key={}", auth.credential()) format!("{base_url}?key={}", auth.credential())
} else { } else {
base_url base_url
}
}
} }
} }
@ -243,11 +286,26 @@ impl GeminiProvider {
auth: &GeminiAuth, auth: &GeminiAuth,
url: &str, url: &str,
request: &GenerateContentRequest, request: &GenerateContentRequest,
model: &str,
) -> reqwest::RequestBuilder { ) -> reqwest::RequestBuilder {
let req = self.client.post(url).json(request);
match auth { match auth {
GeminiAuth::OAuthToken(token) => req.bearer_auth(token), GeminiAuth::OAuthToken(token) => {
_ => req, // 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 url = Self::build_generate_content_url(model, auth);
let response = self let response = self
.build_generate_content_request(auth, &url, &request) .build_generate_content_request(auth, &url, &request, model)
.send() .send()
.await?; .await?;
@ -417,13 +475,23 @@ mod tests {
} }
#[test] #[test]
fn oauth_url_omits_key_query_param() { fn oauth_url_uses_internal_endpoint() {
let auth = GeminiAuth::OAuthToken("ya29.test-token".into()); let auth = GeminiAuth::OAuthToken("ya29.test-token".into());
let url = GeminiProvider::build_generate_content_url("gemini-2.0-flash", &auth); 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.ends_with(":generateContent"));
assert!(!url.contains("generativelanguage.googleapis.com"));
assert!(!url.contains("?key=")); 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] #[test]
fn oauth_request_uses_bearer_auth_header() { fn oauth_request_uses_bearer_auth_header() {
let provider = GeminiProvider { let provider = GeminiProvider {
@ -447,7 +515,7 @@ mod tests {
}; };
let request = provider let request = provider
.build_generate_content_request(&auth, &url, &body) .build_generate_content_request(&auth, &url, &body, "gemini-2.0-flash")
.build() .build()
.unwrap(); .unwrap();
@ -483,7 +551,7 @@ mod tests {
}; };
let request = provider let request = provider
.build_generate_content_request(&auth, &url, &body) .build_generate_content_request(&auth, &url, &body, "gemini-2.0-flash")
.build() .build()
.unwrap(); .unwrap();
@ -518,6 +586,29 @@ mod tests {
assert!(json.contains("\"maxOutputTokens\":8192")); 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] #[test]
fn response_deserialization() { fn response_deserialization() {
let json = r#"{ let json = r#"{