mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86853244ca | ||
|
|
dd9057d069 | ||
|
|
3a72b71d97 | ||
|
|
94f88231a1 | ||
|
|
99566bb997 | ||
|
|
cbf96f25c9 | ||
|
|
a070c069cb | ||
|
|
abf13a3ee2 | ||
|
|
21abbe5691 | ||
|
|
9be29b2c22 | ||
|
|
09a2320e64 | ||
|
|
d4bfc2db58 | ||
|
|
6e86f50bc5 | ||
|
|
12b0e90a7a | ||
|
|
4a2950e777 | ||
|
|
b35771d505 | ||
|
|
e4e747ba54 | ||
|
|
fdb0077736 | ||
|
|
8452102454 | ||
|
|
727283afe3 |
+18
-1
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
|
||||
|
||||
# LLM Provider
|
||||
# LLM_BACKEND=nearai # default
|
||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex
|
||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex, gemini_oauth
|
||||
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
|
||||
|
||||
# === Anthropic Direct ===
|
||||
@@ -110,6 +110,23 @@ NEARAI_AUTH_URL=https://private.near.ai
|
||||
# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
|
||||
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
|
||||
|
||||
# === Google Gemini (OAuth, Gemini CLI compatible) ===
|
||||
# LLM_BACKEND=gemini_oauth
|
||||
# GEMINI_MODEL=gemini-2.5-flash # default
|
||||
# GEMINI_CREDENTIALS_PATH=~/.gemini/oauth_creds.json # default
|
||||
# GEMINI_API_KEY=... # optional: use API key instead of OAuth
|
||||
# GEMINI_API_KEY_AUTH_MECHANISM=query # "query" (default) or "header"
|
||||
# GEMINI_SAFETY_BLOCK_NONE=true # disable safety filters (default: false)
|
||||
# GEMINI_CLI_CUSTOM_HEADERS=Key:Value,Key2:Value2
|
||||
# GEMINI_TOP_P=0.95
|
||||
# GEMINI_TOP_K=40
|
||||
# GEMINI_SEED=42
|
||||
# GEMINI_PRESENCE_PENALTY=0.0
|
||||
# GEMINI_FREQUENCY_PENALTY=0.0
|
||||
# GEMINI_RESPONSE_MIME_TYPE=application/json
|
||||
# GEMINI_RESPONSE_JSON_SCHEMA={"type":"object"}
|
||||
# GEMINI_CACHED_CONTENT=cachedContents/abc123
|
||||
|
||||
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||
|
||||
# Channel Configuration
|
||||
|
||||
+14
-5
@@ -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
|
||||
@@ -204,7 +205,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 (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | 🚧 | thinkingConfig for Gemini models (thinkingBudget/thinkingLevel); no per-level control yet |
|
||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive |
|
||||
| Block-level streaming | ✅ | ❌ | |
|
||||
| Tool-level streaming | ✅ | ❌ | |
|
||||
@@ -236,9 +237,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| NEAR AI | ✅ | ✅ | - | Primary provider |
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default |
|
||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth |
|
||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
||||
| Google Gemini | ✅ | ❌ | P3 | |
|
||||
| NVIDIA API | ✅ | ❌ | P3 | New provider |
|
||||
| AWS Bedrock | ✅ | ✅ | - | Native Converse API via aws-sdk-bedrockruntime (requires `--features bedrock`) |
|
||||
| Google Gemini | ✅ | ✅ | - | OAuth (PKCE + S256), function calling, thinkingConfig, generationConfig |
|
||||
| io.net | ✅ | ✅ | P3 | Via `ionet` adapter |
|
||||
| Mistral | ✅ | ✅ | P3 | Via `mistral` adapter |
|
||||
| Yandex AI Studio | ✅ | ✅ | P3 | Via `yandex` adapter |
|
||||
| Cloudflare Workers AI | ✅ | ✅ | P3 | Via `cloudflare` adapter |
|
||||
| NVIDIA API | ✅ | ✅ | P3 | Via `nvidia` adapter and `providers.json` |
|
||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||
@@ -466,7 +471,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 plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
|
||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth + Gemini OAuth (PKCE, S256) + hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
|
||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| Per-group tool policies | ✅ | ❌ | |
|
||||
@@ -523,6 +528,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)
|
||||
@@ -550,6 +556,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
|
||||
@@ -557,6 +564,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
|
||||
@@ -565,6 +573,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
|
||||
|
||||
+48
-3
@@ -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,7 +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` | `GEMINI_API_KEY` | Gemini models |
|
||||
| Google Gemini | `gemini_oauth` | OAuth (browser) | Gemini models; function calling |
|
||||
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
|
||||
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
|
||||
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
||||
@@ -62,6 +62,51 @@ 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` | ✅ | `thinkingBudget`/`thinkingLevel` for thinking-capable models (does NOT set `includeThoughts`) |
|
||||
| `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.1 Pro Custom Tools | `gemini-3.1-pro-preview-customtools` | Enhanced tool use |
|
||||
| Gemini 3 Pro | `gemini-3-pro-preview` | Preview |
|
||||
| Gemini 3 Flash | `gemini-3-flash-preview` | Fast preview with thinking |
|
||||
| Gemini 3.1 Flash Lite | `gemini-3.1-flash-lite-preview` | Preview, lightweight |
|
||||
| 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` (with hyphen) or `gemini-3` in the name, as well
|
||||
as any `gemini-` model with major version >= 2, 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`).
|
||||
|
||||
---
|
||||
|
||||
## GitHub Copilot
|
||||
|
||||
GitHub Copilot exposes chat endpoint at
|
||||
|
||||
+7
-7
@@ -729,13 +729,13 @@ impl AppBuilder {
|
||||
self.init_database().await?;
|
||||
self.init_secrets().await?;
|
||||
|
||||
// Post-init validation: if a non-nearai backend was selected but
|
||||
// credentials were never resolved (deferred resolution found no keys),
|
||||
// fail early with a clear error instead of a confusing runtime failure.
|
||||
if self.config.llm.backend != "nearai"
|
||||
&& self.config.llm.backend != "bedrock"
|
||||
&& self.config.llm.backend != "openai_codex"
|
||||
&& self.config.llm.provider.is_none()
|
||||
// Post-init validation: backends with dedicated config (nearai, gemini_oauth,
|
||||
// bedrock, openai_codex) handle their own credential resolution. For registry-based
|
||||
// backends, fail early if no provider config was resolved.
|
||||
if !matches!(
|
||||
self.config.llm.backend.as_str(),
|
||||
"nearai" | "gemini_oauth" | "bedrock" | "openai_codex"
|
||||
) && self.config.llm.provider.is_none()
|
||||
{
|
||||
let backend = &self.config.llm.backend;
|
||||
anyhow::bail!(
|
||||
|
||||
+26
-3
@@ -9,6 +9,7 @@ use crate::llm::config::*;
|
||||
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
||||
use crate::llm::session::SessionConfig;
|
||||
use crate::settings::Settings;
|
||||
|
||||
impl LlmConfig {
|
||||
/// Create a test-friendly config without reading env vars.
|
||||
#[cfg(feature = "libsql")]
|
||||
@@ -37,6 +38,7 @@ impl LlmConfig {
|
||||
},
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
gemini_oauth: None,
|
||||
openai_codex: None,
|
||||
request_timeout_secs: 120,
|
||||
cheap_model: None,
|
||||
@@ -73,11 +75,16 @@ impl LlmConfig {
|
||||
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
||||
let is_bedrock =
|
||||
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
|
||||
let is_gemini_oauth = backend_lower == "gemini_oauth" || backend_lower == "gemini-oauth";
|
||||
let is_openai_codex = backend_lower == "openai_codex"
|
||||
|| backend_lower == "openai-codex"
|
||||
|| backend_lower == "codex";
|
||||
|
||||
if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
|
||||
if !is_nearai
|
||||
&& !is_bedrock
|
||||
&& !is_gemini_oauth
|
||||
&& !is_openai_codex
|
||||
&& registry.find(&backend_lower).is_none()
|
||||
{
|
||||
tracing::warn!(
|
||||
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
||||
@@ -131,8 +138,8 @@ impl LlmConfig {
|
||||
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
||||
};
|
||||
|
||||
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
|
||||
let provider = if is_nearai || is_bedrock || is_openai_codex {
|
||||
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Gemini, non-Codex backends)
|
||||
let provider = if is_nearai || is_bedrock || is_gemini_oauth || is_openai_codex {
|
||||
None
|
||||
} else {
|
||||
Some(Self::resolve_registry_provider(
|
||||
@@ -213,6 +220,19 @@ impl LlmConfig {
|
||||
|
||||
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
|
||||
|
||||
let gemini_oauth = if backend_lower == "gemini_oauth" || backend_lower == "gemini-oauth" {
|
||||
let model = Self::resolve_model("GEMINI_MODEL", settings, "gemini-2.5-flash")?;
|
||||
let credentials_path = optional_env("GEMINI_CREDENTIALS_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(GeminiOauthConfig::default_credentials_path);
|
||||
Some(GeminiOauthConfig {
|
||||
model,
|
||||
credentials_path,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Generic cheap model (works with any backend).
|
||||
// Falls back to NearAI-specific cheap_model in provider chain logic.
|
||||
let cheap_model = optional_env("LLM_CHEAP_MODEL")?;
|
||||
@@ -226,6 +246,8 @@ impl LlmConfig {
|
||||
"nearai".to_string()
|
||||
} else if is_bedrock {
|
||||
"bedrock".to_string()
|
||||
} else if is_gemini_oauth {
|
||||
"gemini_oauth".to_string()
|
||||
} else if is_openai_codex {
|
||||
"openai_codex".to_string()
|
||||
} else if let Some(ref p) = provider {
|
||||
@@ -237,6 +259,7 @@ impl LlmConfig {
|
||||
nearai,
|
||||
provider,
|
||||
bedrock,
|
||||
gemini_oauth,
|
||||
openai_codex,
|
||||
request_timeout_secs,
|
||||
cheap_model,
|
||||
|
||||
+2
-2
@@ -56,8 +56,8 @@ pub use self::tunnel::TunnelConfig;
|
||||
pub use self::wasm::WasmConfig;
|
||||
pub use self::workspace::WorkspaceConfig;
|
||||
pub use crate::llm::config::{
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
|
||||
RegistryProviderConfig,
|
||||
BedrockConfig, CacheRetention, GeminiOauthConfig, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
||||
OpenAiCodexConfig, RegistryProviderConfig,
|
||||
};
|
||||
pub use crate::llm::session::SessionConfig;
|
||||
|
||||
|
||||
@@ -165,6 +165,8 @@ pub struct LlmConfig {
|
||||
pub provider: Option<RegistryProviderConfig>,
|
||||
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
||||
pub bedrock: Option<BedrockConfig>,
|
||||
/// Gemini OAuth config (populated when backend=gemini_oauth).
|
||||
pub gemini_oauth: Option<GeminiOauthConfig>,
|
||||
/// OpenAI Codex config (populated when backend=openai_codex).
|
||||
pub openai_codex: Option<OpenAiCodexConfig>,
|
||||
/// HTTP request timeout in seconds for LLM API calls.
|
||||
@@ -267,3 +269,34 @@ impl NearAiConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for Gemini OAuth integration.
|
||||
///
|
||||
/// Extended generation config parameters (topP, topK, seed, etc.) are read from
|
||||
/// environment variables at request time:
|
||||
/// - `GEMINI_TOP_P` — nucleus sampling (0.0–1.0)
|
||||
/// - `GEMINI_TOP_K` — top-k sampling (integer)
|
||||
/// - `GEMINI_SEED` — deterministic generation seed
|
||||
/// - `GEMINI_PRESENCE_PENALTY` — presence penalty (-2.0–2.0)
|
||||
/// - `GEMINI_FREQUENCY_PENALTY` — frequency penalty (-2.0–2.0)
|
||||
/// - `GEMINI_RESPONSE_MIME_TYPE` — e.g. "application/json"
|
||||
/// - `GEMINI_RESPONSE_JSON_SCHEMA` — JSON schema string for structured output
|
||||
/// - `GEMINI_CACHED_CONTENT` — cached content resource name
|
||||
/// - `GEMINI_CLI_CUSTOM_HEADERS` — custom headers (key:value,key:value)
|
||||
/// - `GOOGLE_GENAI_API_VERSION` — API version (default: v1beta)
|
||||
/// - `GEMINI_API_KEY` — optional API key for non-OAuth auth mode
|
||||
/// - `GEMINI_API_KEY_AUTH_MECHANISM` — "x-goog-api-key" (default) or "bearer"
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeminiOauthConfig {
|
||||
pub model: String,
|
||||
pub credentials_path: PathBuf,
|
||||
}
|
||||
|
||||
impl GeminiOauthConfig {
|
||||
pub fn default_credentials_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gemini")
|
||||
.join("oauth_creds.json")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ pub mod config;
|
||||
pub mod costs;
|
||||
pub mod error;
|
||||
pub mod failover;
|
||||
pub mod gemini_oauth;
|
||||
mod github_copilot;
|
||||
pub(crate) mod github_copilot_auth;
|
||||
mod nearai_chat;
|
||||
@@ -50,6 +51,7 @@ pub use config::{
|
||||
};
|
||||
pub use error::LlmError;
|
||||
pub use failover::{CooldownConfig, FailoverProvider};
|
||||
pub use gemini_oauth::GeminiOauthProvider;
|
||||
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
|
||||
pub use openai_codex_provider::OpenAiCodexProvider;
|
||||
pub use openai_codex_session::{OpenAiCodexSession, OpenAiCodexSessionManager};
|
||||
@@ -93,6 +95,10 @@ pub async fn create_llm_provider(
|
||||
return create_llm_provider_with_config(&config.nearai, session, timeout);
|
||||
}
|
||||
|
||||
if config.backend == "gemini_oauth" || config.backend == "gemini-oauth" {
|
||||
return create_gemini_oauth_provider(config);
|
||||
}
|
||||
|
||||
// Bedrock uses a native AWS SDK, not the rig-core registry
|
||||
if config.backend == "bedrock" {
|
||||
#[cfg(feature = "bedrock")]
|
||||
@@ -490,6 +496,19 @@ fn create_cheap_provider_for_backend(
|
||||
});
|
||||
}
|
||||
|
||||
if config.backend == "gemini_oauth" {
|
||||
let Some(ref gemini_config) = config.gemini_oauth else {
|
||||
return Err(LlmError::RequestFailed {
|
||||
provider: "gemini_oauth".to_string(),
|
||||
reason: "Gemini OAuth config not available for cheap model".to_string(),
|
||||
});
|
||||
};
|
||||
let mut cheap_gemini_config = gemini_config.clone();
|
||||
cheap_gemini_config.model = cheap_model.to_string();
|
||||
let provider = GeminiOauthProvider::new(cheap_gemini_config)?;
|
||||
return Ok(Some(Arc::new(provider)));
|
||||
}
|
||||
|
||||
// Registry-based provider: clone config and swap model
|
||||
let reg_config = config.provider.as_ref().ok_or_else(|| LlmError::RequestFailed {
|
||||
provider: config.backend.clone(),
|
||||
@@ -674,6 +693,17 @@ pub async fn build_provider_chain(
|
||||
Ok((llm, cheap_llm, recording_handle))
|
||||
}
|
||||
|
||||
pub fn create_gemini_oauth_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let gemini_config = config
|
||||
.gemini_oauth
|
||||
.clone()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "gemini_oauth".to_string(),
|
||||
})?;
|
||||
let provider = gemini_oauth::GeminiOauthProvider::new(gemini_config)?;
|
||||
Ok(Arc::new(provider))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -705,6 +735,7 @@ mod tests {
|
||||
nearai: test_nearai_config(),
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
gemini_oauth: None,
|
||||
request_timeout_secs: 120,
|
||||
cheap_model: None,
|
||||
smart_routing_cascade: true,
|
||||
@@ -786,6 +817,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_cheap_llm_provider_gemini_oauth_creates_provider() {
|
||||
let mut config = test_llm_config();
|
||||
config.backend = "gemini_oauth".to_string();
|
||||
config.cheap_model = Some("gemini-2.5-flash-lite".to_string());
|
||||
config.gemini_oauth = Some(crate::config::GeminiOauthConfig {
|
||||
model: "gemini-2.5-pro".to_string(),
|
||||
credentials_path: std::path::PathBuf::from("/tmp/nonexistent-creds.json"),
|
||||
});
|
||||
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
let result = create_cheap_llm_provider(&config, session);
|
||||
|
||||
// Should succeed and return a provider (credentials validation is deferred
|
||||
// until the first LLM call, not at construction time).
|
||||
let provider = result.expect("gemini_oauth cheap provider should succeed");
|
||||
assert!(provider.is_some(), "Should return Some(provider)");
|
||||
assert_eq!(
|
||||
provider.unwrap().model_name(),
|
||||
"gemini-2.5-flash-lite",
|
||||
"Cheap provider should use the overridden model name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cheap_model_name_resolution() {
|
||||
// Generic takes priority
|
||||
|
||||
@@ -344,6 +344,7 @@ pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
||||
nearai: crate::config::NearAiConfig::for_model_discovery(),
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
gemini_oauth: None,
|
||||
request_timeout_secs: 120,
|
||||
cheap_model: None,
|
||||
smart_routing_cascade: false,
|
||||
|
||||
+206
-103
@@ -1078,23 +1078,40 @@ impl SetupWizard {
|
||||
.map(|s| s.display_name().to_string())
|
||||
.unwrap_or_else(|| def.id.clone())
|
||||
} else {
|
||||
current.clone()
|
||||
match current.as_str() {
|
||||
"nearai" => "NEAR AI".to_string(),
|
||||
"gemini_oauth" | "gemini-oauth" => "Gemini API (OAuth)".to_string(),
|
||||
_ => {
|
||||
if let Some(def) = registry.find(¤t) {
|
||||
def.setup
|
||||
.as_ref()
|
||||
.map(|s| s.display_name().to_string())
|
||||
.unwrap_or_else(|| def.id.clone())
|
||||
} else {
|
||||
current.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
print_info(&format!("Current provider: {}", display));
|
||||
println!();
|
||||
|
||||
let is_known = current == "nearai"
|
||||
|| current == "bedrock"
|
||||
|| current == "gemini_oauth"
|
||||
|| current == "gemini-oauth"
|
||||
|| current == "openai_codex"
|
||||
|| registry.is_known(¤t);
|
||||
|
||||
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
||||
if current == "bedrock" {
|
||||
// Keeping the existing Bedrock config — no need to re-run
|
||||
// the full setup flow (region, auth, cross-region).
|
||||
print_info("Keeping existing AWS Bedrock configuration.");
|
||||
return Ok(());
|
||||
}
|
||||
if current == "gemini_oauth" || current == "gemini-oauth" {
|
||||
print_info("Keeping existing Gemini CLI OAuth configuration.");
|
||||
return Ok(());
|
||||
}
|
||||
if current == "openai_codex" {
|
||||
print_info("Keeping existing OpenAI Codex configuration.");
|
||||
return Ok(());
|
||||
@@ -1113,13 +1130,15 @@ impl SetupWizard {
|
||||
print_info("Select your inference provider:");
|
||||
println!();
|
||||
|
||||
// Build menu: NearAI first, then OpenAI Codex, then registry providers, then Bedrock
|
||||
// Build menu: NearAI first, then Gemini OAuth, then OpenAI Codex, then registry providers, then Bedrock
|
||||
let selectable = registry.selectable();
|
||||
let mut options: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
||||
let mut provider_ids: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
||||
let mut options: Vec<String> = Vec::with_capacity(3 + selectable.len());
|
||||
let mut provider_ids: Vec<String> = Vec::with_capacity(3 + selectable.len());
|
||||
|
||||
options.push("NEAR AI - multi-model access via NEAR account".to_string());
|
||||
provider_ids.push("nearai".to_string());
|
||||
options.push("Gemini CLI - Official Gemini API via Gemini CLI OAuth".to_string());
|
||||
provider_ids.push("gemini_oauth".to_string());
|
||||
|
||||
options.push("OpenAI Codex - ChatGPT subscription (Plus/Pro/Max)".to_string());
|
||||
provider_ids.push("openai_codex".to_string());
|
||||
@@ -1147,6 +1166,8 @@ impl SetupWizard {
|
||||
|
||||
if selected_id == "bedrock" {
|
||||
self.setup_bedrock().await?;
|
||||
} else if selected_id == "gemini_oauth" {
|
||||
self.setup_gemini_oauth().await?;
|
||||
} else {
|
||||
self.run_provider_setup(selected_id, ®istry).await?;
|
||||
}
|
||||
@@ -1795,6 +1816,40 @@ impl SetupWizard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_gemini_oauth(&mut self) -> Result<(), SetupError> {
|
||||
self.settings.llm_backend = Some("gemini_oauth".to_string());
|
||||
print_info("Starting Gemini CLI OAuth authentication...");
|
||||
println!();
|
||||
|
||||
let creds_path = crate::config::GeminiOauthConfig::default_credentials_path();
|
||||
let cred_manager =
|
||||
crate::llm::gemini_oauth::CredentialManager::new(&creds_path).map_err(|e| {
|
||||
SetupError::Config(format!(
|
||||
"Failed to initialize Gemini credential manager: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
match cred_manager.get_valid_credential().await {
|
||||
Ok(cred) => {
|
||||
print_success("Gemini CLI authentication successful!");
|
||||
if let Some(ref pid) = cred.project_id {
|
||||
print_info(&format!("Cloud Code project: {}", pid));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(SetupError::Config(format!(
|
||||
"Gemini CLI authentication failed: {}. Please try again.",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
print_success("Gemini API configured via Gemini CLI");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 4: Model selection.
|
||||
///
|
||||
/// Branches on the selected LLM backend and fetches models from the
|
||||
@@ -1818,109 +1873,157 @@ impl SetupWizard {
|
||||
let backend = self.settings.llm_backend.as_deref().unwrap_or("nearai");
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
|
||||
if backend == "nearai" {
|
||||
// NEAR AI: use existing provider list_models()
|
||||
let fetched = self.fetch_nearai_models().await;
|
||||
let models = if fetched.is_empty() {
|
||||
crate::llm::default_models()
|
||||
} else {
|
||||
fetched.iter().map(|m| (m.clone(), m.clone())).collect()
|
||||
};
|
||||
self.select_from_model_list(&models)?;
|
||||
} else if let Some(def) = registry.find(backend) {
|
||||
let can_list = def
|
||||
.setup
|
||||
.as_ref()
|
||||
.map(|s| s.can_list_models())
|
||||
.unwrap_or(false);
|
||||
|
||||
if can_list {
|
||||
// Try to fetch models from the provider's /v1/models endpoint
|
||||
let cached_key = self
|
||||
.llm_api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string());
|
||||
|
||||
let models = match backend {
|
||||
"anthropic" => fetch_anthropic_models(cached_key.as_deref()).await,
|
||||
"openai" => fetch_openai_models(cached_key.as_deref()).await,
|
||||
"ollama" => {
|
||||
let base_url = self
|
||||
.settings
|
||||
.ollama_base_url
|
||||
.as_deref()
|
||||
.or(def.default_base_url.as_deref())
|
||||
.unwrap_or("http://localhost:11434");
|
||||
let models = fetch_ollama_models(base_url).await;
|
||||
if models.is_empty() {
|
||||
print_info("No models found. Pull one first: ollama pull llama3");
|
||||
}
|
||||
models
|
||||
}
|
||||
_ => {
|
||||
// Generic OpenAI-compatible model listing
|
||||
let base_url = def.default_base_url.as_deref().unwrap_or("");
|
||||
fetch_openai_compatible_models(base_url, cached_key.as_deref()).await
|
||||
}
|
||||
};
|
||||
|
||||
// Apply models_filter from setup hint (e.g., Groq "chat" filters non-chat models)
|
||||
let models =
|
||||
if let Some(filter) = def.setup.as_ref().and_then(|s| s.models_filter()) {
|
||||
let filter_lower = filter.to_lowercase();
|
||||
models
|
||||
.into_iter()
|
||||
.filter(|(id, _)| id.to_lowercase().contains(&filter_lower))
|
||||
.collect()
|
||||
} else {
|
||||
models
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
// Fall back to manual entry
|
||||
let default = &def.default_model;
|
||||
let model_id = input(&format!("Model name (default: {default})"))
|
||||
.map_err(SetupError::Io)?;
|
||||
let model_id = if model_id.is_empty() {
|
||||
default.clone()
|
||||
} else {
|
||||
model_id
|
||||
};
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
match backend {
|
||||
"nearai" => {
|
||||
// NEAR AI: use existing provider list_models()
|
||||
let fetched = self.fetch_nearai_models().await;
|
||||
let models = if fetched.is_empty() {
|
||||
crate::llm::default_models()
|
||||
} else {
|
||||
self.select_from_model_list(&models)?;
|
||||
}
|
||||
} else {
|
||||
// Manual model entry
|
||||
let default = &def.default_model;
|
||||
fetched.iter().map(|m| (m.clone(), m.clone())).collect()
|
||||
};
|
||||
self.select_from_model_list(&models)?;
|
||||
}
|
||||
"gemini_oauth" | "gemini-oauth" => {
|
||||
let default_models: Vec<(String, String)> = vec![
|
||||
(
|
||||
"gemini-3.1-pro-preview".into(),
|
||||
"Gemini 3.1 Pro (Latest, strongest reasoning)".into(),
|
||||
),
|
||||
(
|
||||
"gemini-3.1-pro-preview-customtools".into(),
|
||||
"Gemini 3.1 Pro Custom Tools (Enhanced tool use)".into(),
|
||||
),
|
||||
(
|
||||
"gemini-3-pro-preview".into(),
|
||||
"Gemini 3 Pro (Preview)".into(),
|
||||
),
|
||||
(
|
||||
"gemini-3-flash-preview".into(),
|
||||
"Gemini 3 Flash (Fast preview with thinking)".into(),
|
||||
),
|
||||
(
|
||||
"gemini-3.1-flash-lite-preview".into(),
|
||||
"Gemini 3.1 Flash Lite (Preview, lightweight)".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)?;
|
||||
}
|
||||
"bedrock" => {
|
||||
let model_id =
|
||||
input(&format!("Model name (default: {default})")).map_err(SetupError::Io)?;
|
||||
let model_id = if model_id.is_empty() {
|
||||
default.clone()
|
||||
} else {
|
||||
model_id
|
||||
};
|
||||
input("Bedrock model ID (e.g., anthropic.claude-v3-sonnet-20240229-v1:0)")
|
||||
.map_err(SetupError::Io)?;
|
||||
if model_id.is_empty() {
|
||||
return Err(SetupError::Config("Model ID is required".to_string()));
|
||||
}
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
}
|
||||
} else if backend == "bedrock" {
|
||||
let model_id = input("Bedrock model ID (e.g., anthropic.claude-opus-4-6-v1)")
|
||||
.map_err(SetupError::Io)?;
|
||||
if model_id.is_empty() {
|
||||
return Err(SetupError::Config("Model ID is required".to_string()));
|
||||
_ => {
|
||||
if let Some(def) = registry.find(backend) {
|
||||
let can_list = def
|
||||
.setup
|
||||
.as_ref()
|
||||
.map(|s| s.can_list_models())
|
||||
.unwrap_or(false);
|
||||
|
||||
if can_list {
|
||||
// Try to fetch models from the provider's /v1/models endpoint
|
||||
let cached_key = self
|
||||
.llm_api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string());
|
||||
|
||||
let models = match backend {
|
||||
"anthropic" => fetch_anthropic_models(cached_key.as_deref()).await,
|
||||
"openai" => fetch_openai_models(cached_key.as_deref()).await,
|
||||
"ollama" => {
|
||||
let base_url = self
|
||||
.settings
|
||||
.ollama_base_url
|
||||
.as_deref()
|
||||
.or(def.default_base_url.as_deref())
|
||||
.unwrap_or("http://localhost:11434");
|
||||
let models = fetch_ollama_models(base_url).await;
|
||||
if models.is_empty() {
|
||||
print_info(
|
||||
"No models found. Pull one first: ollama pull llama3",
|
||||
);
|
||||
}
|
||||
models
|
||||
}
|
||||
_ => {
|
||||
// Generic OpenAI-compatible model listing
|
||||
let base_url = def.default_base_url.as_deref().unwrap_or("");
|
||||
fetch_openai_compatible_models(base_url, cached_key.as_deref())
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
// Apply models_filter from setup hint
|
||||
let models = if let Some(filter) =
|
||||
def.setup.as_ref().and_then(|s| s.models_filter())
|
||||
{
|
||||
let filter_lower = filter.to_lowercase();
|
||||
models
|
||||
.into_iter()
|
||||
.filter(|(id, _)| id.to_lowercase().contains(&filter_lower))
|
||||
.collect()
|
||||
} else {
|
||||
models
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
// Fall back to manual entry
|
||||
let default = &def.default_model;
|
||||
let model_id = input(&format!("Model name (default: {default})"))
|
||||
.map_err(SetupError::Io)?;
|
||||
let model_id = if model_id.is_empty() {
|
||||
default.clone()
|
||||
} else {
|
||||
model_id
|
||||
};
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
} else {
|
||||
self.select_from_model_list(&models)?;
|
||||
}
|
||||
} else {
|
||||
// Manual model entry
|
||||
let default = &def.default_model;
|
||||
let model_id = input(&format!("Model name (default: {default})"))
|
||||
.map_err(SetupError::Io)?;
|
||||
let model_id = if model_id.is_empty() {
|
||||
default.clone()
|
||||
} else {
|
||||
model_id
|
||||
};
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
}
|
||||
} else {
|
||||
// Unknown provider, manual entry
|
||||
let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)")
|
||||
.map_err(SetupError::Io)?;
|
||||
if model_id.is_empty() {
|
||||
return Err(SetupError::Config("Model name is required".to_string()));
|
||||
}
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
}
|
||||
}
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
} else {
|
||||
// Unknown provider, manual entry
|
||||
let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)")
|
||||
.map_err(SetupError::Io)?;
|
||||
if model_id.is_empty() {
|
||||
return Err(SetupError::Config("Model name is required".to_string()));
|
||||
}
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
use ironclaw::llm::ChatMessage;
|
||||
use ironclaw::llm::gemini_oauth::GeminiOauthProvider;
|
||||
|
||||
/// Regression: Cloud Code API routing for Gemini 2.0+ models.
|
||||
/// Gemini 1.x → legacy generativelanguage.googleapis.com
|
||||
/// Gemini 2.0+ → Cloud Code API (cloudcode-pa.googleapis.com)
|
||||
#[test]
|
||||
fn test_regression_cloud_code_api_routing() {
|
||||
// Legacy models (1.x) → false
|
||||
assert!(!GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"gemini-1.5-pro"
|
||||
));
|
||||
assert!(!GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"gemini-1.5-flash"
|
||||
));
|
||||
|
||||
// 2.0+ models → true
|
||||
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"gemini-2.0-flash"
|
||||
));
|
||||
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"gemini-2.5-pro"
|
||||
));
|
||||
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"gemini-2.5-flash"
|
||||
));
|
||||
|
||||
// Preview models with hyphen → true
|
||||
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"gemini-3.1-pro-preview"
|
||||
));
|
||||
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"gemini-3-flash-preview"
|
||||
));
|
||||
|
||||
// Gemini 3 family → true
|
||||
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"gemini-3-pro"
|
||||
));
|
||||
}
|
||||
|
||||
/// Regression: "preview" false-positive fix.
|
||||
/// `model.contains("-preview")` (with hyphen) prevents models whose name
|
||||
/// happens to include "preview" without a hyphen prefix from being
|
||||
/// mis-routed to Cloud Code API.
|
||||
#[test]
|
||||
fn test_regression_preview_false_positive_fix() {
|
||||
// "my-preview-custom" still matches (contains "-preview")
|
||||
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"my-preview-custom"
|
||||
));
|
||||
|
||||
// "mypreviewcustom" does NOT match (no hyphen before "preview")
|
||||
assert!(!GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"mypreviewcustom"
|
||||
));
|
||||
|
||||
// Non-Gemini models without "-preview" → false
|
||||
assert!(!GeminiOauthProvider::model_uses_cloud_code_api(
|
||||
"not-a-gemini-model"
|
||||
));
|
||||
}
|
||||
|
||||
/// Regression: model list consistency.
|
||||
/// Wizard, list_models(), and LLM_PROVIDERS.md all return the same 8 models.
|
||||
#[test]
|
||||
fn test_regression_standardized_model_list() {
|
||||
let expected_models = [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.1-pro-preview-customtools",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
];
|
||||
|
||||
// All standardized models must route to Cloud Code API (all are >= 2.0)
|
||||
for model in &expected_models {
|
||||
assert!(
|
||||
GeminiOauthProvider::model_uses_cloud_code_api(model),
|
||||
"Standardized model '{}' should route to Cloud Code API",
|
||||
model
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: ChatMessage helper constructors.
|
||||
#[test]
|
||||
fn test_regression_chat_message_helpers() {
|
||||
let user_msg = ChatMessage::user("hello");
|
||||
assert_eq!(user_msg.role, ironclaw::llm::Role::User);
|
||||
assert_eq!(user_msg.content, "hello");
|
||||
|
||||
let system_msg = ChatMessage::system("you are helpful");
|
||||
assert_eq!(system_msg.role, ironclaw::llm::Role::System);
|
||||
assert_eq!(system_msg.content, "you are helpful");
|
||||
}
|
||||
Reference in New Issue
Block a user