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:
@@ -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 "Claude Code" as a target backend.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-white/60 mb-1.5">Default Model</label>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user