From 66bbdd739814137a3fcb05abd642eb9501b024bb Mon Sep 17 00:00:00 2001 From: null Date: Thu, 21 May 2026 19:42:46 -0500 Subject: [PATCH] fix: claude local --- backend/app/services/provider_usage.py | 66 ++- compose.yml | 5 + .../src/app/settings/ai-providers/page.tsx | 561 +++++++++++++----- 3 files changed, 480 insertions(+), 152 deletions(-) diff --git a/backend/app/services/provider_usage.py b/backend/app/services/provider_usage.py index 38fbba9..feecc30 100644 --- a/backend/app/services/provider_usage.py +++ b/backend/app/services/provider_usage.py @@ -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 diff --git a/compose.yml b/compose.yml index e1d8e97..cefb48a 100644 --- a/compose.yml +++ b/compose.yml @@ -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 diff --git a/frontend/src/app/settings/ai-providers/page.tsx b/frontend/src/app/settings/ai-providers/page.tsx index 2ed5350..84bdc77 100644 --- a/frontend/src/app/settings/ai-providers/page.tsx +++ b/frontend/src/app/settings/ai-providers/page.tsx @@ -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 ( +
+
    + {steps.map((step, i) => ( +
  1. {step}
  2. + ))} +
+
+ ); +} + +function HelpToggle({ + steps, + open, + onToggle, +}: { + steps: readonly string[]; + open: boolean; + onToggle: () => void; +}) { + if (steps.length === 0) return null; + return ( + + ); +} // --------------------------------------------------------------------------- // 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(null); const [testError, setTestError] = useState(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({ {showBaseUrl && (
- +
+ + {apiKeyHelp?.length && ( + setShowApiHelp((v) => !v)} + /> + )} +
setBaseUrl(e.target.value)} placeholder="http://localhost:11434" disabled={isSaving} /> + {showApiHelp && apiKeyHelp?.length ? ( + + ) : null}
)}
- +
+ + {!showBaseUrl && apiKeyHelp?.length ? ( + setShowApiHelp((v) => !v)} + /> + ) : null} +
+ {showApiHelp && !showBaseUrl && apiKeyHelp?.length ? ( + + ) : null}
{showSessionKey && (
- + {autoDetectedNote && ( +
+ +

{autoDetectedNote}

+
+ )} +
+ + {sessionKeyHelp?.length ? ( + setShowSessionHelp((v) => !v)} + /> + ) : null} +
+ {showSessionHelp && sessionKeyHelp?.length ? ( + + ) : null}
)} {error && ( @@ -232,13 +394,19 @@ function CredentialForm({ ) : ( )} - {isTesting ? "Testing…" : "Test"} + {isTesting ? "Testing…" : "Test connection"} @@ -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 = { 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 )} - {/* ── Subscription usage windows (from session key) ── */} + {/* ── Subscription usage windows (plan-level usage from OAuth) ── */} {subWindows.length > 0 && ( -
- {usage.subscription_plan && ( -
Plan: {usage.subscription_plan}
- )} +
+
+ + Subscription usage + {usage.subscription_plan && ( + + {usage.subscription_plan} + + )} +
{subWindows.map((w) => ( {(usage.models?.length ?? 0) > 0 && ( - {usage.models!.length} model{usage.models!.length !== 1 ? "s" : ""} available + + {usage.models!.length} model{usage.models!.length !== 1 ? "s" : ""} available + )} -
@@ -483,18 +690,23 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
Usage (last probe) - in {fmtTokens(usage.sample_input_tokens)} · out {fmtTokens(usage.sample_output_tokens)} + in {fmtTokens(usage.sample_input_tokens)} · out{" "} + {fmtTokens(usage.sample_output_tokens)}
)} {usage.sample_latency_ms != null && (
Time (last probe) - {fmtLatencyMs(usage.sample_latency_ms)} + + {fmtLatencyMs(usage.sample_latency_ms)} +
)}
- {lastFetched && Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago} + {lastFetched && ( + Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago + )}
) : ( @@ -507,7 +719,13 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
{usageBars.map((bar) => ( - + ))}
@@ -517,7 +735,8 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
Usage (last probe) - in {fmtTokens(usage.sample_input_tokens)} · out {fmtTokens(usage.sample_output_tokens)} + in {fmtTokens(usage.sample_input_tokens)} · out{" "} + {fmtTokens(usage.sample_output_tokens)}
)} @@ -530,14 +749,20 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider )}

- Connected — provider did not return API rate-limit windows for percent + reset diagnostics. + Connected — no rate-limit window data returned by provider.

) : null}
- {lastFetched && Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago} - @@ -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 (
-
-
-

- {cred.display_name || cred.account_key} -

-
- key: {cred.account_key} - {cred.has_api_key && cred.api_key_last_four ? ( - - - ••••{cred.api_key_last_four} - - ) : cred.has_api_key ? ( - - - set - - ) : ( - no key - )} - {cred.has_session_key && ( - - - subscription key ••••{cred.session_key_last_four ?? ""} - - )} - {cred.base_url && {cred.base_url}} +
+
+

+ {cred.display_name || cred.account_key} +

+
+ key: {cred.account_key} + {cred.has_api_key && cred.api_key_last_four ? ( + + + ••••{cred.api_key_last_four} + + ) : cred.has_api_key ? ( + + + set + + ) : ( + no API key + )} + {cred.has_session_key && ( + + + subscription token ••••{cred.session_key_last_four ?? ""} + + )} + {cred.base_url && ( + {cred.base_url} + )} +
+ {isAdmin && ( +
+ + +
+ )}
- {isAdmin && ( -
- - -
+ {showUsage && cred.active && (cred.has_api_key || cred.base_url) && ( + )}
- {showUsage && cred.active && (cred.has_api_key || cred.base_url) && ( - - )} -
); } @@ -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; - onTest: (providerId: ProviderId, data: { account_key: string; api_key: string; base_url: string }) => Promise; + onAdd: ( + providerId: ProviderId, + data: { + account_key: string; + display_name: string; + api_key: string; + session_key: string; + base_url: string; + }, + ) => Promise; + onTest: ( + providerId: ProviderId, + data: { account_key: string; api_key: string; base_url: string }, + ) => Promise; onDelete: (cred: ProviderCredentialRead) => void; onToggle: (cred: ProviderCredentialRead) => Promise; } -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