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:
NathanFlurry 2026-02-07 07:56:06 +00:00
parent 915d484845
commit c54f83e1a6
No known key found for this signature in database
GPG key ID: 6A5F43A4F3241BCA
13 changed files with 807 additions and 9 deletions

View file

@ -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-") {

View file

@ -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))
}

View file

@ -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()