All components
Proxy Admin
dashboardsA drop-in admin dashboard for a proxykit LLM gateway: connect providers via OAuth or token paste, watch connection status and available models, and run live model tests. Talks to the proxykit REST API over a configurable base path.
responsive · 720px
Install
Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:
$
npx shadcn@latest add https://your-domain/r/proxy-admin.jsonUsage
"use client";
import * as React from "react";
import { ProxyAdmin } from "@/registry/dashboards/proxy-admin";
// A self-contained demo: the dashboard normally talks to a live proxykit
// backend over REST, but here we inject a mock `fetcher` that returns canned
// responses so the preview renders a fully-populated control panel with no
// server attached. The shapes below match the proxykit gateway API exactly.
function json(data: unknown): Response {
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function buildStatus() {
const now = Date.now();
const iso = (offsetMs: number) => new Date(now + offsetMs).toISOString();
return {
internalKeySet: true,
proxyBase: "https://api.myapp.com/api/proxy/gateway/proxy",
providers: [
{
provider: "codex",
baseURL: "https://chatgpt.com/backend-api/codex",
authKind: "oauth-pkce",
configured: true,
maskedToken: "sk-proj-…a9F2",
hasRefreshToken: true,
hasAccountId: true,
expiresAt: iso(6 * 60 * 60 * 1000), // 6h from now
updatedAt: iso(-12 * 60 * 1000), // 12m ago
updatedBy: "admin",
models: [
{
id: "gpt-5-codex",
label: "gpt-5-codex",
think: { reasoning_effort: "high", max_tokens: 4096 },
noThink: { reasoning_effort: "low", max_tokens: 1024 },
},
{ id: "gpt-5", label: "gpt-5" },
{ id: "o4-mini", label: "o4-mini" },
],
},
{
provider: "opencode",
baseURL: "https://opencode.ai/v1",
authKind: "static",
configured: true,
maskedToken: "oc-live-…4B1a",
hasRefreshToken: false,
hasAccountId: false,
expiresAt: null,
updatedAt: iso(-2 * 60 * 60 * 1000), // 2h ago
updatedBy: "admin",
models: [
{ id: "claude-opus-4-8", label: "claude-opus-4-8" },
{ id: "claude-sonnet-4-6", label: "claude-sonnet-4-6" },
],
},
],
};
}
const mockFetcher: typeof fetch = async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
const method = (init?.method ?? "GET").toUpperCase();
if (url.includes("/admin/gateway/status")) {
await sleep(450); // let the loading skeleton flash, then populate
return json(buildStatus());
}
if (url.includes("/admin/gateway/test")) {
await sleep(600); // simulate a round-trip
return json({
success: true,
provider: "codex",
model: "gpt-5-codex",
latencyMs: 612,
httpStatus: 200,
reply: "API OK",
finishReason: "stop",
usage: { prompt_tokens: 14, completion_tokens: 3, total_tokens: 17 },
raw: {
id: "chatcmpl-demo",
object: "chat.completion",
choices: [
{ index: 0, message: { role: "assistant", content: "API OK" }, finish_reason: "stop" },
],
},
});
}
if (url.includes("/start-auth")) {
return json({ oauthUrl: "https://example.com/oauth/authorize?demo=1" });
}
if (url.includes("/refresh")) {
await sleep(400);
return json({ success: true, maskedToken: "sk-proj-…b7C3" });
}
if (method === "PUT" && url.includes("/admin/gateway/providers/")) {
return json({ success: true, provider: url.split("/").pop() });
}
return new Response("Not mocked", { status: 404 });
};
export default function ProxyAdminDemo() {
return (
<div className="mx-auto w-full max-w-4xl p-6">
<ProxyAdmin basePath="/api/proxy" fetcher={mockFetcher} />
</div>
);
}Component source
"use client";
import * as React from "react";
import { toast } from "sonner";
import {
RefreshCw,
RefreshCcw,
LogIn,
KeyRound,
Save,
Server,
Zap,
Copy,
Check,
ChevronDown,
ChevronUp,
Play,
Timer,
Cpu,
AlertCircle,
CheckCircle2,
Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
// ── Public API ────────────────────────────────────────────────────────────────
export interface ProxyAdminProps {
/** Base path where the proxykit REST API is mounted. Default: "/api/proxy". */
basePath?: string;
/**
* Drop-in replacement for the global fetch. Lets the host inject auth
* headers at the transport level (e.g. a pre-signed fetcher).
*/
fetcher?: typeof fetch;
/**
* Called before every request. Return headers that are merged into the
* request, e.g. { Authorization: "Bearer <token>" }.
* Can be sync or async.
*/
getAuthHeaders?: () =>
| Record<string, string>
| Promise<Record<string, string>>;
className?: string;
}
// ── REST response shapes ───────────────────────────────────────────────────────
interface ProviderModel {
id: string;
label?: string;
/** Params preset to send when "thinking on" is chosen for this model. */
think?: Record<string, unknown>;
/** Params preset to send when "thinking off" is chosen for this model. */
noThink?: Record<string, unknown>;
}
interface ProviderStatus {
provider: string;
baseURL: string;
authKind: "oauth-pkce" | "static";
configured: boolean;
maskedToken: string | null;
hasRefreshToken: boolean;
hasAccountId: boolean;
expiresAt: string | null;
updatedAt: string | null;
updatedBy: string | null;
models: ProviderModel[];
}
interface GatewayStatusResponse {
providers: ProviderStatus[];
internalKeySet: boolean;
proxyBase: string;
}
interface TestResult {
success: boolean;
provider: string;
model: string;
latencyMs: number;
httpStatus?: number;
reply?: string | null;
finishReason?: string;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
} | null;
raw?: unknown;
error?: string;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function relativeTime(iso: string | null | undefined): string {
if (!iso) return "";
try {
const diff = Date.now() - new Date(iso).getTime();
const abs = Math.abs(diff);
const future = diff < 0;
if (abs < 60_000) return future ? "in a moment" : "just now";
if (abs < 3_600_000) {
const m = Math.round(abs / 60_000);
return future ? `in ${m}m` : `${m}m ago`;
}
if (abs < 86_400_000) {
const h = Math.round(abs / 3_600_000);
return future ? `in ${h}h` : `${h}h ago`;
}
const d = Math.round(abs / 86_400_000);
return future ? `in ${d}d` : `${d}d ago`;
} catch {
return iso;
}
}
function isExpired(iso: string | null | undefined): boolean {
if (!iso) return false;
try {
return new Date(iso).getTime() < Date.now();
} catch {
return false;
}
}
function tryParseJson(
str: string
): { ok: true; value: Record<string, unknown> } | { ok: false; error: string } {
try {
const parsed = JSON.parse(str) as unknown;
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
return { ok: false, error: "Must be a JSON object." };
}
return { ok: true, value: parsed as Record<string, unknown> };
} catch (e) {
return {
ok: false,
error: e instanceof SyntaxError ? e.message : "Invalid JSON",
};
}
}
// ── CopyButton ────────────────────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false);
const handleClick = () => {
if (typeof navigator === "undefined") return;
void navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button
type="button"
onClick={handleClick}
className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
aria-label="Copy to clipboard"
>
{copied ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
</button>
);
}
// ── StatusBadge ───────────────────────────────────────────────────────────────
function StatusBadge({ p }: { p: ProviderStatus }) {
const expired = isExpired(p.expiresAt);
if (p.configured && !expired) {
return (
<Badge className="bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20">
connected
</Badge>
);
}
if (expired) {
return (
<Badge variant="destructive">expired</Badge>
);
}
return (
<Badge variant="outline" className="text-amber-600 border-amber-400/40">
not connected
</Badge>
);
}
// ── ModelTestPanel ────────────────────────────────────────────────────────────
interface ModelTestPanelProps {
provider: string;
models: ProviderModel[];
basePath: string;
doFetch: (url: string, init: RequestInit) => Promise<Response>;
}
function ModelTestPanel({
provider,
models,
basePath,
doFetch,
}: ModelTestPanelProps) {
const [open, setOpen] = React.useState(false);
const [selectedModel, setSelectedModel] = React.useState(
models[0]?.id ?? ""
);
const [thinkingOn, setThinkingOn] = React.useState(false);
const [prompt, setPrompt] = React.useState(
'Hi! Just say "API OK" and nothing else.'
);
const [paramsJson, setParamsJson] = React.useState(() => {
const first = models[0];
const preset = first?.noThink ?? first?.think ?? {
temperature: 0.2,
max_tokens: 256,
};
return JSON.stringify(preset, null, 2);
});
const [running, setRunning] = React.useState(false);
const [result, setResult] = React.useState<TestResult | null>(null);
const [showRaw, setShowRaw] = React.useState(false);
const paramsValid = tryParseJson(paramsJson).ok;
// When model or thinking toggle changes, auto-fill params from the model preset.
const applyPreset = React.useCallback(
(modelId: string, thinking: boolean) => {
const m = models.find((x) => x.id === modelId);
if (!m) return;
const preset = thinking ? m.think : m.noThink;
if (preset) {
setParamsJson(JSON.stringify(preset, null, 2));
}
},
[models]
);
const handleModelChange = (modelId: string) => {
setSelectedModel(modelId);
applyPreset(modelId, thinkingOn);
};
const handleThinkingToggle = () => {
const next = !thinkingOn;
setThinkingOn(next);
applyPreset(selectedModel, next);
};
const selectedMeta = models.find((m) => m.id === selectedModel);
const canToggleThinking = !!(selectedMeta?.think || selectedMeta?.noThink);
const run = async () => {
const parseResult = tryParseJson(paramsJson);
if (!parseResult.ok) {
toast.error("Fix JSON params before running.");
return;
}
setRunning(true);
setResult(null);
try {
const res = await doFetch(`${basePath}/admin/gateway/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
model: selectedModel,
params: parseResult.value,
prompt: prompt.trim() || undefined,
}),
});
const data = (await res.json()) as TestResult;
setResult(data);
setShowRaw(false);
} catch (err) {
setResult({
success: false,
provider,
model: selectedModel,
latencyMs: 0,
error: err instanceof Error ? err.message : String(err),
});
} finally {
setRunning(false);
}
};
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Play className="size-3" />
Test a model
</button>
);
}
return (
<div className="space-y-3 pt-1">
<div className="h-px bg-border" />
<div className="flex items-center justify-between">
<p className="text-xs font-semibold flex items-center gap-1.5">
<Play className="size-3.5 text-muted-foreground" />
Model test
</p>
<button
type="button"
onClick={() => {
setOpen(false);
setResult(null);
}}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
close
</button>
</div>
{/* Model picker */}
<div className="space-y-1.5">
<Label className="text-xs">Model</Label>
<div className="flex gap-2">
{models.length > 0 ? (
<Select value={selectedModel} onValueChange={handleModelChange}>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.id} value={m.id} className="text-xs font-mono">
{m.label ?? m.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs font-mono flex-1"
placeholder="model id"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
/>
)}
{/* Custom model override when list exists */}
{models.length > 0 && (
<Input
className="h-8 text-xs font-mono w-36"
placeholder="custom id"
value={models.some((m) => m.id === selectedModel) ? "" : selectedModel}
onChange={(e) => {
if (e.target.value) setSelectedModel(e.target.value);
}}
/>
)}
</div>
</div>
{/* Thinking toggle */}
{canToggleThinking && (
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={thinkingOn}
onClick={handleThinkingToggle}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
thinkingOn ? "bg-primary" : "bg-input"
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
thinkingOn ? "translate-x-4" : "translate-x-1"
)}
/>
</button>
<span className="text-xs text-muted-foreground">
{thinkingOn ? "Thinking on" : "Thinking off"}, auto-fills the
params preset
</span>
</div>
)}
{/* Prompt */}
<div className="space-y-1.5">
<Label className="text-xs">Prompt</Label>
<textarea
className="w-full min-h-[56px] rounded-lg border border-input px-2.5 py-2 text-xs bg-transparent resize-y focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:border-ring placeholder:text-muted-foreground"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
spellCheck={false}
placeholder='e.g. "Say hi" or "What is 2+2?"'
/>
</div>
{/* Params JSON */}
<div className="space-y-1.5">
<div className="flex items-baseline justify-between gap-2">
<Label className="text-xs">Params (JSON)</Label>
{!paramsValid && (
<span className="text-[10px] text-destructive font-mono">
{tryParseJson(paramsJson).ok ? "" : (tryParseJson(paramsJson) as { ok: false; error: string }).error}
</span>
)}
</div>
<textarea
className={cn(
"w-full min-h-[80px] rounded-lg border px-2.5 py-2 text-xs font-mono bg-transparent resize-y focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:border-ring placeholder:text-muted-foreground",
paramsValid ? "border-input" : "border-destructive"
)}
value={paramsJson}
onChange={(e) => setParamsJson(e.target.value)}
spellCheck={false}
/>
<p className="text-[10px] text-muted-foreground">
temperature, max_tokens, reasoning_effort, thinking flags...
</p>
</div>
{/* Run */}
<Button
size="sm"
className="gap-1.5 h-8 text-xs"
onClick={() => void run()}
disabled={running || !selectedModel || !paramsValid}
>
{running ? (
<>
<RefreshCw className="size-3 animate-spin" />
Running...
</>
) : (
<>
<Sparkles className="size-3" />
Run test
</>
)}
</Button>
{/* Result */}
{result && (
<div
className={cn(
"rounded-lg border p-3 space-y-2 text-xs",
result.success
? "border-green-200 bg-green-50/50 dark:border-green-900/40 dark:bg-green-900/10"
: "border-red-200 bg-red-50/50 dark:border-red-900/40 dark:bg-red-900/10"
)}
>
{/* Status row */}
<div className="flex items-center gap-2 flex-wrap">
{result.success ? (
<span className="flex items-center gap-1 font-medium text-green-700 dark:text-green-400">
<CheckCircle2 className="size-3.5" />
OK
</span>
) : (
<span className="flex items-center gap-1 font-medium text-red-600 dark:text-red-400">
<AlertCircle className="size-3.5" />
Failed
</span>
)}
<span className="font-mono text-muted-foreground">{result.model}</span>
{result.httpStatus != null && (
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-mono",
result.httpStatus < 400
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-600"
)}
>
HTTP {result.httpStatus}
</span>
)}
<span className="ml-auto flex items-center gap-1 text-muted-foreground">
<Timer className="size-3" />
{result.latencyMs}ms
</span>
</div>
{/* Reply */}
<div className="space-y-0.5">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide font-medium">
reply
</p>
<p className="font-mono text-foreground whitespace-pre-wrap break-all bg-background/60 rounded-lg px-2 py-1.5 border text-[11px]">
{result.reply ?? (
<span className="text-amber-600 italic">
(null. Thinking models may put output in the reasoning
field, check raw below)
</span>
)}
</p>
</div>
{/* Error text */}
{result.error && (
<div className="space-y-0.5">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide font-medium">
error
</p>
<p className="font-mono text-red-600 dark:text-red-400 whitespace-pre-wrap break-all bg-background/60 rounded-lg px-2 py-1.5 border text-[11px]">
{result.error}
</p>
</div>
)}
{/* Usage + finish reason */}
{(result.usage || result.finishReason) && (
<div className="flex items-center gap-3 pt-0.5 text-[10px] text-muted-foreground flex-wrap">
{result.usage && (
<span className="flex items-center gap-1">
<Cpu className="size-3" />
{result.usage.prompt_tokens ?? "?"} in /{" "}
{result.usage.completion_tokens ?? "?"} out
{result.usage.total_tokens
? ` (${result.usage.total_tokens} total)`
: ""}
</span>
)}
{result.finishReason && (
<span>
finish:{" "}
<span className="font-mono">{result.finishReason}</span>
</span>
)}
</div>
)}
{/* Raw toggle */}
<div className="space-y-1 pt-1">
<button
type="button"
onClick={() => setShowRaw((x) => !x)}
className="text-[10px] text-sky-600 dark:text-sky-400 hover:underline font-medium"
>
{showRaw ? "Hide raw response" : "Show raw response"}
</button>
{showRaw && (
<pre className="text-[10px] font-mono bg-muted/60 border rounded-lg p-2 overflow-auto max-h-96 whitespace-pre-wrap break-all">
{JSON.stringify(result.raw, null, 2)}
</pre>
)}
</div>
</div>
)}
</div>
);
}
// ── ProviderCard ──────────────────────────────────────────────────────────────
interface ProviderCardProps {
p: ProviderStatus;
basePath: string;
doFetch: (url: string, init: RequestInit) => Promise<Response>;
onRefetchStatus: () => void;
}
function ProviderCard({
p,
basePath,
doFetch,
onRefetchStatus,
}: ProviderCardProps) {
const [expanded, setExpanded] = React.useState(false);
const [manualOpen, setManualOpen] = React.useState(false);
// Manual paste form state
const [token, setToken] = React.useState("");
const [refreshTokenVal, setRefreshTokenVal] = React.useState("");
const [accountId, setAccountId] = React.useState("");
// In-flight state
const [connectingOAuth, setConnectingOAuth] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const isOAuth = p.authKind === "oauth-pkce";
const expired = isExpired(p.expiresAt);
// OAuth popup + postMessage listener
React.useEffect(() => {
if (!isOAuth) return;
const handler = (e: MessageEvent) => {
// Guard: only accept messages matching this provider
const { type, provider: msgProvider } = (e.data ?? {}) as {
type?: string;
provider?: string;
error?: string;
};
if (msgProvider !== p.provider) return;
if (type === "proxykit:connected") {
setConnectingOAuth(false);
toast.success(`${p.provider} connected.`);
onRefetchStatus();
}
if (type === "proxykit:error") {
setConnectingOAuth(false);
const errMsg = (e.data as { error?: string }).error ?? "Unknown error";
toast.error(`${p.provider} auth failed: ${errMsg}`);
}
};
if (typeof window !== "undefined") {
window.addEventListener("message", handler);
}
return () => {
if (typeof window !== "undefined") {
window.removeEventListener("message", handler);
}
};
}, [isOAuth, p.provider, onRefetchStatus]);
const startOAuth = async () => {
try {
const res = await doFetch(
`${basePath}/admin/gateway/${p.provider}/start-auth`,
{ method: "POST" }
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
message?: string;
};
toast.error(body.message ?? "Failed to start OAuth");
return;
}
const { oauthUrl } = (await res.json()) as { oauthUrl: string };
setConnectingOAuth(true);
if (typeof window !== "undefined") {
window.open(oauthUrl, "_blank", "width=560,height=720");
}
} catch (err) {
toast.error(
"Failed to start OAuth: " +
(err instanceof Error ? err.message : String(err))
);
}
};
const doRefresh = async () => {
setRefreshing(true);
try {
const res = await doFetch(
`${basePath}/admin/gateway/${p.provider}/refresh`,
{ method: "POST" }
);
const body = (await res.json()) as { success: boolean; error?: string };
if (body.success) {
toast.success("Token refreshed.");
onRefetchStatus();
} else {
toast.error(body.error ?? "Refresh failed");
}
} catch (err) {
toast.error(
"Refresh failed: " +
(err instanceof Error ? err.message : String(err))
);
} finally {
setRefreshing(false);
}
};
const doSaveManual = async () => {
if (!token.trim()) return;
setSaving(true);
try {
const body: Record<string, string> = { token: token.trim() };
if (refreshTokenVal.trim()) body.refreshToken = refreshTokenVal.trim();
if (accountId.trim()) body.accountId = accountId.trim();
const res = await doFetch(
`${basePath}/admin/gateway/providers/${p.provider}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as {
message?: string;
};
toast.error(err.message ?? "Save failed");
return;
}
toast.success(`Token saved for ${p.provider}.`);
setToken("");
setRefreshTokenVal("");
setAccountId("");
setManualOpen(false);
onRefetchStatus();
} catch (err) {
toast.error(
"Save failed: " +
(err instanceof Error ? err.message : String(err))
);
} finally {
setSaving(false);
}
};
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2 flex-wrap">
<Server className="size-4 text-muted-foreground shrink-0" />
<span className="font-mono">{p.provider}</span>
<StatusBadge p={p} />
{p.hasRefreshToken && (
<Badge variant="secondary" className="text-sky-600 dark:text-sky-400 bg-sky-500/10 border-sky-400/20">
auto-refresh
</Badge>
)}
{p.hasAccountId && (
<Badge variant="secondary" className="text-violet-600 dark:text-violet-400 bg-violet-500/10 border-violet-400/20">
account id set
</Badge>
)}
</CardTitle>
<CardDescription className="text-[11px]">
{p.authKind === "oauth-pkce"
? "OAuth 2.0 with PKCE. Use the Connect button below."
: "Static token. Paste via the form below."}
</CardDescription>
</div>
<button
type="button"
onClick={() => setExpanded((x) => !x)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
{expanded ? (
<>
<ChevronUp className="size-3.5" />
Collapse
</>
) : (
<>
<ChevronDown className="size-3.5" />
Manage
</>
)}
</button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Token status row */}
<div className="rounded-lg bg-muted/40 border px-3 py-2 space-y-1">
<div className="flex items-center gap-1.5">
<KeyRound className="size-3 text-muted-foreground" />
{p.maskedToken ? (
<span className="text-[11px] font-mono">{p.maskedToken}</span>
) : (
<span className="text-[11px] text-amber-600 dark:text-amber-400">
no token stored
</span>
)}
</div>
{p.expiresAt && (
<div
className={cn(
"text-[10px]",
expired ? "text-red-500" : "text-muted-foreground"
)}
>
{expired ? "Expired" : "Expires"} {relativeTime(p.expiresAt)}
</div>
)}
{p.updatedAt && (
<div className="text-[10px] text-muted-foreground">
Updated {relativeTime(p.updatedAt)}
{p.updatedBy ? ` by ${p.updatedBy}` : ""}
</div>
)}
</div>
{/* Available models list */}
{p.models.length > 0 && (
<div className="space-y-1.5">
<p className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
available models
</p>
<div className="flex flex-wrap gap-1">
{p.models.map((m) => (
<span
key={m.id}
className="text-[10px] px-1.5 py-0.5 rounded-md bg-muted font-mono"
>
{m.label ?? m.id}
</span>
))}
</div>
</div>
)}
{/* Inline model test (only when configured) */}
{p.configured && p.models.length > 0 && (
<ModelTestPanel
provider={p.provider}
models={p.models}
basePath={basePath}
doFetch={doFetch}
/>
)}
{/* Actions panel (expanded) */}
{expanded && (
<div className="space-y-3 pt-1">
<div className="h-px bg-border" />
{/* OAuth flow (oauth-pkce providers) */}
{isOAuth && (
<div className="space-y-2">
<Button
className="w-full gap-2 h-9 text-sm"
onClick={() => void startOAuth()}
disabled={connectingOAuth}
>
{connectingOAuth ? (
<>
<RefreshCw className="size-3.5 animate-spin" />
Waiting for sign-in...
</>
) : (
<>
<LogIn className="size-3.5" />
{p.configured ? `Re-connect ${p.provider}` : `Connect ${p.provider}`}
</>
)}
</Button>
{connectingOAuth && (
<p className="text-[10px] text-muted-foreground text-center">
Complete sign-in in the popup. This page updates
automatically when done.
</p>
)}
{/* Refresh button */}
{p.hasRefreshToken && (
<Button
variant="outline"
className="w-full gap-2 h-8 text-xs"
onClick={() => void doRefresh()}
disabled={refreshing}
>
{refreshing ? (
<>
<RefreshCw className="size-3 animate-spin" />
Refreshing...
</>
) : (
<>
<RefreshCcw className="size-3" />
Refresh token now
</>
)}
</Button>
)}
{/* Manual paste toggle */}
<button
type="button"
onClick={() => setManualOpen((x) => !x)}
className="flex items-center justify-center gap-1 text-[11px] text-muted-foreground hover:text-foreground w-full pt-1 transition-colors"
>
{manualOpen ? "Hide manual paste" : "Paste token manually instead"}
</button>
</div>
)}
{/* Manual paste form */}
{(manualOpen || !isOAuth) && (
<div className="space-y-2.5">
<div className="space-y-1.5">
<Label className="text-xs">
{isOAuth ? "Access Token" : "API Key / Token"}
</Label>
<Input
type="password"
className="h-8 text-xs font-mono"
placeholder={
p.maskedToken
? `${p.maskedToken} -- leave blank to keep`
: "Paste token or key"
}
value={token}
onChange={(e) => setToken(e.target.value)}
autoComplete="new-password"
/>
<p className="text-[10px] text-muted-foreground">
Write-only. Existing value shown masked above. Leave blank
to keep.
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Refresh Token (optional)</Label>
<Input
type="password"
className="h-8 text-xs font-mono"
placeholder="refresh_token, if available"
value={refreshTokenVal}
onChange={(e) => setRefreshTokenVal(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Account ID (optional)</Label>
<Input
className="h-8 text-xs font-mono"
placeholder={
p.hasAccountId
? "Already set -- leave blank to keep"
: "e.g. user-abc123"
}
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
autoComplete="off"
/>
<p className="text-[10px] text-muted-foreground">
Leave blank to auto-extract from token or keep existing.
</p>
</div>
<Button
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => void doSaveManual()}
disabled={saving || !token.trim()}
>
{saving ? (
<>
<RefreshCw className="size-3 animate-spin" />
Saving...
</>
) : (
<>
<Save className="size-3" />
Save token
</>
)}
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
// ── ProxyAdmin ────────────────────────────────────────────────────────────────
/**
* ProxyAdmin is the control UI for a proxykit token-management proxy.
* Drop it anywhere in your app -- it talks directly to the proxykit REST API
* via fetch and needs no extra data-fetching library.
*
* Props:
* basePath - where the proxykit API is mounted (default "/api/proxy")
* fetcher - override fetch (e.g. for auth injection)
* getAuthHeaders - async fn returning headers merged into every request
* className - extra classes on the root element
*/
export function ProxyAdmin({
basePath = "/api/proxy",
fetcher,
getAuthHeaders,
className,
}: ProxyAdminProps) {
const [status, setStatus] =
React.useState<GatewayStatusResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
// Memoized fetch wrapper: merges auth headers, forwards credentials.
const doFetch = React.useCallback(
async (url: string, init: RequestInit = {}): Promise<Response> => {
const base = fetcher ?? fetch;
let authHeaders: Record<string, string> = {};
if (getAuthHeaders) {
authHeaders = await Promise.resolve(getAuthHeaders());
}
return base(url, {
credentials: "include",
...init,
headers: {
...authHeaders,
...(init.headers as Record<string, string> | undefined),
},
});
},
[fetcher, getAuthHeaders]
);
const fetchStatus = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await doFetch(`${basePath}/admin/gateway/status`, {});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
message?: string;
};
throw new Error(
body.message ?? `HTTP ${res.status}: ${res.statusText}`
);
}
const data = (await res.json()) as GatewayStatusResponse;
setStatus(data);
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to load gateway status.";
setError(msg);
toast.error(msg);
} finally {
setLoading(false);
}
}, [basePath, doFetch]);
React.useEffect(() => {
void fetchStatus();
}, [fetchStatus]);
const totalProviders = status?.providers.length ?? 0;
const connectedCount = status?.providers.filter(
(p) => p.configured && !isExpired(p.expiresAt)
).length ?? 0;
return (
<div className={cn("space-y-6", className)}>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Zap className="size-5 text-muted-foreground" />
Proxy Admin
</h2>
<p className="text-sm text-muted-foreground">
{loading
? "Loading gateway status..."
: error
? "Could not reach the proxy API."
: status
? `${connectedCount} of ${totalProviders} provider${totalProviders === 1 ? "" : "s"} connected.` +
(status.internalKeySet
? " Internal key is set."
: " Internal key is NOT set.")
: "No data."}
</p>
</div>
<Button
variant="outline"
size="sm"
className="gap-1.5 shrink-0"
onClick={() => void fetchStatus()}
disabled={loading}
>
<RefreshCw className={cn("size-3.5", loading && "animate-spin")} />
Refresh
</Button>
</div>
{/* Gateway meta card */}
{status && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="size-4 text-muted-foreground" />
Gateway
{status.internalKeySet ? (
<Badge className="bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20">
key set
</Badge>
) : (
<Badge variant="destructive">key NOT set</Badge>
)}
</CardTitle>
<CardDescription>
Set <span className="font-mono">GATEWAY_INTERNAL_KEY</span> in
your server environment. Clients authenticating to the proxy must
supply this value as their bearer token.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-muted-foreground">
Proxy base:
</span>
<span className="text-[11px] font-mono break-all">
{status.proxyBase}
</span>
<CopyButton text={status.proxyBase} />
</div>
{!status.internalKeySet && (
<p className="mt-2 text-[10px] text-amber-600 dark:text-amber-400">
Without a key the proxy is open to anyone. Set
GATEWAY_INTERNAL_KEY before exposing this service.
</p>
)}
</CardContent>
</Card>
)}
{/* Loading skeleton */}
{loading && (
<div className="space-y-4">
{[1, 2].map((i) => (
<div
key={i}
className="h-40 rounded-xl bg-muted/40 animate-pulse"
/>
))}
</div>
)}
{/* Error state */}
{!loading && error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-6 text-center space-y-3">
<AlertCircle className="size-8 text-destructive mx-auto" />
<p className="font-semibold text-sm">Failed to load gateway status</p>
<p className="text-xs text-muted-foreground">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => void fetchStatus()}
>
Retry
</Button>
</div>
)}
{/* Empty state */}
{!loading && !error && status && status.providers.length === 0 && (
<div className="rounded-lg border border-dashed p-8 text-center space-y-2">
<Server className="size-8 text-muted-foreground mx-auto" />
<p className="text-sm font-medium">No providers configured</p>
<p className="text-xs text-muted-foreground">
The gateway returned an empty providers list. Check your proxykit
server configuration.
</p>
</div>
)}
{/* Provider cards */}
{!loading && !error && status && status.providers.length > 0 && (
<Tabs defaultValue="providers">
<TabsList>
<TabsTrigger value="providers" className="gap-1.5">
<Server className="size-3.5" />
Providers
</TabsTrigger>
<TabsTrigger value="test" className="gap-1.5">
<Play className="size-3.5" />
Quick test
</TabsTrigger>
</TabsList>
{/* Providers tab */}
<TabsContent value="providers" className="mt-4">
<div className="grid gap-4 sm:grid-cols-2">
{status.providers.map((p) => (
<ProviderCard
key={p.provider}
p={p}
basePath={basePath}
doFetch={doFetch}
onRefetchStatus={() => void fetchStatus()}
/>
))}
</div>
</TabsContent>
{/* Standalone quick-test tab (all providers, all models in one panel) */}
<TabsContent value="test" className="mt-4">
<QuickTestPanel
providers={status.providers}
basePath={basePath}
doFetch={doFetch}
/>
</TabsContent>
</Tabs>
)}
</div>
);
}
ProxyAdmin.displayName = "ProxyAdmin";
// ── QuickTestPanel ────────────────────────────────────────────────────────────
// A standalone test panel that spans all configured providers and their models,
// useful for one-stop validation without drilling into individual provider cards.
interface QuickTestPanelProps {
providers: ProviderStatus[];
basePath: string;
doFetch: (url: string, init: RequestInit) => Promise<Response>;
}
function QuickTestPanel({ providers, basePath, doFetch }: QuickTestPanelProps) {
const configured = providers.filter(
(p) => p.configured && !isExpired(p.expiresAt)
);
const [selectedProvider, setSelectedProvider] = React.useState(
configured[0]?.provider ?? ""
);
const currentProvider = providers.find((p) => p.provider === selectedProvider);
const models = currentProvider?.models ?? [];
const [selectedModel, setSelectedModel] = React.useState(
models[0]?.id ?? ""
);
const [thinkingOn, setThinkingOn] = React.useState(false);
const [prompt, setPrompt] = React.useState(
'Hi! Just say "API OK" and nothing else.'
);
const [paramsJson, setParamsJson] = React.useState(() => {
const first = models[0];
const preset = first?.noThink ?? first?.think ?? {
temperature: 0.2,
max_tokens: 256,
};
return JSON.stringify(preset, null, 2);
});
const [running, setRunning] = React.useState(false);
const [result, setResult] = React.useState<TestResult | null>(null);
const [showRaw, setShowRaw] = React.useState(false);
const paramsValid = tryParseJson(paramsJson).ok;
// Sync model list when provider changes
React.useEffect(() => {
const newModels = providers.find((p) => p.provider === selectedProvider)?.models ?? [];
setSelectedModel(newModels[0]?.id ?? "");
const first = newModels[0];
const preset = first?.noThink ?? first?.think ?? {
temperature: 0.2,
max_tokens: 256,
};
setParamsJson(JSON.stringify(preset, null, 2));
setThinkingOn(false);
setResult(null);
}, [selectedProvider, providers]);
const currentModelMeta = models.find((m) => m.id === selectedModel);
const canToggleThinking = !!(
currentModelMeta?.think || currentModelMeta?.noThink
);
const handleThinkingToggle = () => {
const next = !thinkingOn;
setThinkingOn(next);
if (currentModelMeta) {
const preset = next ? currentModelMeta.think : currentModelMeta.noThink;
if (preset) setParamsJson(JSON.stringify(preset, null, 2));
}
};
const run = async () => {
const parseResult = tryParseJson(paramsJson);
if (!parseResult.ok) {
toast.error("Fix JSON params before running.");
return;
}
setRunning(true);
setResult(null);
try {
const res = await doFetch(`${basePath}/admin/gateway/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: selectedProvider,
model: selectedModel,
params: parseResult.value,
prompt: prompt.trim() || undefined,
}),
});
const data = (await res.json()) as TestResult;
setResult(data);
setShowRaw(false);
} catch (err) {
setResult({
success: false,
provider: selectedProvider,
model: selectedModel,
latencyMs: 0,
error: err instanceof Error ? err.message : String(err),
});
} finally {
setRunning(false);
}
};
if (configured.length === 0) {
return (
<div className="rounded-lg border border-dashed p-8 text-center space-y-2">
<Play className="size-8 text-muted-foreground mx-auto" />
<p className="text-sm font-medium">No connected providers</p>
<p className="text-xs text-muted-foreground">
Connect at least one provider to use the quick test panel.
</p>
</div>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="size-4 text-muted-foreground" />
Quick test
</CardTitle>
<CardDescription>
Send a one-off completion request through the proxy to verify a
provider and model are working.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Provider + model selectors */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Provider</Label>
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{configured.map((p) => (
<SelectItem key={p.provider} value={p.provider} className="text-xs font-mono">
{p.provider}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Model</Label>
{models.length > 0 ? (
<Select
value={models.some((m) => m.id === selectedModel) ? selectedModel : ""}
onValueChange={(v) => {
setSelectedModel(v);
const m = models.find((x) => x.id === v);
if (m) {
const preset = thinkingOn ? m.think : m.noThink;
if (preset) setParamsJson(JSON.stringify(preset, null, 2));
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.id} value={m.id} className="text-xs font-mono">
{m.label ?? m.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs font-mono"
placeholder="model id"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
/>
)}
</div>
</div>
{/* Thinking toggle */}
{canToggleThinking && (
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={thinkingOn}
onClick={handleThinkingToggle}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
thinkingOn ? "bg-primary" : "bg-input"
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
thinkingOn ? "translate-x-4" : "translate-x-1"
)}
/>
</button>
<span className="text-xs text-muted-foreground">
{thinkingOn ? "Thinking on" : "Thinking off"}
</span>
</div>
)}
{/* Prompt */}
<div className="space-y-1.5">
<Label className="text-xs">Prompt</Label>
<textarea
className="w-full min-h-[56px] rounded-lg border border-input px-2.5 py-2 text-xs bg-transparent resize-y focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:border-ring placeholder:text-muted-foreground"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
spellCheck={false}
/>
</div>
{/* Params JSON */}
<div className="space-y-1.5">
<div className="flex items-baseline justify-between gap-2">
<Label className="text-xs">Params (JSON)</Label>
{!paramsValid && (
<span className="text-[10px] text-destructive font-mono">
Invalid JSON
</span>
)}
</div>
<textarea
className={cn(
"w-full min-h-[80px] rounded-lg border px-2.5 py-2 text-xs font-mono bg-transparent resize-y focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:border-ring placeholder:text-muted-foreground",
paramsValid ? "border-input" : "border-destructive"
)}
value={paramsJson}
onChange={(e) => setParamsJson(e.target.value)}
spellCheck={false}
/>
</div>
{/* Run */}
<Button
className="gap-1.5 text-sm"
onClick={() => void run()}
disabled={running || !selectedProvider || !selectedModel || !paramsValid}
>
{running ? (
<>
<RefreshCw className="size-4 animate-spin" />
Running...
</>
) : (
<>
<Sparkles className="size-4" />
Run test
</>
)}
</Button>
{/* Result */}
{result && (
<div
className={cn(
"rounded-lg border p-3 space-y-2 text-xs",
result.success
? "border-green-200 bg-green-50/50 dark:border-green-900/40 dark:bg-green-900/10"
: "border-red-200 bg-red-50/50 dark:border-red-900/40 dark:bg-red-900/10"
)}
>
<div className="flex items-center gap-2 flex-wrap">
{result.success ? (
<span className="flex items-center gap-1 font-medium text-green-700 dark:text-green-400">
<CheckCircle2 className="size-3.5" />
OK
</span>
) : (
<span className="flex items-center gap-1 font-medium text-red-600 dark:text-red-400">
<AlertCircle className="size-3.5" />
Failed
</span>
)}
<span className="font-mono text-muted-foreground">
{result.provider} / {result.model}
</span>
{result.httpStatus != null && (
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-mono",
result.httpStatus < 400
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-600"
)}
>
HTTP {result.httpStatus}
</span>
)}
<span className="ml-auto flex items-center gap-1 text-muted-foreground">
<Timer className="size-3" />
{result.latencyMs}ms
</span>
</div>
<div className="space-y-0.5">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide font-medium">
reply
</p>
<p className="font-mono text-foreground whitespace-pre-wrap break-all bg-background/60 rounded-lg px-2 py-1.5 border text-[11px]">
{result.reply ?? (
<span className="text-amber-600 italic">
(null -- check raw below for reasoning-only models)
</span>
)}
</p>
</div>
{result.error && (
<p className="font-mono text-red-600 dark:text-red-400 whitespace-pre-wrap break-all bg-background/60 rounded-lg px-2 py-1.5 border text-[11px]">
{result.error}
</p>
)}
{(result.usage || result.finishReason) && (
<div className="flex items-center gap-3 pt-0.5 text-[10px] text-muted-foreground flex-wrap">
{result.usage && (
<span className="flex items-center gap-1">
<Cpu className="size-3" />
{result.usage.prompt_tokens ?? "?"} in /{" "}
{result.usage.completion_tokens ?? "?"} out
{result.usage.total_tokens
? ` (${result.usage.total_tokens} total)`
: ""}
</span>
)}
{result.finishReason && (
<span>
finish: <span className="font-mono">{result.finishReason}</span>
</span>
)}
</div>
)}
<div className="space-y-1 pt-1">
<button
type="button"
onClick={() => setShowRaw((x) => !x)}
className="text-[10px] text-sky-600 dark:text-sky-400 hover:underline font-medium"
>
{showRaw ? "Hide raw response" : "Show raw response"}
</button>
{showRaw && (
<pre className="text-[10px] font-mono bg-muted/60 border rounded-lg p-2 overflow-auto max-h-96 whitespace-pre-wrap break-all">
{JSON.stringify(result.raw, null, 2)}
</pre>
)}
</div>
</div>
)}
</CardContent>
</Card>
);
}Dependencies
sonnerlucide-react
Registry dependencies
buttoncardinputbadgetabsselectlabel