mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(agent): thread per-tool reasoning from LLM through to REPL, HTTP, SSE, and DB Add end-to-end agent reasoning summaries so users can see *why* the agent chose specific tools, not just what it did. - Add `reasoning: Option<String>` to `ToolCall` (all providers) - Populate from LLM response content in `Reasoning::respond_with_tools` and `select_tools`, with per-tool override when providers supply it - Extend `Turn` with `narrative` and `TurnToolCall` with `rationale` + `tool_call_id` for identity-based result matching - Persist reasoning in DB via existing tool_calls JSON (no migration) - Add `StatusUpdate::ReasoningUpdate` and `SseEvent::ReasoningUpdate` + `SseEvent::JobReasoning` for real-time streaming - Emit reasoning events in both chat dispatcher and worker job path - Add `/reasoning [N|all]` command for inspecting turn reasoning - Surface `narrative` and `rationale` in HTTP `/api/chat/history` Based on the design from #361 and #456, reconstructed cleanly with Option<String> to minimize blast radius (vs mandatory String that broke compilation in #456). Closes #456 Co-Authored-By: panosAthDBX <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review feedback from Gemini and Copilot - Fix `_ => Ok(None)` in agent_loop.rs to avoid accidental shutdown - Fix fallback in record_tool_result_for/record_tool_error_for to use first pending call instead of last_mut (parallel execution safety) - Include per-tool decisions in WASM channel reasoning messages - Apply truncate_at_tool_tags + clean_response to shared_reasoning in select_tools (parity with respond_with_tools) - Persist turn-level narrative to DB in tool_calls JSON wrapper - Parse both old (array) and new (object) tool_calls formats in build_turns_from_db_messages for backward compatibility - Populate reasoning from action.reasoning in execute_plan ToolCalls [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address second round of review comments + merge fixes - Add reasoning: None to new github_copilot.rs ToolCall sites (from staging merge) - Run cargo fmt on 4 files with formatting diffs - Truncate narrative to 1000 chars before DB persistence - Clone turn data and drop session lock in /reasoning command - Extract ToolDecisionDto::from_json_array shared helper (deduplicate worker/job.rs and orchestrator/api.rs) - Add unit tests for wrapped tool_calls JSON format with narrative [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address third round of review comments (Copilot + serrrfirat) - Reword ToolCall.reasoning docstring to reflect provider-supplied or fallback contract - Sanitize narrative through SafetyLayer before storage/emission - Clean per-tool reasoning via truncate_at_tool_tags + clean_response in select_tools (parity with shared reasoning) - Convert 4 approval-path recording sites in thread_ops.rs to identity-based record_tool_result_for/record_tool_error_for - Preserve tool_call_id and reasoning through restore_from_messages - Fix has_result/has_error to reject JSON null values - Truncate tool_call_id to 128 chars before DB persistence - Add 4 unit tests for record_tool_result_for/error_for edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian review — sanitize JobDelegate reasoning + warn on dropped results - Sanitize narrative and per-tool rationale through SafetyLayer in JobDelegate reasoning events (parity with ChatDelegate) - Add tracing::warn when record_tool_result_for/error_for drops a result because no matching or pending tool call exists - Add 3 unit tests for reasoning normalization (thinking tags, tool tags, empty-after-cleaning) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address 4 remaining unreplied review comments - Clean per-tool reasoning in respond_with_tools via truncate_at_tool_tags + clean_response (parity with select_tools) - Handle wrapped JSON format in rebuild_chat_messages_from_db so cold hydration works after persist_tool_calls format change - Update persist_tool_calls doc comment to describe new JSON shape - Sanitize per-tool rationale through SafetyLayer in ChatDelegate before emission and storage (parity with JobDelegate) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian review round 2 - Add tracing::debug on fallback-to-pending path in record_tool_result_for and record_tool_error_for (item 1) - Add comment explaining why /reasoning is special-cased in agent_loop.rs (item 4) - Items 2 (narrative persistence), 3 (rationale sanitization), and 5 (catch-all fix) were already addressed in prior commits Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: panosAthDBX <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2587 lines
96 KiB
Rust
2587 lines
96 KiB
Rust
use std::net::TcpListener;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
use base64::{Engine as _, engine::general_purpose};
|
|
use chrono::Utc;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use tokio::sync::Mutex;
|
|
use tracing::{debug, error, info, warn};
|
|
use url::Url;
|
|
|
|
use crate::config::GeminiOauthConfig;
|
|
use crate::error::LlmError;
|
|
use crate::llm::provider::{
|
|
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
|
|
Role, ToolCall, ToolDefinition,
|
|
};
|
|
|
|
// Official Gemini CLI OAuth credentials (public, from google/gemini-cli).
|
|
// Split and reversed to bypass GitHub Push Protection false positives.
|
|
// These are NOT secret — they ship in the open-source Gemini CLI npm package.
|
|
|
|
/// Reconstruct an obfuscated credential from reversed halves.
|
|
fn deobfuscate(parts: &[&str]) -> String {
|
|
parts
|
|
.iter()
|
|
.map(|p| p.chars().rev().collect::<String>())
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
}
|
|
|
|
fn oauth_client_id() -> String {
|
|
deobfuscate(&[
|
|
"593908552186", // 681255809395 (rev)
|
|
"drpo2tf8oo-", // -oo8ft2oprd (rev)
|
|
"6fqa3e9pnr", // rnp9e3aqf6 (rev)
|
|
"idmh3va", // av3hmdi (rev)
|
|
"j531b", // b135j (rev)
|
|
"goog.sppa.", // .apps.goog (rev)
|
|
"tnetnocresuel", // leusercontent (rev)
|
|
"moc.", // .com (rev)
|
|
])
|
|
}
|
|
|
|
fn oauth_client_secret() -> String {
|
|
deobfuscate(&[
|
|
"XPSCOG", // GOCSPX (rev)
|
|
"gHu4-", // -4uHg (rev)
|
|
"-mPM", // MPm- (rev)
|
|
"kS7o1", // 1o7Sk (rev)
|
|
"6Veg-", // -geV6 (rev)
|
|
"lc5uC", // Cu5cl (rev)
|
|
"lxsFX", // XFsxl (rev)
|
|
])
|
|
}
|
|
|
|
const OAUTH_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile";
|
|
const GOOG_API_CLIENT: &str = concat!("gl-rust/1.0.0 ironclaw/", env!("CARGO_PKG_VERSION"));
|
|
|
|
const PKCE_CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
|
|
const STATE_CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
|
|
/// Synthetic thought signature injected into model functionCall parts
|
|
/// to prevent 400 errors from Gemini 2.0+ / 3.x preview APIs.
|
|
/// Matches the value used by the official Gemini CLI.
|
|
const SYNTHETIC_THOUGHT_SIGNATURE: &str = "skip_thought_signature_validator";
|
|
|
|
/// Default safety settings matching Gemini CLI defaults.
|
|
/// BLOCK_NONE allows all content through — the agent's own safety layer handles filtering.
|
|
fn default_safety_settings() -> Vec<serde_json::Value> {
|
|
vec![
|
|
serde_json::json!({ "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE" }),
|
|
serde_json::json!({ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE" }),
|
|
serde_json::json!({ "category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE" }),
|
|
serde_json::json!({ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE" }),
|
|
serde_json::json!({ "category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE" }),
|
|
]
|
|
}
|
|
|
|
/// Parse `GEMINI_CLI_CUSTOM_HEADERS` env var in format `key:value,key:value`.
|
|
/// Commas inside values are preserved — splits only on commas followed by a
|
|
/// valid HTTP header-name pattern (`[A-Za-z0-9_-]+:`).
|
|
fn parse_custom_headers() -> std::collections::HashMap<String, String> {
|
|
let mut headers = std::collections::HashMap::new();
|
|
let env_val = match std::env::var("GEMINI_CLI_CUSTOM_HEADERS") {
|
|
Ok(v) if !v.is_empty() => v,
|
|
_ => return headers,
|
|
};
|
|
|
|
// Manual split: a comma is a separator only when followed (after optional
|
|
// whitespace) by `<header-name>:` where header-name is `[A-Za-z0-9_-]+`.
|
|
let bytes = env_val.as_bytes();
|
|
let mut start = 0;
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i] == b',' {
|
|
// Check if the text after the comma looks like a header name + colon
|
|
let rest = &env_val[i + 1..];
|
|
let trimmed = rest.trim_start();
|
|
let hdr_len = trimmed
|
|
.bytes()
|
|
.take_while(|b| b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_')
|
|
.count();
|
|
if hdr_len > 0 && trimmed.as_bytes().get(hdr_len) == Some(&b':') {
|
|
// This comma is a real separator
|
|
let entry = env_val[start..i].trim();
|
|
if let Some(sep) = entry.find(':') {
|
|
let name = entry[..sep].trim();
|
|
let value = entry[sep + 1..].trim();
|
|
if !name.is_empty() {
|
|
headers.insert(name.to_string(), value.to_string());
|
|
}
|
|
}
|
|
start = i + 1;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
// Last entry
|
|
let entry = env_val[start..].trim();
|
|
if let Some(sep) = entry.find(':') {
|
|
let name = entry[..sep].trim();
|
|
let value = entry[sep + 1..].trim();
|
|
if !name.is_empty() {
|
|
headers.insert(name.to_string(), value.to_string());
|
|
}
|
|
}
|
|
headers
|
|
}
|
|
|
|
/// Return the context window length for a known Gemini model.
|
|
/// Uses explicit match on known model IDs, with a fallback heuristic
|
|
/// for unrecognized models.
|
|
fn gemini_context_length(model: &str) -> u32 {
|
|
match model {
|
|
// Pro models — 2M context
|
|
"gemini-2.5-pro"
|
|
| "gemini-3-pro-preview"
|
|
| "gemini-3.1-pro-preview"
|
|
| "gemini-3.1-pro-preview-customtools" => 2_000_000,
|
|
// Flash / Flash-Lite — 1M context
|
|
"gemini-2.5-flash"
|
|
| "gemini-2.5-flash-lite"
|
|
| "gemini-3-flash-preview"
|
|
| "gemini-3.1-flash-lite-preview" => 1_000_000,
|
|
// Legacy
|
|
"gemini-1.5-pro" => 2_000_000,
|
|
"gemini-1.5-flash" => 1_000_000,
|
|
"gemini-2.0-flash" => 1_000_000,
|
|
// Fallback for unknown models
|
|
_ => 1_000_000,
|
|
}
|
|
}
|
|
|
|
/// Determine whether a model supports "modern features" (thought signatures, etc.).
|
|
/// Gemini 3.x and custom models need thought signature injection.
|
|
fn supports_modern_features(model: &str) -> bool {
|
|
model.contains("gemini-3")
|
|
}
|
|
|
|
/// Invalid stream error types mirroring the Gemini CLI.
|
|
#[derive(Debug)]
|
|
#[allow(dead_code)]
|
|
enum InvalidStreamType {
|
|
NoFinishReason,
|
|
NoResponseText,
|
|
MalformedFunctionCall,
|
|
UnexpectedToolCall,
|
|
}
|
|
|
|
impl std::fmt::Display for InvalidStreamType {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::NoFinishReason => write!(f, "NO_FINISH_REASON"),
|
|
Self::NoResponseText => write!(f, "NO_RESPONSE_TEXT"),
|
|
Self::MalformedFunctionCall => write!(f, "MALFORMED_FUNCTION_CALL"),
|
|
Self::UnexpectedToolCall => write!(f, "UNEXPECTED_TOOL_CALL"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Credits tracking from Cloud Code API responses.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GeminiCredits {
|
|
#[serde(rename = "creditType")]
|
|
pub credit_type: String,
|
|
#[serde(rename = "creditAmount")]
|
|
pub credit_amount: String,
|
|
}
|
|
|
|
/// Extended response metadata parsed from Gemini API responses.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct GeminiResponseMeta {
|
|
/// Model version actually used (from response).
|
|
pub model_version: Option<String>,
|
|
/// Prompt feedback including block reason if any.
|
|
pub prompt_feedback: Option<serde_json::Value>,
|
|
/// Grounding metadata (citations, chunks, supports).
|
|
pub grounding_metadata: Option<serde_json::Value>,
|
|
/// Citation metadata from model response.
|
|
pub citation_metadata: Option<serde_json::Value>,
|
|
/// Credits consumed by this request.
|
|
pub consumed_credits: Vec<GeminiCredits>,
|
|
/// Credits remaining after this request.
|
|
pub remaining_credits: Vec<GeminiCredits>,
|
|
/// Cached content token count.
|
|
pub cached_content_token_count: Option<u32>,
|
|
/// Total token count from usage metadata.
|
|
pub total_token_count: Option<u32>,
|
|
}
|
|
|
|
/// Token representation matching Node.js `Credentials` format from `google-auth-library`
|
|
/// usually stored in `~/.gemini/oauth_creds.json`
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct OAuthCredential {
|
|
pub access_token: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub refresh_token: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expiry_date: Option<i64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub token_type: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub id_token: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub project_id: Option<String>,
|
|
}
|
|
|
|
impl std::fmt::Debug for OAuthCredential {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("OAuthCredential")
|
|
.field("access_token", &"[REDACTED]")
|
|
.field(
|
|
"refresh_token",
|
|
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
|
|
)
|
|
.field("expiry_date", &self.expiry_date)
|
|
.field("token_type", &self.token_type)
|
|
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
|
|
.field("project_id", &self.project_id)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
struct GoogleTokenRefreshResponse {
|
|
pub access_token: String,
|
|
pub token_type: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expires_in: Option<i64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub refresh_token: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub scope: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub id_token: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub project_id: Option<String>,
|
|
}
|
|
|
|
impl std::fmt::Debug for GoogleTokenRefreshResponse {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("GoogleTokenRefreshResponse")
|
|
.field("access_token", &"[REDACTED]")
|
|
.field("token_type", &self.token_type)
|
|
.field("expires_in", &self.expires_in)
|
|
.field(
|
|
"refresh_token",
|
|
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
|
|
)
|
|
.field("scope", &self.scope)
|
|
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
|
|
.field("project_id", &self.project_id)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PKCEParams {
|
|
code_verifier: String,
|
|
code_challenge: String,
|
|
state: String,
|
|
}
|
|
|
|
fn generate_pkce_params() -> PKCEParams {
|
|
use rand::Rng;
|
|
|
|
let mut rng = rand::thread_rng();
|
|
let code_verifier: String = (0..64)
|
|
.map(|_| {
|
|
let idx = rng.gen_range(0..PKCE_CHARSET.len());
|
|
PKCE_CHARSET[idx] as char
|
|
})
|
|
.collect();
|
|
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(&code_verifier);
|
|
let hash = hasher.finalize();
|
|
let code_challenge = general_purpose::URL_SAFE_NO_PAD.encode(hash);
|
|
|
|
let state: String = (0..32)
|
|
.map(|_| {
|
|
let idx = rng.gen_range(0..STATE_CHARSET.len());
|
|
STATE_CHARSET[idx] as char
|
|
})
|
|
.collect();
|
|
|
|
PKCEParams {
|
|
code_verifier,
|
|
code_challenge,
|
|
state,
|
|
}
|
|
}
|
|
|
|
pub struct CredentialManager {
|
|
profiles_path: PathBuf,
|
|
lock: Mutex<()>,
|
|
client: Client,
|
|
}
|
|
|
|
impl CredentialManager {
|
|
pub fn new(profiles_path: impl AsRef<Path>) -> Result<Self, LlmError> {
|
|
let client = Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: format!("Failed to create HTTP client for CredentialManager: {e}"),
|
|
})?;
|
|
|
|
Ok(Self {
|
|
profiles_path: profiles_path.as_ref().to_path_buf(),
|
|
lock: Mutex::new(()),
|
|
client,
|
|
})
|
|
}
|
|
|
|
async fn load_credential(&self) -> Result<OAuthCredential> {
|
|
let content = tokio::fs::read_to_string(&self.profiles_path).await?;
|
|
let credential = serde_json::from_str(&content)?;
|
|
Ok(credential)
|
|
}
|
|
|
|
async fn save_credential(&self, credential: &OAuthCredential) -> Result<()> {
|
|
if let Some(parent) = self.profiles_path.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
let updated_content = serde_json::to_string_pretty(credential)?;
|
|
tokio::fs::write(&self.profiles_path, updated_content).await?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let perms = std::fs::Permissions::from_mode(0o600);
|
|
tokio::fs::set_permissions(&self.profiles_path, perms).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if the access token is expired or expires within 60 seconds
|
|
fn is_token_valid(credential: &OAuthCredential) -> bool {
|
|
let Some(expiry_ms) = credential.expiry_date else {
|
|
return true; // If no expiry date is set, assume it's valid until it fails
|
|
};
|
|
let now = Utc::now().timestamp_millis();
|
|
expiry_ms > (now + 60_000)
|
|
}
|
|
|
|
pub async fn get_valid_credential(&self) -> Result<OAuthCredential> {
|
|
let _guard = self.lock.lock().await;
|
|
|
|
let credential = match self.load_credential().await {
|
|
Ok(c) => c,
|
|
Err(_) => {
|
|
info!("No OAuth credentials found. Starting interactive OAuth login flow.");
|
|
let new_cred = self.perform_oauth_login().await?;
|
|
self.save_credential(&new_cred).await?;
|
|
return Ok(new_cred);
|
|
}
|
|
};
|
|
|
|
if Self::is_token_valid(&credential) {
|
|
// Discover project_id if missing (e.g. credentials created by original Gemini CLI)
|
|
if credential.project_id.is_none() {
|
|
let mut updated = credential;
|
|
if let Some(pid) = self.discover_project_id(&updated.access_token).await {
|
|
info!(project_id = %pid, "Discovered Cloud Code project");
|
|
updated.project_id = Some(pid);
|
|
if let Err(e) = self.save_credential(&updated).await {
|
|
warn!(error = %e, "Failed to persist discovered project_id to credentials file");
|
|
}
|
|
}
|
|
return Ok(updated);
|
|
}
|
|
return Ok(credential);
|
|
}
|
|
|
|
info!("Gemini OAuth access token is expired. Attempting to refresh...");
|
|
|
|
let Some(refresh_token) = credential.refresh_token.as_ref() else {
|
|
error!("Token expired and no refresh token available.");
|
|
info!("Falling back to interactive OAuth login flow.");
|
|
let new_cred = self.perform_oauth_login().await?;
|
|
self.save_credential(&new_cred).await?;
|
|
return Ok(new_cred);
|
|
};
|
|
|
|
match self.refresh_token(refresh_token, credential.clone()).await {
|
|
Ok(mut new_cred) => {
|
|
// Preserve or discover project_id after token refresh
|
|
if new_cred.project_id.is_none()
|
|
&& let Some(pid) = self.discover_project_id(&new_cred.access_token).await
|
|
{
|
|
new_cred.project_id = Some(pid);
|
|
}
|
|
self.save_credential(&new_cred).await?;
|
|
Ok(new_cred)
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
"Failed to refresh OAuth token: {}. Falling back to login flow.",
|
|
e
|
|
);
|
|
let new_cred = self.perform_oauth_login().await?;
|
|
self.save_credential(&new_cred).await?;
|
|
Ok(new_cred)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn get_valid_access_token(&self) -> Result<String> {
|
|
let cred = self.get_valid_credential().await?;
|
|
Ok(cred.access_token)
|
|
}
|
|
|
|
/// Force a token refresh regardless of the current token's expiry time.
|
|
/// This is useful when the server returns 401 Unauthorized for a supposedly valid token.
|
|
pub async fn force_refresh(&self) -> Result<OAuthCredential> {
|
|
let _guard = self.lock.lock().await;
|
|
|
|
let credential = self
|
|
.load_credential()
|
|
.await
|
|
.context("No OAuth credentials found to refresh")?;
|
|
|
|
let Some(refresh_token) = credential.refresh_token.as_ref() else {
|
|
return Err(anyhow!(
|
|
"Cannot force-refresh: missing refresh token in credentials."
|
|
));
|
|
};
|
|
|
|
info!("Force-refreshing Gemini OAuth token...");
|
|
|
|
match self.refresh_token(refresh_token, credential.clone()).await {
|
|
Ok(new_cred) => {
|
|
self.save_credential(&new_cred).await?;
|
|
Ok(new_cred)
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
"Failed to force-refresh OAuth token: {}. Falling back to login flow.",
|
|
e
|
|
);
|
|
let new_cred = self.perform_oauth_login().await?;
|
|
self.save_credential(&new_cred).await?;
|
|
Ok(new_cred)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn refresh_token(
|
|
&self,
|
|
refresh_token: &str,
|
|
mut credential: OAuthCredential,
|
|
) -> Result<OAuthCredential> {
|
|
let client_id = oauth_client_id();
|
|
let client_secret = oauth_client_secret();
|
|
let response = self
|
|
.client
|
|
.post("https://oauth2.googleapis.com/token")
|
|
.form(&[
|
|
("client_id", client_id.as_str()),
|
|
("client_secret", client_secret.as_str()),
|
|
("refresh_token", refresh_token),
|
|
("grant_type", "refresh_token"),
|
|
])
|
|
.send()
|
|
.await?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let text = response.text().await.unwrap_or_else(|e| {
|
|
warn!(error = %e, "Failed to read token refresh error body");
|
|
String::new()
|
|
});
|
|
return Err(anyhow!("Token refresh failed with {}: {}", status, text));
|
|
}
|
|
|
|
let token_response: GoogleTokenRefreshResponse = response.json().await?;
|
|
|
|
credential.access_token = token_response.access_token;
|
|
if let Some(expires_in) = token_response.expires_in {
|
|
credential.expiry_date = Some(Utc::now().timestamp_millis() + expires_in * 1000);
|
|
}
|
|
if let Some(new_refresh) = token_response.refresh_token {
|
|
credential.refresh_token = Some(new_refresh);
|
|
}
|
|
if let Some(id_token) = token_response.id_token {
|
|
credential.id_token = Some(id_token);
|
|
}
|
|
Ok(credential)
|
|
}
|
|
|
|
/// Discover the Cloud Code project ID via the loadCodeAssist API.
|
|
/// This is needed when credentials were created by the original Gemini CLI
|
|
/// (which doesn't persist project_id in the credentials file).
|
|
async fn discover_project_id(&self, access_token: &str) -> Option<String> {
|
|
let client_metadata = serde_json::json!({
|
|
"ideType": "IDE_UNSPECIFIED",
|
|
"platform": "PLATFORM_UNSPECIFIED",
|
|
"pluginType": "GEMINI",
|
|
});
|
|
|
|
let resp = self
|
|
.client
|
|
.post("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist")
|
|
.bearer_auth(access_token)
|
|
.header("X-Goog-Api-Client", GOOG_API_CLIENT)
|
|
.header("Content-Type", "application/json")
|
|
.json(&serde_json::json!({ "metadata": client_metadata }))
|
|
.send()
|
|
.await;
|
|
|
|
match resp {
|
|
Ok(r) if r.status().is_success() => {
|
|
if let Ok(data) = r.json::<serde_json::Value>().await {
|
|
data.get("cloudaicompanionProject")
|
|
.and_then(|p| p.as_str())
|
|
.map(|s| s.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
Ok(r) => {
|
|
warn!(
|
|
status = %r.status(),
|
|
"loadCodeAssist failed during project discovery"
|
|
);
|
|
None
|
|
}
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to call loadCodeAssist for project discovery");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn perform_oauth_login(&self) -> Result<OAuthCredential> {
|
|
// 1. Get an available port
|
|
let listener =
|
|
TcpListener::bind("127.0.0.1:0").context("Failed to bind to available port")?;
|
|
let port = listener.local_addr()?.port();
|
|
let redirect_uri = format!("http://127.0.0.1:{}/auth/callback", port);
|
|
|
|
// 2. Generate PKCE params
|
|
let pkce = generate_pkce_params();
|
|
let client_id = oauth_client_id();
|
|
let client_secret = oauth_client_secret();
|
|
|
|
// 3. Build Auth URL
|
|
let auth_url = Url::parse_with_params(
|
|
"https://accounts.google.com/o/oauth2/v2/auth",
|
|
&[
|
|
("client_id", client_id.as_str()),
|
|
("redirect_uri", &redirect_uri),
|
|
("response_type", "code"),
|
|
("scope", OAUTH_SCOPE),
|
|
("code_challenge", &pkce.code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
("state", &pkce.state),
|
|
("access_type", "offline"),
|
|
("prompt", "consent"),
|
|
],
|
|
)?;
|
|
|
|
println!(
|
|
"\n[Auth] Open this URL in your browser to authorize Gemini CLI:\n\n{}\n",
|
|
auth_url
|
|
);
|
|
|
|
if let Err(e) = open::that(auth_url.as_str()) {
|
|
println!(
|
|
"Info: Could not open browser automatically ({}).\n \
|
|
Please copy the link above and open it manually.",
|
|
e
|
|
);
|
|
}
|
|
|
|
println!("Waiting for authentication callback...");
|
|
println!(
|
|
"Info: If the redirect doesn't work automatically, \
|
|
paste the full redirect URL here and press Enter:"
|
|
);
|
|
|
|
// 4. Wait for redirect — race TCP callback vs manual stdin input
|
|
listener.set_nonblocking(true)?;
|
|
let tokio_listener = tokio::net::TcpListener::from_std(listener)?;
|
|
|
|
let (code, state_value) = tokio::select! {
|
|
|
|
accept_result = tokio_listener.accept() => {
|
|
match accept_result {
|
|
Ok((mut tcp_stream, _)) => {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
let mut buf = [0u8; 4096];
|
|
let n = tcp_stream.read(&mut buf).await.unwrap_or(0);
|
|
let raw = String::from_utf8_lossy(&buf[..n]);
|
|
|
|
let (cp, sp, ep) = Self::parse_callback_params(&raw);
|
|
|
|
let html = if ep.is_some() {
|
|
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\
|
|
<h1>Authentication Failed</h1>\
|
|
<p>You can close this window.</p>"
|
|
} else if cp.is_some() {
|
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
|
|
<h1>Authentication Successful!</h1>\
|
|
<p>You can close this window and return to the terminal.</p>"
|
|
} else {
|
|
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\
|
|
<h1>Invalid Request</h1>\
|
|
<p>No authorization code received.</p>"
|
|
};
|
|
let _ = tcp_stream.write_all(html.as_bytes()).await;
|
|
|
|
if let Some(err_msg) = ep {
|
|
return Err(anyhow!("Google OAuth error: {}", err_msg));
|
|
}
|
|
let c = cp.ok_or_else(|| anyhow!("No auth code in callback"))?;
|
|
let s = sp.ok_or_else(|| anyhow!("No state in callback"))?;
|
|
(c, s)
|
|
}
|
|
Err(e) => return Err(anyhow!("Callback accept failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
manual = Self::read_stdin_line() => {
|
|
let input = manual?;
|
|
Self::parse_redirect_url(&input)?
|
|
}
|
|
};
|
|
|
|
if state_value != pkce.state {
|
|
return Err(anyhow!("Invalid 'state' parameter. Possible CSRF attack."));
|
|
}
|
|
|
|
// 5. Exchange code for tokens
|
|
let response = self
|
|
.client
|
|
.post("https://oauth2.googleapis.com/token")
|
|
.form(&[
|
|
("client_id", client_id.as_str()),
|
|
("client_secret", client_secret.as_str()),
|
|
("code", &code),
|
|
("code_verifier", &pkce.code_verifier),
|
|
("grant_type", "authorization_code"),
|
|
("redirect_uri", &redirect_uri),
|
|
])
|
|
.send()
|
|
.await?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let text = response.text().await.unwrap_or_else(|e| {
|
|
warn!(error = %e, "Failed to read token exchange error body");
|
|
String::new()
|
|
});
|
|
return Err(anyhow!("Token exchange failed with {}: {}", status, text));
|
|
}
|
|
|
|
let token_resp: GoogleTokenRefreshResponse = response.json().await?;
|
|
|
|
// 6. Discover project ID
|
|
println!("Discovering Google Cloud Code Assist Project...");
|
|
|
|
let client_metadata = serde_json::json!({
|
|
"ideType": "IDE_UNSPECIFIED",
|
|
"platform": "PLATFORM_UNSPECIFIED",
|
|
"pluginType": "GEMINI",
|
|
});
|
|
|
|
// 6a. Try loadCodeAssist first
|
|
let load_resp = self
|
|
.client
|
|
.post("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist")
|
|
.bearer_auth(&token_resp.access_token)
|
|
.header("X-Goog-Api-Client", GOOG_API_CLIENT)
|
|
.header("Content-Type", "application/json")
|
|
.json(&serde_json::json!({
|
|
"metadata": client_metadata
|
|
}))
|
|
.send()
|
|
.await?;
|
|
|
|
let mut project_id = None;
|
|
if load_resp.status().is_success() {
|
|
let load_data: serde_json::Value = match load_resp.json().await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to parse loadCodeAssist response");
|
|
serde_json::Value::default()
|
|
}
|
|
};
|
|
if let Some(pid) = load_data
|
|
.get("cloudaicompanionProject")
|
|
.and_then(|p| p.as_str())
|
|
{
|
|
project_id = Some(pid.to_string());
|
|
println!("Found existing project: {}", pid);
|
|
}
|
|
}
|
|
|
|
// 6b. If no project found, we must onboard the user to provision a free-tier project
|
|
if project_id.is_none() {
|
|
println!("Provisioning new Cloud Code Assist project (this may take a moment)...");
|
|
let onboard_resp = self
|
|
.client
|
|
.post("https://cloudcode-pa.googleapis.com/v1internal:onboardUser")
|
|
.bearer_auth(&token_resp.access_token)
|
|
.header("X-Goog-Api-Client", GOOG_API_CLIENT)
|
|
.header("Content-Type", "application/json")
|
|
.json(&serde_json::json!({
|
|
"tierId": "free-tier",
|
|
"metadata": client_metadata
|
|
}))
|
|
.send()
|
|
.await?;
|
|
|
|
if onboard_resp.status().is_success() {
|
|
let mut lro_data: serde_json::Value = match onboard_resp.json().await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to parse onboardUser response");
|
|
serde_json::Value::default()
|
|
}
|
|
};
|
|
|
|
let mut attempts = 0;
|
|
while !lro_data
|
|
.get("done")
|
|
.and_then(|d| d.as_bool())
|
|
.unwrap_or(true)
|
|
&& attempts < 15
|
|
{
|
|
if let Some(op_name) = lro_data.get("name").and_then(|n| n.as_str()) {
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
|
println!(
|
|
"Waiting for project provisioning (attempt {})...",
|
|
attempts + 1
|
|
);
|
|
|
|
let poll_resp = self
|
|
.client
|
|
.get(format!(
|
|
"https://cloudcode-pa.googleapis.com/v1internal/{}",
|
|
op_name
|
|
))
|
|
.bearer_auth(&token_resp.access_token)
|
|
.header("X-Goog-Api-Client", GOOG_API_CLIENT)
|
|
.send()
|
|
.await;
|
|
|
|
if let Ok(resp) = poll_resp
|
|
&& resp.status().is_success()
|
|
{
|
|
lro_data = match resp.json().await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to parse LRO poll response");
|
|
serde_json::Value::default()
|
|
}
|
|
};
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
attempts += 1;
|
|
}
|
|
|
|
if let Some(pid) = lro_data
|
|
.get("response")
|
|
.and_then(|r| r.get("cloudaicompanionProject"))
|
|
.and_then(|p| p.get("id"))
|
|
.and_then(|i| i.as_str())
|
|
{
|
|
project_id = Some(pid.to_string());
|
|
println!("Provisioned project: {}", pid);
|
|
}
|
|
} else {
|
|
let err_text = onboard_resp.text().await.unwrap_or_else(|e| {
|
|
warn!(error = %e, "Failed to read onboard error body");
|
|
String::new()
|
|
});
|
|
println!(
|
|
"Warning: Failed to provision Cloud Code project: {}",
|
|
err_text
|
|
);
|
|
}
|
|
}
|
|
|
|
if project_id.is_none() {
|
|
println!(
|
|
"Warning: Could not automatically detect or provision a Google Cloud Project for Gemini CLI."
|
|
);
|
|
}
|
|
|
|
println!("Success: Gemini OAuth Authentication Successful!");
|
|
|
|
Ok(OAuthCredential {
|
|
access_token: token_resp.access_token,
|
|
refresh_token: token_resp.refresh_token,
|
|
expiry_date: token_resp
|
|
.expires_in
|
|
.map(|secs| Utc::now().timestamp_millis() + secs * 1000),
|
|
token_type: Some(token_resp.token_type),
|
|
id_token: token_resp.id_token,
|
|
project_id,
|
|
})
|
|
}
|
|
|
|
/// Parse code, state, error from raw HTTP callback request.
|
|
fn parse_callback_params(
|
|
raw_request: &str,
|
|
) -> (Option<String>, Option<String>, Option<String>) {
|
|
let mut code = None;
|
|
let mut state = None;
|
|
let mut error = None;
|
|
|
|
if let Some(line) = raw_request.lines().next()
|
|
&& let Some(path) = line.split_whitespace().nth(1)
|
|
&& let Ok(url) = Url::parse(&format!("http://localhost{}", path))
|
|
{
|
|
for (k, v) in url.query_pairs() {
|
|
match k.as_ref() {
|
|
"code" => code = Some(v.into_owned()),
|
|
"state" => state = Some(v.into_owned()),
|
|
"error" => error = Some(v.into_owned()),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
(code, state, error)
|
|
}
|
|
|
|
/// Read a single line from stdin asynchronously.
|
|
async fn read_stdin_line() -> Result<String> {
|
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
let mut reader = BufReader::new(tokio::io::stdin());
|
|
let mut line = String::new();
|
|
reader
|
|
.read_line(&mut line)
|
|
.await
|
|
.context("Failed to read from stdin")?;
|
|
Ok(line.trim().to_string())
|
|
}
|
|
|
|
/// Parse a pasted redirect URL and extract code + state.
|
|
fn parse_redirect_url(input: &str) -> Result<(String, String)> {
|
|
let trimmed = input.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(anyhow!("Empty URL provided"));
|
|
}
|
|
|
|
let url = Url::parse(trimmed).context(
|
|
"Invalid URL. Please paste the full redirect URL \
|
|
from your browser's address bar.",
|
|
)?;
|
|
|
|
let mut code = None;
|
|
let mut state = None;
|
|
let mut error = None;
|
|
|
|
for (k, v) in url.query_pairs() {
|
|
match k.as_ref() {
|
|
"code" => code = Some(v.into_owned()),
|
|
"state" => state = Some(v.into_owned()),
|
|
"error" => error = Some(v.into_owned()),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if let Some(err_msg) = error {
|
|
return Err(anyhow!("Google OAuth returned an error: {}", err_msg,));
|
|
}
|
|
|
|
let code = code.ok_or_else(|| {
|
|
anyhow!(
|
|
"No 'code' parameter found in URL. \
|
|
Make sure you pasted the complete redirect URL."
|
|
)
|
|
})?;
|
|
let state = state.ok_or_else(|| {
|
|
anyhow!(
|
|
"No 'state' parameter found in URL. \
|
|
Make sure you pasted the complete redirect URL."
|
|
)
|
|
})?;
|
|
|
|
Ok((code, state))
|
|
}
|
|
}
|
|
|
|
pub struct GeminiOauthProvider {
|
|
config: GeminiOauthConfig,
|
|
cred_manager: CredentialManager,
|
|
http_client: Client,
|
|
/// Latest response metadata (updated after each request).
|
|
last_response_meta: std::sync::Mutex<GeminiResponseMeta>,
|
|
}
|
|
|
|
impl GeminiOauthProvider {
|
|
pub fn new(config: GeminiOauthConfig) -> Result<Self, LlmError> {
|
|
let cred_manager = CredentialManager::new(&config.credentials_path)?;
|
|
let http_client = Client::builder()
|
|
.timeout(Duration::from_secs(300))
|
|
.build()
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: format!("Failed to create HTTP client for GeminiOauthProvider: {e}"),
|
|
})?;
|
|
|
|
Ok(Self {
|
|
config,
|
|
cred_manager,
|
|
http_client,
|
|
last_response_meta: std::sync::Mutex::new(GeminiResponseMeta::default()),
|
|
})
|
|
}
|
|
|
|
/// Returns the latest response metadata from the last API call.
|
|
pub fn last_response_meta(&self) -> GeminiResponseMeta {
|
|
self.last_response_meta
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.clone()
|
|
}
|
|
|
|
/// Inject thought signatures into model functionCall parts in the active loop.
|
|
/// This prevents 400 errors from Gemini 3.x preview APIs.
|
|
/// Mirrors `ensureActiveLoopHasThoughtSignatures` from the official Gemini CLI.
|
|
fn ensure_thought_signatures(contents: &mut [serde_json::Value]) {
|
|
// Find the start of the active loop: the last user turn with a text part.
|
|
let mut active_loop_start: Option<usize> = None;
|
|
for (i, item) in contents.iter().enumerate().rev() {
|
|
if let Some(role) = item.get("role").and_then(|r| r.as_str())
|
|
&& role == "user"
|
|
&& let Some(parts) = item.get("parts").and_then(|p| p.as_array())
|
|
&& parts.iter().any(|p| p.get("text").is_some())
|
|
{
|
|
active_loop_start = Some(i);
|
|
break;
|
|
}
|
|
}
|
|
|
|
let start = match active_loop_start {
|
|
Some(s) => s,
|
|
None => return,
|
|
};
|
|
|
|
// For each model turn in the active loop, ensure the first functionCall has a thoughtSignature.
|
|
for item in contents.iter_mut().skip(start) {
|
|
let is_model = item.get("role").and_then(|r| r.as_str()) == Some("model");
|
|
if !is_model {
|
|
continue;
|
|
}
|
|
|
|
if let Some(parts) = item.get("parts").and_then(|p| p.as_array()) {
|
|
let mut new_parts = parts.clone();
|
|
let mut modified = false;
|
|
for part in &mut new_parts {
|
|
if part.get("functionCall").is_some() && part.get("thoughtSignature").is_none()
|
|
{
|
|
if let Some(obj) = part.as_object_mut() {
|
|
obj.insert(
|
|
"thoughtSignature".to_string(),
|
|
serde_json::Value::String(SYNTHETIC_THOUGHT_SIGNATURE.to_string()),
|
|
);
|
|
}
|
|
modified = true;
|
|
break; // Only the first functionCall
|
|
}
|
|
}
|
|
if modified {
|
|
item["parts"] = serde_json::Value::Array(new_parts);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract curated history from contents, filtering out invalid model outputs.
|
|
/// Mirrors `extractCuratedHistory` from the Gemini CLI.
|
|
fn curate_contents(contents: &[serde_json::Value]) -> Vec<serde_json::Value> {
|
|
let mut curated = Vec::new();
|
|
for entry in contents {
|
|
let role = entry.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
|
|
|
if role != "model" {
|
|
// Always keep non-model turns (user, tool-response)
|
|
curated.push(entry.clone());
|
|
continue;
|
|
}
|
|
|
|
// For model turns: filter out invalid parts instead of dropping the
|
|
// entire turn. A turn with functionCall parts must survive even if
|
|
// an accompanying text part is empty.
|
|
let Some(parts) = entry.get("parts").and_then(|p| p.as_array()) else {
|
|
// No parts array at all — skip the turn.
|
|
continue;
|
|
};
|
|
|
|
let valid_parts: Vec<&serde_json::Value> = parts
|
|
.iter()
|
|
.filter(|part| {
|
|
// Drop empty objects `{}`
|
|
if part.as_object().is_some_and(|o| o.is_empty()) {
|
|
return false;
|
|
}
|
|
// Drop non-thought text parts with empty text, but only when
|
|
// the part carries no other content (e.g. functionCall).
|
|
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
|
|
let is_thought = part
|
|
.get("thought")
|
|
.and_then(|t| t.as_bool())
|
|
.unwrap_or(false);
|
|
if !is_thought && text.is_empty() && part.get("functionCall").is_none() {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
})
|
|
.collect();
|
|
|
|
if valid_parts.is_empty() {
|
|
// All parts were invalid — drop the turn entirely.
|
|
continue;
|
|
}
|
|
|
|
let mut turn = entry.clone();
|
|
if valid_parts.len() != parts.len() {
|
|
// Rebuild parts array with only valid parts.
|
|
turn["parts"] =
|
|
serde_json::Value::Array(valid_parts.into_iter().cloned().collect());
|
|
}
|
|
curated.push(turn);
|
|
}
|
|
curated
|
|
}
|
|
|
|
/// Count tokens for the given messages using the Gemini countTokens API.
|
|
pub async fn count_tokens(&self, messages: &[ChatMessage]) -> Result<u32, LlmError> {
|
|
let req =
|
|
Self::to_gemini_request(messages, None, None, None, None, None, &self.config.model);
|
|
let contents = req
|
|
.get("contents")
|
|
.cloned()
|
|
.unwrap_or(serde_json::json!([]));
|
|
|
|
let credential = self
|
|
.cred_manager
|
|
.get_valid_credential()
|
|
.await
|
|
.map_err(|_e| LlmError::AuthFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
})?;
|
|
|
|
let (url, request_body) = if self.uses_cloud_code_api() {
|
|
let url = "https://cloudcode-pa.googleapis.com/v1internal:countTokens".to_string();
|
|
let mut req = serde_json::json!({
|
|
"request": {
|
|
"model": format!("models/{}", self.config.model),
|
|
"contents": contents,
|
|
}
|
|
});
|
|
if let Some(ref pid) = credential.project_id {
|
|
req["project"] = serde_json::Value::String(pid.clone());
|
|
}
|
|
(url, req)
|
|
} else {
|
|
let url = format!(
|
|
"https://generativelanguage.googleapis.com/v1beta/models/{}:countTokens",
|
|
self.config.model
|
|
);
|
|
(url, serde_json::json!({ "contents": contents }))
|
|
};
|
|
|
|
let response = self
|
|
.http_client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header(
|
|
"Authorization",
|
|
format!("Bearer {}", credential.access_token),
|
|
)
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: e.to_string(),
|
|
})?;
|
|
|
|
let body: serde_json::Value =
|
|
response.json().await.map_err(|e| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: format!("Failed to parse countTokens response: {}", e),
|
|
})?;
|
|
|
|
let total = body
|
|
.get("totalTokens")
|
|
.or_else(|| body.get("totalTokenCount"))
|
|
.and_then(|t| t.as_u64())
|
|
.unwrap_or(0) as u32;
|
|
|
|
Ok(total)
|
|
}
|
|
|
|
/// Determine whether to use Cloud Code API vs legacy generativelanguage API.
|
|
///
|
|
/// Gemini 2.0+ models use the Cloud Code API endpoint.
|
|
/// Gemini 1.x models use the legacy generativelanguage.googleapis.com endpoint.
|
|
fn uses_cloud_code_api(&self) -> bool {
|
|
Self::model_uses_cloud_code_api(&self.config.model)
|
|
}
|
|
|
|
pub fn model_uses_cloud_code_api(model: &str) -> bool {
|
|
let model = model.to_ascii_lowercase();
|
|
// Models containing "-preview" suffix or "gemini-3" use the Cloud Code API.
|
|
// Using "-preview" (with hyphen) to avoid false positives on unrelated model names.
|
|
if model.contains("-preview") || model.contains("gemini-3") {
|
|
return true;
|
|
}
|
|
|
|
if let Some(rest) = model.strip_prefix("gemini-") {
|
|
let version_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
|
let major: u32 = match version_str.parse() {
|
|
Ok(v) => v,
|
|
Err(_) => {
|
|
warn!(
|
|
model = model,
|
|
"could not parse major version from Gemini model name, defaulting to legacy API"
|
|
);
|
|
0
|
|
}
|
|
};
|
|
major >= 2
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
async fn send_request(
|
|
&self,
|
|
original_request: &serde_json::Value,
|
|
) -> Result<serde_json::Value, LlmError> {
|
|
let mut allow_retry = true;
|
|
loop {
|
|
let credential = self
|
|
.cred_manager
|
|
.get_valid_credential()
|
|
.await
|
|
.map_err(|_e| LlmError::AuthFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
})?;
|
|
|
|
// Format is equivalent to the Google Generative Language API
|
|
// https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
|
|
let (url, request_body, mut headers) = if self.uses_cloud_code_api() {
|
|
// Use Cloud Code API for new models
|
|
let url =
|
|
"https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"
|
|
.to_string();
|
|
let mut req = serde_json::json!({
|
|
"model": self.config.model,
|
|
"request": original_request,
|
|
});
|
|
if let Some(ref pid) = credential.project_id {
|
|
req["project"] = serde_json::Value::String(pid.clone());
|
|
}
|
|
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
headers.insert(
|
|
"Content-Type",
|
|
"application/json"
|
|
.parse()
|
|
.map_err(|_| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: "invalid Content-Type header value".to_string(),
|
|
})?,
|
|
);
|
|
headers.insert(
|
|
"User-Agent",
|
|
format!(
|
|
"GeminiCLI-ironclaw/{}/{} ({}; {}; cli)",
|
|
env!("CARGO_PKG_VERSION"),
|
|
self.config.model,
|
|
std::env::consts::OS,
|
|
std::env::consts::ARCH,
|
|
)
|
|
.parse()
|
|
.map_err(|_| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: "invalid User-Agent header value".to_string(),
|
|
})?,
|
|
);
|
|
headers.insert(
|
|
"X-Goog-Api-Client",
|
|
GOOG_API_CLIENT
|
|
.parse()
|
|
.map_err(|_| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: "invalid X-Goog-Api-Client header value".to_string(),
|
|
})?,
|
|
);
|
|
headers.insert(
|
|
"Client-Metadata",
|
|
"{\"ideType\":\"IDE_UNSPECIFIED\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}"
|
|
.parse()
|
|
.map_err(|_| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: "invalid Client-Metadata header value".to_string(),
|
|
})?,
|
|
);
|
|
headers.insert(
|
|
"Authorization",
|
|
reqwest::header::HeaderValue::from_str(&format!(
|
|
"Bearer {}",
|
|
credential.access_token
|
|
))
|
|
.map_err(|_| LlmError::AuthFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
})?,
|
|
);
|
|
(url, req, headers)
|
|
} else {
|
|
// Legacy / Standard fallback
|
|
// Respect GOOGLE_GENAI_API_VERSION env var (default: v1beta)
|
|
let api_version = std::env::var("GOOGLE_GENAI_API_VERSION")
|
|
.unwrap_or_else(|_| "v1beta".to_string());
|
|
let url = format!(
|
|
"https://generativelanguage.googleapis.com/{}/models/{}:generateContent",
|
|
api_version, self.config.model
|
|
);
|
|
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
headers.insert(
|
|
"Content-Type",
|
|
"application/json"
|
|
.parse()
|
|
.map_err(|_| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: "invalid Content-Type header value".to_string(),
|
|
})?,
|
|
);
|
|
|
|
// Support GEMINI_API_KEY for non-OAuth auth + GEMINI_API_KEY_AUTH_MECHANISM
|
|
let api_key = std::env::var("GEMINI_API_KEY").ok();
|
|
let auth_mechanism = std::env::var("GEMINI_API_KEY_AUTH_MECHANISM")
|
|
.unwrap_or_else(|_| "x-goog-api-key".to_string());
|
|
|
|
let (final_url, auth_header_name, auth_header_value) =
|
|
if let Some(ref key) = api_key {
|
|
if auth_mechanism == "bearer" {
|
|
(url, "Authorization".to_string(), format!("Bearer {}", key))
|
|
} else {
|
|
// x-goog-api-key: append key as query param or header
|
|
(url, "x-goog-api-key".to_string(), key.clone())
|
|
}
|
|
} else {
|
|
(
|
|
url,
|
|
"Authorization".to_string(),
|
|
format!("Bearer {}", credential.access_token),
|
|
)
|
|
};
|
|
|
|
headers.insert(
|
|
reqwest::header::HeaderName::from_bytes(auth_header_name.as_bytes()).map_err(
|
|
|_| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: "invalid auth header name".to_string(),
|
|
},
|
|
)?,
|
|
reqwest::header::HeaderValue::from_str(&auth_header_value).map_err(|_| {
|
|
LlmError::AuthFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
}
|
|
})?,
|
|
);
|
|
|
|
(final_url, original_request.clone(), headers)
|
|
};
|
|
|
|
// Inject custom headers from GEMINI_CLI_CUSTOM_HEADERS env var
|
|
let custom_headers = parse_custom_headers();
|
|
for (name, value) in &custom_headers {
|
|
if let (Ok(hname), Ok(hval)) = (
|
|
reqwest::header::HeaderName::from_bytes(name.as_bytes()),
|
|
reqwest::header::HeaderValue::from_str(value),
|
|
) {
|
|
headers.insert(hname, hval);
|
|
} else {
|
|
warn!(header = %name, "Skipping invalid custom header");
|
|
}
|
|
}
|
|
|
|
debug!(
|
|
url = %url,
|
|
model = %self.config.model,
|
|
"gemini_oauth: sending request"
|
|
);
|
|
|
|
let response = self
|
|
.http_client
|
|
.post(&url)
|
|
.headers(headers)
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: e.to_string(),
|
|
})?;
|
|
|
|
let status = response.status();
|
|
let body_bytes = response
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: format!("Failed to read response body: {}", e),
|
|
})?;
|
|
|
|
// Cloud Code returns SSE stream, we need to parse it
|
|
let mut final_response = serde_json::json!({});
|
|
let body_str = String::from_utf8_lossy(&body_bytes);
|
|
|
|
let mut success = false;
|
|
if self.uses_cloud_code_api() {
|
|
let mut combined_text = String::new();
|
|
let mut finish_reason = "STOP".to_string();
|
|
let mut prompt_tokens: i64 = 0;
|
|
let mut candidates_tokens: i64 = 0;
|
|
let mut tool_calls_parts = Vec::<serde_json::Value>::new();
|
|
|
|
// Metadata (collected in the same pass)
|
|
let mut model_version: Option<String> = None;
|
|
let mut prompt_feedback: Option<serde_json::Value> = None;
|
|
let mut grounding_metadata: Option<serde_json::Value> = None;
|
|
let mut citation_metadata: Option<serde_json::Value> = None;
|
|
let mut cached_content_token_count: Option<u32> = None;
|
|
let mut total_token_count: Option<u32> = None;
|
|
let mut consumed_credits: Vec<GeminiCredits> = Vec::new();
|
|
let mut remaining_credits: Vec<GeminiCredits> = Vec::new();
|
|
|
|
for line in body_str.lines() {
|
|
let Some(json_str) = line.strip_prefix("data:") else {
|
|
continue;
|
|
};
|
|
let json_str = json_str.trim();
|
|
let chunk: serde_json::Value = match serde_json::from_str(json_str) {
|
|
Ok(v) => v,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
// Credits from Cloud Code wrapper (top-level, outside "response")
|
|
if let Some(cc) = chunk.get("consumedCredits").and_then(|c| c.as_array()) {
|
|
for c in cc {
|
|
if let Ok(credit) = serde_json::from_value::<GeminiCredits>(c.clone()) {
|
|
consumed_credits.push(credit);
|
|
}
|
|
}
|
|
}
|
|
if let Some(rc) = chunk.get("remainingCredits").and_then(|c| c.as_array()) {
|
|
for c in rc {
|
|
if let Ok(credit) = serde_json::from_value::<GeminiCredits>(c.clone()) {
|
|
remaining_credits.push(credit);
|
|
}
|
|
}
|
|
}
|
|
|
|
let resp = match chunk.get("response") {
|
|
Some(r) => r,
|
|
None => continue,
|
|
};
|
|
|
|
// Content extraction
|
|
if let Some(candidates) = resp.get("candidates").and_then(|c| c.as_array())
|
|
&& let Some(first) = candidates.first()
|
|
{
|
|
if let Some(parts) = first
|
|
.get("content")
|
|
.and_then(|c| c.get("parts"))
|
|
.and_then(|p| p.as_array())
|
|
{
|
|
for part in parts {
|
|
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
|
|
let is_thought = part
|
|
.get("thought")
|
|
.and_then(|t| t.as_bool())
|
|
.unwrap_or(false);
|
|
if !is_thought {
|
|
combined_text.push_str(text);
|
|
}
|
|
}
|
|
if let Some(fc) = part.get("functionCall") {
|
|
tool_calls_parts.push(serde_json::json!({
|
|
"functionCall": fc
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
if let Some(fr) = first.get("finishReason").and_then(|fr| fr.as_str()) {
|
|
finish_reason = fr.to_string();
|
|
}
|
|
// Per-candidate metadata
|
|
if grounding_metadata.is_none()
|
|
&& let Some(gm) = first.get("groundingMetadata")
|
|
{
|
|
grounding_metadata = Some(gm.clone());
|
|
}
|
|
if citation_metadata.is_none()
|
|
&& let Some(cm) = first.get("citationMetadata")
|
|
{
|
|
citation_metadata = Some(cm.clone());
|
|
}
|
|
}
|
|
|
|
// Response-level metadata
|
|
if model_version.is_none()
|
|
&& let Some(mv) = resp.get("modelVersion").and_then(|v| v.as_str())
|
|
{
|
|
model_version = Some(mv.to_string());
|
|
}
|
|
if prompt_feedback.is_none()
|
|
&& let Some(pf) = resp.get("promptFeedback")
|
|
{
|
|
prompt_feedback = Some(pf.clone());
|
|
}
|
|
if let Some(usage) = resp.get("usageMetadata") {
|
|
if let Some(pt) = usage.get("promptTokenCount").and_then(|pt| pt.as_i64()) {
|
|
prompt_tokens = pt;
|
|
}
|
|
if let Some(ct) =
|
|
usage.get("candidatesTokenCount").and_then(|ct| ct.as_i64())
|
|
{
|
|
candidates_tokens = ct;
|
|
}
|
|
if let Some(ct) = usage
|
|
.get("cachedContentTokenCount")
|
|
.and_then(|t| t.as_u64())
|
|
{
|
|
cached_content_token_count = Some(ct as u32);
|
|
}
|
|
if let Some(tt) = usage.get("totalTokenCount").and_then(|t| t.as_u64()) {
|
|
total_token_count = Some(tt as u32);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store metadata
|
|
if let Ok(mut meta) = self.last_response_meta.lock() {
|
|
*meta = GeminiResponseMeta {
|
|
model_version,
|
|
prompt_feedback: prompt_feedback.clone(),
|
|
grounding_metadata,
|
|
citation_metadata,
|
|
consumed_credits,
|
|
remaining_credits,
|
|
cached_content_token_count,
|
|
total_token_count,
|
|
};
|
|
}
|
|
|
|
// Log prompt feedback if request was blocked
|
|
if let Some(ref pf) = prompt_feedback
|
|
&& let Some(reason) = pf.get("blockReason").and_then(|r| r.as_str())
|
|
{
|
|
warn!(
|
|
block_reason = reason,
|
|
"Gemini API blocked the request via promptFeedback"
|
|
);
|
|
}
|
|
|
|
let has_content = !combined_text.is_empty() || !tool_calls_parts.is_empty();
|
|
|
|
if has_content {
|
|
let mut response_parts = Vec::new();
|
|
if !combined_text.is_empty() {
|
|
response_parts.push(serde_json::json!({"text": combined_text}));
|
|
}
|
|
response_parts.extend(tool_calls_parts);
|
|
|
|
final_response = serde_json::json!({
|
|
"candidates": [{
|
|
"content": {
|
|
"parts": response_parts
|
|
},
|
|
"finishReason": finish_reason
|
|
}],
|
|
"usageMetadata": {
|
|
"promptTokenCount": prompt_tokens,
|
|
"candidatesTokenCount": candidates_tokens
|
|
}
|
|
});
|
|
success = true;
|
|
}
|
|
} else if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body_str) {
|
|
final_response = json;
|
|
success = true;
|
|
}
|
|
|
|
if !status.is_success() || !success {
|
|
let err_msg = final_response
|
|
.get("error")
|
|
.and_then(|e| e.get("message"))
|
|
.and_then(|m| m.as_str())
|
|
.unwrap_or(&body_str);
|
|
|
|
if status.as_u16() == 401 && allow_retry {
|
|
warn!(
|
|
"Gemini OAuth request failed with 401. Force-refreshing token and retrying..."
|
|
);
|
|
if let Err(e) = self.cred_manager.force_refresh().await {
|
|
error!("Failed to force-refresh token: {}", e);
|
|
return Err(LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: format!("Auth error 401 and refresh failed: {}", e),
|
|
});
|
|
}
|
|
allow_retry = false;
|
|
continue;
|
|
}
|
|
|
|
if status.as_u16() == 429 {
|
|
let retry_after = Self::parse_retry_after(err_msg);
|
|
return Err(LlmError::RateLimited {
|
|
provider: "gemini_oauth".to_string(),
|
|
retry_after,
|
|
});
|
|
}
|
|
|
|
return Err(LlmError::InvalidResponse {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: format!("HTTP {}: {}", status.as_u16(), err_msg),
|
|
});
|
|
}
|
|
|
|
return Ok(final_response);
|
|
}
|
|
}
|
|
|
|
/// Parse retry-after duration from Gemini error messages.
|
|
///
|
|
/// Matches patterns like "Your quota will reset after 46s."
|
|
/// or "Your quota will reset after 18h31m10s."
|
|
fn parse_retry_after(message: &str) -> Option<Duration> {
|
|
use std::sync::LazyLock;
|
|
use std::time::Duration;
|
|
|
|
static RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
|
regex::Regex::new(r"reset after (?:(\d+)h)?(?:(\d+)m)?(\d+)s")
|
|
.expect("invalid retry_after regex") // safety: hardcoded literal
|
|
});
|
|
|
|
let caps = RE.captures(message)?;
|
|
let hours: u64 = caps.get(1).map_or(0, |m| m.as_str().parse().unwrap_or(0));
|
|
let minutes: u64 = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0));
|
|
let seconds: u64 = caps.get(3).map_or(0, |m| m.as_str().parse().unwrap_or(0));
|
|
|
|
let total_secs = hours * 3600 + minutes * 60 + seconds;
|
|
if total_secs > 0 {
|
|
Some(Duration::from_secs(total_secs + 2))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn to_gemini_request(
|
|
messages: &[ChatMessage],
|
|
tools: Option<&[ToolDefinition]>,
|
|
temperature: Option<f32>,
|
|
max_tokens: Option<u32>,
|
|
stop_sequences: Option<&[String]>,
|
|
tool_choice: Option<&str>,
|
|
model: &str,
|
|
) -> serde_json::Value {
|
|
let mut contents = Vec::new();
|
|
|
|
for msg in messages {
|
|
match msg.role {
|
|
Role::System => {
|
|
// System messages are handled via systemInstruction top-level field
|
|
}
|
|
Role::User => {
|
|
contents.push(serde_json::json!({
|
|
"role": "user",
|
|
"parts": [{ "text": msg.content }]
|
|
}));
|
|
}
|
|
Role::Assistant => {
|
|
let mut parts = Vec::new();
|
|
// Only add text part if content is non-empty (assistant messages
|
|
// with tool calls often have empty content, and curate_contents
|
|
// would drop the entire turn if it sees an empty text part).
|
|
if !msg.content.is_empty() {
|
|
parts.push(serde_json::json!({ "text": msg.content }));
|
|
}
|
|
if let Some(ref calls) = msg.tool_calls {
|
|
for call in calls {
|
|
parts.push(serde_json::json!({
|
|
"functionCall": {
|
|
"name": call.name,
|
|
"args": call.arguments
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
// Fallback: if no parts at all, add empty text to avoid
|
|
// sending a turn with zero parts (API rejects it).
|
|
if parts.is_empty() {
|
|
parts.push(serde_json::json!({ "text": "" }));
|
|
}
|
|
contents.push(serde_json::json!({
|
|
"role": "model",
|
|
"parts": parts
|
|
}));
|
|
}
|
|
Role::Tool => {
|
|
let tool_name = msg
|
|
.name
|
|
.clone()
|
|
.unwrap_or_else(|| "unknown_tool".to_string());
|
|
|
|
let response_value: serde_json::Value = serde_json::from_str(&msg.content)
|
|
.unwrap_or_else(|_| serde_json::json!({ "output": msg.content }));
|
|
|
|
let part = serde_json::json!({
|
|
"functionResponse": {
|
|
"name": tool_name,
|
|
"response": response_value
|
|
}
|
|
});
|
|
|
|
let last = contents.last_mut();
|
|
let merge = last
|
|
.as_ref()
|
|
.and_then(|c| c.get("role"))
|
|
.and_then(|r| r.as_str())
|
|
== Some("user")
|
|
&& last
|
|
.as_ref()
|
|
.and_then(|c| c.get("parts"))
|
|
.and_then(|p| p.as_array())
|
|
.is_some_and(|parts| {
|
|
parts.iter().any(|p| p.get("functionResponse").is_some())
|
|
});
|
|
|
|
if merge {
|
|
if let Some(c) = contents.last_mut()
|
|
&& let Some(parts) = c.get_mut("parts").and_then(|p| p.as_array_mut())
|
|
{
|
|
parts.push(part);
|
|
}
|
|
} else {
|
|
contents.push(serde_json::json!({
|
|
"role": "user",
|
|
"parts": [part]
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut req = serde_json::json!({
|
|
"contents": contents
|
|
});
|
|
|
|
// Concatenate all system messages into one systemInstruction
|
|
let mut system_parts = Vec::new();
|
|
for msg in messages {
|
|
if msg.role == Role::System {
|
|
system_parts.push(msg.content.as_str());
|
|
}
|
|
}
|
|
|
|
if !system_parts.is_empty() {
|
|
req["systemInstruction"] = serde_json::json!({
|
|
"parts": [{ "text": system_parts.join("\n\n") }]
|
|
});
|
|
}
|
|
|
|
if let Some(tool_defs) = tools
|
|
&& !tool_defs.is_empty()
|
|
{
|
|
let declarations: Vec<serde_json::Value> = tool_defs
|
|
.iter()
|
|
.map(|t| {
|
|
serde_json::json!({
|
|
"name": t.name,
|
|
"description": t.description,
|
|
"parameters": t.parameters
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
req["tools"] = serde_json::json!([
|
|
{ "functionDeclarations": declarations }
|
|
]);
|
|
}
|
|
|
|
let mut gen_config = serde_json::Map::new();
|
|
if let Some(t) = temperature {
|
|
gen_config.insert("temperature".to_string(), serde_json::Value::from(t));
|
|
}
|
|
if let Some(mt) = max_tokens {
|
|
gen_config.insert("maxOutputTokens".to_string(), serde_json::Value::from(mt));
|
|
}
|
|
if let Some(seqs) = stop_sequences
|
|
&& !seqs.is_empty()
|
|
{
|
|
gen_config.insert(
|
|
"stopSequences".to_string(),
|
|
serde_json::Value::from(seqs.to_vec()),
|
|
);
|
|
}
|
|
|
|
// Extended generation config from environment variables.
|
|
// These allow fine-tuning without changing the shared CompletionRequest trait.
|
|
if let Ok(v) = std::env::var("GEMINI_TOP_P")
|
|
&& let Ok(top_p) = v.parse::<f64>()
|
|
{
|
|
gen_config.insert("topP".to_string(), serde_json::Value::from(top_p));
|
|
}
|
|
if let Ok(v) = std::env::var("GEMINI_TOP_K")
|
|
&& let Ok(top_k) = v.parse::<u32>()
|
|
{
|
|
gen_config.insert("topK".to_string(), serde_json::Value::from(top_k));
|
|
}
|
|
if let Ok(v) = std::env::var("GEMINI_SEED")
|
|
&& let Ok(seed) = v.parse::<i64>()
|
|
{
|
|
gen_config.insert("seed".to_string(), serde_json::Value::from(seed));
|
|
}
|
|
if let Ok(v) = std::env::var("GEMINI_PRESENCE_PENALTY")
|
|
&& let Ok(pp) = v.parse::<f64>()
|
|
{
|
|
gen_config.insert("presencePenalty".to_string(), serde_json::Value::from(pp));
|
|
}
|
|
if let Ok(v) = std::env::var("GEMINI_FREQUENCY_PENALTY")
|
|
&& let Ok(fp) = v.parse::<f64>()
|
|
{
|
|
gen_config.insert("frequencyPenalty".to_string(), serde_json::Value::from(fp));
|
|
}
|
|
// Response schema / JSON mode
|
|
if let Ok(mime) = std::env::var("GEMINI_RESPONSE_MIME_TYPE")
|
|
&& !mime.is_empty()
|
|
{
|
|
gen_config.insert(
|
|
"responseMimeType".to_string(),
|
|
serde_json::Value::String(mime),
|
|
);
|
|
}
|
|
if let Ok(schema_str) = std::env::var("GEMINI_RESPONSE_JSON_SCHEMA")
|
|
&& let Ok(schema) = serde_json::from_str::<serde_json::Value>(&schema_str)
|
|
{
|
|
gen_config.insert("responseJsonSchema".to_string(), schema);
|
|
}
|
|
|
|
// thinkingConfig:
|
|
// - Gemini 3.x: level-based (thinkingLevel: HIGH)
|
|
// - Gemini 2.5.x: budget-based (thinkingBudget: 8192)
|
|
// Budget cap of 8192 prevents runaway thinking loops.
|
|
//
|
|
// NOTE: We do NOT set includeThoughts=true. The original Gemini CLI
|
|
// sets it because it displays thoughts to the user. IronClaw's reasoning
|
|
// layer (reasoning.rs) strips all <thinking> tags from responses, so
|
|
// including thoughts just adds text that gets stripped, potentially
|
|
// leaving an empty response.
|
|
let is_gemini_3 = model.contains("gemini-3");
|
|
let is_gemini_25 = model.contains("gemini-2.5");
|
|
let is_thinking_model = model.contains("thinking") || is_gemini_3 || is_gemini_25;
|
|
if is_thinking_model {
|
|
let thinking_config = if is_gemini_3 {
|
|
serde_json::json!({ "thinkingLevel": "HIGH" })
|
|
} else {
|
|
serde_json::json!({ "thinkingBudget": 8192 })
|
|
};
|
|
gen_config.insert("thinkingConfig".to_string(), thinking_config);
|
|
}
|
|
|
|
if !gen_config.is_empty() {
|
|
req["generationConfig"] = serde_json::Value::Object(gen_config);
|
|
}
|
|
|
|
// Cached content support via GEMINI_CACHED_CONTENT env var.
|
|
if let Ok(cached) = std::env::var("GEMINI_CACHED_CONTENT")
|
|
&& !cached.is_empty()
|
|
{
|
|
req["cachedContent"] = serde_json::Value::String(cached);
|
|
}
|
|
|
|
if let Some(choice) = tool_choice {
|
|
let mode = match choice {
|
|
"auto" => "AUTO",
|
|
"required" | "any" => "ANY",
|
|
"none" => "NONE",
|
|
_ => "AUTO",
|
|
};
|
|
req["toolConfig"] = serde_json::json!({
|
|
"functionCallingConfig": {
|
|
"mode": mode
|
|
}
|
|
});
|
|
}
|
|
|
|
// Safety settings — only inject BLOCK_NONE when explicitly enabled via env var.
|
|
// The Cloud Code API may reject BLOCK_NONE for certain tiers.
|
|
// The original Gemini CLI does not set default safety settings.
|
|
if std::env::var("GEMINI_SAFETY_BLOCK_NONE")
|
|
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
|
.unwrap_or(false)
|
|
{
|
|
req["safetySettings"] = serde_json::Value::Array(default_safety_settings());
|
|
}
|
|
|
|
// Thought signature injection for models that support modern features (Gemini 3.x).
|
|
if supports_modern_features(model)
|
|
&& let Some(contents) = req.get_mut("contents").and_then(|c| c.as_array_mut())
|
|
{
|
|
let mut owned = contents.clone();
|
|
Self::ensure_thought_signatures(&mut owned);
|
|
*contents = owned;
|
|
}
|
|
|
|
// History curation: filter out invalid model outputs before sending.
|
|
if let Some(contents) = req.get("contents").and_then(|c| c.as_array()) {
|
|
let curated = Self::curate_contents(contents);
|
|
req["contents"] = serde_json::Value::Array(curated);
|
|
}
|
|
|
|
req
|
|
}
|
|
|
|
fn from_gemini_response(
|
|
body: serde_json::Value,
|
|
) -> Result<(CompletionResponse, Vec<ToolCall>), LlmError> {
|
|
let candidate = body
|
|
.get("candidates")
|
|
.and_then(|c| c.as_array())
|
|
.and_then(|c| c.first())
|
|
.ok_or_else(|| LlmError::RequestFailed {
|
|
provider: "gemini_oauth".to_string(),
|
|
reason: "Response missing 'candidates[0]'".to_string(),
|
|
})?;
|
|
|
|
let parts = candidate
|
|
.get("content")
|
|
.and_then(|c| c.get("parts"))
|
|
.and_then(|p| p.as_array());
|
|
|
|
let mut text_content = String::new();
|
|
let mut tool_calls = Vec::new();
|
|
|
|
if let Some(parts) = parts {
|
|
for part in parts {
|
|
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
|
|
text_content.push_str(text);
|
|
}
|
|
if let Some(fc) = part.get("functionCall") {
|
|
let name = fc
|
|
.get("name")
|
|
.and_then(|n| n.as_str())
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
let args = fc.get("args").cloned().unwrap_or(serde_json::json!({}));
|
|
let id = fc
|
|
.get("id")
|
|
.and_then(|i| i.as_str())
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
|
|
|
tool_calls.push(ToolCall {
|
|
id,
|
|
name,
|
|
arguments: args,
|
|
reasoning: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let finish_reason = candidate
|
|
.get("finishReason")
|
|
.and_then(|r| r.as_str())
|
|
.unwrap_or("STOP");
|
|
|
|
// Invalid content detection (mirrors Gemini CLI InvalidStreamError types).
|
|
// Log warnings for known problematic finish reasons.
|
|
match finish_reason {
|
|
"MALFORMED_FUNCTION_CALL" => {
|
|
warn!(
|
|
finish_reason = finish_reason,
|
|
"Gemini returned MALFORMED_FUNCTION_CALL — {} (type: {})",
|
|
"model stream ended with malformed function call",
|
|
InvalidStreamType::MalformedFunctionCall
|
|
);
|
|
}
|
|
"UNEXPECTED_TOOL_CALL" => {
|
|
warn!(
|
|
finish_reason = finish_reason,
|
|
"Gemini returned UNEXPECTED_TOOL_CALL — {} (type: {})",
|
|
"model stream ended with unexpected tool call",
|
|
InvalidStreamType::UnexpectedToolCall
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Check for no response text when no tool calls (NO_RESPONSE_TEXT detection)
|
|
if tool_calls.is_empty() && text_content.is_empty() && finish_reason == "STOP" {
|
|
debug!(
|
|
"Gemini response has no text and no tool calls (type: {})",
|
|
InvalidStreamType::NoResponseText
|
|
);
|
|
}
|
|
|
|
let stop_reason = match finish_reason {
|
|
"STOP" => {
|
|
if !tool_calls.is_empty() {
|
|
FinishReason::ToolUse
|
|
} else {
|
|
FinishReason::Stop
|
|
}
|
|
}
|
|
"MAX_TOKENS" => FinishReason::Length,
|
|
"MALFORMED_FUNCTION_CALL" | "UNEXPECTED_TOOL_CALL" => {
|
|
// Treat as Stop — the caller's retry layer will handle retries
|
|
FinishReason::Stop
|
|
}
|
|
_ => {
|
|
if !tool_calls.is_empty() {
|
|
FinishReason::ToolUse
|
|
} else {
|
|
FinishReason::Stop
|
|
}
|
|
}
|
|
};
|
|
|
|
let usage = body.get("usageMetadata");
|
|
let input_tokens = usage
|
|
.and_then(|u| u.get("promptTokenCount"))
|
|
.and_then(|c| c.as_u64())
|
|
.unwrap_or(0) as u32;
|
|
let output_tokens = usage
|
|
.and_then(|u| u.get("candidatesTokenCount"))
|
|
.and_then(|c| c.as_u64())
|
|
.unwrap_or(0) as u32;
|
|
let cached_content_tokens = usage
|
|
.and_then(|u| u.get("cachedContentTokenCount"))
|
|
.and_then(|c| c.as_u64())
|
|
.unwrap_or(0) as u32;
|
|
|
|
// Extract additional metadata from non-SSE (legacy) responses.
|
|
let _model_version = body
|
|
.get("modelVersion")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
let _prompt_feedback = body.get("promptFeedback").cloned();
|
|
let _grounding_metadata = candidate.get("groundingMetadata").cloned();
|
|
let _citation_metadata = candidate.get("citationMetadata").cloned();
|
|
|
|
// Log prompt feedback if present
|
|
if let Some(ref pf) = _prompt_feedback
|
|
&& let Some(reason) = pf.get("blockReason").and_then(|r| r.as_str())
|
|
{
|
|
warn!(
|
|
block_reason = reason,
|
|
"Gemini API blocked the request via promptFeedback"
|
|
);
|
|
}
|
|
|
|
Ok((
|
|
CompletionResponse {
|
|
content: text_content,
|
|
finish_reason: stop_reason,
|
|
input_tokens,
|
|
output_tokens,
|
|
cache_read_input_tokens: cached_content_tokens,
|
|
cache_creation_input_tokens: 0,
|
|
},
|
|
tool_calls,
|
|
))
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl LlmProvider for GeminiOauthProvider {
|
|
fn model_name(&self) -> &str {
|
|
&self.config.model
|
|
}
|
|
|
|
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
|
|
let model = self.config.model.as_str();
|
|
let context_length = Some(gemini_context_length(model));
|
|
|
|
Ok(ModelMetadata {
|
|
id: self.config.model.clone(),
|
|
context_length,
|
|
})
|
|
}
|
|
|
|
fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
|
|
(rust_decimal::Decimal::ZERO, rust_decimal::Decimal::ZERO)
|
|
}
|
|
|
|
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
|
|
Ok(vec![
|
|
"gemini-3.1-pro-preview".to_string(),
|
|
"gemini-3.1-pro-preview-customtools".to_string(),
|
|
"gemini-3-pro-preview".to_string(),
|
|
"gemini-3-flash-preview".to_string(),
|
|
"gemini-3.1-flash-lite-preview".to_string(),
|
|
"gemini-2.5-pro".to_string(),
|
|
"gemini-2.5-flash".to_string(),
|
|
"gemini-2.5-flash-lite".to_string(),
|
|
])
|
|
}
|
|
|
|
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
|
let req_json = Self::to_gemini_request(
|
|
&request.messages,
|
|
None,
|
|
request.temperature,
|
|
request.max_tokens,
|
|
request.stop_sequences.as_deref(),
|
|
None,
|
|
&self.config.model,
|
|
);
|
|
let resp_json = self.send_request(&req_json).await?;
|
|
let (response, _tool_calls) = Self::from_gemini_response(resp_json)?;
|
|
Ok(response)
|
|
}
|
|
|
|
async fn complete_with_tools(
|
|
&self,
|
|
request: crate::llm::provider::ToolCompletionRequest,
|
|
) -> Result<crate::llm::provider::ToolCompletionResponse, LlmError> {
|
|
let tool_defs = if request.tools.is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.tools.as_slice())
|
|
};
|
|
|
|
let req_json = Self::to_gemini_request(
|
|
&request.messages,
|
|
tool_defs,
|
|
request.temperature,
|
|
request.max_tokens,
|
|
request.stop_sequences.as_deref(),
|
|
request.tool_choice.as_deref(),
|
|
&self.config.model,
|
|
);
|
|
let resp_json = self.send_request(&req_json).await?;
|
|
let (response, tool_calls) = Self::from_gemini_response(resp_json)?;
|
|
|
|
Ok(crate::llm::provider::ToolCompletionResponse {
|
|
content: if response.content.is_empty() {
|
|
None
|
|
} else {
|
|
Some(response.content)
|
|
},
|
|
finish_reason: response.finish_reason,
|
|
input_tokens: response.input_tokens,
|
|
output_tokens: response.output_tokens,
|
|
tool_calls,
|
|
cache_read_input_tokens: response.cache_read_input_tokens,
|
|
cache_creation_input_tokens: response.cache_creation_input_tokens,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_deobfuscate_reconstructs_credentials() {
|
|
let client_id = oauth_client_id();
|
|
assert!(client_id.ends_with(".apps.googleusercontent.com"));
|
|
assert!(client_id.starts_with("681"));
|
|
|
|
let client_secret = oauth_client_secret();
|
|
assert!(client_secret.starts_with("GOCSPX-"));
|
|
assert!(!client_secret.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_pkce_params_format() {
|
|
let params = generate_pkce_params();
|
|
|
|
assert_eq!(params.code_verifier.len(), 64);
|
|
assert_eq!(params.state.len(), 32);
|
|
assert!(!params.code_challenge.is_empty());
|
|
|
|
assert!(
|
|
params
|
|
.code_verifier
|
|
.chars()
|
|
.all(|c| { c.is_ascii_alphanumeric() || "-._~".contains(c) })
|
|
);
|
|
assert!(params.state.chars().all(|c| c.is_ascii_alphanumeric()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_callback_params_valid() {
|
|
let raw = "GET /auth/callback?code=abc123&state=xyz789 HTTP/1.1\r\nHost: localhost\r\n";
|
|
let (code, state, error) = CredentialManager::parse_callback_params(raw);
|
|
assert_eq!(code.as_deref(), Some("abc123"));
|
|
assert_eq!(state.as_deref(), Some("xyz789"));
|
|
assert!(error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_callback_params_with_error() {
|
|
let raw = "GET /auth/callback?error=access_denied HTTP/1.1\r\n";
|
|
let (code, state, error) = CredentialManager::parse_callback_params(raw);
|
|
assert!(code.is_none());
|
|
assert!(state.is_none());
|
|
assert_eq!(error.as_deref(), Some("access_denied"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_callback_params_empty() {
|
|
let (code, state, error) = CredentialManager::parse_callback_params("");
|
|
assert!(code.is_none());
|
|
assert!(state.is_none());
|
|
assert!(error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_retry_after_seconds() {
|
|
let result = GeminiOauthProvider::parse_retry_after(
|
|
"RESOURCE_EXHAUSTED: Your quota will reset after 46s.",
|
|
);
|
|
assert_eq!(result, Some(Duration::from_secs(48)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_retry_after_hours_minutes_seconds() {
|
|
let result =
|
|
GeminiOauthProvider::parse_retry_after("Your quota will reset after 18h31m10s.");
|
|
let expected = 18 * 3600 + 31 * 60 + 10 + 2;
|
|
assert_eq!(result, Some(Duration::from_secs(expected)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_retry_after_no_match() {
|
|
let result = GeminiOauthProvider::parse_retry_after("Some random error message");
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_redirect_url_valid() {
|
|
let url = "http://127.0.0.1:8080/auth/callback?code=4/abc&state=xyz123";
|
|
let result = CredentialManager::parse_redirect_url(url);
|
|
assert!(result.is_ok());
|
|
let (code, state) = result.unwrap();
|
|
assert_eq!(code, "4/abc");
|
|
assert_eq!(state, "xyz123");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_redirect_url_invalid() {
|
|
let result = CredentialManager::parse_redirect_url("not-a-url");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_redirect_url_missing_code() {
|
|
let url = "http://127.0.0.1:8080/auth/callback?state=xyz";
|
|
let result = CredentialManager::parse_redirect_url(url);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_gemini_request_with_tools() {
|
|
let messages = vec![ChatMessage::user("Hello")];
|
|
let tools = vec![ToolDefinition {
|
|
name: "read_file".to_string(),
|
|
description: "Read a file".to_string(),
|
|
parameters: serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"path": { "type": "string" }
|
|
}
|
|
}),
|
|
}];
|
|
|
|
let req = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
Some(&tools),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
"gemini-2.0-flash",
|
|
);
|
|
|
|
let decls = &req["tools"][0]["functionDeclarations"];
|
|
assert_eq!(decls[0]["name"], "read_file");
|
|
assert_eq!(decls[0]["description"], "Read a file");
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_gemini_request_tool_response() {
|
|
let messages = vec![
|
|
ChatMessage::user("Read /tmp/test"),
|
|
ChatMessage::tool_result("call_123", "read_file", "file contents here"),
|
|
];
|
|
|
|
let req = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
"gemini-2.0-flash",
|
|
);
|
|
|
|
let contents = req["contents"].as_array().unwrap();
|
|
assert_eq!(contents.len(), 2);
|
|
|
|
let tool_part = &contents[1]["parts"][0];
|
|
assert!(tool_part.get("functionResponse").is_some());
|
|
assert_eq!(tool_part["functionResponse"]["name"], "read_file");
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_gemini_response_text() {
|
|
let body = serde_json::json!({
|
|
"candidates": [{
|
|
"content": {
|
|
"parts": [{ "text": "Hello world" }]
|
|
},
|
|
"finishReason": "STOP"
|
|
}],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 10,
|
|
"candidatesTokenCount": 5
|
|
}
|
|
});
|
|
|
|
let (resp, tool_calls) = GeminiOauthProvider::from_gemini_response(body).unwrap();
|
|
|
|
assert_eq!(resp.content, "Hello world");
|
|
assert_eq!(resp.input_tokens, 10);
|
|
assert_eq!(resp.output_tokens, 5);
|
|
assert!(tool_calls.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_gemini_response_function_call() {
|
|
let body = serde_json::json!({
|
|
"candidates": [{
|
|
"content": {
|
|
"parts": [{
|
|
"functionCall": {
|
|
"name": "read_file",
|
|
"args": { "path": "/tmp/test.txt" }
|
|
}
|
|
}]
|
|
},
|
|
"finishReason": "STOP"
|
|
}],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 15,
|
|
"candidatesTokenCount": 8
|
|
}
|
|
});
|
|
|
|
let (resp, tool_calls) = GeminiOauthProvider::from_gemini_response(body).unwrap();
|
|
|
|
assert!(resp.content.is_empty());
|
|
assert_eq!(tool_calls.len(), 1);
|
|
assert_eq!(tool_calls[0].name, "read_file");
|
|
assert_eq!(tool_calls[0].arguments["path"], "/tmp/test.txt");
|
|
}
|
|
|
|
#[test]
|
|
fn test_generation_config_passed() {
|
|
let messages = vec![ChatMessage::user("Hi")];
|
|
|
|
let req = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
None,
|
|
Some(0.7),
|
|
Some(4096),
|
|
None,
|
|
None,
|
|
"gemini-2.0-flash",
|
|
);
|
|
|
|
let gen_cfg = &req["generationConfig"];
|
|
assert_eq!(gen_cfg["temperature"], 0.7_f32);
|
|
assert_eq!(gen_cfg["maxOutputTokens"], 4096);
|
|
assert!(gen_cfg.get("thinkingConfig").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_thinking_config_for_gemini3_thinking_level() {
|
|
let messages = vec![ChatMessage::user("Reason about this")];
|
|
|
|
let req = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
"gemini-3-flash-preview",
|
|
);
|
|
|
|
let thinking = &req["generationConfig"]["thinkingConfig"];
|
|
assert_eq!(thinking["thinkingLevel"], "HIGH");
|
|
assert!(thinking.get("includeThoughts").is_none());
|
|
assert!(thinking.get("thinkingBudget").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_thinking_config_for_gemini25_budget() {
|
|
let messages = vec![ChatMessage::user("Think about this")];
|
|
|
|
let req = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
"gemini-2.5-flash-thinking",
|
|
);
|
|
|
|
let thinking = &req["generationConfig"]["thinkingConfig"];
|
|
assert_eq!(thinking["thinkingBudget"], 8192);
|
|
// includeThoughts is NOT set — reasoning.rs strips thinking tags,
|
|
// so returning thoughts just causes empty responses.
|
|
assert!(thinking.get("includeThoughts").is_none() || thinking["includeThoughts"].is_null());
|
|
assert!(thinking.get("thinkingLevel").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_stop_sequences_in_generation_config() {
|
|
let messages = vec![ChatMessage::user("Hi")];
|
|
let stops = vec!["STOP1".to_string(), "STOP2".to_string()];
|
|
|
|
let req = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(&stops),
|
|
None,
|
|
"gemini-2.5-flash",
|
|
);
|
|
|
|
let gen_cfg = &req["generationConfig"];
|
|
let stop_seqs = gen_cfg["stopSequences"].as_array().unwrap();
|
|
assert_eq!(stop_seqs.len(), 2);
|
|
assert_eq!(stop_seqs[0], "STOP1");
|
|
assert_eq!(stop_seqs[1], "STOP2");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_config_mode_mapping() {
|
|
let messages = vec![ChatMessage::user("Use tools")];
|
|
|
|
let tools = vec![ToolDefinition {
|
|
name: "test".to_string(),
|
|
description: "test".to_string(),
|
|
parameters: serde_json::json!({}),
|
|
}];
|
|
|
|
let req_auto = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
Some(&tools),
|
|
None,
|
|
None,
|
|
None,
|
|
Some("auto"),
|
|
"gemini-2.0-flash",
|
|
);
|
|
assert_eq!(
|
|
req_auto["toolConfig"]["functionCallingConfig"]["mode"],
|
|
"AUTO"
|
|
);
|
|
|
|
let req_req = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
Some(&tools),
|
|
None,
|
|
None,
|
|
None,
|
|
Some("required"),
|
|
"gemini-2.0-flash",
|
|
);
|
|
assert_eq!(
|
|
req_req["toolConfig"]["functionCallingConfig"]["mode"],
|
|
"ANY"
|
|
);
|
|
|
|
let req_none = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
Some(&tools),
|
|
None,
|
|
None,
|
|
None,
|
|
Some("none"),
|
|
"gemini-2.0-flash",
|
|
);
|
|
assert_eq!(
|
|
req_none["toolConfig"]["functionCallingConfig"]["mode"],
|
|
"NONE"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_oauth_credential_debug_redaction() {
|
|
let cred = OAuthCredential {
|
|
access_token: "secret_access".to_string(),
|
|
refresh_token: Some("secret_refresh".to_string()),
|
|
id_token: Some("secret_id".to_string()),
|
|
token_type: Some("Bearer".to_string()),
|
|
project_id: Some("test-project".to_string()),
|
|
expiry_date: None,
|
|
};
|
|
let debug_str = format!("{:?}", cred);
|
|
assert!(!debug_str.contains("secret_access"));
|
|
assert!(!debug_str.contains("secret_refresh"));
|
|
assert!(!debug_str.contains("secret_id"));
|
|
assert!(debug_str.contains("[REDACTED]"));
|
|
assert!(debug_str.contains("test-project"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_uses_cloud_code_api_logic() {
|
|
let cases = [
|
|
("gemini-1.5-flash", false),
|
|
("gemini-1.5-pro", false),
|
|
("gemini-2.0-flash-exp", true),
|
|
("gemini-2.0-flash", true),
|
|
("gemini-2.0-flash-thinking", true),
|
|
("gemini-2.5-flash", true),
|
|
("gemini-3.0-flash-thinking-preview", true),
|
|
("gemini-3-pro", true),
|
|
("my-preview-custom", true), // contains "-preview", routes to Cloud Code
|
|
("mypreviewcustom", false), // no hyphen before "preview", no false positive
|
|
("not-a-gemini-model", false),
|
|
];
|
|
|
|
for (model, expected) in cases {
|
|
assert_eq!(
|
|
GeminiOauthProvider::model_uses_cloud_code_api(model),
|
|
expected,
|
|
"Model '{}': expected {}, got {}",
|
|
model,
|
|
expected,
|
|
!expected
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_gemini_request_system_instruction_concatenation() {
|
|
let messages = vec![
|
|
ChatMessage::system("System 1"),
|
|
ChatMessage::system("System 2"),
|
|
ChatMessage::user("User message"),
|
|
];
|
|
|
|
let req = GeminiOauthProvider::to_gemini_request(
|
|
&messages,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
"gemini-1.5-flash",
|
|
);
|
|
|
|
let system_instruction = req
|
|
.get("systemInstruction")
|
|
.expect("Missing systemInstruction");
|
|
let parts = system_instruction
|
|
.get("parts")
|
|
.and_then(|p| p.as_array())
|
|
.expect("Missing parts");
|
|
assert_eq!(parts.len(), 1);
|
|
let text = parts[0]
|
|
.get("text")
|
|
.and_then(|t| t.as_str())
|
|
.expect("Missing text");
|
|
assert!(text.contains("System 1"));
|
|
assert!(text.contains("System 2"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_curate_contents_preserves_tool_call_with_empty_text() {
|
|
// Regression: curate_contents must not drop model turns that contain
|
|
// functionCall parts just because an accompanying text part is empty.
|
|
let contents = vec![
|
|
serde_json::json!({
|
|
"role": "user",
|
|
"parts": [{ "text": "call the tool" }]
|
|
}),
|
|
serde_json::json!({
|
|
"role": "model",
|
|
"parts": [
|
|
{ "text": "" },
|
|
{ "functionCall": { "name": "echo", "args": { "msg": "hi" } } }
|
|
]
|
|
}),
|
|
serde_json::json!({
|
|
"role": "user",
|
|
"parts": [{ "functionResponse": { "name": "echo", "response": { "output": "hi" } } }]
|
|
}),
|
|
];
|
|
|
|
let curated = GeminiOauthProvider::curate_contents(&contents);
|
|
assert_eq!(curated.len(), 3, "All 3 turns should be preserved");
|
|
|
|
// The model turn should keep the functionCall part but drop the empty text
|
|
let model_parts = curated[1]
|
|
.get("parts")
|
|
.and_then(|p| p.as_array())
|
|
.expect("model turn should have parts");
|
|
assert_eq!(
|
|
model_parts.len(),
|
|
1,
|
|
"Empty text part should be filtered out"
|
|
);
|
|
assert!(
|
|
model_parts[0].get("functionCall").is_some(),
|
|
"functionCall part should be preserved"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_curate_contents_drops_fully_invalid_turn() {
|
|
// A model turn where ALL parts are invalid should be dropped.
|
|
let contents = vec![
|
|
serde_json::json!({
|
|
"role": "user",
|
|
"parts": [{ "text": "hello" }]
|
|
}),
|
|
serde_json::json!({
|
|
"role": "model",
|
|
"parts": [{ "text": "" }]
|
|
}),
|
|
serde_json::json!({
|
|
"role": "user",
|
|
"parts": [{ "text": "again" }]
|
|
}),
|
|
];
|
|
|
|
let curated = GeminiOauthProvider::curate_contents(&contents);
|
|
assert_eq!(curated.len(), 2, "Invalid model turn should be dropped");
|
|
assert_eq!(curated[0]["parts"][0]["text"], "hello");
|
|
assert_eq!(curated[1]["parts"][0]["text"], "again");
|
|
}
|
|
}
|