mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
feat(gemini): implement function calling, generationConfig, and update models
- Implement function calling support (functionDeclarations, functionResponse) - Add functionCall SSE parsing and empty stream retry support - Add generationConfig (temperature, maxOutputTokens) - Add thinkingConfig for Gemini 3 and thinking models - Add toolConfig (functionCallingConfig.mode) - Fix .expect() panics with .ok_or_else() - Restrict oauth credentials file permissions to 0600 - Update docs and FEATURE_PARITY.md - Update wizard to current Gemini 3.1 and 2.5 models
This commit is contained in:
+8
-3
@@ -3,6 +3,7 @@
|
||||
This document tracks feature parity between IronClaw (Rust implementation) and OpenClaw (TypeScript reference implementation). Use this to coordinate work across developers.
|
||||
|
||||
**Legend:**
|
||||
|
||||
- ✅ Implemented
|
||||
- 🚧 Partial (in progress or incomplete)
|
||||
- ❌ Not implemented
|
||||
@@ -183,7 +184,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
|
||||
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
|
||||
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
|
||||
| Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Thinking modes (low/med/high) | ✅ | 🚧 | thinkingConfig for Gemini models (includeThoughts); no per-level control yet |
|
||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
|
||||
| Block-level streaming | ✅ | ❌ | |
|
||||
| Tool-level streaming | ✅ | ❌ | |
|
||||
@@ -216,7 +217,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6 |
|
||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy |
|
||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
||||
| Google Gemini | ✅ | ❌ | P3 | |
|
||||
| Google Gemini | ✅ | ✅ | - | OAuth (PKCE + S256), function calling, thinkingConfig, generationConfig |
|
||||
| NVIDIA API | ✅ | ❌ | P3 | New provider |
|
||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||
@@ -440,7 +441,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Device pairing | ✅ | ❌ | |
|
||||
| Tailscale identity | ✅ | ❌ | |
|
||||
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
|
||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth + Gemini OAuth (PKCE, S256, loopback redirect, offline access) |
|
||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| Per-group tool policies | ✅ | ❌ | |
|
||||
@@ -497,6 +498,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
## Implementation Priorities
|
||||
|
||||
### P0 - Core (Already Done)
|
||||
|
||||
- ✅ TUI channel with approval overlays
|
||||
- ✅ HTTP webhook channel
|
||||
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
|
||||
@@ -524,6 +526,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ✅ OpenAI-compatible / OpenRouter provider support
|
||||
|
||||
### P1 - High Priority
|
||||
|
||||
- ❌ Slack channel (real implementation)
|
||||
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
|
||||
- ❌ WhatsApp channel
|
||||
@@ -531,6 +534,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
|
||||
|
||||
### P2 - Medium Priority
|
||||
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||
- ❌ Configuration hot-reload
|
||||
@@ -539,6 +543,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ❌ Partial output preservation on abort
|
||||
|
||||
### P3 - Lower Priority
|
||||
|
||||
- ❌ Discord channel
|
||||
- ❌ Matrix channel
|
||||
- ❌ Other messaging platforms
|
||||
|
||||
+44
-2
@@ -1,8 +1,8 @@
|
||||
# LLM Provider Configuration
|
||||
|
||||
IronClaw defaults to NEAR AI for model access, but supports any OpenAI-compatible
|
||||
endpoint as well as Anthropic and Ollama directly. This guide covers the most common
|
||||
configurations.
|
||||
endpoint as well as Anthropic, Ollama, and Google Gemini directly. This guide covers
|
||||
the most common configurations.
|
||||
|
||||
## Provider Overview
|
||||
|
||||
@@ -11,6 +11,7 @@ configurations.
|
||||
| NEAR AI | `nearai` | OAuth (browser) | Default; multi-model |
|
||||
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | Claude models |
|
||||
| OpenAI | `openai` | `OPENAI_API_KEY` | GPT models |
|
||||
| Google Gemini | `gemini_oauth` | OAuth (browser) | Gemini models; function calling |
|
||||
| Ollama | `ollama` | No | Local inference |
|
||||
| OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models |
|
||||
| Together AI | `openai_compatible` | `LLM_API_KEY` | Fast inference |
|
||||
@@ -54,6 +55,47 @@ Popular models: `gpt-4o`, `gpt-4o-mini`, `o3-mini`
|
||||
|
||||
---
|
||||
|
||||
## Google Gemini (OAuth)
|
||||
|
||||
Uses Google OAuth with PKCE (S256) for authentication — no API key required.
|
||||
On first run, a browser opens for Google account login. Credentials (including
|
||||
refresh token) are saved to `~/.gemini/oauth_creds.json` with `0600` permissions.
|
||||
|
||||
```env
|
||||
LLM_BACKEND=gemini_oauth
|
||||
GEMINI_MODEL=gemini-2.5-flash
|
||||
```
|
||||
|
||||
### Supported features
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Function calling | ✅ | `functionDeclarations` / `functionCall` / `functionResponse` |
|
||||
| `generationConfig` | ✅ | `temperature`, `maxOutputTokens` passed from request |
|
||||
| `thinkingConfig` | ✅ | `includeThoughts: true` for `gemini-3`/`thinking` models |
|
||||
| `toolConfig` | ✅ | `functionCallingConfig.mode`: `AUTO`/`ANY`/`NONE` |
|
||||
| SSE streaming | ✅ | Cloud Code API with `streamGenerateContent?alt=sse` |
|
||||
| Token refresh | ✅ | Automatic via refresh token |
|
||||
|
||||
### Popular models
|
||||
|
||||
| Model | ID | Notes |
|
||||
|---|---|---|
|
||||
| Gemini 3.1 Pro | `gemini-3.1-pro-preview` | Latest, strongest reasoning |
|
||||
| Gemini 3 Flash | `gemini-3-flash-preview` | Fast preview with thinkingLevel |
|
||||
| Gemini 2.5 Pro | `gemini-2.5-pro` | Stable, strong reasoning |
|
||||
| Gemini 2.5 Flash | `gemini-2.5-flash` | Fast, good quality |
|
||||
| Gemini 2.5 Flash Lite | `gemini-2.5-flash-lite` | Fastest, lightweight |
|
||||
|
||||
### Cloud Code API vs standard API
|
||||
|
||||
Models containing `preview` or `gemini-3` in the name route through the
|
||||
Cloud Code API (`cloudcode-pa.googleapis.com`) which supports SSE streaming
|
||||
and project-scoped access. Other models use the standard Generative Language
|
||||
API (`generativelanguage.googleapis.com`).
|
||||
|
||||
---
|
||||
|
||||
## Ollama (local)
|
||||
|
||||
Install Ollama from [ollama.com](https://ollama.com), pull a model, then:
|
||||
|
||||
+2
-3
@@ -378,9 +378,8 @@ impl LlmConfig {
|
||||
let credentials_path = optional_env("GEMINI_CREDENTIALS_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
crate::bootstrap::ironclaw_base_dir()
|
||||
.parent() // ~/.ironclaw -> ~/
|
||||
.expect("ironclaw_base_dir has no parent")
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(".gemini")
|
||||
.join("oauth_creds.json")
|
||||
});
|
||||
|
||||
+670
-99
@@ -16,8 +16,8 @@ use url::Url;
|
||||
use crate::config::GeminiOauthConfig;
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
|
||||
Role, ToolCall,
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason,
|
||||
LlmProvider, ModelMetadata, Role, ToolCall, ToolDefinition,
|
||||
};
|
||||
|
||||
// Official Gemini CLI OAuth credentials (public, from google/gemini-cli).
|
||||
@@ -35,14 +35,13 @@ fn deobfuscate(parts: &[&str]) -> String {
|
||||
|
||||
fn oauth_client_id() -> String {
|
||||
deobfuscate(&[
|
||||
"59390855218", // 681255809395 (rev)
|
||||
"rdpo2tF8oo-", // -oo8ft2oprd (rev)
|
||||
"6fa3e9pnrn", // rnp9e3aqf6 (rev)
|
||||
"593908552186", // 681255809395 (rev)
|
||||
"drpo2tf8oo-", // -oo8ft2oprd (rev)
|
||||
"6fqa3e9pnr", // rnp9e3aqf6 (rev)
|
||||
"idmh3va", // av3hmdi (rev)
|
||||
"j531b", // b135j (rev)
|
||||
"sgoog.sppa.", // .apps.goog (rev)
|
||||
"tnetnoc", // content (rev)
|
||||
"resu.el", // le.user (rev)
|
||||
"goog.sppa.", // .apps.goog (rev)
|
||||
"tnetnocresuel", // leusercontent (rev)
|
||||
"moc.", // .com (rev)
|
||||
])
|
||||
}
|
||||
@@ -60,6 +59,12 @@ fn oauth_client_secret() -> String {
|
||||
}
|
||||
|
||||
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 = "gl-node/22.17.0";
|
||||
|
||||
const PKCE_CHARSET: &[u8] =
|
||||
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
|
||||
const STATE_CHARSET: &[u8] =
|
||||
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
/// Token representation matching Node.js `Credentials` format from `google-auth-library`
|
||||
/// usually stored in `~/.gemini/oauth_creds.json`
|
||||
@@ -108,11 +113,8 @@ fn generate_pkce_params() -> PKCEParams {
|
||||
let mut rng = rand::thread_rng();
|
||||
let code_verifier: String = (0..64)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..62);
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"
|
||||
.chars()
|
||||
.nth(idx)
|
||||
.unwrap()
|
||||
let idx = rng.gen_range(0..PKCE_CHARSET.len());
|
||||
PKCE_CHARSET[idx] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -123,11 +125,8 @@ fn generate_pkce_params() -> PKCEParams {
|
||||
|
||||
let state: String = (0..32)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..62);
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
.chars()
|
||||
.nth(idx)
|
||||
.unwrap()
|
||||
let idx = rng.gen_range(0..STATE_CHARSET.len());
|
||||
STATE_CHARSET[idx] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -168,6 +167,14 @@ impl CredentialManager {
|
||||
}
|
||||
let updated_content = serde_json::to_string_pretty(credential)?;
|
||||
fs::write(&self.profiles_path, updated_content)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(&self.profiles_path, perms)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -247,7 +254,10 @@ impl CredentialManager {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -363,8 +373,6 @@ impl CredentialManager {
|
||||
return Err(anyhow!("Invalid 'state' parameter. Possible CSRF attack."));
|
||||
}
|
||||
|
||||
let code = code;
|
||||
|
||||
// 5. Exchange code for tokens
|
||||
let response = self
|
||||
.client
|
||||
@@ -382,7 +390,10 @@ impl CredentialManager {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -403,7 +414,7 @@ impl CredentialManager {
|
||||
.client
|
||||
.post("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist")
|
||||
.bearer_auth(&token_resp.access_token)
|
||||
.header("X-Goog-Api-Client", "gl-node/22.17.0")
|
||||
.header("X-Goog-Api-Client", GOOG_API_CLIENT)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({
|
||||
"metadata": client_metadata
|
||||
@@ -413,7 +424,13 @@ impl CredentialManager {
|
||||
|
||||
let mut project_id = None;
|
||||
if load_resp.status().is_success() {
|
||||
let load_data: serde_json::Value = load_resp.json().await.unwrap_or_default();
|
||||
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);
|
||||
@@ -427,7 +444,7 @@ impl CredentialManager {
|
||||
.client
|
||||
.post("https://cloudcode-pa.googleapis.com/v1internal:onboardUser")
|
||||
.bearer_auth(&token_resp.access_token)
|
||||
.header("X-Goog-Api-Client", "gl-node/22.17.0")
|
||||
.header("X-Goog-Api-Client", GOOG_API_CLIENT)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({
|
||||
"tierId": "free-tier",
|
||||
@@ -437,7 +454,13 @@ impl CredentialManager {
|
||||
.await?;
|
||||
|
||||
if onboard_resp.status().is_success() {
|
||||
let mut lro_data: serde_json::Value = onboard_resp.json().await.unwrap_or_default();
|
||||
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 {
|
||||
@@ -449,13 +472,19 @@ impl CredentialManager {
|
||||
.client
|
||||
.get(&format!("https://cloudcode-pa.googleapis.com/v1internal/{}", op_name))
|
||||
.bearer_auth(&token_resp.access_token)
|
||||
.header("X-Goog-Api-Client", "gl-node/22.17.0")
|
||||
.header("X-Goog-Api-Client", GOOG_API_CLIENT)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Ok(resp) = poll_resp {
|
||||
if resp.status().is_success() {
|
||||
lro_data = resp.json().await.unwrap_or_default();
|
||||
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 {
|
||||
@@ -473,7 +502,10 @@ impl CredentialManager {
|
||||
println!("Provisioned project: {}", pid);
|
||||
}
|
||||
} else {
|
||||
let err_text = onboard_resp.text().await.unwrap_or_default();
|
||||
let err_text = onboard_resp.text().await.unwrap_or_else(|e| {
|
||||
warn!(error = %e, "Failed to read onboard error body");
|
||||
String::new()
|
||||
});
|
||||
println!("⚠️ Failed to provision Cloud Code project: {}", err_text);
|
||||
}
|
||||
}
|
||||
@@ -630,7 +662,7 @@ impl GeminiOauthProvider {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert("Content-Type", "application/json".parse().unwrap());
|
||||
headers.insert("User-Agent", "google-cloud-sdk vscode_cloudshelleditor/0.1".parse().unwrap());
|
||||
headers.insert("X-Goog-Api-Client", "gl-node/22.17.0".parse().unwrap());
|
||||
headers.insert("X-Goog-Api-Client", GOOG_API_CLIENT.parse().unwrap());
|
||||
headers.insert("Client-Metadata", "{\"ideType\":\"IDE_UNSPECIFIED\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}".parse().unwrap());
|
||||
|
||||
(url, req, headers)
|
||||
@@ -671,50 +703,102 @@ impl GeminiOauthProvider {
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
let mut success = false;
|
||||
if self.config.model.contains("preview") || self.config.model.contains("gemini-3") {
|
||||
if self.config.model.contains("preview")
|
||||
|| self.config.model.contains("gemini-3")
|
||||
{
|
||||
let mut combined_text = String::new();
|
||||
let mut finish_reason = "STOP".to_string();
|
||||
let mut prompt_tokens = 0;
|
||||
let mut candidates_tokens = 0;
|
||||
|
||||
let mut prompt_tokens: i64 = 0;
|
||||
let mut candidates_tokens: i64 = 0;
|
||||
let mut tool_calls_parts = Vec::<serde_json::Value>::new();
|
||||
|
||||
for line in body_str.lines() {
|
||||
if line.starts_with("data:") {
|
||||
let json_str = line[5..].trim();
|
||||
if let Ok(chunk) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||||
if let Some(resp) = chunk.get("response") {
|
||||
// Extract text
|
||||
if let Some(candidates) = resp.get("candidates").and_then(|c| c.as_array()) {
|
||||
if 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()) {
|
||||
combined_text.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(fr) = first.get("finishReason").and_then(|fr| fr.as_str()) {
|
||||
finish_reason = fr.to_string();
|
||||
if !line.starts_with("data:") {
|
||||
continue;
|
||||
}
|
||||
let json_str = line[5..].trim();
|
||||
let chunk: serde_json::Value = match serde_json::from_str(json_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let resp = match chunk.get("response") {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(candidates) = resp
|
||||
.get("candidates")
|
||||
.and_then(|c| c.as_array())
|
||||
{
|
||||
if 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extract usage
|
||||
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(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 !combined_text.is_empty() {
|
||||
|
||||
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": [{"text": combined_text}]
|
||||
"parts": response_parts
|
||||
},
|
||||
"finishReason": finish_reason
|
||||
}],
|
||||
@@ -761,13 +845,16 @@ impl GeminiOauthProvider {
|
||||
/// 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;
|
||||
|
||||
let re_pattern = regex::Regex::new(
|
||||
r"reset after (?:(\d+)h)?(?:(\d+)m)?(\d+)s"
|
||||
).ok()?;
|
||||
static RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(
|
||||
r"reset after (?:(\d+)h)?(?:(\d+)m)?(\d+)s"
|
||||
).expect("invalid retry_after regex")
|
||||
});
|
||||
|
||||
let caps = re_pattern.captures(message)?;
|
||||
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)
|
||||
@@ -785,7 +872,11 @@ impl GeminiOauthProvider {
|
||||
|
||||
fn to_gemini_request(
|
||||
messages: &[ChatMessage],
|
||||
_tools: Option<&[ToolCall]>,
|
||||
tools: Option<&[ToolDefinition]>,
|
||||
temperature: Option<f32>,
|
||||
max_tokens: Option<u32>,
|
||||
tool_choice: Option<&str>,
|
||||
model: &str,
|
||||
) -> serde_json::Value {
|
||||
let mut contents = Vec::new();
|
||||
let mut system_instruction = None;
|
||||
@@ -810,11 +901,51 @@ impl GeminiOauthProvider {
|
||||
}));
|
||||
}
|
||||
Role::Tool => {
|
||||
// Quick conversion for tool calls (this is an approximation, real Google APIs might require different format)
|
||||
contents.push(serde_json::json!({
|
||||
"role": "user",
|
||||
"parts": [{ "text": format!("Tool response:\n{}", msg.content) }]
|
||||
}));
|
||||
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())
|
||||
.map_or(false, |parts| {
|
||||
parts.iter().any(|p| p.get("functionResponse").is_some())
|
||||
});
|
||||
|
||||
if merge {
|
||||
if let Some(c) = contents.last_mut() {
|
||||
if 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]
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -827,10 +958,71 @@ impl GeminiOauthProvider {
|
||||
req["systemInstruction"] = sys;
|
||||
}
|
||||
|
||||
if let Some(tool_defs) = tools {
|
||||
if !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),
|
||||
);
|
||||
}
|
||||
|
||||
let is_thinking_model = model.contains("thinking")
|
||||
|| model.contains("gemini-3");
|
||||
if is_thinking_model {
|
||||
gen_config.insert(
|
||||
"thinkingConfig".to_string(),
|
||||
serde_json::json!({ "includeThoughts": true }),
|
||||
);
|
||||
}
|
||||
|
||||
if !gen_config.is_empty() {
|
||||
req["generationConfig"] =
|
||||
serde_json::Value::Object(gen_config);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
req
|
||||
}
|
||||
|
||||
fn from_gemini_response(body: serde_json::Value) -> Result<CompletionResponse, LlmError> {
|
||||
fn from_gemini_response(
|
||||
body: serde_json::Value,
|
||||
) -> Result<(CompletionResponse, Vec<ToolCall>), LlmError> {
|
||||
let candidate = body
|
||||
.get("candidates")
|
||||
.and_then(|c| c.as_array())
|
||||
@@ -840,15 +1032,40 @@ impl GeminiOauthProvider {
|
||||
reason: "Response missing 'candidates[0]'".to_string(),
|
||||
})?;
|
||||
|
||||
let content_text = candidate
|
||||
let parts = candidate
|
||||
.get("content")
|
||||
.and_then(|c| c.get("parts"))
|
||||
.and_then(|p| p.as_array())
|
||||
.and_then(|p| p.first())
|
||||
.and_then(|p| p.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finish_reason = candidate
|
||||
.get("finishReason")
|
||||
@@ -856,9 +1073,21 @@ impl GeminiOauthProvider {
|
||||
.unwrap_or("STOP");
|
||||
|
||||
let stop_reason = match finish_reason {
|
||||
"STOP" => FinishReason::Stop,
|
||||
"STOP" => {
|
||||
if !tool_calls.is_empty() {
|
||||
FinishReason::ToolUse
|
||||
} else {
|
||||
FinishReason::Stop
|
||||
}
|
||||
}
|
||||
"MAX_TOKENS" => FinishReason::Length,
|
||||
_ => FinishReason::Stop,
|
||||
_ => {
|
||||
if !tool_calls.is_empty() {
|
||||
FinishReason::ToolUse
|
||||
} else {
|
||||
FinishReason::Stop
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let usage = body.get("usageMetadata");
|
||||
@@ -871,12 +1100,15 @@ impl GeminiOauthProvider {
|
||||
.and_then(|c| c.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
Ok(CompletionResponse {
|
||||
content: content_text,
|
||||
finish_reason: stop_reason,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
})
|
||||
Ok((
|
||||
CompletionResponse {
|
||||
content: text_content,
|
||||
finish_reason: stop_reason,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
},
|
||||
tool_calls,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,33 +1130,372 @@ impl LlmProvider for GeminiOauthProvider {
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let req_json = Self::to_gemini_request(&request.messages, None);
|
||||
let req_json = Self::to_gemini_request(
|
||||
&request.messages,
|
||||
None,
|
||||
request.temperature,
|
||||
request.max_tokens,
|
||||
None,
|
||||
&self.config.model,
|
||||
);
|
||||
let resp_json = self.send_request(&req_json).await?;
|
||||
Self::from_gemini_response(resp_json)
|
||||
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> {
|
||||
// Fallback for completion without tools
|
||||
let comp_req = CompletionRequest {
|
||||
messages: request.messages,
|
||||
model: request.model,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
stop_sequences: None, // No stop_sequences in ToolCompletionRequest
|
||||
metadata: request.metadata,
|
||||
let tool_defs = if request.tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.tools.as_slice())
|
||||
};
|
||||
|
||||
let response = self.complete(comp_req).await?;
|
||||
let req_json = Self::to_gemini_request(
|
||||
&request.messages,
|
||||
tool_defs,
|
||||
request.temperature,
|
||||
request.max_tokens,
|
||||
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: Some(response.content),
|
||||
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: vec![],
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
role: Role::User,
|
||||
content: "Hello".to_string(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
];
|
||||
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,
|
||||
"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 {
|
||||
role: Role::User,
|
||||
content: "Read /tmp/test".to_string(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
ChatMessage {
|
||||
role: Role::Tool,
|
||||
content: "file contents here".to_string(),
|
||||
tool_call_id: Some("call_123".to_string()),
|
||||
name: Some("read_file".to_string()),
|
||||
tool_calls: None,
|
||||
},
|
||||
];
|
||||
|
||||
let req = GeminiOauthProvider::to_gemini_request(
|
||||
&messages, 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 {
|
||||
role: Role::User,
|
||||
content: "Hi".to_string(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
}];
|
||||
|
||||
let req = GeminiOauthProvider::to_gemini_request(
|
||||
&messages, None,
|
||||
Some(0.7), Some(4096), 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() {
|
||||
let messages = vec![ChatMessage {
|
||||
role: Role::User,
|
||||
content: "Reason about this".to_string(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
}];
|
||||
|
||||
let req = GeminiOauthProvider::to_gemini_request(
|
||||
&messages, None, None, None, None,
|
||||
"gemini-3.0-flash-thinking",
|
||||
);
|
||||
|
||||
let thinking = &req["generationConfig"]["thinkingConfig"];
|
||||
assert_eq!(thinking["includeThoughts"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_config_mode_mapping() {
|
||||
let messages = vec![ChatMessage {
|
||||
role: Role::User,
|
||||
content: "Use tools".to_string(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
}];
|
||||
|
||||
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, 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, 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, Some("none"),
|
||||
"gemini-2.0-flash",
|
||||
);
|
||||
assert_eq!(
|
||||
req_none["toolConfig"]["functionCallingConfig"]["mode"],
|
||||
"NONE"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -475,6 +475,7 @@ mod tests {
|
||||
ollama: None,
|
||||
openai_compatible: None,
|
||||
tinfoil: None,
|
||||
gemini_oauth: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,6 +521,8 @@ pub fn create_gemini_oauth_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPro
|
||||
let gemini_config = config
|
||||
.gemini_oauth
|
||||
.clone()
|
||||
.expect("Gemini OAuth config must be present when backend is GeminiOauth");
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "gemini_oauth".to_string(),
|
||||
})?;
|
||||
Ok(Arc::new(gemini_oauth::GeminiOauthProvider::new(gemini_config)))
|
||||
}
|
||||
|
||||
+5
-4
@@ -1209,10 +1209,11 @@ impl SetupWizard {
|
||||
}
|
||||
"gemini_oauth" => {
|
||||
let default_models: Vec<(String, String)> = vec![
|
||||
("gemini-3-flash-preview".into(), "Gemini 3 Flash (Preview)".into()),
|
||||
("gemini-3-pro-preview".into(), "Gemini 3 Pro (Preview)".into()),
|
||||
("gemini-3.1-pro-preview".into(), "Gemini 3.1 Pro (Preview)".into()),
|
||||
("gemini-3.1-pro-preview-customtools".into(), "Gemini 3.1 Pro Custom Tools (Preview)".into()),
|
||||
("gemini-3.1-pro-preview".into(), "Gemini 3.1 Pro (Latest, strongest reasoning)".into()),
|
||||
("gemini-3-flash-preview".into(), "Gemini 3 Flash (Fast preview with thinking)".into()),
|
||||
("gemini-2.5-pro".into(), "Gemini 2.5 Pro (Stable, strong reasoning)".into()),
|
||||
("gemini-2.5-flash".into(), "Gemini 2.5 Flash (Fast, good quality)".into()),
|
||||
("gemini-2.5-flash-lite".into(), "Gemini 2.5 Flash Lite (Fastest, lightweight)".into()),
|
||||
];
|
||||
self.select_from_model_list(&default_models)?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user