fix: claude local
This commit is contained in:
parent
4ca3ede009
commit
66bbdd7398
|
|
@ -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 0–100
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue