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 from __future__ import annotations
import json as _json_module
import os
import re import re
import time as _time_module
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any 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" _ANTHROPIC_SUBSCRIPTION_URL = "https://api.anthropic.com/api/oauth/usage"
_CODEX_SUBSCRIPTION_URL = "https://chatgpt.com/backend-api/wham/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] = { _ANTHROPIC_WINDOW_LABELS: dict[str, str] = {
"five_hour": "Current session", "five_hour": "Current session",
"seven_day": "All models", "seven_day": "All models",
@ -706,7 +755,7 @@ async def _fetch_anthropic_subscription(session_key: str) -> list[SubscriptionWi
windows.append(SubscriptionWindow( windows.append(SubscriptionWindow(
key=key, key=key,
label=label, label=label,
pct_used=round(float(utilization) * 100, 1), pct_used=min(100.0, round(float(utilization), 1)), # already 0100
reset_at=reset_dt, reset_at=reset_dt,
)) ))
@ -851,7 +900,13 @@ async def fetch_provider_usage(
return cached return cached
if provider == "anthropic": 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( result = ProviderUsageLive(
provider=provider, account_key=account_key, provider=provider, account_key=account_key,
checked_at=utcnow(), reachable=False, checked_at=utcnow(), reachable=False,
@ -860,14 +915,13 @@ async def fetch_provider_usage(
elif api_key: elif api_key:
result = await _fetch_anthropic(api_key, base_url) result = await _fetch_anthropic(api_key, base_url)
else: else:
# session_key only — can still get subscription data, mark as reachable
result = ProviderUsageLive( result = ProviderUsageLive(
provider=provider, account_key=account_key, provider=provider, account_key=account_key,
checked_at=utcnow(), reachable=True, checked_at=utcnow(), reachable=True,
) )
# Overlay subscription windows when session_key available # Overlay subscription windows from OAuth token (auto or explicit)
if session_key and result.reachable is not False: if effective_session_key and result.reachable is not False:
sub_windows = await _fetch_anthropic_subscription(session_key) sub_windows = await _fetch_anthropic_subscription(effective_session_key)
if sub_windows: if sub_windows:
result.subscription_windows = sub_windows result.subscription_windows = sub_windows
result.reachable = True result.reachable = True

View File

@ -44,6 +44,11 @@ services:
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN} LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
BASE_URL: ${BASE_URL:-http://localhost:8000} BASE_URL: ${BASE_URL:-http://localhost:8000}
RQ_REDIS_URL: redis://redis:6379/0 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@ -3,7 +3,21 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { useCallback, useEffect, useState } from "react"; 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 { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
@ -25,40 +39,100 @@ import {
// Provider metadata // 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", id: "anthropic",
label: "Claude (Anthropic)", label: "Claude (Anthropic)",
icon: Bot, 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", keyLabel: "API Key",
keyPlaceholder: "sk-ant-api03-…", keyPlaceholder: "sk-ant-api03-…",
showBaseUrl: false, showBaseUrl: false,
allowMultiple: true, allowMultiple: true,
accountKeyDefault: "default", 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, showSessionKey: true,
sessionKeyLabel: "Claude.ai session key (for subscription usage)", sessionKeyLabel: "Subscription token (optional — auto-detected via Claude Code)",
sessionKeyPlaceholder: "sk-ant-sid-… (from claude.ai cookie)", 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", id: "openai",
label: "Codex / OpenAI", label: "Codex / OpenAI",
icon: Bot, icon: Sparkles,
description: "OpenAI API key. Add multiple accounts to track usage separately.", description:
"OpenAI API key. Add multiple accounts to track usage separately.",
keyLabel: "API Key", keyLabel: "API Key",
keyPlaceholder: "sk-proj-…", keyPlaceholder: "sk-proj-…",
showBaseUrl: false, showBaseUrl: false,
allowMultiple: true, allowMultiple: true,
accountKeyDefault: "", 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, showSessionKey: true,
sessionKeyLabel: "ChatGPT session token (for subscription usage)", sessionKeyLabel: "ChatGPT subscription token (optional — for plan usage %)",
sessionKeyPlaceholder: "Bearer token from browser session", 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", id: "ollama",
label: "Ollama", label: "Ollama",
icon: Server, 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)", keyLabel: "API Key (optional)",
keyPlaceholder: "Leave blank for unauthenticated", keyPlaceholder: "Leave blank for unauthenticated",
showBaseUrl: true, showBaseUrl: true,
@ -67,10 +141,52 @@ const PROVIDERS = [
showSessionKey: false, showSessionKey: false,
sessionKeyLabel: "", sessionKeyLabel: "",
sessionKeyPlaceholder: "", 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 // Add/Edit form
@ -86,6 +202,9 @@ interface CredentialFormProps {
keyLabel: string; keyLabel: string;
keyPlaceholder: string; keyPlaceholder: string;
accountKeyDefault: string; accountKeyDefault: string;
apiKeyHelp?: readonly string[];
sessionKeyHelp?: readonly string[];
autoDetectedNote?: string;
onSave: (data: { onSave: (data: {
account_key: string; account_key: string;
display_name: string; display_name: string;
@ -112,6 +231,9 @@ function CredentialForm({
keyLabel, keyLabel,
keyPlaceholder, keyPlaceholder,
accountKeyDefault, accountKeyDefault,
apiKeyHelp,
sessionKeyHelp,
autoDetectedNote,
onSave, onSave,
onTest, onTest,
onCancel, onCancel,
@ -126,6 +248,8 @@ function CredentialForm({
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<ProviderUsageLiveRead | null>(null); const [testResult, setTestResult] = useState<ProviderUsageLiveRead | null>(null);
const [testError, setTestError] = useState<string | 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 canTest = showBaseUrl ? Boolean(baseUrl.trim()) : Boolean(apiKey.trim());
const runTest = async () => { const runTest = async () => {
@ -178,21 +302,40 @@ function CredentialForm({
</div> </div>
{showBaseUrl && ( {showBaseUrl && (
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted"> <div className="mb-1 flex items-center justify-between">
Base URL <span className="text-[color:var(--danger)]">*</span> <label className="text-xs font-medium text-muted">
</label> Base URL <span className="text-[color:var(--danger)]">*</span>
</label>
{apiKeyHelp?.length && (
<HelpToggle
steps={apiKeyHelp}
open={showApiHelp}
onToggle={() => setShowApiHelp((v) => !v)}
/>
)}
</div>
<Input <Input
value={baseUrl} value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)} onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://localhost:11434" placeholder="http://localhost:11434"
disabled={isSaving} disabled={isSaving}
/> />
{showApiHelp && apiKeyHelp?.length ? (
<HelpSection steps={apiKeyHelp} />
) : null}
</div> </div>
)} )}
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted"> <div className="mb-1 flex items-center justify-between">
{keyLabel} <label className="text-xs font-medium text-muted">{keyLabel}</label>
</label> {!showBaseUrl && apiKeyHelp?.length ? (
<HelpToggle
steps={apiKeyHelp}
open={showApiHelp}
onToggle={() => setShowApiHelp((v) => !v)}
/>
) : null}
</div>
<Input <Input
type="password" type="password"
value={apiKey} value={apiKey}
@ -201,12 +344,28 @@ function CredentialForm({
disabled={isSaving} disabled={isSaving}
autoComplete="new-password" autoComplete="new-password"
/> />
{showApiHelp && !showBaseUrl && apiKeyHelp?.length ? (
<HelpSection steps={apiKeyHelp} />
) : null}
</div> </div>
{showSessionKey && ( {showSessionKey && (
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted"> {autoDetectedNote && (
{sessionKeyLabel} <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">
</label> <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 <Input
type="password" type="password"
value={sessionKey} value={sessionKey}
@ -215,6 +374,9 @@ function CredentialForm({
disabled={isSaving} disabled={isSaving}
autoComplete="new-password" autoComplete="new-password"
/> />
{showSessionHelp && sessionKeyHelp?.length ? (
<HelpSection steps={sessionKeyHelp} />
) : null}
</div> </div>
)} )}
{error && ( {error && (
@ -232,13 +394,19 @@ function CredentialForm({
) : ( ) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> <RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)} )}
{isTesting ? "Testing…" : "Test"} {isTesting ? "Testing…" : "Test connection"}
</Button> </Button>
<Button <Button
size="sm" size="sm"
disabled={isSaving || !accountKey.trim() || (showBaseUrl && !baseUrl.trim())} disabled={isSaving || !accountKey.trim() || (showBaseUrl && !baseUrl.trim())}
onClick={() => 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 ? ( {isSaving ? (
@ -257,29 +425,34 @@ function CredentialForm({
<p className="text-sm text-[color:var(--danger)]">{testError}</p> <p className="text-sm text-[color:var(--danger)]">{testError}</p>
)} )}
{testResult && ( {testResult && (
<div className={`rounded-lg border p-3 text-xs ${ <div
testResult.reachable className={`rounded-lg border p-3 text-xs ${
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)]" testResult.reachable
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)]" ? "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)]" >
}`}> <p
className={`font-medium ${
testResult.reachable
? "text-[color:var(--success)]"
: "text-[color:var(--danger)]"
}`}
>
{testResult.reachable ? "Connection successful" : "Connection failed"} {testResult.reachable ? "Connection successful" : "Connection failed"}
</p> </p>
<p className="mt-1 text-muted"> <p className="mt-1 text-muted">
{testResult.error ?? ( {testResult.error ??
testResult.sample_input_tokens != null || testResult.sample_output_tokens != null (testResult.sample_input_tokens != null ||
testResult.sample_output_tokens != null
? `Usage probe: in ${fmtTokens(testResult.sample_input_tokens)} · out ${fmtTokens(testResult.sample_output_tokens)}` ? `Usage probe: in ${fmtTokens(testResult.sample_input_tokens)} · out ${fmtTokens(testResult.sample_output_tokens)}`
: "Connected." : "Connected.")}
)}
</p> </p>
{testResult.sample_latency_ms != null && ( {testResult.sample_latency_ms != null && (
<p className="mt-1 text-muted"> <p className="mt-1 text-muted">
Probe time: {fmtLatencyMs(testResult.sample_latency_ms)} Probe time: {fmtLatencyMs(testResult.sample_latency_ms)}
</p> </p>
)} )}
</div> </div>
)} )}
</div> </div>
@ -328,14 +501,22 @@ interface UsageWindowBarProps {
label: string; label: string;
pct: number; pct: number;
resetInMs?: number | null; 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)); const clamped = Math.max(0, Math.min(100, pct));
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between text-[11px]"> <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> <span className="tabular-nums text-strong">{fmtPct(clamped)} used</span>
</div> </div>
<div className="h-1.5 overflow-hidden rounded-full bg-[color:var(--surface-strong)]"> <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 [error, setError] = useState<string | null>(null);
const [lastFetched, setLastFetched] = useState<Date | null>(null); const [lastFetched, setLastFetched] = useState<Date | null>(null);
const fetch = useCallback(async (refresh = false) => { const fetchUsage = useCallback(
setLoading(true); async (refresh = false) => {
setError(null); setLoading(true);
try { setError(null);
const res = await getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet( try {
credentialId, const res = await getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet(
refresh ? { refresh: true } : undefined, credentialId,
); refresh ? { refresh: true } : undefined,
if (res.status === 200) { );
setUsage(res.data); if (res.status === 200) {
setLastFetched(new Date()); 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."); [credentialId],
} finally { );
setLoading(false);
}
}, [credentialId]);
useEffect(() => { void fetch(); }, [fetch]); useEffect(() => {
void fetchUsage();
}, [fetchUsage]);
if (loading && !usage) { if (loading && !usage) {
return ( return (
@ -389,7 +575,11 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
return ( return (
<div className="mt-2 flex items-center gap-2 text-xs text-[color:var(--danger)]"> <div className="mt-2 flex items-center gap-2 text-xs text-[color:var(--danger)]">
<span>{usage?.error ?? error ?? "Provider unreachable."}</span> <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 Retry
</button> </button>
</div> </div>
@ -402,41 +592,46 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
const req = usage.requests; const req = usage.requests;
const subWindows = usage.subscription_windows ?? []; const subWindows = usage.subscription_windows ?? [];
const isOllama = provider === "ollama"; const isOllama = provider === "ollama";
const sourceLabel: Record<string, string> = { const sourceLabel: Record<string, string> = {
provider_native: "Provider native", provider_native: "Provider native",
provider_api_rate_limit: "API rate limit", provider_api_rate_limit: "API rate limit",
local_jsonl_estimate: "Local estimate", local_jsonl_estimate: "Local estimate",
configured_limit: "Configured limit", 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[] = []; const usageBars: UsageWindowBarProps[] = [];
if (outputTok?.pct_used != null) { if (outputTok?.pct_used != null) {
usageBars.push({ usageBars.push({
label: "API rate limit · output tokens", label: "Output tokens",
pct: outputTok.pct_used, pct: outputTok.pct_used,
resetInMs: outputTok.reset_in_ms, resetInMs: outputTok.reset_in_ms,
badge: "rate limit",
}); });
} }
if (inputTok.pct_used != null) { if (inputTok.pct_used != null) {
usageBars.push({ usageBars.push({
label: "API rate limit · input tokens", label: "Input tokens",
pct: inputTok.pct_used, pct: inputTok.pct_used,
resetInMs: inputTok.reset_in_ms, resetInMs: inputTok.reset_in_ms,
badge: "rate limit",
}); });
} }
if (tok.pct_used != null) { if (tok.pct_used != null) {
usageBars.push({ usageBars.push({
label: "API rate limit · tokens (combined)", label: "Tokens combined",
pct: tok.pct_used, pct: tok.pct_used,
resetInMs: tok.reset_in_ms, resetInMs: tok.reset_in_ms,
badge: "rate limit",
}); });
} }
if (usageBars.length === 0 && req.limit != null && req.remaining != null && req.limit > 0) { if (usageBars.length === 0 && req.limit != null && req.remaining != null && req.limit > 0) {
usageBars.push({ usageBars.push({
label: "API rate limit · requests", label: "Requests",
pct: ((req.limit - req.remaining) / req.limit) * 100, pct: ((req.limit - req.remaining) / req.limit) * 100,
resetInMs: req.reset_in_ms, resetInMs: req.reset_in_ms,
badge: "rate limit",
}); });
} }
@ -448,12 +643,18 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
</div> </div>
)} )}
{/* ── Subscription usage windows (from session key) ── */} {/* ── Subscription usage windows (plan-level usage from OAuth) ── */}
{subWindows.length > 0 && ( {subWindows.length > 0 && (
<div className="mb-2 space-y-2"> <div className="mb-3 space-y-2.5">
{usage.subscription_plan && ( <div className="flex items-center gap-1.5 text-[11px] text-muted">
<div className="text-[11px] text-muted">Plan: {usage.subscription_plan}</div> <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) => ( {subWindows.map((w) => (
<UsageWindowBar <UsageWindowBar
key={w.key} key={w.key}
@ -473,9 +674,15 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
Connected Connected
</span> </span>
{(usage.models?.length ?? 0) > 0 && ( {(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" : ""}`} /> <RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</button> </button>
</div> </div>
@ -483,18 +690,23 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
<div className="flex items-center justify-between text-[11px] text-muted"> <div className="flex items-center justify-between text-[11px] text-muted">
<span>Usage (last probe)</span> <span>Usage (last probe)</span>
<span className="tabular-nums text-strong"> <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> </span>
</div> </div>
)} )}
{usage.sample_latency_ms != null && ( {usage.sample_latency_ms != null && (
<div className="flex items-center justify-between text-[11px] text-muted"> <div className="flex items-center justify-between text-[11px] text-muted">
<span>Time (last probe)</span> <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>
)} )}
<div className="flex items-center justify-between text-[11px] text-muted"> <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>
</div> </div>
) : ( ) : (
@ -507,7 +719,13 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{usageBars.map((bar) => ( {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> </div>
</> </>
@ -517,7 +735,8 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
<div className="flex items-center justify-between text-[11px] text-muted"> <div className="flex items-center justify-between text-[11px] text-muted">
<span>Usage (last probe)</span> <span>Usage (last probe)</span>
<span className="tabular-nums text-strong"> <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> </span>
</div> </div>
)} )}
@ -530,14 +749,20 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
</div> </div>
)} )}
<p className="text-[11px] text-muted"> <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> </p>
</> </>
) : null} ) : null}
<div className="flex items-center justify-between text-[11px] text-muted"> <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 && (
<button type="button" onClick={() => fetch(true)} className="ml-auto flex items-center gap-1 hover:text-strong"> <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" : ""}`} /> <RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
Refresh Refresh
</button> </button>
@ -560,7 +785,13 @@ interface CredentialRowProps {
showUsage?: boolean; 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); const [toggling, setToggling] = useState(false);
return ( return (
<div <div
@ -570,68 +801,70 @@ function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }:
: "border-[color:var(--border)] bg-[color:var(--surface)] opacity-60" : "border-[color:var(--border)] bg-[color:var(--surface)] opacity-60"
}`} }`}
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-strong"> <p className="truncate text-sm font-medium text-strong">
{cred.display_name || cred.account_key} {cred.display_name || cred.account_key}
</p> </p>
<div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0 text-[11px] text-muted"> <div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0 text-[11px] text-muted">
<span>key: {cred.account_key}</span> <span>key: {cred.account_key}</span>
{cred.has_api_key && cred.api_key_last_four ? ( {cred.has_api_key && cred.api_key_last_four ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<KeyRound className="h-3 w-3" /> <KeyRound className="h-3 w-3" />
{cred.api_key_last_four} {cred.api_key_last_four}
</span> </span>
) : cred.has_api_key ? ( ) : cred.has_api_key ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<KeyRound className="h-3 w-3" /> <KeyRound className="h-3 w-3" />
set set
</span> </span>
) : ( ) : (
<span className="text-[color:var(--warning)]">no key</span> <span className="text-[color:var(--warning)]">no API key</span>
)} )}
{cred.has_session_key && ( {cred.has_session_key && (
<span className="flex items-center gap-1 text-[color:var(--success)]"> <span className="flex items-center gap-1 text-[color:var(--success)]">
<KeyRound className="h-3 w-3" /> <CheckCircle2 className="h-3 w-3" />
subscription key {cred.session_key_last_four ?? ""} subscription token {cred.session_key_last_four ?? ""}
</span> </span>
)} )}
{cred.base_url && <span className="truncate max-w-[200px]">{cred.base_url}</span>} {cred.base_url && (
<span className="max-w-[200px] truncate">{cred.base_url}</span>
)}
</div>
</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> </div>
{isAdmin && ( {showUsage && cred.active && (cred.has_api_key || cred.base_url) && (
<div className="flex shrink-0 items-center gap-1.5"> <UsageStrip credentialId={cred.id} provider={cred.provider} />
<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> </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 { interface ProviderSectionProps {
provider: (typeof PROVIDERS)[number]; provider: ProviderConfig;
credentials: ProviderCredentialRead[]; credentials: ProviderCredentialRead[];
isAdmin: boolean; isAdmin: boolean;
onAdd: (providerId: ProviderId, data: { account_key: string; display_name: string; api_key: string; session_key: string; base_url: string }) => Promise<void>; onAdd: (
onTest: (providerId: ProviderId, data: { account_key: string; api_key: string; base_url: string }) => Promise<ProviderUsageLiveRead>; 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; onDelete: (cred: ProviderCredentialRead) => void;
onToggle: (cred: ProviderCredentialRead) => Promise<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 Icon = provider.icon;
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -673,7 +926,10 @@ function ProviderSection({ provider, credentials, isAdmin, onAdd, onTest, onDele
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { setShowForm(true); setSaveError(null); }} onClick={() => {
setShowForm(true);
setSaveError(null);
}}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add Add
@ -692,6 +948,9 @@ function ProviderSection({ provider, credentials, isAdmin, onAdd, onTest, onDele
keyLabel={provider.keyLabel} keyLabel={provider.keyLabel}
keyPlaceholder={provider.keyPlaceholder} keyPlaceholder={provider.keyPlaceholder}
accountKeyDefault={provider.accountKeyDefault} accountKeyDefault={provider.accountKeyDefault}
apiKeyHelp={provider.apiKeyHelp}
sessionKeyHelp={provider.sessionKeyHelp}
autoDetectedNote={provider.autoDetectedNote}
onSave={async (data) => { onSave={async (data) => {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
@ -756,7 +1015,9 @@ export default function AIProvidersSettingsPage() {
if (res.status === 200) setCredentials(res.data); if (res.status === 200) setCredentials(res.data);
setError(null); setError(null);
} catch (err) { } 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -768,7 +1029,13 @@ export default function AIProvidersSettingsPage() {
const handleAdd = async ( const handleAdd = async (
providerId: ProviderId, 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({ const res = await createProviderCredentialApiV1ProviderCredentialsPost({
provider: providerId, provider: providerId,
@ -839,7 +1106,7 @@ export default function AIProvidersSettingsPage() {
signUpForceRedirectUrl: "/settings/ai-providers", signUpForceRedirectUrl: "/settings/ai-providers",
}} }}
title="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 ? ( {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"> <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 <ConfirmActionDialog
open={Boolean(deleteTarget)} open={Boolean(deleteTarget)}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }} onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
title="Remove provider account" title="Remove provider account"
description={ description={
deleteTarget deleteTarget