diff --git a/backend/app/api/provider_credentials.py b/backend/app/api/provider_credentials.py index 8eee9a6..9129706 100644 --- a/backend/app/api/provider_credentials.py +++ b/backend/app/api/provider_credentials.py @@ -25,6 +25,7 @@ from app.schemas.provider_credentials import ( ProviderCredentialUpdate, ProviderUsageLiveRead, RequestWindowRead, + SubscriptionWindowRead, TokenWindowRead, ) from app.services.provider_usage import fetch_provider_usage @@ -48,6 +49,8 @@ def _to_read(cred: ProviderCredential) -> ProviderCredentialRead: display_name=cred.display_name, api_key_last_four=cred.api_key_last_four, has_api_key=bool(cred.api_key), + session_key_last_four=cred.session_key_last_four, + has_session_key=bool(cred.session_key), base_url=cred.base_url, active=cred.active, created_at=cred.created_at, @@ -101,6 +104,7 @@ async def create_provider_credential( ) last_four = payload.api_key[-4:] if payload.api_key and len(payload.api_key) >= 4 else None + sk_last_four = payload.session_key[-4:] if payload.session_key and len(payload.session_key) >= 4 else None cred = ProviderCredential( organization_id=ctx.organization.id, provider=payload.provider, @@ -108,6 +112,8 @@ async def create_provider_credential( display_name=payload.display_name or payload.account_key, api_key=payload.api_key or None, api_key_last_four=last_four, + session_key=payload.session_key or None, + session_key_last_four=sk_last_four, base_url=payload.base_url or None, active=payload.active, ) @@ -169,6 +175,16 @@ async def test_provider_credential( input_tokens=_tok(live.input_tokens), output_tokens=_tok(live.output_tokens), requests=_req(live.requests), + subscription_windows=[ + SubscriptionWindowRead( + key=w.key, + label=w.label, + pct_used=w.pct_used, + reset_in_ms=w.reset_in_ms, + ) + for w in live.subscription_windows + ], + subscription_plan=live.subscription_plan, models=live.models, sample_model=live.sample_model, sample_input_tokens=live.sample_input_tokens, @@ -215,6 +231,13 @@ async def update_provider_credential( else: cred.api_key = payload.api_key cred.api_key_last_four = payload.api_key[-4:] if len(payload.api_key) >= 4 else None + if payload.session_key is not None: + if payload.session_key == "": + cred.session_key = None + cred.session_key_last_four = None + else: + cred.session_key = payload.session_key + cred.session_key_last_four = payload.session_key[-4:] if len(payload.session_key) >= 4 else None cred.updated_at = utcnow() await crud.save(session, cred) @@ -244,6 +267,7 @@ async def get_provider_usage_live( account_key=cred.account_key, api_key=cred.api_key, base_url=cred.base_url, + session_key=cred.session_key, force_refresh=refresh, ) @@ -274,6 +298,16 @@ async def get_provider_usage_live( input_tokens=_tok(live.input_tokens), output_tokens=_tok(live.output_tokens), requests=_req(live.requests), + subscription_windows=[ + SubscriptionWindowRead( + key=w.key, + label=w.label, + pct_used=w.pct_used, + reset_in_ms=w.reset_in_ms, + ) + for w in live.subscription_windows + ], + subscription_plan=live.subscription_plan, models=live.models, sample_model=live.sample_model, sample_input_tokens=live.sample_input_tokens, diff --git a/backend/app/models/provider_credentials.py b/backend/app/models/provider_credentials.py index 39d32b4..9464a01 100644 --- a/backend/app/models/provider_credentials.py +++ b/backend/app/models/provider_credentials.py @@ -42,6 +42,14 @@ class ProviderCredential(QueryModel, table=True): api_key_last_four: str | None = Field(default=None) # shown in UI for verification base_url: str | None = Field(default=None) # Ollama, Azure, custom endpoints + # Session / OAuth token for subscription-usage endpoints. + # Anthropic: claude.ai sessionKey cookie (sk-ant-sid-...) + # OpenAI Codex: ChatGPT bearer token from browser session + # This is a different credential from the API key — it authenticates against + # the provider's subscription system rather than the generation API. + session_key: str | None = Field(default=None) # full token — never returned in API + session_key_last_four: str | None = Field(default=None) + active: bool = Field(default=True, index=True) created_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/schemas/provider_credentials.py b/backend/app/schemas/provider_credentials.py index 8980c6b..e83be80 100644 --- a/backend/app/schemas/provider_credentials.py +++ b/backend/app/schemas/provider_credentials.py @@ -15,6 +15,7 @@ class ProviderCredentialCreate(SQLModel): account_key: str display_name: str = "" api_key: str | None = None + session_key: str | None = None # Claude.ai / ChatGPT session token for subscription usage base_url: str | None = None active: bool = True @@ -23,16 +24,29 @@ class ProviderCredentialTestRequest(SQLModel): provider: str account_key: str = "test" api_key: str | None = None + session_key: str | None = None base_url: str | None = None class ProviderCredentialUpdate(SQLModel): display_name: str | None = None - api_key: str | None = None # None = keep existing; "" = clear it + api_key: str | None = None # None = keep existing; "" = clear it + session_key: str | None = None # None = keep existing; "" = clear it base_url: str | None = None active: bool | None = None +class SubscriptionWindowRead(SQLModel): + """One subscription-plan usage window from a session/OAuth token.""" + + key: str # "five_hour" | "seven_day" | "seven_day_sonnet" | etc. + label: str # "Current session" | "All models" | "Sonnet" | "3h" | "Week" + pct_used: float # 0–100 + reset_in_ms: int | None = None + source: str = "provider_native" + confidence: str = "high" + + class TokenWindowRead(SQLModel): limit: int | None = None remaining: int | None = None @@ -63,6 +77,9 @@ class ProviderUsageLiveRead(SQLModel): input_tokens: TokenWindowRead # Anthropic input-only window output_tokens: TokenWindowRead # Anthropic output-only window requests: RequestWindowRead + # Subscription usage windows — populated when session_key is configured + subscription_windows: list[SubscriptionWindowRead] = [] + subscription_plan: str | None = None # e.g. "pro", "plus ($15.00)" models: list[str] = [] sample_model: str | None = None sample_input_tokens: int | None = None @@ -73,7 +90,7 @@ class ProviderUsageLiveRead(SQLModel): class ProviderCredentialRead(SQLModel): - """Safe read schema — api_key is never included.""" + """Safe read schema — api_key and session_key are never included.""" id: UUID organization_id: UUID @@ -82,6 +99,8 @@ class ProviderCredentialRead(SQLModel): display_name: str api_key_last_four: str | None has_api_key: bool + session_key_last_four: str | None = None + has_session_key: bool = False base_url: str | None active: bool created_at: datetime diff --git a/backend/app/services/provider_usage.py b/backend/app/services/provider_usage.py index c9f911c..71b055e 100644 --- a/backend/app/services/provider_usage.py +++ b/backend/app/services/provider_usage.py @@ -98,6 +98,23 @@ class RequestWindow: return max(0, int(delta * 1000)) +@dataclass +class SubscriptionWindow: + """One subscription-plan usage window (e.g. 5h session, 7-day all-models).""" + + key: str # "five_hour" | "seven_day" | "seven_day_sonnet" | "seven_day_opus" + label: str # human label: "Current session" | "All models" | "Sonnet" | "Opus" + pct_used: float # 0–100 + reset_at: datetime | None = None # UTC naive datetime + + @property + def reset_in_ms(self) -> int | None: + if self.reset_at is None: + return None + delta = (self.reset_at - utcnow()).total_seconds() + return max(0, int(delta * 1000)) + + @dataclass class ProviderUsageLive: provider: str @@ -113,6 +130,9 @@ class ProviderUsageLive: input_tokens: TokenWindow = field(default_factory=TokenWindow) # Anthropic input-only window output_tokens: TokenWindow = field(default_factory=TokenWindow) # Anthropic output-only window requests: RequestWindow = field(default_factory=RequestWindow) + # Provider subscription windows — populated when session_key is provided + subscription_windows: list[SubscriptionWindow] = field(default_factory=list) + subscription_plan: str | None = None # e.g. "pro", "plus ($15.00)" models: list[str] = field(default_factory=list) # model IDs available on this key raw_headers: dict[str, str] = field(default_factory=dict) sample_model: str | None = None @@ -612,6 +632,174 @@ async def _fetch_ollama(base_url: str | None, api_key: str | None) -> ProviderUs return result +# --------------------------------------------------------------------------- +# Subscription usage fetchers — require session/OAuth tokens, not API keys +# --------------------------------------------------------------------------- + +_ANTHROPIC_SUBSCRIPTION_URL = "https://api.anthropic.com/api/oauth/usage" +_CODEX_SUBSCRIPTION_URL = "https://chatgpt.com/backend-api/wham/usage" + +_ANTHROPIC_WINDOW_LABELS: dict[str, str] = { + "five_hour": "Current session", + "seven_day": "All models", + "seven_day_sonnet": "Sonnet", + "seven_day_opus": "Opus", +} + + +async def _fetch_anthropic_subscription(session_key: str) -> list[SubscriptionWindow]: + """Fetch Claude subscription usage windows via the Anthropic OAuth usage endpoint. + + Requires a Claude.ai session key (the ``sessionKey`` cookie value, which + starts with ``sk-ant-``). Returns an empty list if the key is invalid or + the endpoint is unreachable. + """ + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + try: + resp = await client.get( + _ANTHROPIC_SUBSCRIPTION_URL, + headers={ + "Authorization": f"Bearer {session_key}", + "User-Agent": "pipeline", + "Accept": "application/json", + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + }, + ) + except Exception as exc: + logger.warning("provider_usage.subscription.anthropic.fetch_failed error=%s", exc) + return [] + + if not resp.status_code == 200: + logger.debug( + "provider_usage.subscription.anthropic.http_error status=%s body=%s", + resp.status_code, + resp.text[:200], + ) + return [] + + try: + data = resp.json() + except Exception: + return [] + + windows: list[SubscriptionWindow] = [] + for key, label in _ANTHROPIC_WINDOW_LABELS.items(): + block = data.get(key) + if not isinstance(block, dict): + continue + utilization = block.get("utilization") + if utilization is None: + continue + resets_at_str = block.get("resets_at") + reset_dt: datetime | None = None + if isinstance(resets_at_str, str): + try: + normalized = resets_at_str.replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is not None: + reset_dt = parsed.astimezone(timezone.utc).replace(tzinfo=None) + else: + reset_dt = parsed + except ValueError: + pass + windows.append(SubscriptionWindow( + key=key, + label=label, + pct_used=round(float(utilization) * 100, 1), + reset_at=reset_dt, + )) + + logger.info( + "provider_usage.subscription.anthropic.fetched windows=%d", + len(windows), + ) + return windows + + +async def _fetch_codex_subscription(session_key: str) -> tuple[list[SubscriptionWindow], str | None]: + """Fetch Codex/ChatGPT subscription usage windows via the wham/usage endpoint. + + Requires a ChatGPT bearer token (from browser session, not the standard + API key). Returns (windows, plan_label) or ([], None) on failure. + """ + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + try: + resp = await client.get( + _CODEX_SUBSCRIPTION_URL, + headers={ + "Authorization": f"Bearer {session_key}", + "Accept": "application/json", + }, + ) + except Exception as exc: + logger.warning("provider_usage.subscription.codex.fetch_failed error=%s", exc) + return [], None + + if resp.status_code != 200: + logger.debug( + "provider_usage.subscription.codex.http_error status=%s", + resp.status_code, + ) + return [], None + + try: + data = resp.json() + except Exception: + return [], None + + windows: list[SubscriptionWindow] = [] + rate_limit = data.get("rate_limit") or {} + + primary = rate_limit.get("primary_window") + if isinstance(primary, dict) and primary.get("used_percent") is not None: + window_seconds = primary.get("limit_window_seconds") or 10800 + window_hours = round(window_seconds / 3600) + reset_ts = primary.get("reset_at") + reset_dt: datetime | None = None + if isinstance(reset_ts, (int, float)): + reset_dt = datetime.fromtimestamp(reset_ts, tz=timezone.utc).replace(tzinfo=None) + windows.append(SubscriptionWindow( + key="primary", + label=f"{window_hours}h", + pct_used=round(float(primary["used_percent"]), 1), + reset_at=reset_dt, + )) + + secondary = rate_limit.get("secondary_window") + if isinstance(secondary, dict) and secondary.get("used_percent") is not None: + sec_seconds = secondary.get("limit_window_seconds") or 86400 + sec_hours = round(sec_seconds / 3600) + sec_label = "Week" if sec_hours >= 168 else f"{sec_hours}h" + reset_ts = secondary.get("reset_at") + reset_dt = None + if isinstance(reset_ts, (int, float)): + reset_dt = datetime.fromtimestamp(reset_ts, tz=timezone.utc).replace(tzinfo=None) + windows.append(SubscriptionWindow( + key="secondary", + label=sec_label, + pct_used=round(float(secondary["used_percent"]), 1), + reset_at=reset_dt, + )) + + # Plan label + plan_type = data.get("plan_type") + credits = data.get("credits") or {} + balance = credits.get("balance") + plan_label: str | None = None + if isinstance(balance, (int, float)) and balance > 0: + plan_label = f"{plan_type} (${float(balance):.2f})" if plan_type else f"${float(balance):.2f}" + elif plan_type: + plan_label = str(plan_type) + + logger.info( + "provider_usage.subscription.codex.fetched windows=%d plan=%s", + len(windows), + plan_label, + ) + return windows, plan_label + + # --------------------------------------------------------------------------- # In-memory TTL cache # --------------------------------------------------------------------------- @@ -645,10 +833,15 @@ async def fetch_provider_usage( api_key: str | None, base_url: str | None, *, + session_key: str | None = None, force_refresh: bool = False, ) -> ProviderUsageLive: """Fetch live usage from the provider API. + When ``session_key`` is provided, also fetches subscription-plan usage + windows (e.g. "Current session 77% used, resets in 23 min") in addition + to the standard API rate-limit diagnostics. + Results are cached for CACHE_TTL_SECONDS. Pass force_refresh=True to bypass the cache (e.g., when the user clicks Refresh). """ @@ -658,24 +851,50 @@ async def fetch_provider_usage( return cached if provider == "anthropic": - if not api_key: + if not api_key and not session_key: result = ProviderUsageLive( provider=provider, account_key=account_key, checked_at=utcnow(), reachable=False, error="No API key configured.", ) - else: + 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) + if sub_windows: + result.subscription_windows = sub_windows + result.reachable = True + elif not result.reachable: + result.error = result.error or "No subscription data returned." elif provider in ("openai", "codex"): - if not api_key: + if not api_key and not session_key: result = ProviderUsageLive( provider=provider, account_key=account_key, checked_at=utcnow(), reachable=False, error="No API key configured.", ) - else: + elif api_key: result = await _fetch_openai(api_key, base_url) + else: + 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, plan_label = await _fetch_codex_subscription(session_key) + if sub_windows: + result.subscription_windows = sub_windows + result.subscription_plan = plan_label + result.reachable = True elif provider == "ollama": result = await _fetch_ollama(base_url, api_key) diff --git a/backend/migrations/versions/a1b2c3d4e5f9_add_session_key_to_provider_credentials.py b/backend/migrations/versions/a1b2c3d4e5f9_add_session_key_to_provider_credentials.py new file mode 100644 index 0000000..28e043e --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f9_add_session_key_to_provider_credentials.py @@ -0,0 +1,26 @@ +"""Add session_key and session_key_last_four to provider_credentials. + +Revision ID: a1b2c3d4e5f9 +Revises: f0a1b2c3d4e5 +Create Date: 2026-05-21 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "a1b2c3d4e5f9" +down_revision = "f0a1b2c3d4e5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("provider_credentials", sa.Column("session_key", sa.String(), nullable=True)) + op.add_column("provider_credentials", sa.Column("session_key_last_four", sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("provider_credentials", "session_key_last_four") + op.drop_column("provider_credentials", "session_key") diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index dd56806..74954e9 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -258,6 +258,7 @@ export * from "./skillPackRead"; export * from "./skillPackReadMetadata"; export * from "./skillPackSyncResponse"; export * from "./soulsDirectoryMarkdownResponse"; +export * from "./subscriptionWindowRead"; export * from "./soulsDirectorySearchResponse"; export * from "./soulsDirectorySoulRef"; export * from "./soulUpdateRequest"; diff --git a/frontend/src/api/generated/model/providerCredentialCreate.ts b/frontend/src/api/generated/model/providerCredentialCreate.ts index e5b69f0..37b2fc7 100644 --- a/frontend/src/api/generated/model/providerCredentialCreate.ts +++ b/frontend/src/api/generated/model/providerCredentialCreate.ts @@ -10,6 +10,7 @@ export interface ProviderCredentialCreate { account_key: string; display_name?: string; api_key?: string | null; + session_key?: string | null; base_url?: string | null; active?: boolean; } diff --git a/frontend/src/api/generated/model/providerCredentialRead.ts b/frontend/src/api/generated/model/providerCredentialRead.ts index e343d07..84d6416 100644 --- a/frontend/src/api/generated/model/providerCredentialRead.ts +++ b/frontend/src/api/generated/model/providerCredentialRead.ts @@ -16,6 +16,8 @@ export interface ProviderCredentialRead { display_name: string; api_key_last_four: string | null; has_api_key: boolean; + session_key_last_four?: string | null; + has_session_key?: boolean; base_url: string | null; active: boolean; created_at: string; diff --git a/frontend/src/api/generated/model/providerUsageLiveRead.ts b/frontend/src/api/generated/model/providerUsageLiveRead.ts index 70e8772..deea853 100644 --- a/frontend/src/api/generated/model/providerUsageLiveRead.ts +++ b/frontend/src/api/generated/model/providerUsageLiveRead.ts @@ -5,6 +5,7 @@ * OpenAPI spec version: 0.1.0 */ import type { RequestWindowRead } from "./requestWindowRead"; +import type { SubscriptionWindowRead } from "./subscriptionWindowRead"; import type { TokenWindowRead } from "./tokenWindowRead"; /** @@ -20,7 +21,10 @@ export interface ProviderUsageLiveRead { error?: string | null; tokens: TokenWindowRead; input_tokens: TokenWindowRead; + output_tokens: TokenWindowRead; requests: RequestWindowRead; + subscription_windows: SubscriptionWindowRead[]; + subscription_plan?: string | null; models?: string[]; sample_model?: string | null; sample_input_tokens?: number | null; diff --git a/frontend/src/api/generated/model/subscriptionWindowRead.ts b/frontend/src/api/generated/model/subscriptionWindowRead.ts new file mode 100644 index 0000000..18ff751 --- /dev/null +++ b/frontend/src/api/generated/model/subscriptionWindowRead.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +/** + * One subscription-plan usage window from a session/OAuth token. + */ +export interface SubscriptionWindowRead { + key: string; + label: string; + pct_used: number; + reset_in_ms?: number | null; + source?: string; + confidence?: string; +} diff --git a/frontend/src/app/settings/ai-providers/page.tsx b/frontend/src/app/settings/ai-providers/page.tsx index 0315248..2ed5350 100644 --- a/frontend/src/app/settings/ai-providers/page.tsx +++ b/frontend/src/app/settings/ai-providers/page.tsx @@ -32,10 +32,13 @@ const PROVIDERS = [ icon: Bot, description: "Anthropic Claude API key. Add multiple accounts to track separate keys or billing profiles.", keyLabel: "API Key", - keyPlaceholder: "sk-ant-…", + keyPlaceholder: "sk-ant-api03-…", showBaseUrl: false, allowMultiple: true, accountKeyDefault: "default", + showSessionKey: true, + sessionKeyLabel: "Claude.ai session key (for subscription usage)", + sessionKeyPlaceholder: "sk-ant-sid-… (from claude.ai cookie)", }, { id: "openai", @@ -43,10 +46,13 @@ const PROVIDERS = [ icon: Bot, description: "OpenAI API key. Add multiple accounts to track usage separately.", keyLabel: "API Key", - keyPlaceholder: "sk-…", + keyPlaceholder: "sk-proj-…", showBaseUrl: false, allowMultiple: true, accountKeyDefault: "", + showSessionKey: true, + sessionKeyLabel: "ChatGPT session token (for subscription usage)", + sessionKeyPlaceholder: "Bearer token from browser session", }, { id: "ollama", @@ -58,6 +64,9 @@ const PROVIDERS = [ showBaseUrl: true, allowMultiple: true, accountKeyDefault: "default", + showSessionKey: false, + sessionKeyLabel: "", + sessionKeyPlaceholder: "", }, ] as const; @@ -71,6 +80,9 @@ interface CredentialFormProps { providerId: ProviderId; allowMultiple: boolean; showBaseUrl: boolean; + showSessionKey: boolean; + sessionKeyLabel: string; + sessionKeyPlaceholder: string; keyLabel: string; keyPlaceholder: string; accountKeyDefault: string; @@ -78,6 +90,7 @@ interface CredentialFormProps { account_key: string; display_name: string; api_key: string; + session_key: string; base_url: string; }) => Promise; onTest: (data: { @@ -93,6 +106,9 @@ interface CredentialFormProps { function CredentialForm({ allowMultiple, showBaseUrl, + showSessionKey, + sessionKeyLabel, + sessionKeyPlaceholder, keyLabel, keyPlaceholder, accountKeyDefault, @@ -105,6 +121,7 @@ function CredentialForm({ const [accountKey, setAccountKey] = useState(accountKeyDefault); const [displayName, setDisplayName] = useState(""); const [apiKey, setApiKey] = useState(""); + const [sessionKey, setSessionKey] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [isTesting, setIsTesting] = useState(false); const [testResult, setTestResult] = useState(null); @@ -185,6 +202,21 @@ function CredentialForm({ autoComplete="new-password" /> + {showSessionKey && ( +
+ + setSessionKey(e.target.value)} + placeholder={sessionKeyPlaceholder} + disabled={isSaving} + autoComplete="new-password" + /> +
+ )} {error && (

{error}

)} @@ -206,7 +238,7 @@ function CredentialForm({ size="sm" disabled={isSaving || !accountKey.trim() || (showBaseUrl && !baseUrl.trim())} onClick={() => - onSave({ account_key: accountKey.trim(), display_name: displayName.trim(), api_key: apiKey, base_url: baseUrl.trim() }) + onSave({ account_key: accountKey.trim(), display_name: displayName.trim(), api_key: apiKey, session_key: sessionKey, base_url: baseUrl.trim() }) } > {isSaving ? ( @@ -267,7 +299,7 @@ function fmtTokens(n: number | null | undefined): string { } function fmtResetMs(ms: number | null | undefined): string { - if (ms == null || ms <= 0) return "now"; + if (ms == null || ms <= 0) return "< 1m"; const s = Math.floor(ms / 1000); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); @@ -366,7 +398,9 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider const tok = usage.tokens; const inputTok = usage.input_tokens; + const outputTok = usage.output_tokens; const req = usage.requests; + const subWindows = usage.subscription_windows ?? []; const isOllama = provider === "ollama"; const sourceLabel: Record = { provider_native: "Provider native", @@ -374,7 +408,16 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider 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 const usageBars: UsageWindowBarProps[] = []; + if (outputTok?.pct_used != null) { + usageBars.push({ + label: "API rate limit · output tokens", + pct: outputTok.pct_used, + resetInMs: outputTok.reset_in_ms, + }); + } if (inputTok.pct_used != null) { usageBars.push({ label: "API rate limit · input tokens", @@ -384,7 +427,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider } if (tok.pct_used != null) { usageBars.push({ - label: "API rate limit · tokens", + label: "API rate limit · tokens (combined)", pct: tok.pct_used, resetInMs: tok.reset_in_ms, }); @@ -399,11 +442,31 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider return (
+ {usage.error && ( +
+ {usage.error} +
+ )} + + {/* ── Subscription usage windows (from session key) ── */} + {subWindows.length > 0 && ( +
+ {usage.subscription_plan && ( +
Plan: {usage.subscription_plan}
+ )} + {subWindows.map((w) => ( + + ))} +
+ )} + {isOllama ? (
-
- Source: {sourceLabel[usage.source] ?? usage.source} · confidence: {usage.confidence} -
@@ -416,34 +479,19 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
- {usageBars.length > 0 ? ( -
- {usageBars.map((bar) => ( - - ))} + {(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && ( +
+ Usage (last probe) + + 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)}
- ) : ( - <> - {(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && ( -
- Usage (last probe) - - 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)} - -
- )} -

- Provider did not return API rate-limit windows for percent + reset diagnostics. -

- )}
{lastFetched && Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago} @@ -451,16 +499,19 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
) : (
-
- Source: {sourceLabel[usage.source] ?? usage.source} · confidence: {usage.confidence} -
+ {/* API rate-limit diagnostic bars */} {usageBars.length > 0 ? ( -
- {usageBars.map((bar) => ( - - ))} -
- ) : ( + <> +
+ {sourceLabel[usage.source] ?? usage.source} · {usage.confidence} confidence +
+
+ {usageBars.map((bar) => ( + + ))} +
+ + ) : subWindows.length === 0 ? ( <> {(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && (
@@ -482,7 +533,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider Connected — provider did not return API rate-limit windows for percent + reset diagnostics.

- )} + ) : null}
{lastFetched && Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago} @@ -539,6 +590,12 @@ function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }: ) : ( no key )} + {cred.has_session_key && ( + + + subscription key ••••{cred.session_key_last_four ?? ""} + + )} {cred.base_url && {cred.base_url}}
@@ -586,7 +643,7 @@ interface ProviderSectionProps { provider: (typeof PROVIDERS)[number]; credentials: ProviderCredentialRead[]; isAdmin: boolean; - onAdd: (providerId: ProviderId, data: { account_key: string; display_name: 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; @@ -629,6 +686,9 @@ function ProviderSection({ provider, credentials, isAdmin, onAdd, onTest, onDele providerId={provider.id} allowMultiple={provider.allowMultiple} showBaseUrl={provider.showBaseUrl} + showSessionKey={provider.showSessionKey} + sessionKeyLabel={provider.sessionKeyLabel} + sessionKeyPlaceholder={provider.sessionKeyPlaceholder} keyLabel={provider.keyLabel} keyPlaceholder={provider.keyPlaceholder} accountKeyDefault={provider.accountKeyDefault} @@ -708,13 +768,14 @@ export default function AIProvidersSettingsPage() { const handleAdd = async ( providerId: ProviderId, - data: { account_key: string; display_name: string; api_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, account_key: data.account_key, display_name: data.display_name || data.account_key, api_key: data.api_key || undefined, + session_key: data.session_key || undefined, base_url: data.base_url || undefined, }); if (res.status === 201) {