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.
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<Content>,
#[serde(skip_serializing_if = "Option::is_none")]
system_instruction: Option<Content>,
}
#[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<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 {
/// Create a new Gemini provider.
///
@ -225,10 +249,27 @@ 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 {
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!(
"https://generativelanguage.googleapis.com/v1beta/{model_name}:generateContent"
"{PUBLIC_API_ENDPOINT}/{model_name}:generateContent"
);
if auth.is_api_key() {
@ -237,17 +278,34 @@ impl GeminiProvider {
base_url
}
}
}
}
fn build_generate_content_request(
&self,
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#"{