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, listBackends,
getBackendConfig, getBackendConfig,
updateBackendConfig, updateBackendConfig,
getProviderForBackend,
BackendProviderResponse,
} from '@/lib/api'; } from '@/lib/api';
import { import {
Server, Server,
@@ -169,6 +171,13 @@ export default function SettingsPage() {
{ revalidateOnFocus: false } { 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 // Check if there are unsaved changes
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl; const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
@@ -354,15 +363,11 @@ export default function SettingsPage() {
default_model: claudeForm.default_model || null, default_model: claudeForm.default_model || null,
cli_path: claudeForm.cli_path || null, cli_path: claudeForm.cli_path || null,
}; };
if (claudeForm.api_key) {
settings.api_key = claudeForm.api_key;
}
const result = await updateBackendConfig('claudecode', settings, { const result = await updateBackendConfig('claudecode', settings, {
enabled: claudeForm.enabled, enabled: claudeForm.enabled,
}); });
toast.success(result.message || 'Claude Code settings updated'); toast.success(result.message || 'Claude Code settings updated');
setClaudeForm((prev) => ({ ...prev, api_key: '' }));
mutateClaudeBackend(); mutateClaudeBackend();
} catch (err) { } catch (err) {
toast.error( toast.error(
@@ -511,7 +516,7 @@ export default function SettingsPage() {
/> />
{/* AI Providers */} {/* 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 justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500/10"> <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> <div>
<h2 className="text-sm font-medium text-white">AI Providers</h2> <h2 className="text-sm font-medium text-white">AI Providers</h2>
<p className="text-xs text-white/40"> <p className="text-xs text-white/40">
Configure inference providers for OpenCode Configure inference providers for OpenCode and Claude Code
</p> </p>
</div> </div>
</div> </div>
@@ -624,6 +629,20 @@ export default function SettingsPage() {
<span className="text-base">{config.icon}</span> <span className="text-base">{config.icon}</span>
<span className="text-sm text-white/80 flex-1 truncate">{provider.name}</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 */} {/* Status indicators */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{provider.is_default && ( {provider.is_default && (
@@ -799,26 +818,57 @@ export default function SettingsPage() {
Enabled Enabled
</label> </label>
</div> </div>
<div className="text-xs text-white/50"> {/* Anthropic Provider Status */}
API key status:{' '} <div className="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
<span className={claudeForm.api_key_configured ? 'text-emerald-400' : 'text-amber-400'}> <div className="flex items-center justify-between">
{claudeForm.api_key_configured ? 'Configured' : 'Not configured'} <div className="flex items-center gap-2">
</span> <span className="text-xl">🧠</span>
</div> <div>
<div> <div className="text-sm text-white">
<label className="block text-xs text-white/60 mb-1.5">API Key</label> {claudecodeProvider?.configured
<input ? claudecodeProvider.provider_name || 'Anthropic'
type="password" : 'Anthropic Provider'}
value={claudeForm.api_key} </div>
onChange={(e) => <div className="text-xs text-white/40">
setClaudeForm((prev) => ({ ...prev, api_key: e.target.value })) {claudecodeProvider?.configured
} ? claudecodeProvider.oauth
placeholder="sk-..." ? 'Connected via OAuth'
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" : claudecodeProvider.api_key
/> ? 'Using API key'
<p className="mt-1.5 text-xs text-white/30"> : 'Configured'
Stored securely in the secrets vault; leave blank to keep existing key. : 'Not configured for Claude Code'}
</p> </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>
<div> <div>
<label className="block text-xs text-white/60 mb-1.5">Default Model</label> <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 []; 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) { export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: AddProviderModalProps) {
const dialogRef = useRef<HTMLDivElement>(null); 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 [oauthResponse, setOauthResponse] = useState<OAuthAuthorizeResponse | null>(null);
const [oauthCode, setOauthCode] = useState(''); const [oauthCode, setOauthCode] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Backend selection for Anthropic (OpenCode and/or Claude Code)
const [selectedBackends, setSelectedBackends] = useState<string[]>(['opencode']);
// Get selected provider info // Get selected provider info
const selectedTypeInfo = selectedProvider ? providerTypes.find(t => t.id === selectedProvider) : null; const selectedTypeInfo = selectedProvider ? providerTypes.find(t => t.id === selectedProvider) : null;
@@ -106,6 +108,7 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
setOauthResponse(null); setOauthResponse(null);
setOauthCode(''); setOauthCode('');
setLoading(false); setLoading(false);
setSelectedBackends(['opencode']);
} }
}, [open]); }, [open]);
@@ -155,6 +158,12 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
const method = authMethods[methodIndex]; const method = authMethods[methodIndex];
setSelectedMethodIndex(methodIndex); setSelectedMethodIndex(methodIndex);
// For Anthropic, show backend selection step first
if (selectedProvider === 'anthropic') {
setStep('select-backends');
return;
}
if (method.type === 'api') { if (method.type === 'api') {
setStep('enter-api-key'); setStep('enter-api-key');
} else { } 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 () => { const handleSubmitApiKey = async () => {
if (!apiKey.trim() || !selectedProvider) return; if (!apiKey.trim() || !selectedProvider) return;
@@ -182,6 +224,8 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
provider_type: selectedProvider, provider_type: selectedProvider,
name: selectedTypeInfo?.name || selectedProvider, name: selectedTypeInfo?.name || selectedProvider,
api_key: apiKey, api_key: apiKey,
// Include backend targeting for Anthropic
use_for_backends: selectedProvider === 'anthropic' ? selectedBackends : undefined,
}); });
toast.success('Provider added'); toast.success('Provider added');
onSuccess(); onSuccess();
@@ -198,7 +242,13 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
setLoading(true); setLoading(true);
try { try {
await oauthCallback(selectedProvider, selectedMethodIndex, oauthCode); await oauthCallback(
selectedProvider,
selectedMethodIndex,
oauthCode,
// Include backend targeting for Anthropic
selectedProvider === 'anthropic' ? selectedBackends : undefined
);
toast.success('Provider connected'); toast.success('Provider connected');
onSuccess(); onSuccess();
onClose(); onClose();
@@ -213,8 +263,12 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
if (step === 'select-method') { if (step === 'select-method') {
setStep('select-provider'); setStep('select-provider');
setSelectedProvider(null); setSelectedProvider(null);
} else if (step === 'select-backends') {
setStep('select-method');
} else if (step === 'enter-api-key') { } else if (step === 'enter-api-key') {
if (hasOAuth) { if (selectedProvider === 'anthropic') {
setStep('select-backends');
} else if (hasOAuth) {
setStep('select-method'); setStep('select-method');
} else { } else {
setStep('select-provider'); setStep('select-provider');
@@ -230,6 +284,7 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
switch (step) { switch (step) {
case 'select-provider': return 'Add Provider'; case 'select-provider': return 'Add Provider';
case 'select-method': return `Connect ${selectedTypeInfo?.name}`; case 'select-method': return `Connect ${selectedTypeInfo?.name}`;
case 'select-backends': return 'Select Backends';
case 'enter-api-key': return `${selectedTypeInfo?.name} API Key`; case 'enter-api-key': return `${selectedTypeInfo?.name} API Key`;
case 'oauth-callback': return 'Complete Authorization'; case 'oauth-callback': return 'Complete Authorization';
default: return 'Add Provider'; default: return 'Add Provider';
@@ -323,6 +378,48 @@ export function AddProviderModal({ open, onClose, onSuccess, providerTypes }: Ad
</div> </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 3: Enter API Key */}
{step === 'enter-api-key' && ( {step === 'enter-api-key' && (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -2345,6 +2345,8 @@ export interface AIProvider {
uses_oauth: boolean; uses_oauth: boolean;
auth_methods: AIProviderAuthMethod[]; auth_methods: AIProviderAuthMethod[];
status: AIProviderStatus; status: AIProviderStatus;
/** Which backends this provider is used for (e.g., ["opencode", "claudecode"]) */
use_for_backends: string[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -2389,6 +2391,8 @@ export async function createAIProvider(data: {
api_key?: string; api_key?: string;
base_url?: string; base_url?: string;
enabled?: boolean; enabled?: boolean;
/** Which backends this provider is used for (e.g., ["opencode", "claudecode"]) */
use_for_backends?: string[];
}): Promise<AIProvider> { }): Promise<AIProvider> {
const res = await apiFetch("/api/ai/providers", { const res = await apiFetch("/api/ai/providers", {
method: "POST", method: "POST",
@@ -2407,6 +2411,8 @@ export async function updateAIProvider(
api_key?: string | null; api_key?: string | null;
base_url?: string | null; base_url?: string | null;
enabled?: boolean; enabled?: boolean;
/** Which backends this provider is used for (e.g., ["opencode", "claudecode"]) */
use_for_backends?: string[];
} }
): Promise<AIProvider> { ): Promise<AIProvider> {
const res = await apiFetch(`/api/ai/providers/${id}`, { 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"); 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) // Authenticate provider (initiate OAuth or check API key)
export async function authenticateAIProvider(id: string): Promise<AIProviderAuthResponse> { export async function authenticateAIProvider(id: string): Promise<AIProviderAuthResponse> {
const res = await apiFetch(`/api/ai/providers/${id}/auth`, { method: "POST" }); 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 // 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`, { const res = await apiFetch(`/api/ai/providers/${id}/oauth/callback`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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) { if (!res.ok) {
const error = await res.text(); const error = await res.text();

View File

@@ -8,6 +8,7 @@
//! - Delete provider //! - Delete provider
//! - Authenticate provider (OAuth flow) //! - Authenticate provider (OAuth flow)
//! - Set default provider //! - Set default provider
//! - Get provider credentials for specific backend (Claude Code)
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -116,6 +117,7 @@ pub fn routes() -> Router<Arc<super::routes::AppState>> {
.route("/types", get(list_provider_types)) .route("/types", get(list_provider_types))
.route("/opencode-auth", get(get_opencode_auth)) .route("/opencode-auth", get(get_opencode_auth))
.route("/opencode-auth", post(set_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", get(get_provider))
.route("/:id", put(update_provider)) .route("/:id", put(update_provider))
.route("/:id", delete(delete_provider)) .route("/:id", delete(delete_provider))
@@ -126,6 +128,75 @@ pub fn routes() -> Router<Arc<super::routes::AppState>> {
.route("/:id/default", post(set_default)) .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 // Request/Response Types
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -148,6 +219,10 @@ pub struct CreateProviderRequest {
pub base_url: Option<String>, pub base_url: Option<String>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enabled: bool, 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 { fn default_true() -> bool {
@@ -160,6 +235,8 @@ pub struct UpdateProviderRequest {
pub api_key: Option<Option<String>>, pub api_key: Option<Option<String>>,
pub base_url: Option<Option<String>>, pub base_url: Option<Option<String>>,
pub enabled: Option<bool>, 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)] #[derive(Debug, Serialize)]
@@ -176,6 +253,8 @@ pub struct ProviderResponse {
pub uses_oauth: bool, pub uses_oauth: bool,
pub auth_methods: Vec<AuthMethod>, pub auth_methods: Vec<AuthMethod>,
pub status: ProviderStatusResponse, 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 created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>, pub updated_at: chrono::DateTime<chrono::Utc>,
} }
@@ -200,6 +279,8 @@ struct ProviderConfigEntry {
name: Option<String>, name: Option<String>,
base_url: Option<String>, base_url: Option<String>,
enabled: Option<bool>, enabled: Option<bool>,
/// Which backends this provider is used for (e.g., ["opencode", "claudecode"])
use_for_backends: Option<Vec<String>>,
} }
fn build_provider_response( 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 { ProviderResponse {
id: provider_type.id().to_string(), id: provider_type.id().to_string(),
provider_type, provider_type,
@@ -242,6 +334,7 @@ fn build_provider_response(
uses_oauth: provider_type.uses_oauth(), uses_oauth: provider_type.uses_oauth(),
auth_methods: provider_type.auth_methods(), auth_methods: provider_type.auth_methods(),
status, status,
use_for_backends,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }
@@ -255,6 +348,31 @@ pub struct AuthResponse {
pub auth_url: Option<String>, 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. /// Request to initiate OAuth authorization.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct OAuthAuthorizeRequest { pub struct OAuthAuthorizeRequest {
@@ -280,6 +398,8 @@ pub struct OAuthCallbackRequest {
pub method_index: usize, pub method_index: usize,
/// Authorization code from the OAuth flow /// Authorization code from the OAuth flow
pub code: String, 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. /// Request to set OpenCode auth credentials directly.
@@ -654,10 +774,19 @@ fn get_provider_config_entry(
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
let enabled = entry.get("enabled").and_then(|v| v.as_bool()); 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 { Some(ProviderConfigEntry {
name, name,
base_url, base_url,
enabled, enabled,
use_for_backends,
}) })
} }
@@ -667,6 +796,7 @@ fn set_provider_config_entry(
name: Option<String>, name: Option<String>,
base_url: Option<Option<String>>, base_url: Option<Option<String>>,
enabled: Option<bool>, enabled: Option<bool>,
use_for_backends: Option<Vec<String>>,
) { ) {
if !config.is_object() { if !config.is_object() {
*config = serde_json::json!({}); *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. // We treat providers as enabled when present and avoid writing this field.
let _ = enabled; let _ = enabled;
entry_obj.remove("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) { fn remove_provider_config_entry(config: &mut serde_json::Value, provider: ProviderType) {
@@ -1024,6 +1166,128 @@ async fn list_providers(
Ok(Json(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. /// POST /api/ai/providers - Create a new provider.
async fn create_provider( async fn create_provider(
State(state): State<Arc<super::routes::AppState>>, State(state): State<Arc<super::routes::AppState>>,
@@ -1045,12 +1309,21 @@ async fn create_provider(
let mut opencode_config = let mut opencode_config =
read_opencode_config(&config_path).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; 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( set_provider_config_entry(
&mut opencode_config, &mut opencode_config,
provider_type, provider_type,
Some(req.name), Some(req.name),
Some(req.base_url), Some(req.base_url),
Some(req.enabled), Some(req.enabled),
use_for_backends,
); );
write_opencode_config(&config_path, &opencode_config) write_opencode_config(&config_path, &opencode_config)
@@ -1135,6 +1408,7 @@ async fn update_provider(
req.name, req.name,
req.base_url, req.base_url,
req.enabled, req.enabled,
req.use_for_backends,
); );
write_opencode_config(&config_path, &opencode_config) 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 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))?; .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 default_provider = get_default_provider(&opencode_config);
let config_entry = get_provider_config_entry(&opencode_config, provider_type); let config_entry = get_provider_config_entry(&opencode_config, provider_type);
let response = build_provider_response( 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 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))?; .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 default_provider = get_default_provider(&opencode_config);
let config_entry = get_provider_config_entry(&opencode_config, provider_type); let config_entry = get_provider_config_entry(&opencode_config, provider_type);
let response = build_provider_response( let response = build_provider_response(

View File

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