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:
parent
36062fb1c2
commit
1d8e57d388
1 changed files with 107 additions and 16 deletions
|
|
@ -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,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 {
|
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 model_name = Self::format_model_name(model);
|
||||||
let base_url = format!(
|
let base_url = format!(
|
||||||
"https://generativelanguage.googleapis.com/v1beta/{model_name}:generateContent"
|
"{PUBLIC_API_ENDPOINT}/{model_name}:generateContent"
|
||||||
);
|
);
|
||||||
|
|
||||||
if auth.is_api_key() {
|
if auth.is_api_key() {
|
||||||
|
|
@ -237,17 +278,34 @@ impl GeminiProvider {
|
||||||
base_url
|
base_url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_generate_content_request(
|
fn build_generate_content_request(
|
||||||
&self,
|
&self,
|
||||||
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#"{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue