From a5a6492165adb72f85c3134beec0aa15992fe712 Mon Sep 17 00:00:00 2001 From: financialvice <100445773+financialvice@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:17:01 -0800 Subject: [PATCH] fix: support Claude OAuth token for model listing (#109) --- server/ARCHITECTURE.md | 2 +- server/packages/agent-credentials/src/lib.rs | 147 +++++++++++++++++++ server/packages/sandbox-agent/src/router.rs | 40 +++++ 3 files changed, 188 insertions(+), 1 deletion(-) diff --git a/server/ARCHITECTURE.md b/server/ARCHITECTURE.md index 5951670..5366f51 100644 --- a/server/ARCHITECTURE.md +++ b/server/ARCHITECTURE.md @@ -261,7 +261,7 @@ All agents receive API keys via environment variables: | Agent | Environment Variables | |-------|----------------------| -| Claude | `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` | +| Claude | `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN` | | Codex | `OPENAI_API_KEY`, `CODEX_API_KEY` | | OpenCode | `OPENAI_API_KEY` | | Amp | `ANTHROPIC_API_KEY` | diff --git a/server/packages/agent-credentials/src/lib.rs b/server/packages/agent-credentials/src/lib.rs index 1192241..b456a2b 100644 --- a/server/packages/agent-credentials/src/lib.rs +++ b/server/packages/agent-credentials/src/lib.rs @@ -282,6 +282,22 @@ pub fn extract_all_credentials(options: &CredentialExtractionOptions) -> Extract auth_type: AuthType::ApiKey, provider: "anthropic".to_string(), }); + } else if options.include_oauth { + if let Ok(value) = std::env::var("CLAUDE_CODE_OAUTH_TOKEN") { + result.anthropic = Some(ProviderCredentials { + api_key: value, + source: "environment".to_string(), + auth_type: AuthType::Oauth, + provider: "anthropic".to_string(), + }); + } else if let Ok(value) = std::env::var("ANTHROPIC_AUTH_TOKEN") { + result.anthropic = Some(ProviderCredentials { + api_key: value, + source: "environment".to_string(), + auth_type: AuthType::Oauth, + provider: "anthropic".to_string(), + }); + } } if let Ok(value) = std::env::var("OPENAI_API_KEY") { @@ -376,3 +392,134 @@ fn is_expired_rfc3339(value: &str) -> bool { Err(_) => false, } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::fs; + use std::path::PathBuf; + use std::sync::Mutex; + use std::time::{SystemTime, UNIX_EPOCH}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + const ANTHROPIC_ENV_KEYS: [&str; 5] = [ + "ANTHROPIC_API_KEY", + "CLAUDE_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_AUTH_TOKEN", + "OPENAI_API_KEY", + ]; + + fn with_env(mutations: &[(&str, Option<&str>)], test_fn: impl FnOnce()) { + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); + + let mut snapshot: HashMap> = HashMap::new(); + for key in ANTHROPIC_ENV_KEYS { + snapshot.insert(key.to_string(), std::env::var(key).ok()); + } + + for (key, value) in mutations { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + + test_fn(); + + for (key, value) in snapshot { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + fn empty_home_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos(); + let path = + std::env::temp_dir().join(format!("sandbox-agent-agent-credentials-test-{nanos}")); + fs::create_dir_all(&path).expect("failed to create temp home dir"); + path + } + + #[test] + fn extract_all_credentials_reads_claude_code_oauth_env() { + with_env( + &[ + ("ANTHROPIC_API_KEY", None), + ("CLAUDE_API_KEY", None), + ("CLAUDE_CODE_OAUTH_TOKEN", Some("oauth-token-123")), + ("ANTHROPIC_AUTH_TOKEN", None), + ], + || { + let options = CredentialExtractionOptions { + home_dir: Some(empty_home_dir()), + include_oauth: true, + }; + let creds = extract_all_credentials(&options); + let anthropic = creds + .anthropic + .expect("expected anthropic credentials from oauth env"); + + assert_eq!(anthropic.api_key, "oauth-token-123"); + assert_eq!(anthropic.source, "environment"); + assert_eq!(anthropic.auth_type, AuthType::Oauth); + assert_eq!(anthropic.provider, "anthropic"); + }, + ); + } + + #[test] + fn extract_all_credentials_ignores_oauth_env_when_disabled() { + with_env( + &[ + ("ANTHROPIC_API_KEY", None), + ("CLAUDE_API_KEY", None), + ("CLAUDE_CODE_OAUTH_TOKEN", Some("oauth-token-123")), + ("ANTHROPIC_AUTH_TOKEN", None), + ], + || { + let options = CredentialExtractionOptions { + home_dir: Some(empty_home_dir()), + include_oauth: false, + }; + let creds = extract_all_credentials(&options); + assert!( + creds.anthropic.is_none(), + "oauth env should be ignored when include_oauth is false" + ); + }, + ); + } + + #[test] + fn extract_all_credentials_prefers_api_key_over_oauth_env() { + with_env( + &[ + ("ANTHROPIC_API_KEY", Some("sk-ant-priority")), + ("CLAUDE_API_KEY", None), + ("CLAUDE_CODE_OAUTH_TOKEN", Some("oauth-token-123")), + ("ANTHROPIC_AUTH_TOKEN", Some("oauth-token-456")), + ], + || { + let options = CredentialExtractionOptions { + home_dir: Some(empty_home_dir()), + include_oauth: true, + }; + let creds = extract_all_credentials(&options); + let anthropic = creds + .anthropic + .expect("expected anthropic credentials from api key env"); + + assert_eq!(anthropic.api_key, "sk-ant-priority"); + assert_eq!(anthropic.auth_type, AuthType::ApiKey); + }, + ); + } +} diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 0910da4..6cd24fa 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -55,6 +55,32 @@ static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1); const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models?beta=true"; const ANTHROPIC_VERSION: &str = "2023-06-01"; +fn claude_oauth_fallback_models() -> AgentModelsResponse { + AgentModelsResponse { + models: vec![ + AgentModelInfo { + id: "default".to_string(), + name: Some("Default (recommended)".to_string()), + variants: None, + default_variant: None, + }, + AgentModelInfo { + id: "opus".to_string(), + name: Some("Opus".to_string()), + variants: None, + default_variant: None, + }, + AgentModelInfo { + id: "haiku".to_string(), + name: Some("Haiku".to_string()), + variants: None, + default_variant: None, + }, + ], + default_model: Some("default".to_string()), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum BrandingMode { #[default] @@ -3318,6 +3344,13 @@ impl SessionManager { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); + if matches!(cred.auth_type, AuthType::Oauth) { + tracing::warn!( + status = %status, + "Anthropic model list rejected OAuth credentials; using Claude OAuth fallback models" + ); + return Ok(claude_oauth_fallback_models()); + } return Err(SandboxError::StreamError { message: format!("Anthropic models request failed {status}: {body}"), }); @@ -3372,6 +3405,13 @@ impl SessionManager { default_model = models.first().map(|model| model.id.clone()); } + if models.is_empty() && matches!(cred.auth_type, AuthType::Oauth) { + tracing::warn!( + "Anthropic model list was empty for OAuth credentials; using Claude OAuth fallback models" + ); + return Ok(claude_oauth_fallback_models()); + } + Ok(AgentModelsResponse { models, default_model,