fix(scripts): ai
This commit is contained in:
parent
1a0eaeee68
commit
a6c24673bc
|
|
@ -25,6 +25,7 @@ from app.schemas.provider_credentials import (
|
||||||
ProviderCredentialUpdate,
|
ProviderCredentialUpdate,
|
||||||
ProviderUsageLiveRead,
|
ProviderUsageLiveRead,
|
||||||
RequestWindowRead,
|
RequestWindowRead,
|
||||||
|
SubscriptionWindowRead,
|
||||||
TokenWindowRead,
|
TokenWindowRead,
|
||||||
)
|
)
|
||||||
from app.services.provider_usage import fetch_provider_usage
|
from app.services.provider_usage import fetch_provider_usage
|
||||||
|
|
@ -48,6 +49,8 @@ def _to_read(cred: ProviderCredential) -> ProviderCredentialRead:
|
||||||
display_name=cred.display_name,
|
display_name=cred.display_name,
|
||||||
api_key_last_four=cred.api_key_last_four,
|
api_key_last_four=cred.api_key_last_four,
|
||||||
has_api_key=bool(cred.api_key),
|
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,
|
base_url=cred.base_url,
|
||||||
active=cred.active,
|
active=cred.active,
|
||||||
created_at=cred.created_at,
|
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
|
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(
|
cred = ProviderCredential(
|
||||||
organization_id=ctx.organization.id,
|
organization_id=ctx.organization.id,
|
||||||
provider=payload.provider,
|
provider=payload.provider,
|
||||||
|
|
@ -108,6 +112,8 @@ async def create_provider_credential(
|
||||||
display_name=payload.display_name or payload.account_key,
|
display_name=payload.display_name or payload.account_key,
|
||||||
api_key=payload.api_key or None,
|
api_key=payload.api_key or None,
|
||||||
api_key_last_four=last_four,
|
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,
|
base_url=payload.base_url or None,
|
||||||
active=payload.active,
|
active=payload.active,
|
||||||
)
|
)
|
||||||
|
|
@ -169,6 +175,16 @@ async def test_provider_credential(
|
||||||
input_tokens=_tok(live.input_tokens),
|
input_tokens=_tok(live.input_tokens),
|
||||||
output_tokens=_tok(live.output_tokens),
|
output_tokens=_tok(live.output_tokens),
|
||||||
requests=_req(live.requests),
|
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,
|
models=live.models,
|
||||||
sample_model=live.sample_model,
|
sample_model=live.sample_model,
|
||||||
sample_input_tokens=live.sample_input_tokens,
|
sample_input_tokens=live.sample_input_tokens,
|
||||||
|
|
@ -215,6 +231,13 @@ async def update_provider_credential(
|
||||||
else:
|
else:
|
||||||
cred.api_key = payload.api_key
|
cred.api_key = payload.api_key
|
||||||
cred.api_key_last_four = payload.api_key[-4:] if len(payload.api_key) >= 4 else None
|
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()
|
cred.updated_at = utcnow()
|
||||||
await crud.save(session, cred)
|
await crud.save(session, cred)
|
||||||
|
|
@ -244,6 +267,7 @@ async def get_provider_usage_live(
|
||||||
account_key=cred.account_key,
|
account_key=cred.account_key,
|
||||||
api_key=cred.api_key,
|
api_key=cred.api_key,
|
||||||
base_url=cred.base_url,
|
base_url=cred.base_url,
|
||||||
|
session_key=cred.session_key,
|
||||||
force_refresh=refresh,
|
force_refresh=refresh,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -274,6 +298,16 @@ async def get_provider_usage_live(
|
||||||
input_tokens=_tok(live.input_tokens),
|
input_tokens=_tok(live.input_tokens),
|
||||||
output_tokens=_tok(live.output_tokens),
|
output_tokens=_tok(live.output_tokens),
|
||||||
requests=_req(live.requests),
|
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,
|
models=live.models,
|
||||||
sample_model=live.sample_model,
|
sample_model=live.sample_model,
|
||||||
sample_input_tokens=live.sample_input_tokens,
|
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
|
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
|
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)
|
active: bool = Field(default=True, index=True)
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class ProviderCredentialCreate(SQLModel):
|
||||||
account_key: str
|
account_key: str
|
||||||
display_name: str = ""
|
display_name: str = ""
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
|
session_key: str | None = None # Claude.ai / ChatGPT session token for subscription usage
|
||||||
base_url: str | None = None
|
base_url: str | None = None
|
||||||
active: bool = True
|
active: bool = True
|
||||||
|
|
||||||
|
|
@ -23,16 +24,29 @@ class ProviderCredentialTestRequest(SQLModel):
|
||||||
provider: str
|
provider: str
|
||||||
account_key: str = "test"
|
account_key: str = "test"
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
|
session_key: str | None = None
|
||||||
base_url: str | None = None
|
base_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProviderCredentialUpdate(SQLModel):
|
class ProviderCredentialUpdate(SQLModel):
|
||||||
display_name: str | None = None
|
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
|
base_url: str | None = None
|
||||||
active: bool | 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):
|
class TokenWindowRead(SQLModel):
|
||||||
limit: int | None = None
|
limit: int | None = None
|
||||||
remaining: int | None = None
|
remaining: int | None = None
|
||||||
|
|
@ -63,6 +77,9 @@ class ProviderUsageLiveRead(SQLModel):
|
||||||
input_tokens: TokenWindowRead # Anthropic input-only window
|
input_tokens: TokenWindowRead # Anthropic input-only window
|
||||||
output_tokens: TokenWindowRead # Anthropic output-only window
|
output_tokens: TokenWindowRead # Anthropic output-only window
|
||||||
requests: RequestWindowRead
|
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] = []
|
models: list[str] = []
|
||||||
sample_model: str | None = None
|
sample_model: str | None = None
|
||||||
sample_input_tokens: int | None = None
|
sample_input_tokens: int | None = None
|
||||||
|
|
@ -73,7 +90,7 @@ class ProviderUsageLiveRead(SQLModel):
|
||||||
|
|
||||||
|
|
||||||
class ProviderCredentialRead(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
|
id: UUID
|
||||||
organization_id: UUID
|
organization_id: UUID
|
||||||
|
|
@ -82,6 +99,8 @@ class ProviderCredentialRead(SQLModel):
|
||||||
display_name: str
|
display_name: str
|
||||||
api_key_last_four: str | None
|
api_key_last_four: str | None
|
||||||
has_api_key: bool
|
has_api_key: bool
|
||||||
|
session_key_last_four: str | None = None
|
||||||
|
has_session_key: bool = False
|
||||||
base_url: str | None
|
base_url: str | None
|
||||||
active: bool
|
active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,23 @@ class RequestWindow:
|
||||||
return max(0, int(delta * 1000))
|
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
|
@dataclass
|
||||||
class ProviderUsageLive:
|
class ProviderUsageLive:
|
||||||
provider: str
|
provider: str
|
||||||
|
|
@ -113,6 +130,9 @@ class ProviderUsageLive:
|
||||||
input_tokens: TokenWindow = field(default_factory=TokenWindow) # Anthropic input-only window
|
input_tokens: TokenWindow = field(default_factory=TokenWindow) # Anthropic input-only window
|
||||||
output_tokens: TokenWindow = field(default_factory=TokenWindow) # Anthropic output-only window
|
output_tokens: TokenWindow = field(default_factory=TokenWindow) # Anthropic output-only window
|
||||||
requests: RequestWindow = field(default_factory=RequestWindow)
|
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
|
models: list[str] = field(default_factory=list) # model IDs available on this key
|
||||||
raw_headers: dict[str, str] = field(default_factory=dict)
|
raw_headers: dict[str, str] = field(default_factory=dict)
|
||||||
sample_model: str | None = None
|
sample_model: str | None = None
|
||||||
|
|
@ -612,6 +632,174 @@ async def _fetch_ollama(base_url: str | None, api_key: str | None) -> ProviderUs
|
||||||
return result
|
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
|
# In-memory TTL cache
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -645,10 +833,15 @@ async def fetch_provider_usage(
|
||||||
api_key: str | None,
|
api_key: str | None,
|
||||||
base_url: str | None,
|
base_url: str | None,
|
||||||
*,
|
*,
|
||||||
|
session_key: str | None = None,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
) -> ProviderUsageLive:
|
) -> ProviderUsageLive:
|
||||||
"""Fetch live usage from the provider API.
|
"""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
|
Results are cached for CACHE_TTL_SECONDS. Pass force_refresh=True to
|
||||||
bypass the cache (e.g., when the user clicks Refresh).
|
bypass the cache (e.g., when the user clicks Refresh).
|
||||||
"""
|
"""
|
||||||
|
|
@ -658,24 +851,50 @@ async def fetch_provider_usage(
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
if not api_key:
|
if not api_key and not session_key:
|
||||||
result = ProviderUsageLive(
|
result = ProviderUsageLive(
|
||||||
provider=provider, account_key=account_key,
|
provider=provider, account_key=account_key,
|
||||||
checked_at=utcnow(), reachable=False,
|
checked_at=utcnow(), reachable=False,
|
||||||
error="No API key configured.",
|
error="No API key configured.",
|
||||||
)
|
)
|
||||||
else:
|
elif api_key:
|
||||||
result = await _fetch_anthropic(api_key, base_url)
|
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"):
|
elif provider in ("openai", "codex"):
|
||||||
if not api_key:
|
if not api_key and not session_key:
|
||||||
result = ProviderUsageLive(
|
result = ProviderUsageLive(
|
||||||
provider=provider, account_key=account_key,
|
provider=provider, account_key=account_key,
|
||||||
checked_at=utcnow(), reachable=False,
|
checked_at=utcnow(), reachable=False,
|
||||||
error="No API key configured.",
|
error="No API key configured.",
|
||||||
)
|
)
|
||||||
else:
|
elif api_key:
|
||||||
result = await _fetch_openai(api_key, base_url)
|
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":
|
elif provider == "ollama":
|
||||||
result = await _fetch_ollama(base_url, api_key)
|
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 "./skillPackReadMetadata";
|
||||||
export * from "./skillPackSyncResponse";
|
export * from "./skillPackSyncResponse";
|
||||||
export * from "./soulsDirectoryMarkdownResponse";
|
export * from "./soulsDirectoryMarkdownResponse";
|
||||||
|
export * from "./subscriptionWindowRead";
|
||||||
export * from "./soulsDirectorySearchResponse";
|
export * from "./soulsDirectorySearchResponse";
|
||||||
export * from "./soulsDirectorySoulRef";
|
export * from "./soulsDirectorySoulRef";
|
||||||
export * from "./soulUpdateRequest";
|
export * from "./soulUpdateRequest";
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export interface ProviderCredentialCreate {
|
||||||
account_key: string;
|
account_key: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
api_key?: string | null;
|
api_key?: string | null;
|
||||||
|
session_key?: string | null;
|
||||||
base_url?: string | null;
|
base_url?: string | null;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export interface ProviderCredentialRead {
|
||||||
display_name: string;
|
display_name: string;
|
||||||
api_key_last_four: string | null;
|
api_key_last_four: string | null;
|
||||||
has_api_key: boolean;
|
has_api_key: boolean;
|
||||||
|
session_key_last_four?: string | null;
|
||||||
|
has_session_key?: boolean;
|
||||||
base_url: string | null;
|
base_url: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { RequestWindowRead } from "./requestWindowRead";
|
import type { RequestWindowRead } from "./requestWindowRead";
|
||||||
|
import type { SubscriptionWindowRead } from "./subscriptionWindowRead";
|
||||||
import type { TokenWindowRead } from "./tokenWindowRead";
|
import type { TokenWindowRead } from "./tokenWindowRead";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,7 +21,10 @@ export interface ProviderUsageLiveRead {
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
tokens: TokenWindowRead;
|
tokens: TokenWindowRead;
|
||||||
input_tokens: TokenWindowRead;
|
input_tokens: TokenWindowRead;
|
||||||
|
output_tokens: TokenWindowRead;
|
||||||
requests: RequestWindowRead;
|
requests: RequestWindowRead;
|
||||||
|
subscription_windows: SubscriptionWindowRead[];
|
||||||
|
subscription_plan?: string | null;
|
||||||
models?: string[];
|
models?: string[];
|
||||||
sample_model?: string | null;
|
sample_model?: string | null;
|
||||||
sample_input_tokens?: number | 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,
|
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",
|
keyLabel: "API Key",
|
||||||
keyPlaceholder: "sk-ant-…",
|
keyPlaceholder: "sk-ant-api03-…",
|
||||||
showBaseUrl: false,
|
showBaseUrl: false,
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
accountKeyDefault: "default",
|
accountKeyDefault: "default",
|
||||||
|
showSessionKey: true,
|
||||||
|
sessionKeyLabel: "Claude.ai session key (for subscription usage)",
|
||||||
|
sessionKeyPlaceholder: "sk-ant-sid-… (from claude.ai cookie)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "openai",
|
id: "openai",
|
||||||
|
|
@ -43,10 +46,13 @@ const PROVIDERS = [
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
description: "OpenAI API key. Add multiple accounts to track usage separately.",
|
description: "OpenAI API key. Add multiple accounts to track usage separately.",
|
||||||
keyLabel: "API Key",
|
keyLabel: "API Key",
|
||||||
keyPlaceholder: "sk-…",
|
keyPlaceholder: "sk-proj-…",
|
||||||
showBaseUrl: false,
|
showBaseUrl: false,
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
accountKeyDefault: "",
|
accountKeyDefault: "",
|
||||||
|
showSessionKey: true,
|
||||||
|
sessionKeyLabel: "ChatGPT session token (for subscription usage)",
|
||||||
|
sessionKeyPlaceholder: "Bearer token from browser session",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ollama",
|
id: "ollama",
|
||||||
|
|
@ -58,6 +64,9 @@ const PROVIDERS = [
|
||||||
showBaseUrl: true,
|
showBaseUrl: true,
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
accountKeyDefault: "default",
|
accountKeyDefault: "default",
|
||||||
|
showSessionKey: false,
|
||||||
|
sessionKeyLabel: "",
|
||||||
|
sessionKeyPlaceholder: "",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -71,6 +80,9 @@ interface CredentialFormProps {
|
||||||
providerId: ProviderId;
|
providerId: ProviderId;
|
||||||
allowMultiple: boolean;
|
allowMultiple: boolean;
|
||||||
showBaseUrl: boolean;
|
showBaseUrl: boolean;
|
||||||
|
showSessionKey: boolean;
|
||||||
|
sessionKeyLabel: string;
|
||||||
|
sessionKeyPlaceholder: string;
|
||||||
keyLabel: string;
|
keyLabel: string;
|
||||||
keyPlaceholder: string;
|
keyPlaceholder: string;
|
||||||
accountKeyDefault: string;
|
accountKeyDefault: string;
|
||||||
|
|
@ -78,6 +90,7 @@ interface CredentialFormProps {
|
||||||
account_key: string;
|
account_key: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
|
session_key: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
onTest: (data: {
|
onTest: (data: {
|
||||||
|
|
@ -93,6 +106,9 @@ interface CredentialFormProps {
|
||||||
function CredentialForm({
|
function CredentialForm({
|
||||||
allowMultiple,
|
allowMultiple,
|
||||||
showBaseUrl,
|
showBaseUrl,
|
||||||
|
showSessionKey,
|
||||||
|
sessionKeyLabel,
|
||||||
|
sessionKeyPlaceholder,
|
||||||
keyLabel,
|
keyLabel,
|
||||||
keyPlaceholder,
|
keyPlaceholder,
|
||||||
accountKeyDefault,
|
accountKeyDefault,
|
||||||
|
|
@ -105,6 +121,7 @@ function CredentialForm({
|
||||||
const [accountKey, setAccountKey] = useState(accountKeyDefault);
|
const [accountKey, setAccountKey] = useState(accountKeyDefault);
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [sessionKey, setSessionKey] = useState("");
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<ProviderUsageLiveRead | null>(null);
|
const [testResult, setTestResult] = useState<ProviderUsageLiveRead | null>(null);
|
||||||
|
|
@ -185,6 +202,21 @@ function CredentialForm({
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<p className="text-sm text-[color:var(--danger)]">{error}</p>
|
<p className="text-sm text-[color:var(--danger)]">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -206,7 +238,7 @@ function CredentialForm({
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isSaving || !accountKey.trim() || (showBaseUrl && !baseUrl.trim())}
|
disabled={isSaving || !accountKey.trim() || (showBaseUrl && !baseUrl.trim())}
|
||||||
onClick={() =>
|
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 ? (
|
{isSaving ? (
|
||||||
|
|
@ -267,7 +299,7 @@ function fmtTokens(n: number | null | undefined): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtResetMs(ms: 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);
|
const s = Math.floor(ms / 1000);
|
||||||
if (s < 60) return `${s}s`;
|
if (s < 60) return `${s}s`;
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
|
|
@ -366,7 +398,9 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
||||||
|
|
||||||
const tok = usage.tokens;
|
const tok = usage.tokens;
|
||||||
const inputTok = usage.input_tokens;
|
const inputTok = usage.input_tokens;
|
||||||
|
const outputTok = usage.output_tokens;
|
||||||
const req = usage.requests;
|
const req = usage.requests;
|
||||||
|
const subWindows = usage.subscription_windows ?? [];
|
||||||
const isOllama = provider === "ollama";
|
const isOllama = provider === "ollama";
|
||||||
const sourceLabel: Record<string, string> = {
|
const sourceLabel: Record<string, string> = {
|
||||||
provider_native: "Provider native",
|
provider_native: "Provider native",
|
||||||
|
|
@ -374,7 +408,16 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
||||||
local_jsonl_estimate: "Local estimate",
|
local_jsonl_estimate: "Local estimate",
|
||||||
configured_limit: "Configured limit",
|
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[] = [];
|
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) {
|
if (inputTok.pct_used != null) {
|
||||||
usageBars.push({
|
usageBars.push({
|
||||||
label: "API rate limit · input tokens",
|
label: "API rate limit · input tokens",
|
||||||
|
|
@ -384,7 +427,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
||||||
}
|
}
|
||||||
if (tok.pct_used != null) {
|
if (tok.pct_used != null) {
|
||||||
usageBars.push({
|
usageBars.push({
|
||||||
label: "API rate limit · tokens",
|
label: "API rate limit · tokens (combined)",
|
||||||
pct: tok.pct_used,
|
pct: tok.pct_used,
|
||||||
resetInMs: tok.reset_in_ms,
|
resetInMs: tok.reset_in_ms,
|
||||||
});
|
});
|
||||||
|
|
@ -399,11 +442,31 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-2.5">
|
<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 ? (
|
{isOllama ? (
|
||||||
<div className="space-y-1.5">
|
<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">
|
<div className="flex items-center gap-3 text-xs text-muted">
|
||||||
<span className="flex items-center gap-1 text-[color:var(--success)]">
|
<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)]" />
|
<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" : ""}`} />
|
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{usageBars.length > 0 ? (
|
{(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && (
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between text-[11px] text-muted">
|
||||||
{usageBars.map((bar) => (
|
<span>Usage (last probe)</span>
|
||||||
<UsageWindowBar key={bar.label} label={bar.label} pct={bar.pct} resetInMs={bar.resetInMs} />
|
<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>
|
</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">
|
<div className="flex items-center justify-between text-[11px] text-muted">
|
||||||
{lastFetched && <span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>}
|
{lastFetched && <span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>}
|
||||||
|
|
@ -451,16 +499,19 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="text-[11px] text-muted">
|
{/* API rate-limit diagnostic bars */}
|
||||||
Source: {sourceLabel[usage.source] ?? usage.source} · confidence: {usage.confidence}
|
|
||||||
</div>
|
|
||||||
{usageBars.length > 0 ? (
|
{usageBars.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
{usageBars.map((bar) => (
|
<div className="text-[11px] text-muted">
|
||||||
<UsageWindowBar key={bar.label} label={bar.label} pct={bar.pct} resetInMs={bar.resetInMs} />
|
{sourceLabel[usage.source] ?? usage.source} · {usage.confidence} confidence
|
||||||
))}
|
</div>
|
||||||
</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) && (
|
{(usage.sample_input_tokens != null || usage.sample_output_tokens != null) && (
|
||||||
<div className="flex items-center justify-between text-[11px] text-muted">
|
<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.
|
Connected — provider did not return API rate-limit windows for percent + reset diagnostics.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-muted">
|
<div className="flex items-center justify-between text-[11px] text-muted">
|
||||||
{lastFetched && <span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>}
|
{lastFetched && <span>Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago</span>}
|
||||||
|
|
@ -539,6 +590,12 @@ function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }:
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[color:var(--warning)]">no key</span>
|
<span className="text-[color:var(--warning)]">no key</span>
|
||||||
)}
|
)}
|
||||||
|
{cred.has_session_key && (
|
||||||
|
<span className="flex items-center gap-1 text-[color:var(--success)]">
|
||||||
|
<KeyRound className="h-3 w-3" />
|
||||||
|
subscription key ••••{cred.session_key_last_four ?? ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{cred.base_url && <span className="truncate max-w-[200px]">{cred.base_url}</span>}
|
{cred.base_url && <span className="truncate max-w-[200px]">{cred.base_url}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -586,7 +643,7 @@ interface ProviderSectionProps {
|
||||||
provider: (typeof PROVIDERS)[number];
|
provider: (typeof PROVIDERS)[number];
|
||||||
credentials: ProviderCredentialRead[];
|
credentials: ProviderCredentialRead[];
|
||||||
isAdmin: boolean;
|
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>;
|
onTest: (providerId: ProviderId, data: { account_key: string; api_key: string; base_url: string }) => Promise<ProviderUsageLiveRead>;
|
||||||
onDelete: (cred: ProviderCredentialRead) => void;
|
onDelete: (cred: ProviderCredentialRead) => void;
|
||||||
onToggle: (cred: ProviderCredentialRead) => Promise<void>;
|
onToggle: (cred: ProviderCredentialRead) => Promise<void>;
|
||||||
|
|
@ -629,6 +686,9 @@ function ProviderSection({ provider, credentials, isAdmin, onAdd, onTest, onDele
|
||||||
providerId={provider.id}
|
providerId={provider.id}
|
||||||
allowMultiple={provider.allowMultiple}
|
allowMultiple={provider.allowMultiple}
|
||||||
showBaseUrl={provider.showBaseUrl}
|
showBaseUrl={provider.showBaseUrl}
|
||||||
|
showSessionKey={provider.showSessionKey}
|
||||||
|
sessionKeyLabel={provider.sessionKeyLabel}
|
||||||
|
sessionKeyPlaceholder={provider.sessionKeyPlaceholder}
|
||||||
keyLabel={provider.keyLabel}
|
keyLabel={provider.keyLabel}
|
||||||
keyPlaceholder={provider.keyPlaceholder}
|
keyPlaceholder={provider.keyPlaceholder}
|
||||||
accountKeyDefault={provider.accountKeyDefault}
|
accountKeyDefault={provider.accountKeyDefault}
|
||||||
|
|
@ -708,13 +768,14 @@ export default function AIProvidersSettingsPage() {
|
||||||
|
|
||||||
const handleAdd = async (
|
const handleAdd = async (
|
||||||
providerId: ProviderId,
|
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({
|
const res = await createProviderCredentialApiV1ProviderCredentialsPost({
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
account_key: data.account_key,
|
account_key: data.account_key,
|
||||||
display_name: data.display_name || data.account_key,
|
display_name: data.display_name || data.account_key,
|
||||||
api_key: data.api_key || undefined,
|
api_key: data.api_key || undefined,
|
||||||
|
session_key: data.session_key || undefined,
|
||||||
base_url: data.base_url || undefined,
|
base_url: data.base_url || undefined,
|
||||||
});
|
});
|
||||||
if (res.status === 201) {
|
if (res.status === 201) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue