Pipeline/frontend/src/app/settings/ai-providers/page.tsx

835 lines
30 KiB
TypeScript

"use client";
export const dynamic = "force-dynamic";
import { useCallback, useEffect, useState } from "react";
import { Bot, KeyRound, Loader2, Plus, RefreshCw, Server, Trash2, X } from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type { ProviderCredentialRead, ProviderUsageLiveRead } from "@/api/generated/model";
import { customFetch } from "@/api/mutator";
import {
listProviderCredentialsApiV1ProviderCredentialsGet,
createProviderCredentialApiV1ProviderCredentialsPost,
updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch,
deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete,
getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet,
} from "@/api/generated/provider-credentials/provider-credentials";
// ---------------------------------------------------------------------------
// Provider metadata
// ---------------------------------------------------------------------------
const PROVIDERS = [
{
id: "anthropic",
label: "Claude (Anthropic)",
icon: Bot,
description: "Anthropic Claude API key. Add multiple accounts to track separate keys or billing profiles.",
keyLabel: "API Key",
keyPlaceholder: "sk-ant-…",
showBaseUrl: false,
allowMultiple: true,
accountKeyDefault: "default",
},
{
id: "openai",
label: "Codex / OpenAI",
icon: Bot,
description: "OpenAI API key. Add multiple accounts to track usage separately.",
keyLabel: "API Key",
keyPlaceholder: "sk-…",
showBaseUrl: false,
allowMultiple: true,
accountKeyDefault: "",
},
{
id: "ollama",
label: "Ollama",
icon: Server,
description: "Ollama instance — local, on-prem, or cloud. Add the base URL; API key only needed for authenticated deployments.",
keyLabel: "API Key (optional)",
keyPlaceholder: "Leave blank for unauthenticated",
showBaseUrl: true,
allowMultiple: true,
accountKeyDefault: "default",
},
] as const;
type ProviderId = (typeof PROVIDERS)[number]["id"];
// ---------------------------------------------------------------------------
// Add/Edit form
// ---------------------------------------------------------------------------
interface CredentialFormProps {
providerId: ProviderId;
allowMultiple: boolean;
showBaseUrl: boolean;
keyLabel: string;
keyPlaceholder: string;
accountKeyDefault: string;
onSave: (data: {
account_key: string;
display_name: string;
api_key: string;
base_url: string;
}) => Promise<void>;
onTest: (data: {
account_key: string;
api_key: string;
base_url: string;
}) => Promise<ProviderUsageLiveRead>;
onCancel: () => void;
isSaving: boolean;
error: string | null;
}
function CredentialForm({
allowMultiple,
showBaseUrl,
keyLabel,
keyPlaceholder,
accountKeyDefault,
onSave,
onTest,
onCancel,
isSaving,
error,
}: CredentialFormProps) {
const [accountKey, setAccountKey] = useState(accountKeyDefault);
const [displayName, setDisplayName] = useState("");
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<ProviderUsageLiveRead | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const canTest = showBaseUrl ? Boolean(baseUrl.trim()) : Boolean(apiKey.trim());
const runTest = async () => {
setIsTesting(true);
setTestResult(null);
setTestError(null);
try {
const result = await onTest({
account_key: accountKey.trim() || accountKeyDefault || "test",
api_key: apiKey.trim(),
base_url: baseUrl.trim(),
});
setTestResult(result);
} catch (err) {
setTestError(err instanceof Error ? err.message : "Test failed.");
} finally {
setIsTesting(false);
}
};
return (
<div className="mt-3 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
<div className="space-y-3">
{allowMultiple && (
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Account name <span className="text-[color:var(--danger)]">*</span>
</label>
<Input
value={accountKey}
onChange={(e) => setAccountKey(e.target.value)}
placeholder="e.g. work, personal, gpu-box"
disabled={isSaving}
/>
<p className="mt-1 text-[11px] text-muted">
Used to tell accounts apart in cost reports.
</p>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Display name (optional)
</label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={accountKey || "My account"}
disabled={isSaving}
/>
</div>
{showBaseUrl && (
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Base URL <span className="text-[color:var(--danger)]">*</span>
</label>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://localhost:11434"
disabled={isSaving}
/>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-muted">
{keyLabel}
</label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={keyPlaceholder}
disabled={isSaving}
autoComplete="new-password"
/>
</div>
{error && (
<p className="text-sm text-[color:var(--danger)]">{error}</p>
)}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={isSaving || isTesting || !canTest}
onClick={runTest}
>
{isTesting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
{isTesting ? "Testing…" : "Test"}
</Button>
<Button
size="sm"
disabled={isSaving || !accountKey.trim() || (showBaseUrl && !baseUrl.trim())}
onClick={() =>
onSave({ account_key: accountKey.trim(), display_name: displayName.trim(), api_key: apiKey, base_url: baseUrl.trim() })
}
>
{isSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
)}
{isSaving ? "Saving…" : "Save"}
</Button>
<Button variant="outline" size="sm" onClick={onCancel} disabled={isSaving}>
<X className="mr-1.5 h-3.5 w-3.5" />
Cancel
</Button>
</div>
{testError && (
<p className="text-sm text-[color:var(--danger)]">{testError}</p>
)}
{testResult && (
<div className={`rounded-lg border p-3 text-xs ${
testResult.reachable
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)]"
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)]"
}`}>
<p className={`font-medium ${
testResult.reachable ? "text-[color:var(--success)]" : "text-[color:var(--danger)]"
}`}>
{testResult.reachable ? "Connection successful" : "Connection failed"}
</p>
<p className="mt-1 text-muted">
{testResult.error ?? (
testResult.sample_input_tokens != null || testResult.sample_output_tokens != null
? `Usage probe: in ${fmtTokens(testResult.sample_input_tokens)} · out ${fmtTokens(testResult.sample_output_tokens)}`
: "Connected."
)}
</p>
{testResult.sample_latency_ms != null && (
<p className="mt-1 text-muted">
Probe time: {fmtLatencyMs(testResult.sample_latency_ms)}
</p>
)}
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Usage strip — live token data fetched from the provider
// ---------------------------------------------------------------------------
function fmtTokens(n: number | null | undefined): string {
if (n == null) return "—";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
return n.toString();
}
function fmtResetMs(ms: number | null | undefined): string {
if (ms == null || ms <= 0) return "now";
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function fmtLatencyMs(ms: number | null | undefined): string {
if (ms == null || ms < 0) return "—";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function usageBarColor(pct: number): string {
if (pct > 90) return "bg-[color:var(--danger)]";
if (pct > 75) return "bg-[color:var(--warning)]";
return "bg-[color:var(--success)]";
}
function fmtPct(pct: number): string {
return `${Math.max(0, Math.min(100, pct)).toFixed(0)}%`;
}
interface UsageWindowBarProps {
label: string;
pct: number;
resetInMs?: number | null;
}
function UsageWindowBar({ label, pct, resetInMs }: UsageWindowBarProps) {
const clamped = Math.max(0, Math.min(100, pct));
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px]">
<span className="font-medium text-muted">{label}</span>
<span className="tabular-nums text-strong">{fmtPct(clamped)} used</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[color:var(--surface-strong)]">
<div
className={`h-full rounded-full transition-all ${usageBarColor(clamped)}`}
style={{ width: `${clamped}%` }}
/>
</div>
<p className="text-[11px] text-muted">Resets in {fmtResetMs(resetInMs)}</p>
</div>
);
}
function UsageStrip({ credentialId, provider }: { credentialId: string; provider: string }) {
const [usage, setUsage] = useState<ProviderUsageLiveRead | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastFetched, setLastFetched] = useState<Date | null>(null);
const fetch = useCallback(async (refresh = false) => {
setLoading(true);
setError(null);
try {
const res = await getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet(
credentialId,
refresh ? { refresh: true } : undefined,
);
if (res.status === 200) {
setUsage(res.data);
setLastFetched(new Date());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch usage.");
} finally {
setLoading(false);
}
}, [credentialId]);
useEffect(() => { void fetch(); }, [fetch]);
if (loading && !usage) {
return (
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
Fetching live usage
</div>
);
}
if (!usage?.reachable) {
return (
<div className="mt-2 flex items-center gap-2 text-xs text-[color:var(--danger)]">
<span>{usage?.error ?? error ?? "Provider unreachable."}</span>
<button type="button" onClick={() => fetch(true)} className="ml-1 underline hover:opacity-70">
Retry
</button>
</div>
);
}
const tok = usage.tokens;
const inputTok = usage.input_tokens;
const req = usage.requests;
const isOllama = provider === "ollama";
const sourceLabel: Record<string, string> = {
provider_native: "Provider native",
provider_api_rate_limit: "API rate limit",
local_jsonl_estimate: "Local estimate",
configured_limit: "Configured limit",
};
const usageBars: UsageWindowBarProps[] = [];
if (inputTok.pct_used != null) {
usageBars.push({
label: "API rate limit · input tokens",
pct: inputTok.pct_used,
resetInMs: inputTok.reset_in_ms,
});
}
if (tok.pct_used != null) {
usageBars.push({
label: "API rate limit · tokens",
pct: tok.pct_used,
resetInMs: tok.reset_in_ms,
});
}
if (usageBars.length === 0 && req.limit != null && req.remaining != null && req.limit > 0) {
usageBars.push({
label: "API rate limit · requests",
pct: ((req.limit - req.remaining) / req.limit) * 100,
resetInMs: req.reset_in_ms,
});
}
return (
<div className="mt-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-2.5">
{isOllama ? (
<div className="space-y-1.5">
<div className="text-[11px] text-muted">
Source: {sourceLabel[usage.source] ?? usage.source} · confidence: {usage.confidence}
</div>
<div className="flex items-center gap-3 text-xs text-muted">
<span className="flex items-center gap-1 text-[color:var(--success)]">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--success)]" />
Connected
</span>
{(usage.models?.length ?? 0) > 0 && (
<span>{usage.models!.length} model{usage.models!.length !== 1 ? "s" : ""} available</span>
)}
<button type="button" onClick={() => fetch(true)} className="ml-auto text-muted hover:text-strong">
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{usageBars.length > 0 ? (
<div className="space-y-2">
{usageBars.map((bar) => (
<UsageWindowBar key={bar.label} label={bar.label} pct={bar.pct} resetInMs={bar.resetInMs} />
))}
</div>
) : (
<>
{(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && (
<div className="flex items-center justify-between text-[11px] text-muted">
<span>Usage (last probe)</span>
<span className="tabular-nums text-strong">
in {fmtTokens(usage.sample_input_tokens)} · out {fmtTokens(usage.sample_output_tokens)}
</span>
</div>
)}
{usage.sample_latency_ms != null && (
<div className="flex items-center justify-between text-[11px] text-muted">
<span>Time (last probe)</span>
<span className="tabular-nums text-strong">
{fmtLatencyMs(usage.sample_latency_ms)}
</span>
</div>
)}
<p className="text-[11px] text-muted">
Provider did not return API rate-limit windows for percent + reset diagnostics.
</p>
</>
)}
<div className="flex items-center justify-between text-[11px] text-muted">
{lastFetched && <span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>}
</div>
</div>
) : (
<div className="space-y-1.5">
<div className="text-[11px] text-muted">
Source: {sourceLabel[usage.source] ?? usage.source} · confidence: {usage.confidence}
</div>
{usageBars.length > 0 ? (
<div className="space-y-2">
{usageBars.map((bar) => (
<UsageWindowBar key={bar.label} label={bar.label} pct={bar.pct} resetInMs={bar.resetInMs} />
))}
</div>
) : (
<>
{(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && (
<div className="flex items-center justify-between text-[11px] text-muted">
<span>Usage (last probe)</span>
<span className="tabular-nums text-strong">
in {fmtTokens(usage.sample_input_tokens)} · out {fmtTokens(usage.sample_output_tokens)}
</span>
</div>
)}
{usage.sample_latency_ms != null && (
<div className="flex items-center justify-between text-[11px] text-muted">
<span>Time (last probe)</span>
<span className="tabular-nums text-strong">
{fmtLatencyMs(usage.sample_latency_ms)}
</span>
</div>
)}
<p className="text-[11px] text-muted">
Connected provider did not return API rate-limit windows for percent + reset diagnostics.
</p>
</>
)}
<div className="flex items-center justify-between text-[11px] text-muted">
{lastFetched && <span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>}
<button type="button" onClick={() => fetch(true)} className="ml-auto flex items-center gap-1 hover:text-strong">
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
Refresh
</button>
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Credential row
// ---------------------------------------------------------------------------
interface CredentialRowProps {
cred: ProviderCredentialRead;
isAdmin: boolean;
onDelete: (cred: ProviderCredentialRead) => void;
onToggle: (cred: ProviderCredentialRead) => Promise<void>;
showUsage?: boolean;
}
function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }: CredentialRowProps) {
const [toggling, setToggling] = useState(false);
return (
<div
className={`rounded-lg border px-3 py-2.5 ${
cred.active
? "border-[color:var(--border)] bg-[color:var(--surface-muted)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] opacity-60"
}`}
>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-strong">
{cred.display_name || cred.account_key}
</p>
<div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0 text-[11px] text-muted">
<span>key: {cred.account_key}</span>
{cred.has_api_key && cred.api_key_last_four ? (
<span className="flex items-center gap-1">
<KeyRound className="h-3 w-3" />
{cred.api_key_last_four}
</span>
) : cred.has_api_key ? (
<span className="flex items-center gap-1">
<KeyRound className="h-3 w-3" />
set
</span>
) : (
<span className="text-[color:var(--warning)]">no key</span>
)}
{cred.base_url && <span className="truncate max-w-[200px]">{cred.base_url}</span>}
</div>
</div>
{isAdmin && (
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={async () => {
setToggling(true);
await onToggle(cred);
setToggling(false);
}}
disabled={toggling}
className={`rounded-full px-2 py-0.5 text-[11px] font-medium transition ${
cred.active
? "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)] hover:opacity-80"
: "bg-[color:var(--surface-strong)] text-muted hover:opacity-80"
}`}
>
{toggling ? "…" : cred.active ? "Active" : "Inactive"}
</button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[color:var(--danger)] hover:bg-[color:var(--danger-soft)]"
onClick={() => onDelete(cred)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
{showUsage && cred.active && (cred.has_api_key || cred.base_url) && (
<UsageStrip credentialId={cred.id} provider={cred.provider} />
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Provider section
// ---------------------------------------------------------------------------
interface ProviderSectionProps {
provider: (typeof PROVIDERS)[number];
credentials: ProviderCredentialRead[];
isAdmin: boolean;
onAdd: (providerId: ProviderId, data: { account_key: string; display_name: string; api_key: string; base_url: string }) => Promise<void>;
onTest: (providerId: ProviderId, data: { account_key: string; api_key: string; base_url: string }) => Promise<ProviderUsageLiveRead>;
onDelete: (cred: ProviderCredentialRead) => void;
onToggle: (cred: ProviderCredentialRead) => Promise<void>;
}
function ProviderSection({ provider, credentials, isAdmin, onAdd, onTest, onDelete, onToggle }: ProviderSectionProps) {
const Icon = provider.icon;
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const canAdd = isAdmin && (provider.allowMultiple || credentials.length === 0);
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)] shrink-0">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold text-strong">{provider.label}</h2>
<p className="mt-0.5 text-sm text-muted">{provider.description}</p>
</div>
</div>
{canAdd && !showForm && (
<Button
size="sm"
variant="outline"
onClick={() => { setShowForm(true); setSaveError(null); }}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
)}
</div>
{showForm && (
<CredentialForm
providerId={provider.id}
allowMultiple={provider.allowMultiple}
showBaseUrl={provider.showBaseUrl}
keyLabel={provider.keyLabel}
keyPlaceholder={provider.keyPlaceholder}
accountKeyDefault={provider.accountKeyDefault}
onSave={async (data) => {
setSaving(true);
setSaveError(null);
try {
await onAdd(provider.id, data);
setShowForm(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Save failed.");
} finally {
setSaving(false);
}
}}
onTest={(data) => onTest(provider.id, data)}
onCancel={() => setShowForm(false)}
isSaving={saving}
error={saveError}
/>
)}
{credentials.length > 0 && (
<div className="mt-3 space-y-2">
{credentials.map((cred) => (
<CredentialRow
key={cred.id}
cred={cred}
isAdmin={isAdmin}
onDelete={onDelete}
onToggle={onToggle}
/>
))}
</div>
)}
{credentials.length === 0 && !showForm && (
<p className="mt-3 text-sm text-muted">
No {provider.label} accounts configured.
{isAdmin ? " Click Add to set one up." : ""}
</p>
)}
</section>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function AIProvidersSettingsPage() {
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProviderCredentialRead | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setIsLoading(true);
const res = await listProviderCredentialsApiV1ProviderCredentialsGet();
if (res.status === 200) setCredentials(res.data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load provider settings.");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (isSignedIn) void load();
}, [isSignedIn, load]);
const handleAdd = async (
providerId: ProviderId,
data: { account_key: string; display_name: string; api_key: string; base_url: string },
) => {
const res = await createProviderCredentialApiV1ProviderCredentialsPost({
provider: providerId,
account_key: data.account_key,
display_name: data.display_name || data.account_key,
api_key: data.api_key || undefined,
base_url: data.base_url || undefined,
});
if (res.status === 201) {
setCredentials((prev) => [...prev, res.data]);
} else {
throw new Error("Failed to save credential.");
}
};
const handleTest = async (
providerId: ProviderId,
data: { account_key: string; api_key: string; base_url: string },
): Promise<ProviderUsageLiveRead> => {
const response = await customFetch<{
data: ProviderUsageLiveRead;
status: number;
headers: Headers;
}>("/api/v1/provider-credentials/test", {
method: "POST",
body: JSON.stringify({
provider: providerId,
account_key: data.account_key || "test",
api_key: data.api_key || undefined,
base_url: data.base_url || undefined,
}),
});
return response.data;
};
const handleToggle = async (cred: ProviderCredentialRead) => {
const res = await updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch(
cred.id,
{ active: !cred.active },
);
if (res.status === 200) {
setCredentials((prev) => prev.map((c) => (c.id === cred.id ? res.data : c)));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
setDeleteError(null);
try {
await deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete(deleteTarget.id);
setCredentials((prev) => prev.filter((c) => c.id !== deleteTarget.id));
setDeleteTarget(null);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Delete failed.");
} finally {
setIsDeleting(false);
}
};
return (
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to manage AI provider settings.",
forceRedirectUrl: "/settings/ai-providers",
signUpForceRedirectUrl: "/settings/ai-providers",
}}
title="AI Providers"
description="Configure API keys and endpoints for the AI providers your gateway uses."
>
{isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
Loading provider settings
</div>
) : error ? (
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-4 text-sm text-[color:var(--danger)]">
{error}
</div>
) : (
<div className="space-y-4">
{!isAdmin && (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
You can view provider settings. Only org admins can add or change credentials.
</div>
)}
{PROVIDERS.map((provider) => (
<ProviderSection
key={provider.id}
provider={provider}
credentials={credentials.filter((c) => c.provider === provider.id)}
isAdmin={isAdmin}
onAdd={handleAdd}
onTest={handleTest}
onDelete={setDeleteTarget}
onToggle={handleToggle}
/>
))}
</div>
)}
</DashboardPageLayout>
<ConfirmActionDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title="Remove provider account"
description={
deleteTarget
? `Remove "${deleteTarget.display_name || deleteTarget.account_key}" (${deleteTarget.provider})? The stored key will be deleted and cannot be recovered.`
: ""
}
onConfirm={handleDelete}
isConfirming={isDeleting}
errorMessage={deleteError}
confirmLabel="Remove account"
confirmingLabel="Removing…"
confirmClassName="bg-destructive text-destructive-foreground hover:bg-destructive/90"
cancelLabel="Keep it"
/>
</>
);
}