835 lines
30 KiB
TypeScript
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"
|
|
/>
|
|
</>
|
|
);
|
|
}
|