feat: use AI Providers for Claude Code authentication

Replace the separate API key field in Claude Code backend settings with
integration into the AI Providers system:

- Add use_for_backends field to provider data model (stored in opencode.json)
- Add backend selection step in Add Provider modal for Anthropic (OpenCode/Claude Code)
- Add /api/ai/providers/for-backend/:id endpoint to get provider credentials
- Update mission runner to get Anthropic API key from provider system
- Replace API key input with provider status display in Claude Code settings
- Show backend badges on provider cards in AI Providers section

This allows users to authenticate Claude Code using:
- Claude Pro/Max subscription via OAuth
- Anthropic API key through the unified provider system
This commit is contained in:
Thomas Marchand
2026-01-18 15:28:24 +00:00
parent 3b63819402
commit d002aebf35
5 changed files with 545 additions and 44 deletions

View File

@@ -19,6 +19,8 @@ import {
listBackends,
getBackendConfig,
updateBackendConfig,
getProviderForBackend,
BackendProviderResponse,
} from '@/lib/api';
import {
Server,
@@ -169,6 +171,13 @@ export default function SettingsPage() {
{ revalidateOnFocus: false }
);
// Fetch Claude Code provider status (Anthropic provider configured for claudecode)
const { data: claudecodeProvider, mutate: mutateClaudeProvider } = useSWR<BackendProviderResponse>(
'claudecode-provider',
() => getProviderForBackend('claudecode'),
{ revalidateOnFocus: false }
);
// Check if there are unsaved changes
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
@@ -354,15 +363,11 @@ export default function SettingsPage() {
default_model: claudeForm.default_model || null,
cli_path: claudeForm.cli_path || null,
};
if (claudeForm.api_key) {
settings.api_key = claudeForm.api_key;
}
const result = await updateBackendConfig('claudecode', settings, {
enabled: claudeForm.enabled,
});
toast.success(result.message || 'Claude Code settings updated');
setClaudeForm((prev) => ({ ...prev, api_key: '' }));
mutateClaudeBackend();
} catch (err) {
toast.error(
@@ -511,7 +516,7 @@ export default function SettingsPage() {
/>
{/* AI Providers */}
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
<div id="ai-providers" className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500/10">
@@ -520,7 +525,7 @@ export default function SettingsPage() {
<div>
<h2 className="text-sm font-medium text-white">AI Providers</h2>
<p className="text-xs text-white/40">
Configure inference providers for OpenCode
Configure inference providers for OpenCode and Claude Code
</p>
</div>
</div>
@@ -624,6 +629,20 @@ export default function SettingsPage() {
<span className="text-base">{config.icon}</span>
<span className="text-sm text-white/80 flex-1 truncate">{provider.name}</span>
{/* Backend badges */}
{provider.use_for_backends && provider.use_for_backends.length > 0 && (
<div className="flex items-center gap-1">
{provider.use_for_backends.map((backend) => (
<span
key={backend}
className="px-1.5 py-0.5 text-[10px] rounded bg-white/[0.06] text-white/50"
>
{backend === 'claudecode' ? 'Claude' : backend === 'opencode' ? 'OC' : backend}
</span>
))}
</div>
)}
{/* Status indicators */}
<div className="flex items-center gap-2">
{provider.is_default && (
@@ -799,26 +818,57 @@ export default function SettingsPage() {
Enabled
</label>
</div>
<div className="text-xs text-white/50">
API key status:{' '}
<span className={claudeForm.api_key_configured ? 'text-emerald-400' : 'text-amber-400'}>
{claudeForm.api_key_configured ? 'Configured' : 'Not configured'}
</span>
</div>
<div>
<label className="block text-xs text-white/60 mb-1.5">API Key</label>
<input
type="password"
value={claudeForm.api_key}
onChange={(e) =>
setClaudeForm((prev) => ({ ...prev, api_key: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
/>
<p className="mt-1.5 text-xs text-white/30">
Stored securely in the secrets vault; leave blank to keep existing key.
</p>
{/* Anthropic Provider Status */}
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">🧠</span>
<div>
<div className="text-sm text-white">
{claudecodeProvider?.configured
? claudecodeProvider.provider_name || 'Anthropic'
: 'Anthropic Provider'}
</div>
<div className="text-xs text-white/40">
{claudecodeProvider?.configured
? claudecodeProvider.oauth
? 'Connected via OAuth'
: claudecodeProvider.api_key
? 'Using API key'
: 'Configured'
: 'Not configured for Claude Code'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{claudecodeProvider?.configured && claudecodeProvider.has_credentials ? (
<span className="flex items-center gap-1 text-xs text-emerald-400">
<Check className="h-3.5 w-3.5" />
Connected
</span>
) : (
<span className="flex items-center gap-1 text-xs text-amber-400">
<AlertTriangle className="h-3.5 w-3.5" />
Not connected
</span>
)}
</div>
</div>
{!claudecodeProvider?.configured && (
<p className="mt-2 text-xs text-white/50">
Add an Anthropic provider in{' '}
<button
type="button"
onClick={() => {
document.getElementById('ai-providers')?.scrollIntoView({ behavior: 'smooth' });
}}
className="text-indigo-400 hover:text-indigo-300 underline"
>
AI Providers
</button>{' '}
and select &quot;Claude Code&quot; as a target backend.
</p>
)}
</div>
<div>
<label className="block text-xs text-white/60 mb-1.5">Default Model</label>

View File

@@ -77,7 +77,7 @@ const getProviderAuthMethods = (providerType: AIProviderType): AIProviderAuthMet
return [];
};
type ModalStep = 'select-provider' | 'select-method' | 'enter-api-key' | 'oauth-callback';
type ModalStep = 'select-provider' | 'select-method' | 'select-backends' | 'enter-api-key' | 'oauth-callback';
export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: AddProviderModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
@@ -90,6 +90,8 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
const [oauthResponse, setOauthResponse] = useState<OAuthAuthorizeResponse | null>(null);
const [oauthCode, setOauthCode] = useState('');
const [loading, setLoading] = useState(false);
// Backend selection for Anthropic (OpenCode and/or Claude Code)
const [selectedBackends, setSelectedBackends] = useState<string[]>(['opencode']);
// Get selected provider info
const selectedTypeInfo = selectedProvider ? providerTypes.find(t => t.id === selectedProvider) : null;
@@ -106,6 +108,7 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
setOauthResponse(null);
setOauthCode('');
setLoading(false);
setSelectedBackends(['opencode']);
}
}, [open]);
@@ -155,6 +158,12 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
const method = authMethods[methodIndex];
setSelectedMethodIndex(methodIndex);
// For Anthropic, show backend selection step first
if (selectedProvider === 'anthropic') {
setStep('select-backends');
return;
}
if (method.type === 'api') {
setStep('enter-api-key');
} else {
@@ -173,6 +182,39 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
}
};
const handleContinueFromBackends = async () => {
if (selectedBackends.length === 0) {
toast.error('Please select at least one backend');
return;
}
const method = authMethods[selectedMethodIndex!];
if (method.type === 'api') {
setStep('enter-api-key');
} else {
// Start OAuth flow
setLoading(true);
try {
const response = await oauthAuthorize(selectedProvider!, selectedMethodIndex!);
setOauthResponse(response);
setStep('oauth-callback');
window.open(response.url, '_blank');
} catch (err) {
toast.error(`Failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setLoading(false);
}
}
};
const toggleBackend = (backendId: string) => {
setSelectedBackends(prev =>
prev.includes(backendId)
? prev.filter(b => b !== backendId)
: [...prev, backendId]
);
};
const handleSubmitApiKey = async () => {
if (!apiKey.trim() || !selectedProvider) return;
@@ -182,6 +224,8 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
provider_type: selectedProvider,
name: selectedTypeInfo?.name || selectedProvider,
api_key: apiKey,
// Include backend targeting for Anthropic
use_for_backends: selectedProvider === 'anthropic' ? selectedBackends : undefined,
});
toast.success('Provider added');
onSuccess();
@@ -198,7 +242,13 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
setLoading(true);
try {
await oauthCallback(selectedProvider, selectedMethodIndex, oauthCode);
await oauthCallback(
selectedProvider,
selectedMethodIndex,
oauthCode,
// Include backend targeting for Anthropic
selectedProvider === 'anthropic' ? selectedBackends : undefined
);
toast.success('Provider connected');
onSuccess();
onClose();
@@ -213,8 +263,12 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
if (step === 'select-method') {
setStep('select-provider');
setSelectedProvider(null);
} else if (step === 'select-backends') {
setStep('select-method');
} else if (step === 'enter-api-key') {
if (hasOAuth) {
if (selectedProvider === 'anthropic') {
setStep('select-backends');
} else if (hasOAuth) {
setStep('select-method');
} else {
setStep('select-provider');
@@ -230,6 +284,7 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
switch (step) {
case 'select-provider': return 'Add Provider';
case 'select-method': return `Connect ${selectedTypeInfo?.name}`;
case 'select-backends': return 'Select Backends';
case 'enter-api-key': return `${selectedTypeInfo?.name} API Key`;
case 'oauth-callback': return 'Complete Authorization';
default: return 'Add Provider';
@@ -323,6 +378,48 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
</div>
)}
{/* Step 2.5: Select Backends (Anthropic only) */}
{step === 'select-backends' && (
<div className="space-y-4">
<p className="text-sm text-white/60">
Choose which backends should use this Anthropic provider:
</p>
<div className="space-y-2">
<label className="flex items-center gap-3 p-3 rounded-xl border border-white/[0.06] hover:bg-white/[0.02] transition-colors cursor-pointer">
<input
type="checkbox"
checked={selectedBackends.includes('opencode')}
onChange={() => toggleBackend('opencode')}
className="rounded border-white/20 bg-white/[0.02] text-indigo-500 focus:ring-indigo-500/30 cursor-pointer"
/>
<div className="flex-1">
<div className="text-sm text-white">OpenCode</div>
<div className="text-xs text-white/40">Use for OpenCode agents and missions</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 rounded-xl border border-white/[0.06] hover:bg-white/[0.02] transition-colors cursor-pointer">
<input
type="checkbox"
checked={selectedBackends.includes('claudecode')}
onChange={() => toggleBackend('claudecode')}
className="rounded border-white/20 bg-white/[0.02] text-indigo-500 focus:ring-indigo-500/30 cursor-pointer"
/>
<div className="flex-1">
<div className="text-sm text-white">Claude Code</div>
<div className="text-xs text-white/40">Use for Claude CLI-based missions</div>
</div>
</label>
</div>
<button
onClick={handleContinueFromBackends}
disabled={loading || selectedBackends.length === 0}
className="w-full rounded-xl bg-indigo-500 px-4 py-3 text-sm font-medium text-white hover:bg-indigo-600 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? <Loader className="h-4 w-4 animate-spin mx-auto" /> : 'Continue'}
</button>
</div>
)}
{/* Step 3: Enter API Key */}
{step === 'enter-api-key' && (
<div className="space-y-4">

View File

@@ -2345,6 +2345,8 @@ export interface AIProvider {
uses_oauth: boolean;
auth_methods: AIProviderAuthMethod[];
status: AIProviderStatus;
/** Which backends this provider is used for (e.g., ["opencode", "claudecode"]) */
use_for_backends: string[];
created_at: string;
updated_at: string;
}
@@ -2389,6 +2391,8 @@ export async function createAIProvider(data: {
api_key?: string;
base_url?: string;
enabled?: boolean;
/** Which backends this provider is used for (e.g., ["opencode", "claudecode"]) */
use_for_backends?: string[];
}): Promise<AIProvider> {
const res = await apiFetch("/api/ai/providers", {
method: "POST",
@@ -2407,6 +2411,8 @@ export async function updateAIProvider(
api_key?: string | null;
base_url?: string | null;
enabled?: boolean;
/** Which backends this provider is used for (e.g., ["opencode", "claudecode"]) */
use_for_backends?: string[];
}
): Promise<AIProvider> {
const res = await apiFetch(`/api/ai/providers/${id}`, {
@@ -2424,6 +2430,27 @@ export async function deleteAIProvider(id: string): Promise<void> {
if (!res.ok) throw new Error("Failed to delete AI provider");
}
// Provider credentials for a backend
export interface BackendProviderResponse {
configured: boolean;
provider_type: string | null;
provider_name: string | null;
api_key: string | null;
oauth: {
access_token: string;
refresh_token: string;
expires_at: number;
} | null;
has_credentials: boolean;
}
// Get provider credentials for a specific backend (e.g., "claudecode")
export async function getProviderForBackend(backendId: string): Promise<BackendProviderResponse> {
const res = await apiFetch(`/api/ai/providers/for-backend/${backendId}`);
if (!res.ok) throw new Error("Failed to get provider for backend");
return res.json();
}
// Authenticate provider (initiate OAuth or check API key)
export async function authenticateAIProvider(id: string): Promise<AIProviderAuthResponse> {
const res = await apiFetch(`/api/ai/providers/${id}/auth`, { method: "POST" });
@@ -2460,11 +2487,20 @@ export async function oauthAuthorize(id: string, methodIndex: number): Promise<O
}
// Complete OAuth flow with authorization code
export async function oauthCallback(id: string, methodIndex: number, code: string): Promise<AIProvider> {
export async function oauthCallback(
id: string,
methodIndex: number,
code: string,
useForBackends?: string[]
): Promise<AIProvider> {
const res = await apiFetch(`/api/ai/providers/${id}/oauth/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method_index: methodIndex, code }),
body: JSON.stringify({
method_index: methodIndex,
code,
use_for_backends: useForBackends,
}),
});
if (!res.ok) {
const error = await res.text();

View File

@@ -8,6 +8,7 @@
//! - Delete provider
//! - Authenticate provider (OAuth flow)
//! - Set default provider
//! - Get provider credentials for specific backend (Claude Code)
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
@@ -116,6 +117,7 @@ pub fn routes() -> Router<Arc<super::routes::AppState>> {
.route("/types", get(list_provider_types))
.route("/opencode-auth", get(get_opencode_auth))
.route("/opencode-auth", post(set_opencode_auth))
.route("/for-backend/:backend_id", get(get_provider_for_backend))
.route("/:id", get(get_provider))
.route("/:id", put(update_provider))
.route("/:id", delete(delete_provider))
@@ -126,6 +128,75 @@ pub fn routes() -> Router<Arc<super::routes::AppState>> {
.route("/:id/default", post(set_default))
}
// ─────────────────────────────────────────────────────────────────────────────
// Public API for Backend Access
// ─────────────────────────────────────────────────────────────────────────────
/// Get the Anthropic API key for the Claude Code backend.
///
/// This checks if the Anthropic provider has "claudecode" in its use_for_backends
/// configuration and returns the API key if available.
///
/// Returns None if:
/// - Anthropic provider is not configured for claudecode
/// - No API key is set (OAuth-only auth)
/// - Any error occurs reading the config
pub fn get_anthropic_api_key_for_claudecode(working_dir: &Path) -> Option<String> {
// Read the OpenCode config to check use_for_backends
let config_path = get_opencode_config_path(working_dir);
let opencode_config = read_opencode_config(&config_path).ok()?;
// Check if Anthropic provider has claudecode in use_for_backends
let anthropic_config = get_provider_config_entry(&opencode_config, ProviderType::Anthropic);
let use_for_claudecode = anthropic_config
.as_ref()
.and_then(|c| c.use_for_backends.as_ref())
.map(|backends| backends.iter().any(|b| b == "claudecode"))
.unwrap_or(false);
if !use_for_claudecode {
return None;
}
// Get the API key from auth.json
let auth = read_opencode_auth().ok()?;
let anthropic_auth = auth.get("anthropic")?;
// Check for API key (not OAuth)
let auth_type = anthropic_auth.get("type").and_then(|v| v.as_str());
match auth_type {
Some("api_key") | Some("api") => {
anthropic_auth
.get("key")
.or_else(|| anthropic_auth.get("api_key"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
_ => {
// Also check without type field
anthropic_auth
.get("key")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
}
}
/// Check if the Anthropic provider is configured for the Claude Code backend.
pub fn is_anthropic_configured_for_claudecode(working_dir: &Path) -> bool {
let config_path = get_opencode_config_path(working_dir);
let Ok(opencode_config) = read_opencode_config(&config_path) else {
return false;
};
let anthropic_config = get_provider_config_entry(&opencode_config, ProviderType::Anthropic);
anthropic_config
.as_ref()
.and_then(|c| c.use_for_backends.as_ref())
.map(|backends| backends.iter().any(|b| b == "claudecode"))
.unwrap_or(false)
}
// ─────────────────────────────────────────────────────────────────────────────
// Request/Response Types
// ─────────────────────────────────────────────────────────────────────────────
@@ -148,6 +219,10 @@ pub struct CreateProviderRequest {
pub base_url: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
/// Which backends this provider is used for (e.g., ["opencode", "claudecode"])
/// Only applicable for Anthropic provider. Defaults to ["opencode"].
#[serde(default)]
pub use_for_backends: Option<Vec<String>>,
}
fn default_true() -> bool {
@@ -160,6 +235,8 @@ pub struct UpdateProviderRequest {
pub api_key: Option<Option<String>>,
pub base_url: Option<Option<String>>,
pub enabled: Option<bool>,
/// Which backends this provider is used for (e.g., ["opencode", "claudecode"])
pub use_for_backends: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
@@ -176,6 +253,8 @@ pub struct ProviderResponse {
pub uses_oauth: bool,
pub auth_methods: Vec<AuthMethod>,
pub status: ProviderStatusResponse,
/// Which backends this provider is used for (e.g., ["opencode", "claudecode"])
pub use_for_backends: Vec<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -200,6 +279,8 @@ struct ProviderConfigEntry {
name: Option<String>,
base_url: Option<String>,
enabled: Option<bool>,
/// Which backends this provider is used for (e.g., ["opencode", "claudecode"])
use_for_backends: Option<Vec<String>>,
}
fn build_provider_response(
@@ -229,6 +310,17 @@ fn build_provider_response(
}
};
// For Anthropic, use configured backends or default to ["opencode"]
// For other providers, always use ["opencode"]
let use_for_backends = if provider_type == ProviderType::Anthropic {
config
.as_ref()
.and_then(|c| c.use_for_backends.clone())
.unwrap_or_else(|| vec!["opencode".to_string()])
} else {
vec!["opencode".to_string()]
};
ProviderResponse {
id: provider_type.id().to_string(),
provider_type,
@@ -242,6 +334,7 @@ fn build_provider_response(
uses_oauth: provider_type.uses_oauth(),
auth_methods: provider_type.auth_methods(),
status,
use_for_backends,
created_at: now,
updated_at: now,
}
@@ -255,6 +348,31 @@ pub struct AuthResponse {
pub auth_url: Option<String>,
}
/// Response for provider credentials for a specific backend.
#[derive(Debug, Serialize)]
pub struct BackendProviderResponse {
/// Whether a provider is configured for this backend
pub configured: bool,
/// The provider type (e.g., "anthropic")
pub provider_type: Option<String>,
/// The provider name
pub provider_name: Option<String>,
/// API key (if using API key auth)
pub api_key: Option<String>,
/// OAuth credentials (if using OAuth)
pub oauth: Option<BackendOAuthCredentials>,
/// Whether the provider has valid credentials
pub has_credentials: bool,
}
/// OAuth credentials for backend provider.
#[derive(Debug, Serialize)]
pub struct BackendOAuthCredentials {
pub access_token: String,
pub refresh_token: String,
pub expires_at: i64,
}
/// Request to initiate OAuth authorization.
#[derive(Debug, Deserialize)]
pub struct OAuthAuthorizeRequest {
@@ -280,6 +398,8 @@ pub struct OAuthCallbackRequest {
pub method_index: usize,
/// Authorization code from the OAuth flow
pub code: String,
/// Which backends to use this provider for (e.g., ["opencode", "claudecode"])
pub use_for_backends: Option<Vec<String>>,
}
/// Request to set OpenCode auth credentials directly.
@@ -654,10 +774,19 @@ fn get_provider_config_entry(
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let enabled = entry.get("enabled").and_then(|v| v.as_bool());
let use_for_backends = entry
.get("useForBackends")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
});
Some(ProviderConfigEntry {
name,
base_url,
enabled,
use_for_backends,
})
}
@@ -667,6 +796,7 @@ fn set_provider_config_entry(
name: Option<String>,
base_url: Option<Option<String>>,
enabled: Option<bool>,
use_for_backends: Option<Vec<String>>,
) {
if !config.is_object() {
*config = serde_json::json!({});
@@ -707,6 +837,18 @@ fn set_provider_config_entry(
// We treat providers as enabled when present and avoid writing this field.
let _ = enabled;
entry_obj.remove("enabled");
// Store which backends this provider is used for (only for Anthropic)
if let Some(backends) = use_for_backends {
let backends_json: Vec<serde_json::Value> = backends
.into_iter()
.map(serde_json::Value::String)
.collect();
entry_obj.insert(
"useForBackends".to_string(),
serde_json::Value::Array(backends_json),
);
}
}
fn remove_provider_config_entry(config: &mut serde_json::Value, provider: ProviderType) {
@@ -1024,6 +1166,128 @@ async fn list_providers(
Ok(Json(providers))
}
/// GET /api/ai/providers/for-backend/:backend_id - Get provider credentials for a specific backend.
///
/// For Claude Code backend, this returns the Anthropic provider that has "claudecode" in use_for_backends.
async fn get_provider_for_backend(
State(state): State<Arc<super::routes::AppState>>,
AxumPath(backend_id): AxumPath<String>,
) -> Result<Json<BackendProviderResponse>, (StatusCode, String)> {
// Currently only "claudecode" backend uses this endpoint
if backend_id != "claudecode" {
return Ok(Json(BackendProviderResponse {
configured: false,
provider_type: None,
provider_name: None,
api_key: None,
oauth: None,
has_credentials: false,
}));
}
// Read the OpenCode config to find provider with claudecode in use_for_backends
let config_path = get_opencode_config_path(&state.config.working_dir);
let opencode_config =
read_opencode_config(&config_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
// Check if Anthropic provider has claudecode in use_for_backends
let anthropic_config = get_provider_config_entry(&opencode_config, ProviderType::Anthropic);
let use_for_claudecode = anthropic_config
.as_ref()
.and_then(|c| c.use_for_backends.as_ref())
.map(|backends| backends.iter().any(|b| b == "claudecode"))
.unwrap_or(false);
if !use_for_claudecode {
return Ok(Json(BackendProviderResponse {
configured: false,
provider_type: None,
provider_name: None,
api_key: None,
oauth: None,
has_credentials: false,
}));
}
// Get the Anthropic provider credentials from auth.json
let auth = read_opencode_auth().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
let anthropic_auth = auth.get("anthropic");
let (api_key, oauth, has_credentials) = if let Some(auth_entry) = anthropic_auth {
let auth_type = auth_entry.get("type").and_then(|v| v.as_str());
match auth_type {
Some("api_key") | Some("api") => {
let key = auth_entry
.get("key")
.or_else(|| auth_entry.get("api_key"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
(key, None, true)
}
Some("oauth") => {
let oauth_creds = BackendOAuthCredentials {
access_token: auth_entry
.get("access")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
refresh_token: auth_entry
.get("refresh")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
expires_at: auth_entry
.get("expires")
.and_then(|v| v.as_i64())
.unwrap_or(0),
};
(None, Some(oauth_creds), true)
}
_ => {
// Check for OAuth credentials without type field
if auth_entry.get("refresh").is_some() {
let oauth_creds = BackendOAuthCredentials {
access_token: auth_entry
.get("access")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
refresh_token: auth_entry
.get("refresh")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
expires_at: auth_entry
.get("expires")
.and_then(|v| v.as_i64())
.unwrap_or(0),
};
(None, Some(oauth_creds), true)
} else if auth_entry.get("key").is_some() {
let key = auth_entry
.get("key")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
(key, None, true)
} else {
(None, None, false)
}
}
}
} else {
(None, None, false)
};
Ok(Json(BackendProviderResponse {
configured: true,
provider_type: Some("anthropic".to_string()),
provider_name: anthropic_config.and_then(|c| c.name).or_else(|| Some("Anthropic".to_string())),
api_key,
oauth,
has_credentials,
}))
}
/// POST /api/ai/providers - Create a new provider.
async fn create_provider(
State(state): State<Arc<super::routes::AppState>>,
@@ -1045,12 +1309,21 @@ async fn create_provider(
let mut opencode_config =
read_opencode_config(&config_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
// For Anthropic, default use_for_backends to ["opencode"] if not specified
let use_for_backends = if provider_type == ProviderType::Anthropic {
req.use_for_backends
.or_else(|| Some(vec!["opencode".to_string()]))
} else {
None
};
set_provider_config_entry(
&mut opencode_config,
provider_type,
Some(req.name),
Some(req.base_url),
Some(req.enabled),
use_for_backends,
);
write_opencode_config(&config_path, &opencode_config)
@@ -1135,6 +1408,7 @@ async fn update_provider(
req.name,
req.base_url,
req.enabled,
req.use_for_backends,
);
write_opencode_config(&config_path, &opencode_config)
@@ -1635,8 +1909,24 @@ async fn oauth_callback_inner(
}
let config_path = get_opencode_config_path(&state.config.working_dir);
let opencode_config = read_opencode_config(&config_path)
let mut opencode_config = read_opencode_config(&config_path)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
// Update use_for_backends if specified
if req.use_for_backends.is_some() {
set_provider_config_entry(
&mut opencode_config,
provider_type,
None,
None,
None,
req.use_for_backends.clone(),
);
if let Err(e) = write_opencode_config(&config_path, &opencode_config) {
tracing::error!("Failed to write OpenCode config: {}", e);
}
}
let default_provider = get_default_provider(&opencode_config);
let config_entry = get_provider_config_entry(&opencode_config, provider_type);
let response = build_provider_response(
@@ -1683,8 +1973,24 @@ async fn oauth_callback_inner(
}
let config_path = get_opencode_config_path(&state.config.working_dir);
let opencode_config = read_opencode_config(&config_path)
let mut opencode_config = read_opencode_config(&config_path)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
// Update use_for_backends if specified
if req.use_for_backends.is_some() {
set_provider_config_entry(
&mut opencode_config,
provider_type,
None,
None,
None,
req.use_for_backends.clone(),
);
if let Err(e) = write_opencode_config(&config_path, &opencode_config) {
tracing::error!("Failed to write OpenCode config: {}", e);
}
}
let default_provider = get_default_provider(&opencode_config);
let config_entry = get_provider_config_entry(&opencode_config, provider_type);
let response = build_provider_response(

View File

@@ -518,6 +518,7 @@ async fn run_mission_turn(
events_tx.clone(),
cancel,
secrets,
&config.working_dir,
)
.await
}
@@ -566,21 +567,32 @@ async fn run_claudecode_turn(
events_tx: broadcast::Sender<AgentEvent>,
cancel: CancellationToken,
secrets: Option<Arc<SecretsStore>>,
app_working_dir: &std::path::Path,
) -> AgentResult {
use std::collections::HashMap;
use super::ai_providers::get_anthropic_api_key_for_claudecode;
// Get API key from secrets
let api_key = if let Some(ref store) = secrets {
match store.get_secret("claudecode", "api_key").await {
Ok(key) => Some(key),
Err(e) => {
tracing::warn!("Failed to get Claude API key from secrets: {}", e);
// Fall back to environment variable
std::env::var("ANTHROPIC_API_KEY").ok()
}
}
// Try to get API key from Anthropic provider configured for Claude Code backend
let api_key = if let Some(key) = get_anthropic_api_key_for_claudecode(app_working_dir) {
tracing::info!("Using Anthropic API key from provider for Claude Code");
Some(key)
} else {
std::env::var("ANTHROPIC_API_KEY").ok()
// Fall back to secrets vault (legacy support)
if let Some(ref store) = secrets {
match store.get_secret("claudecode", "api_key").await {
Ok(key) => {
tracing::info!("Using Claude Code API key from secrets vault (legacy)");
Some(key)
}
Err(e) => {
tracing::warn!("Failed to get Claude API key from secrets: {}", e);
// Fall back to environment variable
std::env::var("ANTHROPIC_API_KEY").ok()
}
}
} else {
std::env::var("ANTHROPIC_API_KEY").ok()
}
};
// Determine CLI path