fix(scripts): ai
This commit is contained in:
parent
1a0eaeee68
commit
a6c24673bc
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
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<ProviderUsageLiveRead | null>(null);
|
||||
|
|
@ -185,6 +202,21 @@ function CredentialForm({
|
|||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{showSessionKey && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">
|
||||
{sessionKeyLabel}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={sessionKey}
|
||||
onChange={(e) => setSessionKey(e.target.value)}
|
||||
placeholder={sessionKeyPlaceholder}
|
||||
disabled={isSaving}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-[color:var(--danger)]">{error}</p>
|
||||
)}
|
||||
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="mt-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-2.5">
|
||||
{usage.error && (
|
||||
<div className="mb-2 rounded border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] px-2 py-1.5 text-xs text-[color:var(--danger)]">
|
||||
{usage.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Subscription usage windows (from session key) ── */}
|
||||
{subWindows.length > 0 && (
|
||||
<div className="mb-2 space-y-2">
|
||||
{usage.subscription_plan && (
|
||||
<div className="text-[11px] text-muted">Plan: {usage.subscription_plan}</div>
|
||||
)}
|
||||
{subWindows.map((w) => (
|
||||
<UsageWindowBar
|
||||
key={w.key}
|
||||
label={w.label}
|
||||
pct={w.pct_used}
|
||||
resetInMs={w.reset_in_ms}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOllama ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[11px] text-muted">
|
||||
Source: {sourceLabel[usage.source] ?? usage.source} · confidence: {usage.confidence}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted">
|
||||
<span className="flex items-center gap-1 text-[color:var(--success)]">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--success)]" />
|
||||
|
|
@ -416,34 +479,19 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
|||
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{usageBars.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{usageBars.map((bar) => (
|
||||
<UsageWindowBar key={bar.label} label={bar.label} pct={bar.pct} resetInMs={bar.resetInMs} />
|
||||
))}
|
||||
{(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && (
|
||||
<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)}
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && (
|
||||
<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)}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-muted">
|
||||
Provider did not return API rate-limit windows for percent + reset diagnostics.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-[11px] text-muted">
|
||||
{lastFetched && <span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>}
|
||||
|
|
@ -451,16 +499,19 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[11px] text-muted">
|
||||
Source: {sourceLabel[usage.source] ?? usage.source} · confidence: {usage.confidence}
|
||||
</div>
|
||||
{/* API rate-limit diagnostic bars */}
|
||||
{usageBars.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{usageBars.map((bar) => (
|
||||
<UsageWindowBar key={bar.label} label={bar.label} pct={bar.pct} resetInMs={bar.resetInMs} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] text-muted">
|
||||
{sourceLabel[usage.source] ?? usage.source} · {usage.confidence} confidence
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{usageBars.map((bar) => (
|
||||
<UsageWindowBar key={bar.label} label={bar.label} pct={bar.pct} resetInMs={bar.resetInMs} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : subWindows.length === 0 ? (
|
||||
<>
|
||||
{(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && (
|
||||
<div className="flex items-center justify-between text-[11px] text-muted">
|
||||
|
|
@ -482,7 +533,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
|||
Connected — provider did not return API rate-limit windows for percent + reset diagnostics.
|
||||
</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>}
|
||||
|
|
@ -539,6 +590,12 @@ function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }:
|
|||
) : (
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -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<void>;
|
||||
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>;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue