mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(setup): Anthropic OAuth onboarding with setup-token support (#384)
* feat(setup): add Anthropic OAuth and Codex OAuth onboarding flows Add OAuth token authentication as an alternative to API keys during onboarding for both Anthropic (via `claude login`) and OpenAI/Codex (via `~/.codex/auth.json`). Key changes: - New `AnthropicOAuthProvider` using `Authorization: Bearer` header (rig-core hardcodes `x-api-key` which rejects OAuth tokens) - Wizard auth method selector: "Direct API Key" vs "OAuth Token" for both Anthropic and OpenAI providers - Codex token extraction from `$CODEX_HOME/auth.json` / `~/.codex/auth.json` - Claude Code sandbox sub-step in Docker setup (checks for credentials) - Secret injection mappings for `ANTHROPIC_OAUTH_TOKEN` and `CODEX_OAUTH_TOKEN` - `CODEX_OAUTH_TOKEN` falls back to `OPENAI_API_KEY` (same Bearer auth) Supersedes #143 which had a broken auth flow (OAuth token sent as x-api-key → 401). Credit to @bigguybobby for the original approach. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: persist OAuth tokens in bootstrap .env and re-extract at startup OAuth tokens stored only in the secrets DB were invisible to Config::from_env() which runs before the DB connects (chicken-and-egg). Two fixes: 1. write_bootstrap_env() now persists ANTHROPIC_OAUTH_TOKEN and CODEX_OAUTH_TOKEN to ~/.ironclaw/.env (same pattern as NEARAI_API_KEY) 2. main.rs re-extracts a fresh token from the OS credential store (macOS Keychain / ~/.claude/.credentials.json) before config resolution, handling token expiry (8-12h) gracefully Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: persist all LLM credentials in bootstrap .env, not just NEAR AI All providers had the same chicken-and-egg issue: API keys stored in the secrets DB were invisible to Config::from_env() which runs before DB connects. Only NEARAI_API_KEY was written to bootstrap .env. Now write_bootstrap_env() persists all credential env vars: NEARAI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_OAUTH_TOKEN, OPENAI_API_KEY, CODEX_OAUTH_TOKEN, LLM_API_KEY, TINFOIL_API_KEY. Also: setup_api_key_provider() now sets the env var during the wizard session so write_bootstrap_env() can pick it up. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address security review findings for OAuth onboarding - Extract "oauth-placeholder" to named OAUTH_PLACEHOLDER constant shared across config and wizard to prevent silent drift - Document plaintext credential tradeoff in write_bootstrap_env (API keys stored with 0o600 permissions, recommend full-disk encryption) - Add blocking "Press Enter" wait in Anthropic OAuth retry flow so user has time to run `claude login` in another terminal - Add escape hatch from manual OAuth paste back to API key flow (empty input switches to setup_api_key_provider) - Fix Retry-After header: parse u64 seconds into Duration before passing to LlmError::RateLimited - Make config::llm module pub(crate) for constant visibility - Use .bearer_auth() instead of manual format!("Bearer {}") - Remove response body from debug log (may contain PII) - Update Anthropic API version to 2024-10-22 Co-Authored-By: Claude Opus 4.6 <[email protected]> * security: remove plaintext credentials from bootstrap .env Credentials (API keys, OAuth tokens) were being written in plaintext to ~/.ironclaw/.env to work around a chicken-and-egg problem: Config::from_env() runs before the encrypted secrets DB is connected. Instead of storing secrets on disk, LlmConfig::resolve() now defers gracefully when credentials are missing — it returns None for the provider config instead of hard-erroring with MissingRequired. After the DB connects, AppBuilder::build_all() loads secrets from encrypted storage via inject_llm_keys_from_secrets() and re-resolves the config. For Anthropic OAuth tokens (which expire in 8-12h), the secret injection step also tries the OS credential store (macOS Keychain / Linux credentials.json) for a fresh token, overriding the potentially stale copy in the DB. Changes: - LlmConfig::resolve(): OpenAI, Anthropic, OpenAI-compatible, and Tinfoil all return None instead of MissingRequired when credentials are absent - write_bootstrap_env(): no longer writes any credential env vars - inject_llm_keys_from_secrets(): refreshes Anthropic OAuth from OS credential store before overlay is finalized - main.rs: removed OAuth re-extraction hack (no longer needed) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: load OS credential store tokens even without secrets DB The OAuth token extraction from macOS Keychain / Linux credentials files was only running inside inject_llm_keys_from_secrets(), which requires the encrypted secrets DB. When no master key is configured, init_secrets() returned early — skipping both DB secret loading AND OS credential store extraction, leaving the Anthropic OAuth token unavailable. Split into two paths: - inject_llm_keys_from_secrets(): loads from encrypted DB + OS stores - inject_os_credentials(): loads from OS stores only (no DB needed) init_secrets() now calls inject_os_credentials() and re-resolves config even in the no-master-key early-return path, so `claude login` tokens are always available regardless of secrets DB state. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add anthropic-beta header required for OAuth authentication Anthropic's api.anthropic.com requires the `anthropic-beta: oauth-2025-04-20` header to accept OAuth Bearer tokens. Without it, the API returns 401 "OAuth authentication is currently not supported." Also reverts API version to 2023-06-01 since the OAuth beta flag does not support the 2024-10-22 version (returns 400 "not a valid version"). This was the same bug that caused PR #143's 401 errors — the beta header was missing entirely. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Anthropic and OpenAI model resolution respects selected_model The Anthropic and OpenAI config resolution ignored settings.selected_model entirely, only checking the provider-specific env var (ANTHROPIC_MODEL, OPENAI_MODEL) and falling back to a hardcoded default. This meant the model chosen during onboarding wizard was silently overridden. Now follows the same pattern as NearAI and OpenAI-compatible: env var > settings.selected_model > hardcoded default. Also deduplicated the Anthropic config construction (two identical branches for API key vs OAuth now share model/base_url resolution). Co-Authored-By: Claude Opus 4.6 <[email protected]> * test: add provider resolution tests for all LLM backends Covers deferred resolution (no credentials → None instead of error), credential presence, model selection fallback chain, and OAuth token routing for Anthropic, OpenAI, Tinfoil, Ollama, and NearAI. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: handle nested tokens.access_token format in Codex auth.json Codex CLI stores OAuth tokens in a nested format under tokens.access_token (ChatGPT OAuth flow), not at the top level. Also adds ENV_MUTEX to Codex token tests for thread safety. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: remove Codex OAuth onboarding (incompatible with OpenAI API) Codex CLI OAuth tokens use a different endpoint (chatgpt.com/backend-api/codex) and the Responses API wire format, not api.openai.com with Chat Completions. The tokens lack the model.request scope needed for the platform API, so they can't be used as drop-in OPENAI_API_KEY replacements. Removes: extract_codex_oauth_token(), wizard Codex OAuth flow, CODEX_OAUTH_TOKEN env var support, and related tests. OpenAI onboarding now uses direct API key only. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting for CI (cargo fmt) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address Gemini review feedback - Use ? operator for ANTHROPIC_MODEL/BASE_URL env resolution instead of .ok().flatten() to propagate ConfigErrors consistently - Skip Tool messages without tool_call_id with a warning instead of using unwrap_or_default() which would send empty string to Anthropic - Extract credential check into closure to reduce duplication in Claude Code sandbox setup Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(review): address PR review feedback for OAuth onboarding - Gate ANTHROPIC_OAUTH_TOKEN resolution to Anthropic provider only (was needlessly checked for all registry providers) - Add 3 regression tests for OAuth config resolution: - oauth_token sets placeholder api_key - real api_key takes priority over oauth - non-Anthropic providers don't pick up oauth_token - Validate OAuth token prefix (sk-ant-oat) in wizard to catch accidentally pasted API keys - Improve error body read handling in AnthropicOAuthProvider (was silently swallowing read errors with unwrap_or_default) - Remove extra blank line in write_bootstrap_env - Remove stale blank line in RegistryProviderConfig doc comment [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #384 review comments Blocker: - Replace OnceLock<HashMap> with LazyLock<Mutex<HashMap>> for INJECTED_VARS so both inject_os_credentials() and inject_llm_keys_from_secrets() merge data instead of the second caller silently dropping its entries. High: - Add 401 retry with OS credential store re-extraction in AnthropicOAuthProvider, recovering from expired OAuth tokens (~8-12h) without manual intervention. - Fix comment in app.rs: ~/.codex/auth.json → ~/.claude/.credentials.json. Medium: - Remove unsafe { std::env::set_var } from wizard; use thread-safe inject_single_var() overlay instead (safe on multi-threaded Tokio). - Add post-init validation in AppBuilder: fail early with clear error when LLM_BACKEND is set but no credentials were resolved after secret injection. - Add sk-ant-oat prefix validation in parse_oauth_access_token(). - Only route to AnthropicOAuthProvider when api_key is missing or equals OAUTH_PLACEHOLDER (API key takes priority over OAuth token). - Teach fetch_anthropic_models() to use Bearer auth when only OAuth token is available (model listing no longer fails for OAuth-only users). Low: - Use optional_env() in wizard credential checks to read from injected overlay, not just raw env vars. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: [email protected] <[email protected]>
This commit is contained in:
@@ -6,6 +6,18 @@ DATABASE_POOL_SIZE=10
|
|||||||
# LLM_BACKEND=nearai # default
|
# LLM_BACKEND=nearai # default
|
||||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
|
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
|
||||||
|
|
||||||
|
# === Anthropic Direct ===
|
||||||
|
# Two auth modes:
|
||||||
|
# 1. API key: Set ANTHROPIC_API_KEY (from console.anthropic.com/settings/keys)
|
||||||
|
# 2. OAuth token: Set ANTHROPIC_OAUTH_TOKEN (from `claude login`)
|
||||||
|
# OAuth tokens use Authorization: Bearer instead of x-api-key header.
|
||||||
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
# ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-... # from `claude login` credentials
|
||||||
|
# ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||||
|
|
||||||
|
# === OpenAI Direct ===
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
# === NEAR AI (Chat Completions API) ===
|
# === NEAR AI (Chat Completions API) ===
|
||||||
# Two auth modes:
|
# Two auth modes:
|
||||||
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
|
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
|
||||||
|
|||||||
+28
@@ -244,11 +244,28 @@ impl AppBuilder {
|
|||||||
let master_key = match self.config.secrets.master_key() {
|
let master_key = match self.config.secrets.master_key() {
|
||||||
Some(k) => k,
|
Some(k) => k,
|
||||||
None => {
|
None => {
|
||||||
|
// No secrets DB available, but we can still load tokens from
|
||||||
|
// OS credential stores (e.g., Anthropic OAuth via Claude Code's
|
||||||
|
// macOS Keychain / Linux ~/.claude/.credentials.json).
|
||||||
|
crate::config::inject_os_credentials();
|
||||||
|
|
||||||
// Consume unused handles
|
// Consume unused handles
|
||||||
#[cfg(feature = "libsql")]
|
#[cfg(feature = "libsql")]
|
||||||
{
|
{
|
||||||
self.libsql_db.take();
|
self.libsql_db.take();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-resolve config with OS credentials
|
||||||
|
if let Some(ref db) = self.db {
|
||||||
|
let toml_path = self.toml_path.as_deref();
|
||||||
|
if let Ok(refreshed) =
|
||||||
|
Config::from_db_with_toml(db.as_ref(), "default", toml_path).await
|
||||||
|
{
|
||||||
|
self.config = refreshed;
|
||||||
|
tracing::debug!("LlmConfig re-resolved after OS credential injection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -665,6 +682,17 @@ impl AppBuilder {
|
|||||||
self.init_database().await?;
|
self.init_database().await?;
|
||||||
self.init_secrets().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.provider.is_none() {
|
||||||
|
let backend = &self.config.llm.backend;
|
||||||
|
anyhow::bail!(
|
||||||
|
"LLM_BACKEND={backend} is configured but no credentials were found. \
|
||||||
|
Set the appropriate API key environment variable or run the setup wizard."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let (llm, cheap_llm, recording_handle) = if let Some(llm) = self.llm_override.take() {
|
let (llm, cheap_llm, recording_handle) = if let Some(llm) = self.llm_override.take() {
|
||||||
(llm, None, None)
|
(llm, None, None)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ pub(crate) fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to thread-safe overlay (secrets injected from DB)
|
// Fall back to thread-safe overlay (secrets injected from DB)
|
||||||
if let Some(val) = INJECTED_VARS.get().and_then(|map| map.get(key)) {
|
if let Some(val) = INJECTED_VARS
|
||||||
return Ok(Some(val.clone()));
|
.lock()
|
||||||
|
.unwrap_or_else(|p| p.into_inner())
|
||||||
|
.get(key)
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
return Ok(Some(val));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
|
|||||||
+141
-6
@@ -9,6 +9,13 @@ use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
|||||||
use crate::llm::session::SessionConfig;
|
use crate::llm::session::SessionConfig;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
|
/// Sentinel value used as `api_key` when only an OAuth token is present.
|
||||||
|
///
|
||||||
|
/// When we only have an OAuth token the provider factory in `llm/mod.rs`
|
||||||
|
/// checks for this value and routes to `AnthropicOAuthProvider`, so this
|
||||||
|
/// placeholder is never sent over the wire.
|
||||||
|
pub const OAUTH_PLACEHOLDER: &str = "oauth-placeholder";
|
||||||
|
|
||||||
/// Prompt cache retention policy for Anthropic.
|
/// Prompt cache retention policy for Anthropic.
|
||||||
///
|
///
|
||||||
/// Controls Anthropic's automatic prompt caching via a top-level
|
/// Controls Anthropic's automatic prompt caching via a top-level
|
||||||
@@ -66,6 +73,7 @@ pub struct RegistryProviderConfig {
|
|||||||
/// Provider identifier (e.g., "groq", "openai", "tinfoil").
|
/// Provider identifier (e.g., "groq", "openai", "tinfoil").
|
||||||
pub provider_id: String,
|
pub provider_id: String,
|
||||||
/// API key (optional for some providers like Ollama).
|
/// API key (optional for some providers like Ollama).
|
||||||
|
/// For Anthropic OAuth, this is set to `OAUTH_PLACEHOLDER`.
|
||||||
pub api_key: Option<SecretString>,
|
pub api_key: Option<SecretString>,
|
||||||
/// Base URL for the API endpoint.
|
/// Base URL for the API endpoint.
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -73,6 +81,9 @@ pub struct RegistryProviderConfig {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
/// Extra HTTP headers injected into every request.
|
/// Extra HTTP headers injected into every request.
|
||||||
pub extra_headers: Vec<(String, String)>,
|
pub extra_headers: Vec<(String, String)>,
|
||||||
|
/// OAuth token for providers that support Bearer auth (e.g. Anthropic via `claude login`).
|
||||||
|
/// When set, the provider factory routes to the OAuth-specific provider implementation.
|
||||||
|
pub oauth_token: Option<SecretString>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LLM provider configuration.
|
/// LLM provider configuration.
|
||||||
@@ -366,6 +377,22 @@ impl LlmConfig {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolve OAuth token (Anthropic-specific: `claude login` flow).
|
||||||
|
// Only check for OAuth token when the provider is actually Anthropic.
|
||||||
|
let oauth_token = if canonical_id == "anthropic" {
|
||||||
|
optional_env("ANTHROPIC_OAUTH_TOKEN")?.map(SecretString::from)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let api_key = if api_key.is_none() && oauth_token.is_some() {
|
||||||
|
// OAuth token present but no API key: use a placeholder so the
|
||||||
|
// config block is populated. The provider factory will route to
|
||||||
|
// the OAuth provider instead of rig-core's x-api-key client.
|
||||||
|
Some(SecretString::from(OAUTH_PLACEHOLDER.to_string()))
|
||||||
|
} else {
|
||||||
|
api_key
|
||||||
|
};
|
||||||
|
|
||||||
Ok(RegistryProviderConfig {
|
Ok(RegistryProviderConfig {
|
||||||
protocol,
|
protocol,
|
||||||
provider_id: canonical_id.to_string(),
|
provider_id: canonical_id.to_string(),
|
||||||
@@ -373,6 +400,7 @@ impl LlmConfig {
|
|||||||
base_url,
|
base_url,
|
||||||
model,
|
model,
|
||||||
extra_headers,
|
extra_headers,
|
||||||
|
oauth_token,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -677,8 +705,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_alias_normalized_to_canonical_id() {
|
fn backend_alias_normalized_to_canonical_id() {
|
||||||
// When the user sets LLM_BACKEND to an alias (e.g., "open_ai"),
|
|
||||||
// LlmConfig.backend should resolve to the canonical ID ("openai").
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
clear_openai_compatible_env();
|
clear_openai_compatible_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
@@ -705,8 +731,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_backend_falls_back_to_openai_compatible() {
|
fn unknown_backend_falls_back_to_openai_compatible() {
|
||||||
// An unrecognized LLM_BACKEND should fall back to the openai_compatible
|
|
||||||
// provider definition instead of erroring.
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
clear_openai_compatible_env();
|
clear_openai_compatible_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
@@ -717,7 +741,6 @@ mod tests {
|
|||||||
|
|
||||||
let settings = Settings::default();
|
let settings = Settings::default();
|
||||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
// Falls back to openai_compatible since "some_custom_provider" is unknown
|
|
||||||
assert_eq!(cfg.backend, "openai_compatible");
|
assert_eq!(cfg.backend, "openai_compatible");
|
||||||
let provider = cfg.provider.expect("should have provider config");
|
let provider = cfg.provider.expect("should have provider config");
|
||||||
assert_eq!(provider.provider_id, "openai_compatible");
|
assert_eq!(provider.provider_id, "openai_compatible");
|
||||||
@@ -759,7 +782,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn base_url_resolution_priority() {
|
fn base_url_resolution_priority() {
|
||||||
// Env var > settings > registry default
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
clear_openai_compatible_env();
|
clear_openai_compatible_env();
|
||||||
|
|
||||||
@@ -800,6 +822,119 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OAuth resolution tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Clear all Anthropic-related env vars.
|
||||||
|
fn clear_anthropic_env() {
|
||||||
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("LLM_BACKEND");
|
||||||
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
std::env::remove_var("ANTHROPIC_OAUTH_TOKEN");
|
||||||
|
std::env::remove_var("ANTHROPIC_MODEL");
|
||||||
|
std::env::remove_var("ANTHROPIC_BASE_URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_oauth_token_sets_placeholder_api_key() {
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_anthropic_env();
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = Settings {
|
||||||
|
llm_backend: Some("anthropic".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
|
let provider = cfg.provider.expect("provider config should be present");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|k| k.expose_secret().to_string()),
|
||||||
|
Some(OAUTH_PLACEHOLDER.to_string()),
|
||||||
|
"api_key should be the OAuth placeholder when only OAuth token is set"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
provider.oauth_token.is_some(),
|
||||||
|
"oauth_token should be populated"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider.oauth_token.as_ref().unwrap().expose_secret(),
|
||||||
|
"sk-ant-oat01-test-token"
|
||||||
|
);
|
||||||
|
|
||||||
|
clear_anthropic_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_api_key_takes_priority_over_oauth() {
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_anthropic_env();
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-real-key");
|
||||||
|
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = Settings {
|
||||||
|
llm_backend: Some("anthropic".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
|
let provider = cfg.provider.expect("provider config should be present");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|k| k.expose_secret().to_string()),
|
||||||
|
Some("sk-ant-real-key".to_string()),
|
||||||
|
"real API key should take priority over OAuth placeholder"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
provider.oauth_token.is_some(),
|
||||||
|
"oauth_token should still be populated"
|
||||||
|
);
|
||||||
|
|
||||||
|
clear_anthropic_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_anthropic_provider_has_no_oauth_token() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_anthropic_env();
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = Settings {
|
||||||
|
llm_backend: Some("openai".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
|
let provider = cfg.provider.expect("provider config should be present");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
provider.oauth_token.is_none(),
|
||||||
|
"non-Anthropic providers should not pick up ANTHROPIC_OAUTH_TOKEN"
|
||||||
|
);
|
||||||
|
|
||||||
|
clear_anthropic_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache retention tests ───────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cache_retention_from_str_primary_values() {
|
fn cache_retention_from_str_primary_values() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+73
-5
@@ -13,7 +13,7 @@ mod embeddings;
|
|||||||
mod heartbeat;
|
mod heartbeat;
|
||||||
pub(crate) mod helpers;
|
pub(crate) mod helpers;
|
||||||
mod hygiene;
|
mod hygiene;
|
||||||
mod llm;
|
pub(crate) mod llm;
|
||||||
mod routines;
|
mod routines;
|
||||||
mod safety;
|
mod safety;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
@@ -24,7 +24,7 @@ mod tunnel;
|
|||||||
mod wasm;
|
mod wasm;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::OnceLock;
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
@@ -53,7 +53,12 @@ pub use crate::llm::session::SessionConfig;
|
|||||||
/// Used by `inject_llm_keys_from_secrets()` to make API keys available to
|
/// Used by `inject_llm_keys_from_secrets()` to make API keys available to
|
||||||
/// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks
|
/// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks
|
||||||
/// real env vars first, then falls back to this overlay.
|
/// real env vars first, then falls back to this overlay.
|
||||||
static INJECTED_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
|
///
|
||||||
|
/// Uses `Mutex<HashMap>` instead of `OnceLock` so that both
|
||||||
|
/// `inject_os_credentials()` and `inject_llm_keys_from_secrets()` can merge
|
||||||
|
/// their data. Whichever runs first initialises the map; the second merges in.
|
||||||
|
static INJECTED_VARS: LazyLock<Mutex<HashMap<String, String>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
/// Main configuration for the agent.
|
/// Main configuration for the agent.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -285,6 +290,9 @@ impl Config {
|
|||||||
/// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay
|
/// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay
|
||||||
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
||||||
/// so explicit env vars always win.
|
/// so explicit env vars always win.
|
||||||
|
///
|
||||||
|
/// Also loads tokens from OS credential stores (macOS Keychain, Linux
|
||||||
|
/// credentials files) which don't require the secrets DB.
|
||||||
pub async fn inject_llm_keys_from_secrets(
|
pub async fn inject_llm_keys_from_secrets(
|
||||||
secrets: &dyn crate::secrets::SecretsStore,
|
secrets: &dyn crate::secrets::SecretsStore,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -292,7 +300,10 @@ pub async fn inject_llm_keys_from_secrets(
|
|||||||
// Static mappings for well-known providers.
|
// Static mappings for well-known providers.
|
||||||
// The registry's setup hints define secret_name -> env_var mappings,
|
// The registry's setup hints define secret_name -> env_var mappings,
|
||||||
// so new providers added to providers.json get injection automatically.
|
// so new providers added to providers.json get injection automatically.
|
||||||
let mut mappings: Vec<(&str, &str)> = vec![("llm_nearai_api_key", "NEARAI_API_KEY")];
|
let mut mappings: Vec<(&str, &str)> = vec![
|
||||||
|
("llm_nearai_api_key", "NEARAI_API_KEY"),
|
||||||
|
("llm_anthropic_oauth_token", "ANTHROPIC_OAUTH_TOKEN"),
|
||||||
|
];
|
||||||
|
|
||||||
// Dynamically discover secret->env mappings from the provider registry.
|
// Dynamically discover secret->env mappings from the provider registry.
|
||||||
// Uses selectable() which deduplicates user overrides correctly.
|
// Uses selectable() which deduplicates user overrides correctly.
|
||||||
@@ -331,5 +342,62 @@ pub async fn inject_llm_keys_from_secrets(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = INJECTED_VARS.set(injected);
|
inject_os_credential_store_tokens(&mut injected);
|
||||||
|
|
||||||
|
merge_injected_vars(injected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load tokens from OS credential stores (no DB required).
|
||||||
|
///
|
||||||
|
/// Called unconditionally during startup — even when the encrypted secrets DB
|
||||||
|
/// is unavailable (no master key, no DB connection). This ensures OAuth tokens
|
||||||
|
/// from `claude login` (macOS Keychain / Linux credentials.json)
|
||||||
|
/// are available for config resolution.
|
||||||
|
pub fn inject_os_credentials() {
|
||||||
|
let mut injected = HashMap::new();
|
||||||
|
inject_os_credential_store_tokens(&mut injected);
|
||||||
|
merge_injected_vars(injected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge new entries into the global injected-vars overlay.
|
||||||
|
///
|
||||||
|
/// New keys are inserted; existing keys are overwritten (later callers win,
|
||||||
|
/// e.g. fresh OS credential store tokens override stale DB copies).
|
||||||
|
fn merge_injected_vars(new_entries: HashMap<String, String>) {
|
||||||
|
if new_entries.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match INJECTED_VARS.lock() {
|
||||||
|
Ok(mut map) => map.extend(new_entries),
|
||||||
|
Err(poisoned) => poisoned.into_inner().extend(new_entries),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject a single key-value pair into the overlay.
|
||||||
|
///
|
||||||
|
/// Used by the setup wizard to make credentials available to `optional_env()`
|
||||||
|
/// without calling `unsafe { std::env::set_var }`.
|
||||||
|
pub fn inject_single_var(key: &str, value: &str) {
|
||||||
|
match INJECTED_VARS.lock() {
|
||||||
|
Ok(mut map) => {
|
||||||
|
map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
Err(poisoned) => {
|
||||||
|
poisoned
|
||||||
|
.into_inner()
|
||||||
|
.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared helper: extract tokens from OS credential stores into the overlay map.
|
||||||
|
fn inject_os_credential_store_tokens(injected: &mut HashMap<String, String>) {
|
||||||
|
// Try the OS credential store for a fresh Anthropic OAuth token.
|
||||||
|
// Tokens from `claude login` expire in 8-12h, so the DB copy may be stale.
|
||||||
|
// A fresh extraction from macOS Keychain / Linux credentials.json wins
|
||||||
|
// over the (possibly expired) copy stored in the encrypted secrets DB.
|
||||||
|
if let Some(fresh) = crate::config::ClaudeCodeConfig::extract_oauth_token() {
|
||||||
|
injected.insert("ANTHROPIC_OAUTH_TOKEN".to_string(), fresh);
|
||||||
|
tracing::debug!("Refreshed ANTHROPIC_OAUTH_TOKEN from OS credential store");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-5
@@ -233,9 +233,14 @@ impl ClaudeCodeConfig {
|
|||||||
/// Expected shape: `{"claudeAiOauth": {"accessToken": "sk-ant-oat01-..."}}`
|
/// Expected shape: `{"claudeAiOauth": {"accessToken": "sk-ant-oat01-..."}}`
|
||||||
fn parse_oauth_access_token(json: &str) -> Option<String> {
|
fn parse_oauth_access_token(json: &str) -> Option<String> {
|
||||||
let creds: serde_json::Value = serde_json::from_str(json).ok()?;
|
let creds: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||||
creds["claudeAiOauth"]["accessToken"]
|
let token = creds["claudeAiOauth"]["accessToken"].as_str()?;
|
||||||
.as_str()
|
// Validate that the token looks like a real OAuth token before using it.
|
||||||
.map(String::from)
|
// Claude CLI tokens start with "sk-ant-oat".
|
||||||
|
if !token.starts_with("sk-ant-oat") {
|
||||||
|
tracing::debug!("Ignoring credential store token with unexpected prefix");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -401,14 +406,14 @@ mod tests {
|
|||||||
fn parse_oauth_token_nested_extra_fields() {
|
fn parse_oauth_token_nested_extra_fields() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
"claudeAiOauth": {
|
"claudeAiOauth": {
|
||||||
"accessToken": "sk-ant-real-token",
|
"accessToken": "sk-ant-oat01-real-token",
|
||||||
"refreshToken": "rt-abc",
|
"refreshToken": "rt-abc",
|
||||||
"expiresAt": 1700000000
|
"expiresAt": 1700000000
|
||||||
}
|
}
|
||||||
}"#;
|
}"#;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_oauth_access_token(json),
|
parse_oauth_access_token(json),
|
||||||
Some("sk-ant-real-token".to_string())
|
Some("sk-ant-oat01-real-token".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +423,12 @@ mod tests {
|
|||||||
assert_eq!(parse_oauth_access_token(json), None);
|
assert_eq!(parse_oauth_access_token(json), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_oauth_token_rejects_invalid_prefix() {
|
||||||
|
let json = r#"{"claudeAiOauth": {"accessToken": "not-an-oauth-token"}}"#;
|
||||||
|
assert_eq!(parse_oauth_access_token(json), None);
|
||||||
|
}
|
||||||
|
|
||||||
// ── default_claude_code_allowed_tools ───────────────────────────
|
// ── default_claude_code_allowed_tools ───────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
//! Anthropic OAuth provider (direct HTTP, `Authorization: Bearer`).
|
||||||
|
//!
|
||||||
|
//! This provider exists because the `rig-core` Anthropic client hardcodes the
|
||||||
|
//! `x-api-key` header, which is rejected by Anthropic's OAuth tokens from
|
||||||
|
//! `claude login`. OAuth tokens require `Authorization: Bearer <token>` instead.
|
||||||
|
//!
|
||||||
|
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use reqwest::Client;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::RegistryProviderConfig;
|
||||||
|
use crate::error::LlmError;
|
||||||
|
use crate::llm::costs;
|
||||||
|
use crate::llm::provider::{
|
||||||
|
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
|
||||||
|
ToolCompletionRequest, ToolCompletionResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||||
|
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
|
||||||
|
const ANTHROPIC_API_VERSION: &str = "2023-06-01";
|
||||||
|
/// Required beta flag to enable OAuth Bearer auth on api.anthropic.com.
|
||||||
|
/// Without this header, the API returns 401 "OAuth authentication is currently not supported."
|
||||||
|
const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20";
|
||||||
|
const DEFAULT_MAX_TOKENS: u32 = 8192;
|
||||||
|
|
||||||
|
/// Anthropic provider using OAuth Bearer authentication.
|
||||||
|
pub struct AnthropicOAuthProvider {
|
||||||
|
client: Client,
|
||||||
|
token: SecretString,
|
||||||
|
model: String,
|
||||||
|
base_url: Option<String>,
|
||||||
|
active_model: std::sync::RwLock<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnthropicOAuthProvider {
|
||||||
|
pub fn new(config: &RegistryProviderConfig) -> Result<Self, LlmError> {
|
||||||
|
let token = config
|
||||||
|
.oauth_token
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| LlmError::AuthFailed {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
reason: format!("Failed to build HTTP client: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let active_model = std::sync::RwLock::new(config.model.clone());
|
||||||
|
let base_url = if config.base_url.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(config.base_url.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
model: config.model.clone(),
|
||||||
|
base_url,
|
||||||
|
active_model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_url(&self) -> String {
|
||||||
|
if let Some(ref base) = self.base_url {
|
||||||
|
let base = base.trim_end_matches('/');
|
||||||
|
format!("{}/v1/messages", base)
|
||||||
|
} else {
|
||||||
|
ANTHROPIC_API_URL.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_request<R: for<'de> Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
body: &AnthropicRequest,
|
||||||
|
) -> Result<R, LlmError> {
|
||||||
|
let url = self.api_url();
|
||||||
|
|
||||||
|
tracing::debug!("Sending request to Anthropic OAuth: {}", url);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(self.token.expose_secret())
|
||||||
|
.header("anthropic-version", ANTHROPIC_API_VERSION)
|
||||||
|
.header("anthropic-beta", ANTHROPIC_OAUTH_BETA)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
// Parse Retry-After header before consuming the body.
|
||||||
|
let retry_after = response
|
||||||
|
.headers()
|
||||||
|
.get("retry-after")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
.map(std::time::Duration::from_secs);
|
||||||
|
|
||||||
|
let response_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| format!("(failed to read error body: {e})"));
|
||||||
|
|
||||||
|
if status.as_u16() == 401 {
|
||||||
|
// OAuth tokens from `claude login` expire in ~8-12h. Attempt
|
||||||
|
// to re-extract a fresh token from the OS credential store
|
||||||
|
// (macOS Keychain / Linux credentials file) before giving up.
|
||||||
|
if let Some(fresh) = crate::config::ClaudeCodeConfig::extract_oauth_token() {
|
||||||
|
let fresh_token = SecretString::from(fresh);
|
||||||
|
// Retry once with the refreshed token
|
||||||
|
let retry = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(fresh_token.expose_secret())
|
||||||
|
.header("anthropic-version", ANTHROPIC_API_VERSION)
|
||||||
|
.header("anthropic-beta", ANTHROPIC_OAUTH_BETA)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
if retry.status().is_success() {
|
||||||
|
let text = retry.text().await.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
reason: format!("Failed to read response body: {}", e),
|
||||||
|
})?;
|
||||||
|
return serde_json::from_str(&text).map_err(|e| {
|
||||||
|
let truncated = crate::agent::truncate_for_preview(&text, 512);
|
||||||
|
LlmError::InvalidResponse {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
reason: format!("JSON parse error: {}. Raw: {}", e, truncated),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
"Anthropic OAuth 401 retry with refreshed token also failed ({})",
|
||||||
|
retry.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(LlmError::AuthFailed {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if status.as_u16() == 429 {
|
||||||
|
return Err(LlmError::RateLimited {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
retry_after,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
|
||||||
|
return Err(LlmError::RequestFailed {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
reason: format!("HTTP {}: {}", status, truncated),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
reason: format!("Failed to read response body: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Anthropic OAuth response: status={}, bytes={}",
|
||||||
|
status,
|
||||||
|
response_text.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
serde_json::from_str(&response_text).map_err(|e| {
|
||||||
|
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
|
||||||
|
LlmError::InvalidResponse {
|
||||||
|
provider: "anthropic_oauth".to_string(),
|
||||||
|
reason: format!("JSON parse error: {}. Raw: {}", e, truncated),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmProvider for AnthropicOAuthProvider {
|
||||||
|
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||||
|
let model = req.model.unwrap_or_else(|| self.active_model_name());
|
||||||
|
let (system, messages) = convert_messages(req.messages);
|
||||||
|
|
||||||
|
let request = AnthropicRequest {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
system,
|
||||||
|
max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
|
||||||
|
temperature: req.temperature,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response: AnthropicResponse = self.send_request(&request).await?;
|
||||||
|
let (content, _tool_calls) = extract_response_content(&response);
|
||||||
|
|
||||||
|
let finish_reason = match response.stop_reason.as_deref() {
|
||||||
|
Some("end_turn") | Some("stop") => FinishReason::Stop,
|
||||||
|
Some("max_tokens") => FinishReason::Length,
|
||||||
|
Some("tool_use") => FinishReason::ToolUse,
|
||||||
|
_ => FinishReason::Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CompletionResponse {
|
||||||
|
content: content.unwrap_or_default(),
|
||||||
|
finish_reason,
|
||||||
|
input_tokens: response.usage.input_tokens,
|
||||||
|
output_tokens: response.usage.output_tokens,
|
||||||
|
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens: response.usage.cache_read_input_tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete_with_tools(
|
||||||
|
&self,
|
||||||
|
req: ToolCompletionRequest,
|
||||||
|
) -> Result<ToolCompletionResponse, LlmError> {
|
||||||
|
let model = req.model.unwrap_or_else(|| self.active_model_name());
|
||||||
|
let (system, messages) = convert_messages(req.messages);
|
||||||
|
|
||||||
|
let tools: Vec<AnthropicTool> = req
|
||||||
|
.tools
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| AnthropicTool {
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
input_schema: t.parameters,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Map tool_choice from OpenAI format to Anthropic format
|
||||||
|
let tool_choice = req.tool_choice.map(|tc| match tc.as_str() {
|
||||||
|
"auto" => AnthropicToolChoice {
|
||||||
|
choice_type: "auto".to_string(),
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
"required" => AnthropicToolChoice {
|
||||||
|
choice_type: "any".to_string(),
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
"none" => AnthropicToolChoice {
|
||||||
|
choice_type: "none".to_string(),
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
specific => AnthropicToolChoice {
|
||||||
|
choice_type: "tool".to_string(),
|
||||||
|
name: Some(specific.to_string()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = AnthropicRequest {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
system,
|
||||||
|
max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
|
||||||
|
temperature: req.temperature,
|
||||||
|
tools: if tools.is_empty() { None } else { Some(tools) },
|
||||||
|
tool_choice,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response: AnthropicResponse = self.send_request(&request).await?;
|
||||||
|
let (content, tool_calls) = extract_response_content(&response);
|
||||||
|
|
||||||
|
let finish_reason = match response.stop_reason.as_deref() {
|
||||||
|
Some("end_turn") | Some("stop") => FinishReason::Stop,
|
||||||
|
Some("max_tokens") => FinishReason::Length,
|
||||||
|
Some("tool_use") => FinishReason::ToolUse,
|
||||||
|
_ => {
|
||||||
|
if !tool_calls.is_empty() {
|
||||||
|
FinishReason::ToolUse
|
||||||
|
} else {
|
||||||
|
FinishReason::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ToolCompletionResponse {
|
||||||
|
content,
|
||||||
|
tool_calls,
|
||||||
|
finish_reason,
|
||||||
|
input_tokens: response.usage.input_tokens,
|
||||||
|
output_tokens: response.usage.output_tokens,
|
||||||
|
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens: response.usage.cache_read_input_tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_name(&self) -> &str {
|
||||||
|
&self.model
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||||
|
let model = self.active_model_name();
|
||||||
|
costs::model_cost(&model).unwrap_or_else(costs::default_cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_model_name(&self) -> String {
|
||||||
|
match self.active_model.read() {
|
||||||
|
Ok(guard) => guard.clone(),
|
||||||
|
Err(poisoned) => poisoned.into_inner().clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_model(&self, model: &str) -> Result<(), LlmError> {
|
||||||
|
match self.active_model.write() {
|
||||||
|
Ok(mut guard) => {
|
||||||
|
*guard = model.to_string();
|
||||||
|
}
|
||||||
|
Err(poisoned) => {
|
||||||
|
*poisoned.into_inner() = model.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Anthropic Messages API types ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct AnthropicRequest {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<AnthropicMessage>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
system: Option<String>,
|
||||||
|
max_tokens: u32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
temperature: Option<f32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tools: Option<Vec<AnthropicTool>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tool_choice: Option<AnthropicToolChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct AnthropicMessage {
|
||||||
|
role: String,
|
||||||
|
content: AnthropicContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic content can be a simple string or a list of content blocks.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum AnthropicContent {
|
||||||
|
Text(String),
|
||||||
|
Blocks(Vec<AnthropicContentBlock>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
enum AnthropicContentBlock {
|
||||||
|
#[serde(rename = "text")]
|
||||||
|
Text { text: String },
|
||||||
|
#[serde(rename = "tool_use")]
|
||||||
|
ToolUse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: serde_json::Value,
|
||||||
|
},
|
||||||
|
#[serde(rename = "tool_result")]
|
||||||
|
ToolResult {
|
||||||
|
tool_use_id: String,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct AnthropicTool {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
input_schema: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct AnthropicToolChoice {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
choice_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AnthropicResponse {
|
||||||
|
content: Vec<AnthropicResponseBlock>,
|
||||||
|
#[serde(default)]
|
||||||
|
stop_reason: Option<String>,
|
||||||
|
usage: AnthropicUsage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
enum AnthropicResponseBlock {
|
||||||
|
#[serde(rename = "text")]
|
||||||
|
Text { text: String },
|
||||||
|
#[serde(rename = "tool_use")]
|
||||||
|
ToolUse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: serde_json::Value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AnthropicUsage {
|
||||||
|
#[serde(default)]
|
||||||
|
input_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
output_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
cache_creation_input_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
cache_read_input_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert ChatMessage list to Anthropic format.
|
||||||
|
///
|
||||||
|
/// Extracts system messages to the top-level `system` parameter (Anthropic
|
||||||
|
/// doesn't allow system messages in the `messages` array). Tool-call/tool-result
|
||||||
|
/// pairs are converted to content blocks.
|
||||||
|
fn convert_messages(messages: Vec<ChatMessage>) -> (Option<String>, Vec<AnthropicMessage>) {
|
||||||
|
let mut system_parts: Vec<String> = Vec::new();
|
||||||
|
let mut anthropic_msgs: Vec<AnthropicMessage> = Vec::new();
|
||||||
|
|
||||||
|
for msg in messages {
|
||||||
|
match msg.role {
|
||||||
|
Role::System => {
|
||||||
|
if !msg.content.is_empty() {
|
||||||
|
system_parts.push(msg.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Role::User => {
|
||||||
|
anthropic_msgs.push(AnthropicMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: AnthropicContent::Text(msg.content),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Role::Assistant => {
|
||||||
|
if let Some(tool_calls) = msg.tool_calls {
|
||||||
|
// Assistant message with tool calls → content blocks
|
||||||
|
let mut blocks: Vec<AnthropicContentBlock> = Vec::new();
|
||||||
|
if !msg.content.is_empty() {
|
||||||
|
blocks.push(AnthropicContentBlock::Text { text: msg.content });
|
||||||
|
}
|
||||||
|
for tc in tool_calls {
|
||||||
|
blocks.push(AnthropicContentBlock::ToolUse {
|
||||||
|
id: tc.id,
|
||||||
|
name: tc.name,
|
||||||
|
input: tc.arguments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
anthropic_msgs.push(AnthropicMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: AnthropicContent::Blocks(blocks),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
anthropic_msgs.push(AnthropicMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: AnthropicContent::Text(msg.content),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Role::Tool => {
|
||||||
|
let Some(tool_call_id) = msg.tool_call_id else {
|
||||||
|
tracing::warn!("Skipping Tool message without tool_call_id");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Tool results go into a user message with tool_result blocks
|
||||||
|
let block = AnthropicContentBlock::ToolResult {
|
||||||
|
tool_use_id: tool_call_id,
|
||||||
|
content: msg.content,
|
||||||
|
};
|
||||||
|
// If the last message is already a user message with blocks,
|
||||||
|
// append to it (Anthropic requires consecutive tool results
|
||||||
|
// in one user message).
|
||||||
|
if let Some(last) = anthropic_msgs.last_mut()
|
||||||
|
&& last.role == "user"
|
||||||
|
&& let AnthropicContent::Blocks(ref mut blocks) = last.content
|
||||||
|
{
|
||||||
|
blocks.push(block);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
anthropic_msgs.push(AnthropicMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: AnthropicContent::Blocks(vec![block]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let system = if system_parts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(system_parts.join("\n\n"))
|
||||||
|
};
|
||||||
|
|
||||||
|
(system, anthropic_msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text content and tool calls from an Anthropic response.
|
||||||
|
fn extract_response_content(response: &AnthropicResponse) -> (Option<String>, Vec<ToolCall>) {
|
||||||
|
let mut text_parts: Vec<String> = Vec::new();
|
||||||
|
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||||
|
|
||||||
|
for block in &response.content {
|
||||||
|
match block {
|
||||||
|
AnthropicResponseBlock::Text { text } => {
|
||||||
|
text_parts.push(text.clone());
|
||||||
|
}
|
||||||
|
AnthropicResponseBlock::ToolUse { id, name, input } => {
|
||||||
|
tool_calls.push(ToolCall {
|
||||||
|
id: id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
arguments: input.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = if text_parts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(text_parts.join(""))
|
||||||
|
};
|
||||||
|
|
||||||
|
(content, tool_calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_messages_extracts_system() {
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::system("You are helpful."),
|
||||||
|
ChatMessage::user("Hello"),
|
||||||
|
];
|
||||||
|
let (system, msgs) = convert_messages(messages);
|
||||||
|
assert_eq!(system, Some("You are helpful.".to_string()));
|
||||||
|
assert_eq!(msgs.len(), 1);
|
||||||
|
assert_eq!(msgs[0].role, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_messages_multiple_systems() {
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::system("System 1"),
|
||||||
|
ChatMessage::system("System 2"),
|
||||||
|
ChatMessage::user("Hello"),
|
||||||
|
];
|
||||||
|
let (system, msgs) = convert_messages(messages);
|
||||||
|
assert_eq!(system, Some("System 1\n\nSystem 2".to_string()));
|
||||||
|
assert_eq!(msgs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_messages_tool_calls() {
|
||||||
|
let tool_calls = vec![ToolCall {
|
||||||
|
id: "call_1".to_string(),
|
||||||
|
name: "search".to_string(),
|
||||||
|
arguments: serde_json::json!({"q": "test"}),
|
||||||
|
}];
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::user("Search for test"),
|
||||||
|
ChatMessage::assistant_with_tool_calls(Some("Let me search.".to_string()), tool_calls),
|
||||||
|
ChatMessage::tool_result("call_1", "search", "found it"),
|
||||||
|
];
|
||||||
|
let (system, msgs) = convert_messages(messages);
|
||||||
|
assert!(system.is_none());
|
||||||
|
assert_eq!(msgs.len(), 3);
|
||||||
|
assert_eq!(msgs[0].role, "user");
|
||||||
|
assert_eq!(msgs[1].role, "assistant");
|
||||||
|
// Tool result should be a user message
|
||||||
|
assert_eq!(msgs[2].role, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_response_text_only() {
|
||||||
|
let response = AnthropicResponse {
|
||||||
|
content: vec![AnthropicResponseBlock::Text {
|
||||||
|
text: "Hello!".to_string(),
|
||||||
|
}],
|
||||||
|
stop_reason: Some("end_turn".to_string()),
|
||||||
|
usage: AnthropicUsage {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 5,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let (content, tool_calls) = extract_response_content(&response);
|
||||||
|
assert_eq!(content, Some("Hello!".to_string()));
|
||||||
|
assert!(tool_calls.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_response_with_tool_use() {
|
||||||
|
let response = AnthropicResponse {
|
||||||
|
content: vec![
|
||||||
|
AnthropicResponseBlock::Text {
|
||||||
|
text: "Let me search.".to_string(),
|
||||||
|
},
|
||||||
|
AnthropicResponseBlock::ToolUse {
|
||||||
|
id: "call_1".to_string(),
|
||||||
|
name: "search".to_string(),
|
||||||
|
input: serde_json::json!({"q": "test"}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stop_reason: Some("tool_use".to_string()),
|
||||||
|
usage: AnthropicUsage {
|
||||||
|
input_tokens: 20,
|
||||||
|
output_tokens: 15,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let (content, tool_calls) = extract_response_content(&response);
|
||||||
|
assert_eq!(content, Some("Let me search.".to_string()));
|
||||||
|
assert_eq!(tool_calls.len(), 1);
|
||||||
|
assert_eq!(tool_calls[0].name, "search");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
//! - **Ollama**: Local model inference
|
//! - **Ollama**: Local model inference
|
||||||
//! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API
|
//! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API
|
||||||
|
|
||||||
|
mod anthropic_oauth;
|
||||||
pub mod circuit_breaker;
|
pub mod circuit_breaker;
|
||||||
pub mod costs;
|
pub mod costs;
|
||||||
pub mod failover;
|
pub mod failover;
|
||||||
@@ -178,6 +179,24 @@ fn create_openai_compat_from_registry(
|
|||||||
fn create_anthropic_from_registry(
|
fn create_anthropic_from_registry(
|
||||||
config: &RegistryProviderConfig,
|
config: &RegistryProviderConfig,
|
||||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
|
// Route to OAuth provider when an OAuth token is present and no real API
|
||||||
|
// key was provided. When both are set, the API key takes priority (standard
|
||||||
|
// x-api-key auth via rig-core).
|
||||||
|
let api_key_is_placeholder = config
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|k| k.expose_secret() == crate::config::llm::OAUTH_PLACEHOLDER);
|
||||||
|
if config.oauth_token.is_some() && (config.api_key.is_none() || api_key_is_placeholder) {
|
||||||
|
tracing::info!(
|
||||||
|
provider = %config.provider_id,
|
||||||
|
model = %config.model,
|
||||||
|
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
|
||||||
|
"Using Anthropic OAuth API"
|
||||||
|
);
|
||||||
|
let provider = anthropic_oauth::AnthropicOAuthProvider::new(config)?;
|
||||||
|
return Ok(Arc::new(provider));
|
||||||
|
}
|
||||||
|
|
||||||
use crate::config::CacheRetention;
|
use crate::config::CacheRetention;
|
||||||
use crate::config::helpers::optional_env;
|
use crate::config::helpers::optional_env;
|
||||||
use rig::providers::anthropic;
|
use rig::providers::anthropic;
|
||||||
|
|||||||
+4
-1
@@ -158,7 +158,10 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
wizard.run().await?;
|
wizard.run().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load initial config from env + disk + optional TOML (before DB is available)
|
// Load initial config from env + disk + optional TOML (before DB is available).
|
||||||
|
// Credentials may be missing at this point — that's fine. LlmConfig::resolve()
|
||||||
|
// defers gracefully, and AppBuilder::build_all() re-resolves after loading
|
||||||
|
// secrets from the encrypted DB.
|
||||||
let toml_path = cli.config.as_deref();
|
let toml_path = cli.config.as_deref();
|
||||||
let config = match Config::from_env_with_toml(toml_path).await {
|
let config = match Config::from_env_with_toml(toml_path).await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
|
|||||||
@@ -498,6 +498,10 @@ pub struct SandboxSettings {
|
|||||||
/// Additional domains to allow through the network proxy.
|
/// Additional domains to allow through the network proxy.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub extra_allowed_domains: Vec<String>,
|
pub extra_allowed_domains: Vec<String>,
|
||||||
|
|
||||||
|
/// Whether Claude Code sandbox mode is enabled.
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude_code_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sandbox_policy() -> String {
|
fn default_sandbox_policy() -> String {
|
||||||
@@ -531,6 +535,7 @@ impl Default for SandboxSettings {
|
|||||||
image: default_sandbox_image(),
|
image: default_sandbox_image(),
|
||||||
auto_pull_image: true,
|
auto_pull_image: true,
|
||||||
extra_allowed_domains: Vec::new(),
|
extra_allowed_domains: Vec::new(),
|
||||||
|
claude_code_enabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+216
-10
@@ -22,6 +22,7 @@ use crate::bootstrap::ironclaw_base_dir;
|
|||||||
use crate::channels::wasm::{
|
use crate::channels::wasm::{
|
||||||
ChannelCapabilitiesFile, available_channel_names, install_bundled_channel,
|
ChannelCapabilitiesFile, available_channel_names, install_bundled_channel,
|
||||||
};
|
};
|
||||||
|
use crate::config::llm::OAUTH_PLACEHOLDER;
|
||||||
use crate::llm::{SessionConfig, SessionManager};
|
use crate::llm::{SessionConfig, SessionManager};
|
||||||
use crate::secrets::{SecretsCrypto, SecretsStore};
|
use crate::secrets::{SecretsCrypto, SecretsStore};
|
||||||
use crate::settings::{KeySource, Settings};
|
use crate::settings::{KeySource, Settings};
|
||||||
@@ -886,6 +887,11 @@ impl SetupWizard {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Anthropic has a custom flow: API key or OAuth token from `claude login`.
|
||||||
|
if provider_id == "anthropic" {
|
||||||
|
return self.setup_anthropic().await;
|
||||||
|
}
|
||||||
|
|
||||||
match setup {
|
match setup {
|
||||||
crate::llm::registry::SetupHint::ApiKey {
|
crate::llm::registry::SetupHint::ApiKey {
|
||||||
secret_name,
|
secret_name,
|
||||||
@@ -991,6 +997,112 @@ impl SetupWizard {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Anthropic provider setup: API key or OAuth token from `claude login`.
|
||||||
|
async fn setup_anthropic(&mut self) -> Result<(), SetupError> {
|
||||||
|
let options = &["Direct API Key", "OAuth Token (from `claude login`)"];
|
||||||
|
let choice = select_one("How do you want to authenticate with Anthropic?", options)
|
||||||
|
.map_err(SetupError::Io)?;
|
||||||
|
|
||||||
|
if choice == 0 {
|
||||||
|
// Standard API key flow
|
||||||
|
self.setup_api_key_provider(
|
||||||
|
"anthropic",
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
"llm_anthropic_api_key",
|
||||||
|
"Anthropic API key",
|
||||||
|
"https://console.anthropic.com/settings/keys",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
// OAuth token flow
|
||||||
|
self.setup_anthropic_oauth().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic OAuth setup: extract token from `claude login` credentials.
|
||||||
|
async fn setup_anthropic_oauth(&mut self) -> Result<(), SetupError> {
|
||||||
|
self.settings.llm_backend = Some("anthropic".to_string());
|
||||||
|
if self.settings.selected_model.is_some() {
|
||||||
|
self.settings.selected_model = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract existing OAuth token from Claude Code credentials
|
||||||
|
if let Some(token) = crate::config::ClaudeCodeConfig::extract_oauth_token() {
|
||||||
|
print_info(&format!("Found OAuth token: {}", mask_api_key(&token)));
|
||||||
|
if confirm("Use this token?", true).map_err(SetupError::Io)? {
|
||||||
|
return self.save_anthropic_oauth_token(&token).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print_info("No OAuth token found from `claude login`.");
|
||||||
|
print_info("Run `claude login` in a terminal to authenticate, then retry.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
if confirm("Retry after running `claude login`?", true).map_err(SetupError::Io)? {
|
||||||
|
// Block until the user has run `claude login` in another terminal
|
||||||
|
input("Press Enter after running `claude login` in another terminal...")
|
||||||
|
.map_err(SetupError::Io)?;
|
||||||
|
if let Some(token) = crate::config::ClaudeCodeConfig::extract_oauth_token() {
|
||||||
|
print_info(&format!("Found OAuth token: {}", mask_api_key(&token)));
|
||||||
|
return self.save_anthropic_oauth_token(&token).await;
|
||||||
|
}
|
||||||
|
print_error("Still no OAuth token found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: let user paste the token manually, or switch to API key
|
||||||
|
print_info("You can paste your OAuth token directly (starts with sk-ant-oat01-).");
|
||||||
|
print_info("Or press Enter with no input to switch to the API key flow.");
|
||||||
|
let token = secret_input("Anthropic OAuth token").map_err(SetupError::Io)?;
|
||||||
|
let token_str = token.expose_secret();
|
||||||
|
if token_str.is_empty() {
|
||||||
|
print_info("Switching to API key flow...");
|
||||||
|
return self
|
||||||
|
.setup_api_key_provider(
|
||||||
|
"anthropic",
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
"llm_anthropic_api_key",
|
||||||
|
"Anthropic API key",
|
||||||
|
"https://console.anthropic.com/settings/keys",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
self.save_anthropic_oauth_token(token_str).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save an Anthropic OAuth token to secrets and set env for immediate use.
|
||||||
|
async fn save_anthropic_oauth_token(&mut self, token: &str) -> Result<(), SetupError> {
|
||||||
|
// Validate token format to catch accidentally pasted API keys
|
||||||
|
if !token.starts_with("sk-ant-oat") {
|
||||||
|
print_error("Token doesn't look like an OAuth token (expected prefix: sk-ant-oat).");
|
||||||
|
print_info("If you have an API key instead, use the 'Direct API Key' option.");
|
||||||
|
return Err(SetupError::Config("Invalid OAuth token format".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in secrets if available
|
||||||
|
if let Ok(ctx) = self.init_secrets_context().await {
|
||||||
|
let key = SecretString::from(token.to_string());
|
||||||
|
ctx.save_secret("llm_anthropic_oauth_token", &key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SetupError::Config(format!("Failed to save OAuth token: {e}")))?;
|
||||||
|
print_success("OAuth token encrypted and saved");
|
||||||
|
} else {
|
||||||
|
print_info("Secrets not available. Set ANTHROPIC_OAUTH_TOKEN in your environment.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the token visible to `optional_env()` for subsequent config
|
||||||
|
// resolution (model selection step). Uses the thread-safe overlay
|
||||||
|
// instead of `std::env::set_var` to avoid UB on multi-threaded runtimes.
|
||||||
|
crate::config::inject_single_var("ANTHROPIC_OAUTH_TOKEN", token);
|
||||||
|
|
||||||
|
// Cache for model fetching
|
||||||
|
self.llm_api_key = Some(SecretString::from(token.to_string()));
|
||||||
|
|
||||||
|
print_success("Anthropic OAuth configured");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared setup flow for API-key-based providers.
|
/// Shared setup flow for API-key-based providers.
|
||||||
async fn setup_api_key_provider(
|
async fn setup_api_key_provider(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -1052,6 +1164,11 @@ impl SetupWizard {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make key visible to `optional_env()` for subsequent config resolution.
|
||||||
|
// Uses the thread-safe overlay instead of `std::env::set_var` to avoid
|
||||||
|
// UB on multi-threaded runtimes.
|
||||||
|
crate::config::inject_single_var(env_var, key_str);
|
||||||
|
|
||||||
// Cache key in memory for model fetching later in the wizard
|
// Cache key in memory for model fetching later in the wizard
|
||||||
self.llm_api_key = Some(SecretString::from(key_str.to_string()));
|
self.llm_api_key = Some(SecretString::from(key_str.to_string()));
|
||||||
|
|
||||||
@@ -1988,6 +2105,67 @@ impl SetupWizard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Code sandbox sub-step (only if Docker sandbox is enabled)
|
||||||
|
if self.settings.sandbox.enabled {
|
||||||
|
self.step_claude_code_sandbox().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude Code sandbox sub-step: enable Claude CLI inside Docker containers.
|
||||||
|
async fn step_claude_code_sandbox(&mut self) -> Result<(), SetupError> {
|
||||||
|
println!();
|
||||||
|
print_info("Claude Code mode lets the agent delegate complex tasks to Claude CLI");
|
||||||
|
print_info("running inside sandboxed Docker containers.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
if !confirm("Enable Claude Code sandbox mode?", false).map_err(SetupError::Io)? {
|
||||||
|
self.settings.sandbox.claude_code_enabled = false;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Anthropic credentials (API key or OAuth token).
|
||||||
|
// Uses `optional_env()` which reads both real env vars and the
|
||||||
|
// injected overlay (secrets DB, wizard-set values).
|
||||||
|
let has_credentials = || {
|
||||||
|
let has_api_key = crate::config::helpers::optional_env("ANTHROPIC_API_KEY")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some_and(|v| !v.is_empty() && v != OAUTH_PLACEHOLDER);
|
||||||
|
let has_oauth = crate::config::ClaudeCodeConfig::extract_oauth_token().is_some()
|
||||||
|
|| crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some_and(|v| !v.is_empty());
|
||||||
|
has_api_key || has_oauth
|
||||||
|
};
|
||||||
|
|
||||||
|
if has_credentials() {
|
||||||
|
self.settings.sandbox.claude_code_enabled = true;
|
||||||
|
print_success("Claude Code sandbox enabled");
|
||||||
|
} else {
|
||||||
|
print_error("No Anthropic credentials found.");
|
||||||
|
print_info(
|
||||||
|
"Claude Code needs ANTHROPIC_API_KEY or an OAuth token from `claude login`.",
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
if confirm("Retry after setting up credentials?", false).map_err(SetupError::Io)? {
|
||||||
|
if has_credentials() {
|
||||||
|
self.settings.sandbox.claude_code_enabled = true;
|
||||||
|
print_success("Claude Code sandbox enabled");
|
||||||
|
} else {
|
||||||
|
self.settings.sandbox.claude_code_enabled = false;
|
||||||
|
print_info("No credentials found. Claude Code disabled for now.");
|
||||||
|
print_info("Set ANTHROPIC_API_KEY or run `claude login` and enable later.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.settings.sandbox.claude_code_enabled = false;
|
||||||
|
print_info("Claude Code disabled. Enable with CLAUDE_CODE_ENABLED=true later.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2081,6 +2259,12 @@ impl SetupWizard {
|
|||||||
///
|
///
|
||||||
/// These are the chicken-and-egg settings needed before the database is
|
/// These are the chicken-and-egg settings needed before the database is
|
||||||
/// connected (DATABASE_BACKEND, DATABASE_URL, LLM_BACKEND, etc.).
|
/// connected (DATABASE_BACKEND, DATABASE_URL, LLM_BACKEND, etc.).
|
||||||
|
///
|
||||||
|
/// **Credentials are NOT written here.** API keys and OAuth tokens live
|
||||||
|
/// only in the encrypted secrets DB. `LlmConfig::resolve()` defers
|
||||||
|
/// gracefully when credentials are missing during early startup, and the
|
||||||
|
/// re-resolution in `AppBuilder::build_all()` fills them in after
|
||||||
|
/// `inject_llm_keys_from_secrets()` loads from encrypted storage.
|
||||||
fn write_bootstrap_env(&self) -> Result<(), SetupError> {
|
fn write_bootstrap_env(&self) -> Result<(), SetupError> {
|
||||||
let registry = crate::llm::ProviderRegistry::load();
|
let registry = crate::llm::ProviderRegistry::load();
|
||||||
let mut env_vars: Vec<(String, String)> = Vec::new();
|
let mut env_vars: Vec<(String, String)> = Vec::new();
|
||||||
@@ -2146,6 +2330,11 @@ impl SetupWizard {
|
|||||||
env_vars.push(("ONBOARD_COMPLETED".to_string(), "true".to_string()));
|
env_vars.push(("ONBOARD_COMPLETED".to_string(), "true".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Code sandbox mode
|
||||||
|
if self.settings.sandbox.claude_code_enabled {
|
||||||
|
env_vars.push(("CLAUDE_CODE_ENABLED".to_string(), "true".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
// Signal channel env vars (chicken-and-egg: config resolves before DB).
|
// Signal channel env vars (chicken-and-egg: config resolves before DB).
|
||||||
if let Some(ref url) = self.settings.channels.signal_http_url {
|
if let Some(ref url) = self.settings.channels.signal_http_url {
|
||||||
env_vars.push(("SIGNAL_HTTP_URL".to_string(), url.clone()));
|
env_vars.push(("SIGNAL_HTTP_URL".to_string(), url.clone()));
|
||||||
@@ -2513,22 +2702,39 @@ async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String
|
|||||||
let api_key = cached_key
|
let api_key = cached_key
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
|
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
|
||||||
.filter(|k| !k.is_empty());
|
.filter(|k| !k.is_empty() && k != crate::config::llm::OAUTH_PLACEHOLDER);
|
||||||
|
|
||||||
let api_key = match api_key {
|
// Fall back to OAuth token if no API key
|
||||||
Some(k) => k,
|
let oauth_token = if api_key.is_none() {
|
||||||
None => return static_defaults,
|
crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (key_or_token, is_oauth) = match (api_key, oauth_token) {
|
||||||
|
(Some(k), _) => (k, false),
|
||||||
|
(None, Some(t)) => (t, true),
|
||||||
|
(None, None) => return static_defaults,
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let resp = match client
|
let mut request = client
|
||||||
.get("https://api.anthropic.com/v1/models")
|
.get("https://api.anthropic.com/v1/models")
|
||||||
.header("x-api-key", &api_key)
|
|
||||||
.header("anthropic-version", "2023-06-01")
|
.header("anthropic-version", "2023-06-01")
|
||||||
.timeout(std::time::Duration::from_secs(5))
|
.timeout(std::time::Duration::from_secs(5));
|
||||||
.send()
|
|
||||||
.await
|
if is_oauth {
|
||||||
{
|
request = request
|
||||||
|
.bearer_auth(&key_or_token)
|
||||||
|
.header("anthropic-beta", "oauth-2025-04-20");
|
||||||
|
} else {
|
||||||
|
request = request.header("x-api-key", &key_or_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = match request.send().await {
|
||||||
Ok(r) if r.status().is_success() => r,
|
Ok(r) if r.status().is_success() => r,
|
||||||
_ => return static_defaults,
|
_ => return static_defaults,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user