fix: claude local

This commit is contained in:
null 2026-05-21 19:42:46 -05:00
parent 4ca3ede009
commit 66bbdd7398
3 changed files with 480 additions and 152 deletions

View File

@ -38,7 +38,10 @@ avoid hammering provider APIs on every page load.
from __future__ import annotations
import json as _json_module
import os
import re
import time as _time_module
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
@ -639,6 +642,52 @@ async def _fetch_ollama(base_url: str | None, api_key: str | None) -> ProviderUs
_ANTHROPIC_SUBSCRIPTION_URL = "https://api.anthropic.com/api/oauth/usage"
_CODEX_SUBSCRIPTION_URL = "https://chatgpt.com/backend-api/wham/usage"
# Path to the Claude Code OAuth credentials file, mounted read-only from the host.
_CLAUDE_CREDENTIALS_PATH = os.environ.get("CLAUDE_CREDENTIALS_PATH", "")
_claude_oauth_cache: tuple[float, str] | None = None # (expires_at_ms, access_token)
def _read_claude_local_oauth_token() -> str | None:
"""Read the Claude Code OAuth access token from the host credentials file.
Returns a valid access token, or None if the file is absent, unreadable,
or the token has expired. The caller should fall back to the manually
configured session_key when this returns None.
"""
global _claude_oauth_cache
path = _CLAUDE_CREDENTIALS_PATH
if not path:
# Fall back to the default XDG location when no explicit path is set.
home = os.path.expanduser("~")
path = os.path.join(home, ".claude", ".credentials.json")
try:
with open(path) as fh:
data = _json_module.load(fh)
except (FileNotFoundError, PermissionError, ValueError, OSError):
return None
oauth = data.get("claudeAiOauth")
if not isinstance(oauth, dict):
return None
access_token = oauth.get("accessToken")
expires_at = oauth.get("expiresAt")
if not isinstance(access_token, str) or not access_token:
return None
if not isinstance(expires_at, (int, float)) or expires_at <= 0:
return None
now_ms = _time_module.time() * 1000
if expires_at <= now_ms:
logger.debug("provider_usage.claude_oauth.token_expired expires_at=%s", expires_at)
return None
return access_token
_ANTHROPIC_WINDOW_LABELS: dict[str, str] = {
"five_hour": "Current session",
"seven_day": "All models",
@ -706,7 +755,7 @@ async def _fetch_anthropic_subscription(session_key: str) -> list[SubscriptionWi
windows.append(SubscriptionWindow(
key=key,
label=label,
pct_used=round(float(utilization) * 100, 1),
pct_used=min(100.0, round(float(utilization), 1)), # already 0100
reset_at=reset_dt,
))
@ -851,7 +900,13 @@ async def fetch_provider_usage(
return cached
if provider == "anthropic":
if not api_key and not session_key:
# Prefer the local Claude Code OAuth token (sk-ant-oat01-...) because it's
# the correct credential type for the oauth/usage endpoint. Fall back to
# the manually-stored session_key only when no local OAuth token exists.
local_oauth = _read_claude_local_oauth_token()
effective_session_key = local_oauth or session_key
if not api_key and not effective_session_key:
result = ProviderUsageLive(
provider=provider, account_key=account_key,
checked_at=utcnow(), reachable=False,
@ -860,14 +915,13 @@ async def fetch_provider_usage(
elif api_key:
result = await _fetch_anthropic(api_key, base_url)
else:
# session_key only — can still get subscription data, mark as reachable
result = ProviderUsageLive(
provider=provider, account_key=account_key,
checked_at=utcnow(), reachable=True,
)
# Overlay subscription windows when session_key available
if session_key and result.reachable is not False:
sub_windows = await _fetch_anthropic_subscription(session_key)
# Overlay subscription windows from OAuth token (auto or explicit)
if effective_session_key and result.reachable is not False:
sub_windows = await _fetch_anthropic_subscription(effective_session_key)
if sub_windows:
result.subscription_windows = sub_windows
result.reachable = True

View File

@ -44,6 +44,11 @@ services:
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
BASE_URL: ${BASE_URL:-http://localhost:8000}
RQ_REDIS_URL: redis://redis:6379/0
# Claude Code credentials — read-only mount lets the backend auto-detect
# the local OAuth token for subscription usage without manual session keys.
CLAUDE_CREDENTIALS_PATH: /run/secrets/claude_credentials
volumes:
- ${CLAUDE_CREDENTIALS_FILE:-/home/kaspa/.claude/.credentials.json}:/run/secrets/claude_credentials:ro
depends_on:
db:
condition: service_healthy

View File

@ -3,7 +3,21 @@
export const dynamic = "force-dynamic";
import { useCallback, useEffect, useState } from "react";
import { Bot, KeyRound, Loader2, Plus, RefreshCw, Server, Trash2, X } from "lucide-react";
import {
Bot,
CheckCircle2,
ChevronDown,
ChevronUp,
HelpCircle,
KeyRound,
Loader2,
Plus,
RefreshCw,
Server,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
@ -25,40 +39,100 @@ import {
// Provider metadata
// ---------------------------------------------------------------------------
const PROVIDERS = [
type ProviderId = "anthropic" | "openai" | "ollama";
interface ProviderConfig {
id: ProviderId;
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
keyLabel: string;
keyPlaceholder: string;
showBaseUrl: boolean;
allowMultiple: boolean;
accountKeyDefault: string;
showSessionKey: boolean;
sessionKeyLabel: string;
sessionKeyPlaceholder: string;
/** Step-by-step instructions shown in an expandable help section next to the API key field. */
apiKeyHelp?: readonly string[];
/** Step-by-step instructions shown next to the session / subscription token field. */
sessionKeyHelp?: readonly string[];
/**
* When set, a green callout is shown above the session key field explaining that this
* credential is auto-detected so manual entry is optional.
*/
autoDetectedNote?: string;
}
const PROVIDERS: ProviderConfig[] = [
{
id: "anthropic",
label: "Claude (Anthropic)",
icon: Bot,
description: "Anthropic Claude API key. Add multiple accounts to track separate keys or billing profiles.",
description:
"Anthropic Claude API key. Add multiple accounts to track separate keys or billing profiles.",
keyLabel: "API Key",
keyPlaceholder: "sk-ant-api03-…",
showBaseUrl: false,
allowMultiple: true,
accountKeyDefault: "default",
apiKeyHelp: [
"Go to console.anthropic.com and sign in",
"Navigate to Settings → API Keys",
'Click "Create Key" and give it a name',
"Copy the key — it starts with sk-ant-api03-",
"It is only shown once, so store it in a password manager",
],
showSessionKey: true,
sessionKeyLabel: "Claude.ai session key (for subscription usage)",
sessionKeyPlaceholder: "sk-ant-sid-… (from claude.ai cookie)",
sessionKeyLabel: "Subscription token (optional — auto-detected via Claude Code)",
sessionKeyPlaceholder: "sk-ant-oat01-… (leave blank to auto-detect)",
autoDetectedNote:
"Subscription usage (current session %, weekly %) is auto-detected from your local Claude Code login at ~/.claude/.credentials.json. You only need to fill this in if you are running Pipeline on a different machine from Claude Code, or want to override with a specific account.",
sessionKeyHelp: [
"Open a terminal and run: cat ~/.claude/.credentials.json",
'Copy the value of the "accessToken" field — it starts with sk-ant-oat01-',
"Paste it here. The token expires after ~10 hours; Claude Code renews it automatically when you next use it",
],
},
{
id: "openai",
label: "Codex / OpenAI",
icon: Bot,
description: "OpenAI API key. Add multiple accounts to track usage separately.",
icon: Sparkles,
description:
"OpenAI API key. Add multiple accounts to track usage separately.",
keyLabel: "API Key",
keyPlaceholder: "sk-proj-…",
showBaseUrl: false,
allowMultiple: true,
accountKeyDefault: "",
apiKeyHelp: [
"Go to platform.openai.com and sign in",
"Click your org name → API Keys",
'Click "Create new secret key"',
"Copy the key — it starts with sk-proj- or sk-",
"It is only shown once, so store it in a password manager",
],
showSessionKey: true,
sessionKeyLabel: "ChatGPT session token (for subscription usage)",
sessionKeyPlaceholder: "Bearer token from browser session",
sessionKeyLabel: "ChatGPT subscription token (optional — for plan usage %)",
sessionKeyPlaceholder: "eyJhbGc… (Bearer token from chatgpt.com)",
sessionKeyHelp: [
"Sign in to chatgpt.com in Chrome or Firefox",
"Open DevTools: press F12 (Windows/Linux) or Cmd+Option+I (Mac)",
"Click the Network tab, then refresh the page (F5)",
"In the filter box, type backend-api and click any request",
'In the right panel, choose "Headers" → "Request Headers"',
'Find the "Authorization" header — its value starts with "Bearer eyJhbG…"',
'Copy just the token part after the word "Bearer " (do not include "Bearer " itself)',
"Paste it here. Tokens expire when you log out of ChatGPT",
],
},
{
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.",
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,
@ -67,10 +141,52 @@ const PROVIDERS = [
showSessionKey: false,
sessionKeyLabel: "",
sessionKeyPlaceholder: "",
apiKeyHelp: [
"Most local Ollama deployments do not require an API key",
"For cloud-hosted or authenticated Ollama instances, paste the Bearer token here",
'The base URL is the important part — typically http://localhost:11434 for local installs',
],
},
] as const;
];
type ProviderId = (typeof PROVIDERS)[number]["id"];
// ---------------------------------------------------------------------------
// Help toggle — collapsible inline instructions
// ---------------------------------------------------------------------------
function HelpSection({ steps }: { steps: readonly string[] }) {
return (
<div className="mt-1.5 rounded-lg border border-[color:rgba(99,102,241,0.25)] bg-[color:rgba(99,102,241,0.06)] px-3 py-2.5">
<ol className="list-inside list-decimal space-y-1 text-[11px] leading-relaxed text-muted">
{steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</div>
);
}
function HelpToggle({
steps,
open,
onToggle,
}: {
steps: readonly string[];
open: boolean;
onToggle: () => void;
}) {
if (steps.length === 0) return null;
return (
<button
type="button"
onClick={onToggle}
className="flex items-center gap-1 text-[11px] text-[color:var(--accent)] opacity-70 hover:opacity-100 transition-opacity"
>
<HelpCircle className="h-3 w-3" />
{open ? "Hide" : "Where do I find this?"}
{open ? <ChevronUp className="h-2.5 w-2.5" /> : <ChevronDown className="h-2.5 w-2.5" />}
</button>
);
}
// ---------------------------------------------------------------------------
// Add/Edit form
@ -86,6 +202,9 @@ interface CredentialFormProps {
keyLabel: string;
keyPlaceholder: string;
accountKeyDefault: string;
apiKeyHelp?: readonly string[];
sessionKeyHelp?: readonly string[];
autoDetectedNote?: string;
onSave: (data: {
account_key: string;
display_name: string;
@ -112,6 +231,9 @@ function CredentialForm({
keyLabel,
keyPlaceholder,
accountKeyDefault,
apiKeyHelp,
sessionKeyHelp,
autoDetectedNote,
onSave,
onTest,
onCancel,
@ -126,6 +248,8 @@ function CredentialForm({
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<ProviderUsageLiveRead | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const [showApiHelp, setShowApiHelp] = useState(false);
const [showSessionHelp, setShowSessionHelp] = useState(false);
const canTest = showBaseUrl ? Boolean(baseUrl.trim()) : Boolean(apiKey.trim());
const runTest = async () => {
@ -178,21 +302,40 @@ function CredentialForm({
</div>
{showBaseUrl && (
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Base URL <span className="text-[color:var(--danger)]">*</span>
</label>
<div className="mb-1 flex items-center justify-between">
<label className="text-xs font-medium text-muted">
Base URL <span className="text-[color:var(--danger)]">*</span>
</label>
{apiKeyHelp?.length && (
<HelpToggle
steps={apiKeyHelp}
open={showApiHelp}
onToggle={() => setShowApiHelp((v) => !v)}
/>
)}
</div>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://localhost:11434"
disabled={isSaving}
/>
{showApiHelp && apiKeyHelp?.length ? (
<HelpSection steps={apiKeyHelp} />
) : null}
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-muted">
{keyLabel}
</label>
<div className="mb-1 flex items-center justify-between">
<label className="text-xs font-medium text-muted">{keyLabel}</label>
{!showBaseUrl && apiKeyHelp?.length ? (
<HelpToggle
steps={apiKeyHelp}
open={showApiHelp}
onToggle={() => setShowApiHelp((v) => !v)}
/>
) : null}
</div>
<Input
type="password"
value={apiKey}
@ -201,12 +344,28 @@ function CredentialForm({
disabled={isSaving}
autoComplete="new-password"
/>
{showApiHelp && !showBaseUrl && apiKeyHelp?.length ? (
<HelpSection steps={apiKeyHelp} />
) : null}
</div>
{showSessionKey && (
<div>
<label className="mb-1 block text-xs font-medium text-muted">
{sessionKeyLabel}
</label>
{autoDetectedNote && (
<div className="mb-2 flex items-start gap-2 rounded-lg border border-[color:rgba(52,211,153,0.3)] bg-[color:rgba(52,211,153,0.07)] px-3 py-2.5">
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[color:var(--success)]" />
<p className="text-[11px] leading-relaxed text-muted">{autoDetectedNote}</p>
</div>
)}
<div className="mb-1 flex items-center justify-between">
<label className="text-xs font-medium text-muted">{sessionKeyLabel}</label>
{sessionKeyHelp?.length ? (
<HelpToggle
steps={sessionKeyHelp}
open={showSessionHelp}
onToggle={() => setShowSessionHelp((v) => !v)}
/>
) : null}
</div>
<Input
type="password"
value={sessionKey}
@ -215,6 +374,9 @@ function CredentialForm({
disabled={isSaving}
autoComplete="new-password"
/>
{showSessionHelp && sessionKeyHelp?.length ? (
<HelpSection steps={sessionKeyHelp} />
) : null}
</div>
)}
{error && (
@ -232,13 +394,19 @@ function CredentialForm({
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
{isTesting ? "Testing…" : "Test"}
{isTesting ? "Testing…" : "Test connection"}
</Button>
<Button
size="sm"
disabled={isSaving || !accountKey.trim() || (showBaseUrl && !baseUrl.trim())}
onClick={() =>
onSave({ account_key: accountKey.trim(), display_name: displayName.trim(), api_key: apiKey, session_key: sessionKey, base_url: baseUrl.trim() })
onSave({
account_key: accountKey.trim(),
display_name: displayName.trim(),
api_key: apiKey,
session_key: sessionKey,
base_url: baseUrl.trim(),
})
}
>
{isSaving ? (
@ -257,29 +425,34 @@ function CredentialForm({
<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)]"
}`}>
<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
{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."
)}
: "Connected.")}
</p>
{testResult.sample_latency_ms != null && (
<p className="mt-1 text-muted">
Probe time: {fmtLatencyMs(testResult.sample_latency_ms)}
</p>
)}
</div>
)}
</div>
@ -328,14 +501,22 @@ interface UsageWindowBarProps {
label: string;
pct: number;
resetInMs?: number | null;
badge?: string;
}
function UsageWindowBar({ label, pct, resetInMs }: UsageWindowBarProps) {
function UsageWindowBar({ label, pct, resetInMs, badge }: 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="flex items-center gap-1.5 font-medium text-muted">
{label}
{badge && (
<span className="rounded-full bg-[color:rgba(99,102,241,0.15)] px-1.5 py-px text-[10px] font-medium text-[color:var(--accent)]">
{badge}
</span>
)}
</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)]">
@ -355,26 +536,31 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
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());
const fetchUsage = 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);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch usage.");
} finally {
setLoading(false);
}
}, [credentialId]);
},
[credentialId],
);
useEffect(() => { void fetch(); }, [fetch]);
useEffect(() => {
void fetchUsage();
}, [fetchUsage]);
if (loading && !usage) {
return (
@ -389,7 +575,11 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
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">
<button
type="button"
onClick={() => fetchUsage(true)}
className="ml-1 underline hover:opacity-70"
>
Retry
</button>
</div>
@ -402,41 +592,46 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
const req = usage.requests;
const subWindows = usage.subscription_windows ?? [];
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",
};
// output_tokens is the tightest Anthropic limit (90k vs 450k input, 540k combined)
// — show it first so the binding constraint is prominent
// output_tokens is the tightest Anthropic limit (90k/min vs 540k combined)
const usageBars: UsageWindowBarProps[] = [];
if (outputTok?.pct_used != null) {
usageBars.push({
label: "API rate limit · output tokens",
label: "Output tokens",
pct: outputTok.pct_used,
resetInMs: outputTok.reset_in_ms,
badge: "rate limit",
});
}
if (inputTok.pct_used != null) {
usageBars.push({
label: "API rate limit · input tokens",
label: "Input tokens",
pct: inputTok.pct_used,
resetInMs: inputTok.reset_in_ms,
badge: "rate limit",
});
}
if (tok.pct_used != null) {
usageBars.push({
label: "API rate limit · tokens (combined)",
label: "Tokens combined",
pct: tok.pct_used,
resetInMs: tok.reset_in_ms,
badge: "rate limit",
});
}
if (usageBars.length === 0 && req.limit != null && req.remaining != null && req.limit > 0) {
usageBars.push({
label: "API rate limit · requests",
label: "Requests",
pct: ((req.limit - req.remaining) / req.limit) * 100,
resetInMs: req.reset_in_ms,
badge: "rate limit",
});
}
@ -448,12 +643,18 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
</div>
)}
{/* ── Subscription usage windows (from session key) ── */}
{/* ── Subscription usage windows (plan-level usage from OAuth) ── */}
{subWindows.length > 0 && (
<div className="mb-2 space-y-2">
{usage.subscription_plan && (
<div className="text-[11px] text-muted">Plan: {usage.subscription_plan}</div>
)}
<div className="mb-3 space-y-2.5">
<div className="flex items-center gap-1.5 text-[11px] text-muted">
<CheckCircle2 className="h-3 w-3 text-[color:var(--success)]" />
<span className="font-medium">Subscription usage</span>
{usage.subscription_plan && (
<span className="rounded-full bg-[color:rgba(52,211,153,0.15)] px-1.5 py-px text-[10px] font-medium text-[color:var(--success)]">
{usage.subscription_plan}
</span>
)}
</div>
{subWindows.map((w) => (
<UsageWindowBar
key={w.key}
@ -473,9 +674,15 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
Connected
</span>
{(usage.models?.length ?? 0) > 0 && (
<span>{usage.models!.length} model{usage.models!.length !== 1 ? "s" : ""} available</span>
<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">
<button
type="button"
onClick={() => fetchUsage(true)}
className="ml-auto text-muted hover:text-strong"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
@ -483,18 +690,23 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
<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)}
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>
<span className="tabular-nums text-strong">
{fmtLatencyMs(usage.sample_latency_ms)}
</span>
</div>
)}
<div className="flex items-center justify-between text-[11px] text-muted">
{lastFetched && <span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>}
{lastFetched && (
<span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>
)}
</div>
</div>
) : (
@ -507,7 +719,13 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
</div>
<div className="space-y-2">
{usageBars.map((bar) => (
<UsageWindowBar key={bar.label} label={bar.label} pct={bar.pct} resetInMs={bar.resetInMs} />
<UsageWindowBar
key={bar.label}
label={bar.label}
pct={bar.pct}
resetInMs={bar.resetInMs}
badge={bar.badge}
/>
))}
</div>
</>
@ -517,7 +735,8 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
<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)}
in {fmtTokens(usage.sample_input_tokens)} · out{" "}
{fmtTokens(usage.sample_output_tokens)}
</span>
</div>
)}
@ -530,14 +749,20 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
</div>
)}
<p className="text-[11px] text-muted">
Connected provider did not return API rate-limit windows for percent + reset diagnostics.
Connected no rate-limit window data returned by provider.
</p>
</>
) : null}
<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">
{lastFetched && (
<span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>
)}
<button
type="button"
onClick={() => fetchUsage(true)}
className="ml-auto flex items-center gap-1 hover:text-strong"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
Refresh
</button>
@ -560,7 +785,13 @@ interface CredentialRowProps {
showUsage?: boolean;
}
function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }: CredentialRowProps) {
function CredentialRow({
cred,
isAdmin,
onDelete,
onToggle,
showUsage = true,
}: CredentialRowProps) {
const [toggling, setToggling] = useState(false);
return (
<div
@ -570,68 +801,70 @@ function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }:
: "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.has_session_key && (
<span className="flex items-center gap-1 text-[color:var(--success)]">
<KeyRound className="h-3 w-3" />
subscription key {cred.session_key_last_four ?? ""}
</span>
)}
{cred.base_url && <span className="truncate max-w-[200px]">{cred.base_url}</span>}
<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 API key</span>
)}
{cred.has_session_key && (
<span className="flex items-center gap-1 text-[color:var(--success)]">
<CheckCircle2 className="h-3 w-3" />
subscription token {cred.session_key_last_four ?? ""}
</span>
)}
{cred.base_url && (
<span className="max-w-[200px] truncate">{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>
{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>
{showUsage && cred.active && (cred.has_api_key || cred.base_url) && (
<UsageStrip credentialId={cred.id} provider={cred.provider} />
)}
</div>
{showUsage && cred.active && (cred.has_api_key || cred.base_url) && (
<UsageStrip credentialId={cred.id} provider={cred.provider} />
)}
</div>
);
}
@ -640,16 +873,36 @@ function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }:
// ---------------------------------------------------------------------------
interface ProviderSectionProps {
provider: (typeof PROVIDERS)[number];
provider: ProviderConfig;
credentials: ProviderCredentialRead[];
isAdmin: boolean;
onAdd: (providerId: ProviderId, data: { account_key: string; display_name: string; api_key: string; session_key: string; base_url: string }) => Promise<void>;
onTest: (providerId: ProviderId, data: { account_key: string; api_key: string; base_url: string }) => Promise<ProviderUsageLiveRead>;
onAdd: (
providerId: ProviderId,
data: {
account_key: string;
display_name: string;
api_key: string;
session_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) {
function ProviderSection({
provider,
credentials,
isAdmin,
onAdd,
onTest,
onDelete,
onToggle,
}: ProviderSectionProps) {
const Icon = provider.icon;
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
@ -673,7 +926,10 @@ function ProviderSection({ provider, credentials, isAdmin, onAdd, onTest, onDele
<Button
size="sm"
variant="outline"
onClick={() => { setShowForm(true); setSaveError(null); }}
onClick={() => {
setShowForm(true);
setSaveError(null);
}}
>
<Plus className="h-3.5 w-3.5" />
Add
@ -692,6 +948,9 @@ function ProviderSection({ provider, credentials, isAdmin, onAdd, onTest, onDele
keyLabel={provider.keyLabel}
keyPlaceholder={provider.keyPlaceholder}
accountKeyDefault={provider.accountKeyDefault}
apiKeyHelp={provider.apiKeyHelp}
sessionKeyHelp={provider.sessionKeyHelp}
autoDetectedNote={provider.autoDetectedNote}
onSave={async (data) => {
setSaving(true);
setSaveError(null);
@ -756,7 +1015,9 @@ export default function AIProvidersSettingsPage() {
if (res.status === 200) setCredentials(res.data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load provider settings.");
setError(
err instanceof Error ? err.message : "Could not load provider settings.",
);
} finally {
setIsLoading(false);
}
@ -768,7 +1029,13 @@ export default function AIProvidersSettingsPage() {
const handleAdd = async (
providerId: ProviderId,
data: { account_key: string; display_name: string; api_key: string; session_key: string; base_url: string },
data: {
account_key: string;
display_name: string;
api_key: string;
session_key: string;
base_url: string;
},
) => {
const res = await createProviderCredentialApiV1ProviderCredentialsPost({
provider: providerId,
@ -839,7 +1106,7 @@ export default function AIProvidersSettingsPage() {
signUpForceRedirectUrl: "/settings/ai-providers",
}}
title="AI Providers"
description="Configure API keys and endpoints for the AI providers your gateway uses."
description="Configure API keys and subscription tokens 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">
@ -875,7 +1142,9 @@ export default function AIProvidersSettingsPage() {
<ConfirmActionDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
title="Remove provider account"
description={
deleteTarget