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 (
+