mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 04:03:31 +00:00
fix: credential detection and provider auth status (#120)
## Summary Fix credential detection bugs and add credential availability status to the API. Consolidate Claude fallback models and add `sonnet` alias. Builds on #109 (OAuth token support). Related issues: - Fixes #117 (Claude, Codex not showing up in gigacode) - Related to #113 (Default agent should be Claude Code) ## Changes ### Credential detection fixes - **`agent-credentials/src/lib.rs`**: Fix `?` operator bug in `extract_claude_credentials` - now continues to next config path if one is missing instead of returning early ### API credential status - **`sandbox-agent/src/router.rs`**: Add `credentialsAvailable` field to `AgentInfo` struct - **`/v1/agents`** endpoint now reports whether each agent has valid credentials ### OpenCode provider improvements - **`sandbox-agent/src/opencode_compat.rs`**: Build `connected` array based on actual credential availability, not just model presence - Check provider-specific credentials for OpenCode groups (e.g., `opencode:anthropic` only connected if Anthropic creds available) - Add logging when credential extraction fails in model cache building ### Fallback model consolidation - Renamed `claude_oauth_fallback_models()` → `claude_fallback_models()` (used for all fallback cases, not just OAuth) - Added `sonnet` to fallback models (confirmed working via headless CLI test) - Added `codex_fallback_models()` for Codex when credentials missing - Added comment explaining aliases work for both API and OAuth users ### Documentation - **`docs/credentials.mdx`**: New reference doc covering credential sources, extraction behavior, and error handling - Documents that extraction failures are silent (not errors) - Documents that agents spawn without credential pre-validation ### Inspector UI - **`AgentsTab.tsx`**: Added credential status pill showing "Authenticated" or "No Credentials" ## Error Handling Philosophy - **Extraction failures are silent**: Missing/malformed config files don't error, just continue to next source - **Agents spawn without credential validation**: No pre-flight auth check; agent's native error surfaces if credentials are missing - **Fallback models for UI**: When credentials missing, show alias-based models so users can still configure sessions ## Validation - Tested Claude Code model aliases via headless CLI: - `claude --model default --print "say hi"` ✓ - `claude --model sonnet --print "say hi"` ✓ - `claude --model haiku --print "say hi"` ✓ - Build passes - TypeScript types regenerated with `credentialsAvailable` field
This commit is contained in:
parent
915d484845
commit
c54f83e1a6
13 changed files with 807 additions and 9 deletions
|
|
@ -63,7 +63,9 @@ pub fn extract_claude_credentials(
|
|||
];
|
||||
|
||||
for path in config_paths {
|
||||
let data = read_json_file(&path)?;
|
||||
let Some(data) = read_json_file(&path) else {
|
||||
continue;
|
||||
};
|
||||
for key_path in &key_paths {
|
||||
if let Some(key) = read_string_field(&data, key_path) {
|
||||
if key.starts_with("sk-ant-") {
|
||||
|
|
|
|||
|
|
@ -21,12 +21,16 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{json, Value};
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tokio::time::interval;
|
||||
use tracing::warn;
|
||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
||||
|
||||
use crate::router::{
|
||||
is_question_tool_action, AgentModelInfo, AppState, CreateSessionRequest, PermissionReply,
|
||||
};
|
||||
use sandbox_agent_agent_management::agents::AgentId;
|
||||
use sandbox_agent_agent_management::credentials::{
|
||||
extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials,
|
||||
};
|
||||
use sandbox_agent_error::SandboxError;
|
||||
use sandbox_agent_universal_agent_schema::{
|
||||
ContentPart, FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus,
|
||||
|
|
@ -235,6 +239,8 @@ struct OpenCodeModelCache {
|
|||
group_names: HashMap<String, String>,
|
||||
default_group: String,
|
||||
default_model: String,
|
||||
/// Group IDs that have valid credentials available
|
||||
connected: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct OpenCodeState {
|
||||
|
|
@ -639,6 +645,21 @@ async fn opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache {
|
|||
}
|
||||
|
||||
async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache {
|
||||
// Check credentials upfront
|
||||
let credentials = match tokio::task::spawn_blocking(|| {
|
||||
extract_all_credentials(&CredentialExtractionOptions::new())
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(creds) => creds,
|
||||
Err(err) => {
|
||||
warn!("Failed to extract credentials for model cache: {err}");
|
||||
ExtractedCredentials::default()
|
||||
}
|
||||
};
|
||||
let has_anthropic = credentials.anthropic.is_some();
|
||||
let has_openai = credentials.openai.is_some();
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut model_lookup = HashMap::new();
|
||||
let mut ambiguous_models = HashSet::new();
|
||||
|
|
@ -737,6 +758,28 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
|
|||
}
|
||||
}
|
||||
|
||||
// Build connected list based on credential availability
|
||||
let mut connected = Vec::new();
|
||||
for group_id in group_names.keys() {
|
||||
let is_connected = match group_agents.get(group_id) {
|
||||
Some(AgentId::Claude) | Some(AgentId::Amp) => has_anthropic,
|
||||
Some(AgentId::Codex) => has_openai,
|
||||
Some(AgentId::Opencode) => {
|
||||
// Check the specific provider for opencode groups (e.g., "opencode:anthropic")
|
||||
match opencode_group_provider(group_id) {
|
||||
Some("anthropic") => has_anthropic,
|
||||
Some("openai") => has_openai,
|
||||
_ => has_anthropic || has_openai,
|
||||
}
|
||||
}
|
||||
Some(AgentId::Mock) => true,
|
||||
None => false,
|
||||
};
|
||||
if is_connected {
|
||||
connected.push(group_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
OpenCodeModelCache {
|
||||
entries,
|
||||
model_lookup,
|
||||
|
|
@ -745,6 +788,7 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
|
|||
group_names,
|
||||
default_group,
|
||||
default_model,
|
||||
connected,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4005,7 +4049,6 @@ async fn oc_provider_list(State(state): State<Arc<OpenCodeAppState>>) -> impl In
|
|||
}
|
||||
let mut providers = Vec::new();
|
||||
let mut defaults = serde_json::Map::new();
|
||||
let mut connected = Vec::new();
|
||||
for (group_id, entries) in grouped {
|
||||
let mut models = serde_json::Map::new();
|
||||
for entry in entries {
|
||||
|
|
@ -4025,12 +4068,12 @@ async fn oc_provider_list(State(state): State<Arc<OpenCodeAppState>>) -> impl In
|
|||
if let Some(default_model) = cache.group_defaults.get(&group_id) {
|
||||
defaults.insert(group_id.clone(), Value::String(default_model.clone()));
|
||||
}
|
||||
connected.push(group_id);
|
||||
}
|
||||
// Use the connected list from cache (based on credential availability)
|
||||
let providers = json!({
|
||||
"all": providers,
|
||||
"default": Value::Object(defaults),
|
||||
"connected": connected
|
||||
"connected": cache.connected
|
||||
});
|
||||
(StatusCode::OK, Json(providers))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ 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 {
|
||||
fn claude_fallback_models() -> AgentModelsResponse {
|
||||
// Claude Code accepts model aliases: default, sonnet, opus, haiku
|
||||
// These work for both API key and OAuth users
|
||||
AgentModelsResponse {
|
||||
models: vec![
|
||||
AgentModelInfo {
|
||||
|
|
@ -64,6 +66,12 @@ fn claude_oauth_fallback_models() -> AgentModelsResponse {
|
|||
variants: None,
|
||||
default_variant: None,
|
||||
},
|
||||
AgentModelInfo {
|
||||
id: "sonnet".to_string(),
|
||||
name: Some("Sonnet".to_string()),
|
||||
variants: None,
|
||||
default_variant: None,
|
||||
},
|
||||
AgentModelInfo {
|
||||
id: "opus".to_string(),
|
||||
name: Some("Opus".to_string()),
|
||||
|
|
@ -1824,8 +1832,14 @@ impl SessionManager {
|
|||
agent: AgentId,
|
||||
) -> Result<AgentModelsResponse, SandboxError> {
|
||||
match agent {
|
||||
AgentId::Claude => self.fetch_claude_models().await,
|
||||
AgentId::Codex => self.fetch_codex_models().await,
|
||||
AgentId::Claude => match self.fetch_claude_models().await {
|
||||
Ok(response) if !response.models.is_empty() => Ok(response),
|
||||
_ => Ok(claude_fallback_models()),
|
||||
},
|
||||
AgentId::Codex => match self.fetch_codex_models().await {
|
||||
Ok(response) if !response.models.is_empty() => Ok(response),
|
||||
_ => Ok(codex_fallback_models()),
|
||||
},
|
||||
AgentId::Opencode => match self.fetch_opencode_models().await {
|
||||
Ok(models) => Ok(models),
|
||||
Err(_) => Ok(AgentModelsResponse {
|
||||
|
|
@ -3480,7 +3494,7 @@ impl SessionManager {
|
|||
status = %status,
|
||||
"Anthropic model list rejected OAuth credentials; using Claude OAuth fallback models"
|
||||
);
|
||||
return Ok(claude_oauth_fallback_models());
|
||||
return Ok(claude_fallback_models());
|
||||
}
|
||||
return Err(SandboxError::StreamError {
|
||||
message: format!("Anthropic models request failed {status}: {body}"),
|
||||
|
|
@ -3540,7 +3554,7 @@ impl SessionManager {
|
|||
tracing::warn!(
|
||||
"Anthropic model list was empty for OAuth credentials; using Claude OAuth fallback models"
|
||||
);
|
||||
return Ok(claude_oauth_fallback_models());
|
||||
return Ok(claude_fallback_models());
|
||||
}
|
||||
|
||||
Ok(AgentModelsResponse {
|
||||
|
|
@ -4058,6 +4072,8 @@ pub struct ServerStatusInfo {
|
|||
pub struct AgentInfo {
|
||||
pub id: String,
|
||||
pub installed: bool,
|
||||
/// Whether the agent's required provider credentials are available
|
||||
pub credentials_available: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -4325,6 +4341,10 @@ async fn list_agents(
|
|||
|
||||
let agents =
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let credentials = extract_all_credentials(&CredentialExtractionOptions::new());
|
||||
let has_anthropic = credentials.anthropic.is_some();
|
||||
let has_openai = credentials.openai.is_some();
|
||||
|
||||
all_agents()
|
||||
.into_iter()
|
||||
.map(|agent_id| {
|
||||
|
|
@ -4333,6 +4353,13 @@ async fn list_agents(
|
|||
let path = manager.resolve_binary(agent_id).ok();
|
||||
let capabilities = agent_capabilities_for(agent_id);
|
||||
|
||||
let credentials_available = match agent_id {
|
||||
AgentId::Claude | AgentId::Amp => has_anthropic,
|
||||
AgentId::Codex => has_openai,
|
||||
AgentId::Opencode => has_anthropic || has_openai,
|
||||
AgentId::Mock => true,
|
||||
};
|
||||
|
||||
// Add server_status for agents with shared processes
|
||||
let server_status =
|
||||
if capabilities.shared_process {
|
||||
|
|
@ -4352,6 +4379,7 @@ async fn list_agents(
|
|||
AgentInfo {
|
||||
id: agent_id.as_str().to_string(),
|
||||
installed,
|
||||
credentials_available,
|
||||
version,
|
||||
path: path.map(|path| path.to_string_lossy().to_string()),
|
||||
capabilities,
|
||||
|
|
@ -4873,6 +4901,22 @@ fn mock_models_response() -> AgentModelsResponse {
|
|||
}
|
||||
}
|
||||
|
||||
fn codex_fallback_models() -> AgentModelsResponse {
|
||||
let models = ["gpt-4o", "o3", "o4-mini"]
|
||||
.into_iter()
|
||||
.map(|id| AgentModelInfo {
|
||||
id: id.to_string(),
|
||||
name: None,
|
||||
variants: Some(codex_variants()),
|
||||
default_variant: Some("medium".to_string()),
|
||||
})
|
||||
.collect();
|
||||
AgentModelsResponse {
|
||||
models,
|
||||
default_model: Some("gpt-4o".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn amp_variants() -> Vec<String> {
|
||||
vec!["medium", "high", "xhigh"]
|
||||
.into_iter()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue