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
|
||||
# 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) ===
|
||||
# Two auth modes:
|
||||
# 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() {
|
||||
Some(k) => k,
|
||||
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
|
||||
#[cfg(feature = "libsql")]
|
||||
{
|
||||
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(());
|
||||
}
|
||||
};
|
||||
@@ -665,6 +682,17 @@ 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.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() {
|
||||
(llm, None, None)
|
||||
} 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)
|
||||
if let Some(val) = INJECTED_VARS.get().and_then(|map| map.get(key)) {
|
||||
return Ok(Some(val.clone()));
|
||||
if let Some(val) = INJECTED_VARS
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.get(key)
|
||||
.cloned()
|
||||
{
|
||||
return Ok(Some(val));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
|
||||
+141
-6
@@ -9,6 +9,13 @@ use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
||||
use crate::llm::session::SessionConfig;
|
||||
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.
|
||||
///
|
||||
/// Controls Anthropic's automatic prompt caching via a top-level
|
||||
@@ -66,6 +73,7 @@ pub struct RegistryProviderConfig {
|
||||
/// Provider identifier (e.g., "groq", "openai", "tinfoil").
|
||||
pub provider_id: String,
|
||||
/// API key (optional for some providers like Ollama).
|
||||
/// For Anthropic OAuth, this is set to `OAUTH_PLACEHOLDER`.
|
||||
pub api_key: Option<SecretString>,
|
||||
/// Base URL for the API endpoint.
|
||||
pub base_url: String,
|
||||
@@ -73,6 +81,9 @@ pub struct RegistryProviderConfig {
|
||||
pub model: String,
|
||||
/// Extra HTTP headers injected into every request.
|
||||
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.
|
||||
@@ -366,6 +377,22 @@ impl LlmConfig {
|
||||
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 {
|
||||
protocol,
|
||||
provider_id: canonical_id.to_string(),
|
||||
@@ -373,6 +400,7 @@ impl LlmConfig {
|
||||
base_url,
|
||||
model,
|
||||
extra_headers,
|
||||
oauth_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -677,8 +705,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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");
|
||||
clear_openai_compatible_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
@@ -705,8 +731,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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");
|
||||
clear_openai_compatible_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
@@ -717,7 +741,6 @@ mod tests {
|
||||
|
||||
let settings = Settings::default();
|
||||
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");
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(provider.provider_id, "openai_compatible");
|
||||
@@ -759,7 +782,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn base_url_resolution_priority() {
|
||||
// Env var > settings > registry default
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
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]
|
||||
fn cache_retention_from_str_primary_values() {
|
||||
assert_eq!(
|
||||
|
||||
+73
-5
@@ -13,7 +13,7 @@ mod embeddings;
|
||||
mod heartbeat;
|
||||
pub(crate) mod helpers;
|
||||
mod hygiene;
|
||||
mod llm;
|
||||
pub(crate) mod llm;
|
||||
mod routines;
|
||||
mod safety;
|
||||
mod sandbox;
|
||||
@@ -24,7 +24,7 @@ mod tunnel;
|
||||
mod wasm;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use crate::error::ConfigError;
|
||||
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
|
||||
/// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks
|
||||
/// 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.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -285,6 +290,9 @@ impl Config {
|
||||
/// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay
|
||||
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
||||
/// 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(
|
||||
secrets: &dyn crate::secrets::SecretsStore,
|
||||
user_id: &str,
|
||||
@@ -292,7 +300,10 @@ pub async fn inject_llm_keys_from_secrets(
|
||||
// Static mappings for well-known providers.
|
||||
// The registry's setup hints define secret_name -> env_var mappings,
|
||||
// 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.
|
||||
// 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-..."}}`
|
||||
fn parse_oauth_access_token(json: &str) -> Option<String> {
|
||||
let creds: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
creds["claudeAiOauth"]["accessToken"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
let token = creds["claudeAiOauth"]["accessToken"].as_str()?;
|
||||
// Validate that the token looks like a real OAuth token before using it.
|
||||
// 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)]
|
||||
@@ -401,14 +406,14 @@ mod tests {
|
||||
fn parse_oauth_token_nested_extra_fields() {
|
||||
let json = r#"{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-real-token",
|
||||
"accessToken": "sk-ant-oat01-real-token",
|
||||
"refreshToken": "rt-abc",
|
||||
"expiresAt": 1700000000
|
||||
}
|
||||
}"#;
|
||||
assert_eq!(
|
||||
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);
|
||||
}
|
||||
|
||||
#[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 ───────────────────────────
|
||||
|
||||
#[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
|
||||
//! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API
|
||||
|
||||
mod anthropic_oauth;
|
||||
pub mod circuit_breaker;
|
||||
pub mod costs;
|
||||
pub mod failover;
|
||||
@@ -178,6 +179,24 @@ fn create_openai_compat_from_registry(
|
||||
fn create_anthropic_from_registry(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> 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::helpers::optional_env;
|
||||
use rig::providers::anthropic;
|
||||
|
||||
+4
-1
@@ -158,7 +158,10 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
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 config = match Config::from_env_with_toml(toml_path).await {
|
||||
Ok(c) => c,
|
||||
|
||||
@@ -498,6 +498,10 @@ pub struct SandboxSettings {
|
||||
/// Additional domains to allow through the network proxy.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -531,6 +535,7 @@ impl Default for SandboxSettings {
|
||||
image: default_sandbox_image(),
|
||||
auto_pull_image: true,
|
||||
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::{
|
||||
ChannelCapabilitiesFile, available_channel_names, install_bundled_channel,
|
||||
};
|
||||
use crate::config::llm::OAUTH_PLACEHOLDER;
|
||||
use crate::llm::{SessionConfig, SessionManager};
|
||||
use crate::secrets::{SecretsCrypto, SecretsStore};
|
||||
use crate::settings::{KeySource, Settings};
|
||||
@@ -886,6 +887,11 @@ impl SetupWizard {
|
||||
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 {
|
||||
crate::llm::registry::SetupHint::ApiKey {
|
||||
secret_name,
|
||||
@@ -991,6 +997,112 @@ impl SetupWizard {
|
||||
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.
|
||||
async fn setup_api_key_provider(
|
||||
&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
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -2081,6 +2259,12 @@ impl SetupWizard {
|
||||
///
|
||||
/// These are the chicken-and-egg settings needed before the database is
|
||||
/// 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> {
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
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()));
|
||||
}
|
||||
|
||||
// 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).
|
||||
if let Some(ref url) = self.settings.channels.signal_http_url {
|
||||
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
|
||||
.map(String::from)
|
||||
.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 {
|
||||
Some(k) => k,
|
||||
None => return static_defaults,
|
||||
// Fall back to OAuth token if no API key
|
||||
let oauth_token = if api_key.is_none() {
|
||||
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 resp = match client
|
||||
let mut request = client
|
||||
.get("https://api.anthropic.com/v1/models")
|
||||
.header("x-api-key", &api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
.timeout(std::time::Duration::from_secs(5));
|
||||
|
||||
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,
|
||||
_ => return static_defaults,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user