feat(admin): gateway restart, config edit, logs, provider credentials (batch 6, #35)

This commit is contained in:
null 2026-05-20 22:03:57 -05:00
parent 4e40323e71
commit 4c5264d2ed
26 changed files with 4098 additions and 7 deletions

View File

@ -42,6 +42,8 @@ from app.schemas.runtime_usage import (
ProviderUsageScrapeResult,
RuntimeUsageResponse,
)
from app.db.session import async_session_maker
from app.services.activity_log import record_activity
from app.services.openclaw.cron_status import (
compute_job_status,
fetch_cron_jobs,
@ -475,6 +477,181 @@ async def get_gateway_health(
)
# ---------------------------------------------------------------------------
# Admin: logs and config (org-admin gate)
# ---------------------------------------------------------------------------
_SECRET_KEY_PATTERNS = frozenset(
{"token", "secret", "password", "key", "credential", "auth", "bearer", "apikey", "api_key"}
)
def _mask_config_value(key: str, value: object) -> object:
"""Return the value with secrets replaced by a masked string."""
if isinstance(value, str) and any(p in key.lower() for p in _SECRET_KEY_PATTERNS):
return f"••••{value[-4:]}" if len(value) > 4 else "••••"
return value
@router.get(
"/{gateway_id}/logs",
summary="Tail gateway logs (admin)",
description="Return the most recent log lines from the gateway process. Admin-only.",
)
async def get_gateway_logs(
gateway_id: UUID,
lines: int = Query(default=100, ge=1, le=500, description="Number of lines to return"),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> dict:
"""Return recent log lines from the gateway (read-only)."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
try:
raw = await openclaw_call("logs.tail", {"lines": lines}, config=config)
except Exception:
raw = None
if isinstance(raw, list):
log_lines = [str(line) for line in raw if line is not None]
elif isinstance(raw, str):
log_lines = raw.splitlines()
elif isinstance(raw, dict):
nested = raw.get("lines") or raw.get("logs") or raw.get("output") or []
log_lines = [str(l) for l in nested] if isinstance(nested, list) else str(raw).splitlines()
else:
log_lines = []
return {
"gateway_id": str(gateway_id),
"generated_at": utcnow().isoformat(),
"lines": log_lines,
"lines_requested": lines,
}
@router.get(
"/{gateway_id}/config",
summary="Read gateway config (admin)",
description="Return the current gateway configuration with secrets masked. Admin-only.",
)
async def get_gateway_config(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> dict:
"""Return the gateway configuration with sensitive values masked."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
try:
raw = await openclaw_call("config.get", config=config)
except Exception:
raw = None
if isinstance(raw, dict):
masked = {k: _mask_config_value(k, v) for k, v in raw.items()}
else:
masked = {}
return {
"gateway_id": str(gateway_id),
"generated_at": utcnow().isoformat(),
"config": masked,
"available": bool(masked),
}
@router.patch(
"/{gateway_id}/config",
summary="Update gateway config (admin)",
description=(
"Apply partial config updates to the gateway using config.patch + config.apply. "
"Secrets must be submitted in full — not masked values. "
"Each call is audit-logged. Admin-only."
),
)
async def patch_gateway_config(
gateway_id: UUID,
payload: dict,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> dict:
"""Apply config changes and write an audit event."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
gw_config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
if not payload:
from fastapi import HTTPException as _HTTPException
raise _HTTPException(status_code=400, detail="No config keys provided.")
# Strip any masked placeholder values (user accidentally sent ••••xxxx)
clean_payload = {k: v for k, v in payload.items() if not str(v).startswith("••••")}
if not clean_payload:
from fastapi import HTTPException as _HTTPException
raise _HTTPException(status_code=400, detail="All values appear to be masked placeholders.")
try:
await openclaw_call("config.patch", clean_payload, config=gw_config)
await openclaw_call("config.apply", config=gw_config)
except Exception as exc:
from fastapi import HTTPException as _HTTPException
raise _HTTPException(status_code=502, detail=f"Gateway config update failed: {exc}") from exc
# Audit: record what changed (mask secret values in the log message)
changed_keys = list(clean_payload.keys())
masked_summary = ", ".join(
f"{k}={_mask_config_value(k, v)}" for k, v in clean_payload.items()
)
actor = str(ctx.member.user_id)
async with async_session_maker() as audit_session:
record_activity(
audit_session,
event_type="gateway.config.updated",
message=f"Gateway config updated by user {actor} — changed: {masked_summary}",
)
await audit_session.commit()
return {
"gateway_id": str(gateway_id),
"updated_at": utcnow().isoformat(),
"changed_keys": changed_keys,
"applied": True,
}
@router.get(
"/{gateway_id}/runtime-activity",
summary="Recent gateway runtime messages (REST snapshot)",

View File

@ -0,0 +1,227 @@
"""CRUD endpoints for AI provider credentials.
Every org member can read credentials (to see what providers are configured).
Creating, updating, and deleting requires org-admin access.
The full api_key is never returned in any response.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import select
from app.api.deps import require_org_admin, require_org_member
from app.core.time import utcnow
from app.db import crud
from app.db.session import get_session
from app.models.provider_credentials import ProviderCredential, SUPPORTED_PROVIDERS
from app.schemas.provider_credentials import (
ProviderCredentialCreate,
ProviderCredentialRead,
ProviderCredentialUpdate,
ProviderUsageLiveRead,
RequestWindowRead,
TokenWindowRead,
)
from app.services.provider_usage import fetch_provider_usage
from app.services.organizations import OrganizationContext
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/provider-credentials", tags=["provider-credentials"])
SESSION_DEP = Depends(get_session)
ORG_MEMBER_DEP = Depends(require_org_member)
ORG_ADMIN_DEP = Depends(require_org_admin)
def _to_read(cred: ProviderCredential) -> ProviderCredentialRead:
return ProviderCredentialRead(
id=cred.id,
organization_id=cred.organization_id,
provider=cred.provider,
account_key=cred.account_key,
display_name=cred.display_name,
api_key_last_four=cred.api_key_last_four,
has_api_key=bool(cred.api_key),
base_url=cred.base_url,
active=cred.active,
created_at=cred.created_at,
updated_at=cred.updated_at,
)
@router.get("", response_model=list[ProviderCredentialRead])
async def list_provider_credentials(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[ProviderCredentialRead]:
"""List all provider credentials for the caller's organisation."""
rows = (
await session.exec(
select(ProviderCredential)
.where(ProviderCredential.organization_id == ctx.organization.id)
.order_by(ProviderCredential.provider, ProviderCredential.account_key)
)
).all()
return [_to_read(r) for r in rows]
@router.post("", response_model=ProviderCredentialRead, status_code=status.HTTP_201_CREATED)
async def create_provider_credential(
payload: ProviderCredentialCreate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> ProviderCredentialRead:
"""Create a new provider credential. Admin-only."""
if payload.provider not in SUPPORTED_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unsupported provider '{payload.provider}'. Supported: {sorted(SUPPORTED_PROVIDERS)}",
)
# Check for duplicate (provider + account_key per org)
existing = (
await session.exec(
select(ProviderCredential).where(
ProviderCredential.organization_id == ctx.organization.id,
ProviderCredential.provider == payload.provider,
ProviderCredential.account_key == payload.account_key,
)
)
).first()
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A '{payload.provider}' credential with account key '{payload.account_key}' already exists.",
)
last_four = payload.api_key[-4:] if payload.api_key and len(payload.api_key) >= 4 else None
cred = ProviderCredential(
organization_id=ctx.organization.id,
provider=payload.provider,
account_key=payload.account_key,
display_name=payload.display_name or payload.account_key,
api_key=payload.api_key or None,
api_key_last_four=last_four,
base_url=payload.base_url or None,
active=payload.active,
)
session.add(cred)
await session.commit()
await session.refresh(cred)
return _to_read(cred)
@router.get("/{credential_id}", response_model=ProviderCredentialRead)
async def get_provider_credential(
credential_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ProviderCredentialRead:
cred = await crud.get_by_id(session, ProviderCredential, credential_id)
if cred is None or cred.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return _to_read(cred)
@router.patch("/{credential_id}", response_model=ProviderCredentialRead)
async def update_provider_credential(
credential_id: UUID,
payload: ProviderCredentialUpdate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> ProviderCredentialRead:
"""Update a credential. Pass api_key="" to clear it. Admin-only."""
cred = await crud.get_by_id(session, ProviderCredential, credential_id)
if cred is None or cred.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if payload.display_name is not None:
cred.display_name = payload.display_name
if payload.base_url is not None:
cred.base_url = payload.base_url or None
if payload.active is not None:
cred.active = payload.active
if payload.api_key is not None:
if payload.api_key == "":
cred.api_key = None
cred.api_key_last_four = None
else:
cred.api_key = payload.api_key
cred.api_key_last_four = payload.api_key[-4:] if len(payload.api_key) >= 4 else None
cred.updated_at = utcnow()
await crud.save(session, cred)
return _to_read(cred)
@router.get("/{credential_id}/usage", response_model=ProviderUsageLiveRead)
async def get_provider_usage_live(
credential_id: UUID,
refresh: bool = Query(default=False, description="Bypass cache and fetch fresh data"),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ProviderUsageLiveRead:
"""Fetch live token usage and rate limits directly from the provider API.
Calls the provider's model-list endpoint (zero token cost) and reads the
rate-limit response headers. Results are cached for 60 seconds.
Pass ?refresh=true to force a fresh fetch.
"""
cred = await crud.get_by_id(session, ProviderCredential, credential_id)
if cred is None or cred.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
live = await fetch_provider_usage(
credential_id=str(credential_id),
provider=cred.provider,
account_key=cred.account_key,
api_key=cred.api_key,
base_url=cred.base_url,
force_refresh=refresh,
)
def _tok(w) -> TokenWindowRead:
return TokenWindowRead(
limit=w.limit, remaining=w.remaining, used=w.used,
pct_used=w.pct_used,
reset_at=w.reset_at.isoformat() if w.reset_at else None,
reset_in_ms=w.reset_in_ms,
)
def _req(w) -> RequestWindowRead:
return RequestWindowRead(
limit=w.limit, remaining=w.remaining,
reset_at=w.reset_at.isoformat() if w.reset_at else None,
reset_in_ms=w.reset_in_ms,
)
return ProviderUsageLiveRead(
provider=live.provider,
account_key=live.account_key,
checked_at=live.checked_at.isoformat(),
reachable=live.reachable,
error=live.error,
tokens=_tok(live.tokens),
input_tokens=_tok(live.input_tokens),
requests=_req(live.requests),
models=live.models,
)
@router.delete("/{credential_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_provider_credential(
credential_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> None:
"""Delete a credential. Admin-only."""
cred = await crud.get_by_id(session, ProviderCredential, credential_id)
if cred is None or cred.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await session.delete(cred)
await session.commit()

View File

@ -24,6 +24,7 @@ from app.api.board_repository_links import router as board_repository_links_rout
from app.api.board_webhooks import router as board_webhooks_router
from app.api.boards import router as boards_router
from app.api.forgejo_connections import router as forgejo_connections_router
from app.api.provider_credentials import router as provider_credentials_router
from app.api.forgejo_issues import router as forgejo_issues_router
from app.api.forgejo_metrics import router as forgejo_metrics_router
from app.api.forgejo_repositories import router as forgejo_repositories_router
@ -591,6 +592,7 @@ api_v1.include_router(tasks_router)
api_v1.include_router(task_custom_fields_router)
api_v1.include_router(tags_router)
api_v1.include_router(users_router)
api_v1.include_router(provider_credentials_router)
app.include_router(api_v1)
add_pagination(app)

View File

@ -21,6 +21,7 @@ from app.models.organization_invite_board_access import OrganizationInviteBoardA
from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.provider_credentials import ProviderCredential
from app.models.skills import GatewayInstalledSkill, MarketplaceSkill, SkillPack
from app.models.tag_assignments import TagAssignment
from app.models.tags import Tag
@ -55,6 +56,7 @@ __all__ = [
"MarketplaceSkill",
"SkillPack",
"Organization",
"ProviderCredential",
"BoardTaskCustomField",
"TaskCustomFieldDefinition",
"TaskCustomFieldValue",

View File

@ -0,0 +1,48 @@
"""Provider credential records — API keys and endpoints for AI providers.
Each row stores one named account for a single provider (Anthropic, OpenAI,
Ollama, etc.). An organization can have multiple rows per provider, enabling
separate cost tracking for e.g. "work" and "personal" OpenAI accounts.
Token storage follows the same pattern as ForgejoConnection and Gateway:
plaintext in the trusted DB, last-four chars available for verification without
exposing the full key.
"""
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlmodel import Field
from app.core.time import utcnow
from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
SUPPORTED_PROVIDERS = frozenset({"anthropic", "openai", "ollama", "google", "other"})
class ProviderCredential(QueryModel, table=True):
"""One named AI-provider account for an organisation."""
__tablename__ = "provider_credentials" # pyright: ignore[reportAssignmentType]
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
# Provider identity
provider: str = Field(index=True) # "anthropic", "openai", "ollama", …
account_key: str = Field(index=True) # user label: "work", "personal", "local"
display_name: str = Field(default="") # human-readable name shown in the UI
# Credentials (not all providers need both)
api_key: str | None = Field(default=None) # full key — never returned in API
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
active: bool = Field(default=True, index=True)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@ -0,0 +1,72 @@
"""Schemas for AI provider credential CRUD endpoints."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from sqlmodel import SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
class ProviderCredentialCreate(SQLModel):
provider: str
account_key: str
display_name: str = ""
api_key: str | None = None
base_url: str | None = None
active: bool = True
class ProviderCredentialUpdate(SQLModel):
display_name: str | None = None
api_key: str | None = None # None = keep existing; "" = clear it
base_url: str | None = None
active: bool | None = None
class TokenWindowRead(SQLModel):
limit: int | None = None
remaining: int | None = None
used: int | None = None
pct_used: float | None = None
reset_at: str | None = None # ISO 8601 UTC
reset_in_ms: int | None = None # ms until reset
class RequestWindowRead(SQLModel):
limit: int | None = None
remaining: int | None = None
reset_at: str | None = None
reset_in_ms: int | None = None
class ProviderUsageLiveRead(SQLModel):
"""Real-time token usage and rate limit data fetched directly from the provider API."""
provider: str
account_key: str
checked_at: str # ISO 8601 UTC
reachable: bool
error: str | None = None
tokens: TokenWindowRead
input_tokens: TokenWindowRead # Anthropic splits input tokens separately
requests: RequestWindowRead
models: list[str] = []
class ProviderCredentialRead(SQLModel):
"""Safe read schema — api_key is never included."""
id: UUID
organization_id: UUID
provider: str
account_key: str
display_name: str
api_key_last_four: str | None
has_api_key: bool
base_url: str | None
active: bool
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,418 @@
"""Live provider usage — fetch real token limits and reset times directly from provider APIs.
Each provider exposes rate-limit headers on every API response. Pipeline calls
a lightweight, zero-token-cost endpoint (model list) and reads those headers.
No guessing, no JSONL scanning, no estimates. If the provider API is
unreachable or the key is invalid, an error is returned and all limit fields
are None.
Supported providers
-------------------
anthropic GET https://api.anthropic.com/v1/models
Headers: anthropic-ratelimit-tokens-limit/remaining/reset
anthropic-ratelimit-requests-limit/remaining/reset
anthropic-ratelimit-input-tokens-limit/remaining/reset
openai GET https://api.openai.com/v1/models
(codex) Headers: x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens,
x-ratelimit-reset-tokens, x-ratelimit-limit-requests,
x-ratelimit-remaining-requests, x-ratelimit-reset-requests
ollama GET {base_url}/api/tags (health-check only; no rate limits)
Returns: model list, server reachable flag
Caching
-------
Results are cached per credential_id for CACHE_TTL_SECONDS (default 60s) to
avoid hammering provider APIs on every page load.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from app.core.logging import get_logger
from app.core.time import utcnow
logger = get_logger(__name__)
CACHE_TTL_SECONDS = 60
REQUEST_TIMEOUT = 8.0 # seconds
# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------
@dataclass
class TokenWindow:
limit: int | None = None
remaining: int | None = None
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))
@property
def used(self) -> int | None:
if self.limit is not None and self.remaining is not None:
return max(0, self.limit - self.remaining)
return None
@property
def pct_used(self) -> float | None:
if self.limit and self.limit > 0 and self.remaining is not None:
return round((1 - self.remaining / self.limit) * 100, 1)
return None
@dataclass
class RequestWindow:
limit: int | None = None
remaining: int | None = None
reset_at: datetime | None = None
@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
account_key: str
checked_at: datetime
reachable: bool
error: str | None = None
tokens: TokenWindow = field(default_factory=TokenWindow)
input_tokens: TokenWindow = field(default_factory=TokenWindow) # Anthropic splits input/output
requests: RequestWindow = field(default_factory=RequestWindow)
models: list[str] = field(default_factory=list) # model IDs available on this key
raw_headers: dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
def _window(w: TokenWindow | RequestWindow) -> dict[str, Any]:
d: dict[str, Any] = {}
if hasattr(w, "limit"): d["limit"] = w.limit
if hasattr(w, "remaining"): d["remaining"] = w.remaining
if hasattr(w, "reset_in_ms"): d["reset_in_ms"] = w.reset_in_ms
if hasattr(w, "reset_at"): d["reset_at"] = w.reset_at.isoformat() if w.reset_at else None
if isinstance(w, TokenWindow):
d["used"] = w.used
d["pct_used"] = w.pct_used
return d
return {
"provider": self.provider,
"account_key": self.account_key,
"checked_at": self.checked_at.isoformat(),
"reachable": self.reachable,
"error": self.error,
"tokens": _window(self.tokens),
"input_tokens": _window(self.input_tokens),
"requests": _window(self.requests),
"models": self.models[:20], # cap for response size
}
# ---------------------------------------------------------------------------
# Header parsers
# ---------------------------------------------------------------------------
def _parse_int_header(headers: dict[str, str], *names: str) -> int | None:
for name in names:
val = headers.get(name.lower())
if val is not None:
try:
return int(val)
except (ValueError, TypeError):
pass
return None
def _parse_iso_reset(value: str) -> datetime | None:
"""Parse an ISO 8601 reset timestamp → UTC naive datetime."""
if not value:
return None
try:
normalized = value.strip().replace("Z", "+00:00")
dt = datetime.fromisoformat(normalized)
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
except ValueError:
return None
# OpenAI encodes reset as a duration string like "1m30s", "2h", "30s"
_OAI_DURATION_RE = re.compile(
r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$"
)
def _parse_openai_reset(value: str) -> datetime | None:
"""Parse an OpenAI reset header: ISO datetime OR duration like '1m30s'."""
if not value:
return None
# ISO format first
if "T" in value or value.endswith("Z"):
return _parse_iso_reset(value)
# Duration
m = _OAI_DURATION_RE.match(value.strip())
if m and any(m.groups()):
h = float(m.group(1) or 0)
mn = float(m.group(2) or 0)
s = float(m.group(3) or 0)
total_seconds = h * 3600 + mn * 60 + s
return utcnow() + timedelta(seconds=total_seconds)
return None
# ---------------------------------------------------------------------------
# Provider-specific fetch functions
# ---------------------------------------------------------------------------
async def _fetch_anthropic(api_key: str, base_url: str | None) -> ProviderUsageLive:
base = (base_url or "https://api.anthropic.com").rstrip("/")
now = utcnow()
result = ProviderUsageLive(provider="anthropic", account_key="", checked_at=now, reachable=False)
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
try:
resp = await client.get(
f"{base}/v1/models",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
)
except (httpx.ConnectError, httpx.TimeoutException) as exc:
result.error = f"Connection failed: {exc}"
return result
except Exception as exc:
result.error = str(exc)
return result
if resp.status_code == 401:
result.error = "Invalid API key (401)."
return result
if resp.status_code not in (200, 429):
result.error = f"Provider returned HTTP {resp.status_code}."
# Still try to parse headers on 429 (rate limited but data is there)
if resp.status_code != 429:
return result
h = {k.lower(): v for k, v in resp.headers.items()}
result.reachable = True
result.raw_headers = {k: v for k, v in h.items() if "ratelimit" in k}
# Token window (combined input+output)
result.tokens = TokenWindow(
limit = _parse_int_header(h, "anthropic-ratelimit-tokens-limit"),
remaining = _parse_int_header(h, "anthropic-ratelimit-tokens-remaining"),
reset_at = _parse_iso_reset(h.get("anthropic-ratelimit-tokens-reset", "")),
)
# Input-token window (separate limit for input)
result.input_tokens = TokenWindow(
limit = _parse_int_header(h, "anthropic-ratelimit-input-tokens-limit"),
remaining = _parse_int_header(h, "anthropic-ratelimit-input-tokens-remaining"),
reset_at = _parse_iso_reset(h.get("anthropic-ratelimit-input-tokens-reset", "")),
)
result.requests = RequestWindow(
limit = _parse_int_header(h, "anthropic-ratelimit-requests-limit"),
remaining = _parse_int_header(h, "anthropic-ratelimit-requests-remaining"),
reset_at = _parse_iso_reset(h.get("anthropic-ratelimit-requests-reset", "")),
)
# Extract model IDs
try:
data = resp.json()
items = data.get("data") or data if isinstance(data.get("data"), list) else []
result.models = [m.get("id", "") for m in items if isinstance(m, dict) and m.get("id")]
except Exception:
pass
return result
async def _fetch_openai(api_key: str, base_url: str | None) -> ProviderUsageLive:
base = (base_url or "https://api.openai.com").rstrip("/")
now = utcnow()
result = ProviderUsageLive(provider="openai", account_key="", checked_at=now, reachable=False)
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
try:
resp = await client.get(
f"{base}/v1/models",
headers={"Authorization": f"Bearer {api_key}"},
)
except (httpx.ConnectError, httpx.TimeoutException) as exc:
result.error = f"Connection failed: {exc}"
return result
except Exception as exc:
result.error = str(exc)
return result
if resp.status_code == 401:
result.error = "Invalid API key (401)."
return result
if resp.status_code not in (200, 429):
result.error = f"Provider returned HTTP {resp.status_code}."
if resp.status_code != 429:
return result
h = {k.lower(): v for k, v in resp.headers.items()}
result.reachable = True
result.raw_headers = {k: v for k, v in h.items() if "ratelimit" in k}
result.tokens = TokenWindow(
limit = _parse_int_header(h, "x-ratelimit-limit-tokens"),
remaining = _parse_int_header(h, "x-ratelimit-remaining-tokens"),
reset_at = _parse_openai_reset(h.get("x-ratelimit-reset-tokens", "")),
)
result.requests = RequestWindow(
limit = _parse_int_header(h, "x-ratelimit-limit-requests"),
remaining = _parse_int_header(h, "x-ratelimit-remaining-requests"),
reset_at = _parse_openai_reset(h.get("x-ratelimit-reset-requests", "")),
)
try:
data = resp.json()
items = data.get("data") or []
result.models = [m.get("id", "") for m in items if isinstance(m, dict) and m.get("id")]
except Exception:
pass
return result
async def _fetch_ollama(base_url: str | None, api_key: str | None) -> ProviderUsageLive:
base = (base_url or "http://localhost:11434").rstrip("/")
now = utcnow()
result = ProviderUsageLive(provider="ollama", account_key="", checked_at=now, reachable=False)
headers: dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
try:
resp = await client.get(f"{base}/api/tags", headers=headers)
except (httpx.ConnectError, httpx.TimeoutException) as exc:
result.error = f"Ollama unreachable: {exc}"
return result
except Exception as exc:
result.error = str(exc)
return result
if resp.status_code not in (200,):
result.error = f"Ollama returned HTTP {resp.status_code}."
return result
result.reachable = True
# Ollama has no rate limits — just expose available models
try:
data = resp.json()
models_raw = data.get("models") or []
result.models = [m.get("name", "") for m in models_raw if isinstance(m, dict) and m.get("name")]
except Exception:
pass
return result
# ---------------------------------------------------------------------------
# In-memory TTL cache
# ---------------------------------------------------------------------------
_cache: dict[str, tuple[datetime, ProviderUsageLive]] = {}
def _get_cached(credential_id: str) -> ProviderUsageLive | None:
entry = _cache.get(credential_id)
if entry is None:
return None
cached_at, result = entry
if (utcnow() - cached_at).total_seconds() > CACHE_TTL_SECONDS:
del _cache[credential_id]
return None
return result
def _set_cached(credential_id: str, result: ProviderUsageLive) -> None:
_cache[credential_id] = (utcnow(), result)
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
async def fetch_provider_usage(
credential_id: str,
provider: str,
account_key: str,
api_key: str | None,
base_url: str | None,
*,
force_refresh: bool = False,
) -> ProviderUsageLive:
"""Fetch live usage from the provider API.
Results are cached for CACHE_TTL_SECONDS. Pass force_refresh=True to
bypass the cache (e.g., when the user clicks Refresh).
"""
if not force_refresh:
cached = _get_cached(credential_id)
if cached is not None:
return cached
if provider == "anthropic":
if not api_key:
result = ProviderUsageLive(
provider=provider, account_key=account_key,
checked_at=utcnow(), reachable=False,
error="No API key configured.",
)
else:
result = await _fetch_anthropic(api_key, base_url)
elif provider in ("openai", "codex"):
if not api_key:
result = ProviderUsageLive(
provider=provider, account_key=account_key,
checked_at=utcnow(), reachable=False,
error="No API key configured.",
)
else:
result = await _fetch_openai(api_key, base_url)
elif provider == "ollama":
result = await _fetch_ollama(base_url, api_key)
else:
result = ProviderUsageLive(
provider=provider, account_key=account_key,
checked_at=utcnow(), reachable=False,
error=f"Live usage not supported for provider '{provider}'.",
)
result.account_key = account_key
_set_cached(credential_id, result)
logger.info(
"provider_usage.checked provider=%s account=%s reachable=%s error=%s",
provider, account_key, result.reachable, result.error,
)
return result

View File

@ -0,0 +1,50 @@
"""Add provider_credentials table for AI provider API key management.
Revision ID: d1e2f3a4b5c6
Revises: c1d2e3f4a5b6
Create Date: 2026-05-21 12:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "d1e2f3a4b5c6"
down_revision = "c1d2e3f4a5b6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"provider_credentials",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("organization_id", sa.UUID(), nullable=False),
sa.Column("provider", sa.String(), nullable=False),
sa.Column("account_key", sa.String(), nullable=False),
sa.Column("display_name", sa.String(), nullable=False, server_default=""),
sa.Column("api_key", sa.String(), nullable=True),
sa.Column("api_key_last_four", sa.String(), nullable=True),
sa.Column("base_url", sa.String(), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"organization_id", "provider", "account_key",
name="uq_provider_credentials_org_provider_key",
),
)
op.create_index("ix_provider_credentials_org_id", "provider_credentials", ["organization_id"])
op.create_index("ix_provider_credentials_provider", "provider_credentials", ["provider"])
op.create_index("ix_provider_credentials_active", "provider_credentials", ["active"])
def downgrade() -> None:
op.drop_index("ix_provider_credentials_active", table_name="provider_credentials")
op.drop_index("ix_provider_credentials_provider", table_name="provider_credentials")
op.drop_index("ix_provider_credentials_org_id", table_name="provider_credentials")
op.drop_table("provider_credentials")

View File

@ -36,6 +36,9 @@ import type {
GatewayUpdate,
GatewaysStatusApiV1GatewaysStatusGetParams,
GatewaysStatusResponse,
GetGatewayConfigApiV1GatewaysGatewayIdConfigGet200,
GetGatewayLogsApiV1GatewaysGatewayIdLogsGet200,
GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams,
GetGatewayRuntimeActivityApiV1GatewaysGatewayIdRuntimeActivityGet200,
GetGatewaySessionApiV1GatewaysSessionsSessionIdGetParams,
GetSessionHistoryApiV1GatewaysSessionsSessionIdHistoryGetParams,
@ -44,6 +47,8 @@ import type {
ListGatewaySessionsApiV1GatewaysSessionsGetParams,
ListGatewaysApiV1GatewaysGetParams,
OkResponse,
PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatch200,
PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody,
ProviderUsageResponse,
RuntimeUsageResponse,
SendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams,
@ -3920,6 +3925,655 @@ export function useGetGatewayHealthApiV1GatewaysGatewayIdHealthGet<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Return the most recent log lines from the gateway process. Admin-only.
* @summary Tail gateway logs (admin)
*/
export type getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponse200 = {
data: GetGatewayLogsApiV1GatewaysGatewayIdLogsGet200;
status: 200;
};
export type getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponseSuccess =
getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponse200 & {
headers: Headers;
};
export type getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponseError =
getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponse422 & {
headers: Headers;
};
export type getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponse =
| getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponseSuccess
| getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponseError;
export const getGetGatewayLogsApiV1GatewaysGatewayIdLogsGetUrl = (
gatewayId: string,
params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/v1/gateways/${gatewayId}/logs?${stringifiedParams}`
: `/api/v1/gateways/${gatewayId}/logs`;
};
export const getGatewayLogsApiV1GatewaysGatewayIdLogsGet = async (
gatewayId: string,
params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams,
options?: RequestInit,
): Promise<getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponse> => {
return customFetch<getGatewayLogsApiV1GatewaysGatewayIdLogsGetResponse>(
getGetGatewayLogsApiV1GatewaysGatewayIdLogsGetUrl(gatewayId, params),
{
...options,
method: "GET",
},
);
};
export const getGetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryKey = (
gatewayId: string,
params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams,
) => {
return [
`/api/v1/gateways/${gatewayId}/logs`,
...(params ? [params] : []),
] as const;
};
export const getGetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryOptions = <
TData = Awaited<
ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryKey(gatewayId, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>>
> = ({ signal }) =>
getGatewayLogsApiV1GatewaysGatewayIdLogsGet(gatewayId, params, {
signal,
...requestOptions,
});
return {
queryKey,
queryFn,
enabled: !!gatewayId,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type GetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryResult =
NonNullable<
Awaited<ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>>
>;
export type GetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryError =
HTTPValidationError;
export function useGetGatewayLogsApiV1GatewaysGatewayIdLogsGet<
TData = Awaited<
ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
params: undefined | GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<
ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>
>,
TError,
Awaited<
ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetGatewayLogsApiV1GatewaysGatewayIdLogsGet<
TData = Awaited<
ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<
ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>
>,
TError,
Awaited<
ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetGatewayLogsApiV1GatewaysGatewayIdLogsGet<
TData = Awaited<
ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Tail gateway logs (admin)
*/
export function useGetGatewayLogsApiV1GatewaysGatewayIdLogsGet<
TData = Awaited<
ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getGatewayLogsApiV1GatewaysGatewayIdLogsGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getGetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryOptions(
gatewayId,
params,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Return the current gateway configuration with secrets masked. Admin-only.
* @summary Read gateway config (admin)
*/
export type getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponse200 = {
data: GetGatewayConfigApiV1GatewaysGatewayIdConfigGet200;
status: 200;
};
export type getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponseSuccess =
getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponse200 & {
headers: Headers;
};
export type getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponseError =
getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponse422 & {
headers: Headers;
};
export type getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponse =
| getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponseSuccess
| getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponseError;
export const getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetUrl = (
gatewayId: string,
) => {
return `/api/v1/gateways/${gatewayId}/config`;
};
export const getGatewayConfigApiV1GatewaysGatewayIdConfigGet = async (
gatewayId: string,
options?: RequestInit,
): Promise<getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponse> => {
return customFetch<getGatewayConfigApiV1GatewaysGatewayIdConfigGetResponse>(
getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetUrl(gatewayId),
{
...options,
method: "GET",
},
);
};
export const getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryKey = (
gatewayId: string,
) => {
return [`/api/v1/gateways/${gatewayId}/config`] as const;
};
export const getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryOptions = <
TData = Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryKey(gatewayId);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>>
> = ({ signal }) =>
getGatewayConfigApiV1GatewaysGatewayIdConfigGet(gatewayId, {
signal,
...requestOptions,
});
return {
queryKey,
queryFn,
enabled: !!gatewayId,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type GetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryResult =
NonNullable<
Awaited<ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>>
>;
export type GetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryError =
HTTPValidationError;
export function useGetGatewayConfigApiV1GatewaysGatewayIdConfigGet<
TData = Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError,
Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetGatewayConfigApiV1GatewaysGatewayIdConfigGet<
TData = Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError,
Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetGatewayConfigApiV1GatewaysGatewayIdConfigGet<
TData = Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Read gateway config (admin)
*/
export function useGetGatewayConfigApiV1GatewaysGatewayIdConfigGet<
TData = Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError = HTTPValidationError,
>(
gatewayId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getGatewayConfigApiV1GatewaysGatewayIdConfigGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryOptions(
gatewayId,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Apply partial config updates to the gateway using config.patch + config.apply. Secrets must be submitted in full not masked values. Each call is audit-logged. Admin-only.
* @summary Update gateway config (admin)
*/
export type patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponse200 = {
data: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatch200;
status: 200;
};
export type patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponseSuccess =
patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponse200 & {
headers: Headers;
};
export type patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponseError =
patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponse422 & {
headers: Headers;
};
export type patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponse =
| patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponseSuccess
| patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponseError;
export const getPatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchUrl = (
gatewayId: string,
) => {
return `/api/v1/gateways/${gatewayId}/config`;
};
export const patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch = async (
gatewayId: string,
patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody,
options?: RequestInit,
): Promise<patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponse> => {
return customFetch<patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchResponse>(
getPatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchUrl(gatewayId),
{
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(
patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody,
),
},
);
};
export const getPatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<typeof patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch>
>,
TError,
{
gatewayId: string;
data: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody;
},
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<
ReturnType<typeof patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch>
>,
TError,
{
gatewayId: string;
data: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody;
},
TContext
> => {
const mutationKey = ["patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<
ReturnType<typeof patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch>
>,
{
gatewayId: string;
data: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody;
}
> = (props) => {
const { gatewayId, data } = props ?? {};
return patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch(
gatewayId,
data,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchMutationResult =
NonNullable<
Awaited<
ReturnType<typeof patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch>
>
>;
export type PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchMutationBody =
PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody;
export type PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchMutationError =
HTTPValidationError;
/**
* @summary Update gateway config (admin)
*/
export const usePatchGatewayConfigApiV1GatewaysGatewayIdConfigPatch = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<typeof patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch>
>,
TError,
{
gatewayId: string;
data: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody;
},
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<
ReturnType<typeof patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch>
>,
TError,
{
gatewayId: string;
data: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody;
},
TContext
> => {
return useMutation(
getPatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchMutationOptions(
options,
),
queryClient,
);
};
/**
* Return the most recent messages from all active sessions on the gateway, normalised and redacted. Use the streaming endpoint for a live feed.
* @summary Recent gateway runtime messages (REST snapshot)

View File

@ -0,0 +1,10 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type GetGatewayConfigApiV1GatewaysGatewayIdConfigGet200 = {
[key: string]: unknown;
};

View File

@ -0,0 +1,10 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type GetGatewayLogsApiV1GatewaysGatewayIdLogsGet200 = {
[key: string]: unknown;
};

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams = {
/**
* Number of lines to return
* @minimum 1
* @maximum 500
*/
lines?: number;
};

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams =
{
/**
* Bypass cache and fetch fresh data
*/
refresh?: boolean;
};

View File

@ -147,8 +147,12 @@ export * from "./getBoardGroupSnapshotApiV1BoardGroupsGroupIdSnapshotGetParams";
export * from "./getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGetParams";
export * from "./getForgejoHeatmapApiV1ForgejoHeatmapGetParams";
export * from "./getForgejoMetricsApiV1ForgejoMetricsGetParams";
export * from "./getGatewayConfigApiV1GatewaysGatewayIdConfigGet200";
export * from "./getGatewayLogsApiV1GatewaysGatewayIdLogsGet200";
export * from "./getGatewayLogsApiV1GatewaysGatewayIdLogsGetParams";
export * from "./getGatewayRuntimeActivityApiV1GatewaysGatewayIdRuntimeActivityGet200";
export * from "./getGatewaySessionApiV1GatewaysSessionsSessionIdGetParams";
export * from "./getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams";
export * from "./getSessionHistoryApiV1GatewaysSessionsSessionIdHistoryGetParams";
export * from "./getWebhookPayloadApiV1AgentBoardsBoardIdWebhooksWebhookIdPayloadsPayloadIdGetParams";
export * from "./healthHealthGet200";
@ -229,9 +233,16 @@ export * from "./organizationMemberRead";
export * from "./organizationMemberUpdate";
export * from "./organizationRead";
export * from "./organizationUserRead";
export * from "./patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch200";
export * from "./patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody";
export * from "./providerCredentialCreate";
export * from "./providerCredentialRead";
export * from "./providerCredentialUpdate";
export * from "./providerUsageLiveRead";
export * from "./providerUsageResponse";
export * from "./providerUsageScrapeResult";
export * from "./readyzReadyzGet200";
export * from "./requestWindowRead";
export * from "./runtimeUsageBurnRate";
export * from "./runtimeUsageCurrent";
export * from "./runtimeUsagePredictions";
@ -283,6 +294,7 @@ export * from "./taskReadCustomFieldValues";
export * from "./taskReadStatus";
export * from "./taskUpdate";
export * from "./taskUpdateCustomFieldValues";
export * from "./tokenWindowRead";
export * from "./topSession";
export * from "./uninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPostParams";
export * from "./updateAgentApiV1AgentsAgentIdPatchParams";

View File

@ -0,0 +1,10 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatch200 = {
[key: string]: unknown;
};

View File

@ -0,0 +1,10 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody = {
[key: string]: unknown;
};

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface ProviderCredentialCreate {
provider: string;
account_key: string;
display_name?: string;
api_key?: string | null;
base_url?: string | null;
active?: boolean;
}

View File

@ -0,0 +1,23 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Safe read schema api_key is never included.
*/
export interface ProviderCredentialRead {
id: string;
organization_id: string;
provider: string;
account_key: string;
display_name: string;
api_key_last_four: string | null;
has_api_key: boolean;
base_url: string | null;
active: boolean;
created_at: string;
updated_at: string;
}

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface ProviderCredentialUpdate {
display_name?: string | null;
api_key?: string | null;
base_url?: string | null;
active?: boolean | null;
}

View File

@ -0,0 +1,23 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { RequestWindowRead } from "./requestWindowRead";
import type { TokenWindowRead } from "./tokenWindowRead";
/**
* Real-time token usage and rate limit data fetched directly from the provider API.
*/
export interface ProviderUsageLiveRead {
provider: string;
account_key: string;
checked_at: string;
reachable: boolean;
error?: string | null;
tokens: TokenWindowRead;
input_tokens: TokenWindowRead;
requests: RequestWindowRead;
models?: string[];
}

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface RequestWindowRead {
limit?: number | null;
remaining?: number | null;
reset_at?: string | null;
reset_in_ms?: number | null;
}

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface TokenWindowRead {
limit?: number | null;
remaining?: number | null;
used?: number | null;
pct_used?: number | null;
reset_at?: string | null;
reset_in_ms?: number | null;
}

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,12 @@
export const dynamic = "force-dynamic";
import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, RefreshCw, Save } from "lucide-react";
import { AgentsTable } from "@/components/agents/AgentsTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
@ -22,6 +23,9 @@ import {
type getGatewayApiV1GatewaysGatewayIdGetResponse,
useGatewaysStatusApiV1GatewaysStatusGet,
useGetGatewayApiV1GatewaysGatewayIdGet,
getGatewayLogsApiV1GatewaysGatewayIdLogsGet,
getGatewayConfigApiV1GatewaysGatewayIdConfigGet,
patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch,
} from "@/api/generated/gateways/gateways";
import {
type listAgentsApiV1AgentsGetResponse,
@ -40,8 +44,11 @@ const maskToken = (value?: string | null) => {
return `••••${value.slice(-4)}`;
};
type TabKey = "overview" | "logs" | "config";
export default function GatewayDetailPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const params = useParams();
const { isSignedIn } = useAuth();
@ -50,8 +57,58 @@ export default function GatewayDetailPage() {
? gatewayIdParam[0]
: gatewayIdParam;
const rawTab = searchParams.get("tab");
const activeTab: TabKey =
rawTab === "logs" || rawTab === "config" ? rawTab : "overview";
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [deleteTarget, setDeleteTarget] = useState<AgentRead | null>(null);
// ---- Logs tab state ----
const logsQuery = useQuery({
queryKey: ["gateway-logs", gatewayId],
enabled: Boolean(isSignedIn && isAdmin && gatewayId && activeTab === "logs"),
refetchOnMount: true,
queryFn: () =>
getGatewayLogsApiV1GatewaysGatewayIdLogsGet(gatewayId!, { lines: 200 }).then(
(r) => (r.status === 200 ? (r.data as { lines: string[]; generated_at: string }) : null),
),
});
const logsEndRef = useRef<HTMLDivElement>(null);
// ---- Config tab state ----
const configQuery = useQuery({
queryKey: ["gateway-config", gatewayId],
enabled: Boolean(isSignedIn && isAdmin && gatewayId && activeTab === "config"),
refetchOnMount: true,
queryFn: () =>
getGatewayConfigApiV1GatewaysGatewayIdConfigGet(gatewayId!).then(
(r) =>
r.status === 200
? (r.data as { config: Record<string, unknown>; available: boolean })
: null,
),
});
const [configEdits, setConfigEdits] = useState<Record<string, string>>({});
const [configSaveOpen, setConfigSaveOpen] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [configSaveError, setConfigSaveError] = useState<string | null>(null);
const handleConfigSave = async () => {
if (!gatewayId || Object.keys(configEdits).length === 0) return;
setConfigSaving(true);
setConfigSaveError(null);
try {
await patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch(gatewayId, configEdits);
setConfigEdits({});
setConfigSaveOpen(false);
void configQuery.refetch();
} catch (err) {
setConfigSaveError(err instanceof Error ? err.message : "Config update failed.");
} finally {
setConfigSaving(false);
}
};
const agentsKey = getListAgentsApiV1AgentsGetQueryKey(
gatewayId ? { gateway_id: gatewayId } : undefined,
);
@ -166,7 +223,13 @@ export default function GatewayDetailPage() {
forceRedirectUrl: `/gateways/${gatewayId}`,
}}
title={title}
description="Gateway configuration and connection details."
description={
activeTab === "logs"
? "Recent log output from the gateway process."
: activeTab === "config"
? "Current gateway configuration. Secrets are masked."
: "Gateway configuration and connection details."
}
headerActions={
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.push("/gateways")}>
@ -184,15 +247,224 @@ export default function GatewayDetailPage() {
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can access gateways."
>
{gatewayQuery.isLoading ? (
{/* Tab bar */}
{isAdmin && (
<div className="mb-6 flex gap-1 border-b border-border pb-px">
{(
[
{ key: "overview", label: "Overview" },
{ key: "logs", label: "Logs" },
{ key: "config", label: "Config" },
] as const
).map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() =>
router.push(
key === "overview"
? `/gateways/${gatewayId}`
: `/gateways/${gatewayId}?tab=${key}`,
)
}
className={`rounded-t px-4 py-2 text-sm font-medium transition ${
activeTab === key
? "border border-b-card border-border bg-card text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{label}
</button>
))}
</div>
)}
{/* Logs tab */}
{activeTab === "logs" && isAdmin && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{logsQuery.data ? `${logsQuery.data.lines.length} lines` : ""}
</p>
<Button
variant="outline"
size="sm"
onClick={() => void logsQuery.refetch()}
disabled={logsQuery.isFetching}
>
<RefreshCw
className={`mr-2 h-3.5 w-3.5 ${logsQuery.isFetching ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
{logsQuery.isLoading ? (
<div className="rounded-xl border border-border bg-card p-6 text-sm text-muted-foreground">
Loading logs
</div>
) : logsQuery.error ? (
<div className="flex items-start gap-2 rounded-xl border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
{logsQuery.error instanceof Error
? logsQuery.error.message
: "Could not load logs."}
</div>
) : (
<div className="max-h-[60vh] overflow-y-auto rounded-xl border border-border bg-[#0d1117] p-4 font-mono text-xs leading-5">
{(logsQuery.data?.lines ?? []).length === 0 ? (
<p className="text-muted-foreground">No log output available.</p>
) : (
(logsQuery.data?.lines ?? []).map((line, i) => {
const low = line.toLowerCase();
const cls = low.includes("error") || low.includes("err ")
? "text-red-400"
: low.includes("warn")
? "text-yellow-400"
: "text-slate-300";
return (
<div key={i} className={`whitespace-pre-wrap break-all ${cls}`}>
{line}
</div>
);
})
)}
<div ref={logsEndRef} />
</div>
)}
</div>
)}
{/* Config tab */}
{activeTab === "config" && isAdmin && (
<div className="space-y-4">
{configQuery.isLoading ? (
<div className="rounded-xl border border-border bg-card p-6 text-sm text-muted-foreground">
Loading config
</div>
) : configQuery.error ? (
<div className="flex items-start gap-2 rounded-xl border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
{configQuery.error instanceof Error
? configQuery.error.message
: "Could not load config."}
</div>
) : !configQuery.data?.available ? (
<div className="rounded-xl border border-border bg-card p-6 text-sm text-muted-foreground">
Config data is unavailable. The gateway may not expose its configuration via this API.
</div>
) : (
<>
{Object.keys(configEdits).length > 0 && (
<div className="flex items-center justify-between rounded-lg border border-amber-300/50 bg-amber-50/10 px-4 py-2 text-sm text-amber-600">
<span>{Object.keys(configEdits).length} unsaved change{Object.keys(configEdits).length !== 1 ? "s" : ""}</span>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => setConfigEdits({})}>
Discard
</Button>
<Button
size="sm"
onClick={() => setConfigSaveOpen(true)}
>
<Save className="mr-2 h-3.5 w-3.5" />
Review &amp; save
</Button>
</div>
</div>
)}
<div className="rounded-xl border border-border bg-card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground">Key</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground">Value</th>
</tr>
</thead>
<tbody>
{Object.entries(configQuery.data.config).map(([key, rawValue]) => {
const displayValue = configEdits[key] ?? String(rawValue ?? "");
const isMasked = String(rawValue).startsWith("••••");
const isEdited = key in configEdits;
return (
<tr
key={key}
className={`border-b border-border last:border-0 ${isEdited ? "bg-amber-50/5" : ""}`}
>
<td className="px-4 py-2.5">
<code className="text-xs text-foreground">{key}</code>
</td>
<td className="px-4 py-2.5">
{isMasked ? (
<div className="flex items-center gap-2">
<code className="text-xs text-muted-foreground">{String(rawValue)}</code>
<input
type="password"
placeholder="Enter new value to change"
value={configEdits[key] ?? ""}
onChange={(e) => {
const v = e.target.value;
setConfigEdits((prev) =>
v ? { ...prev, [key]: v } : Object.fromEntries(Object.entries(prev).filter(([k]) => k !== key)),
);
}}
className="h-7 w-48 rounded border border-border bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
autoComplete="new-password"
/>
</div>
) : (
<input
type="text"
value={displayValue}
onChange={(e) => {
const v = e.target.value;
setConfigEdits((prev) =>
v !== String(rawValue ?? "")
? { ...prev, [key]: v }
: Object.fromEntries(Object.entries(prev).filter(([k]) => k !== key)),
);
}}
className={`h-7 w-full rounded border px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring ${isEdited ? "border-amber-400 bg-amber-50/5" : "border-transparent bg-transparent hover:border-border"}`}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{/* Config save confirmation */}
<ConfirmActionDialog
open={configSaveOpen}
onOpenChange={setConfigSaveOpen}
title="Apply config changes"
description={
Object.keys(configEdits).length > 0
? `You are about to change ${Object.keys(configEdits).length} setting${Object.keys(configEdits).length !== 1 ? "s" : ""}: ${Object.keys(configEdits).join(", ")}. The gateway will apply the changes immediately. This is logged.`
: ""
}
onConfirm={handleConfigSave}
isConfirming={configSaving}
errorMessage={configSaveError}
confirmLabel="Apply changes"
confirmingLabel="Applying…"
cancelLabel="Go back"
/>
{/* Overview tab (existing content) */}
{activeTab === "overview" && gatewayQuery.isLoading ? (
<div className="rounded-xl border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
Loading gateway
</div>
) : gatewayQuery.error ? (
) : activeTab === "overview" && gatewayQuery.error ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-700">
{gatewayQuery.error.message}
</div>
) : gateway ? (
) : activeTab === "overview" && gateway ? (
<div className="space-y-6">
<div className="grid gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">

View File

@ -0,0 +1,646 @@
"use client";
export const dynamic = "force-dynamic";
import { useCallback, useEffect, useState } from "react";
import { Bot, KeyRound, Loader2, Plus, RefreshCw, Server, Trash2, X } from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type { ProviderCredentialRead, ProviderUsageLiveRead } from "@/api/generated/model";
import {
listProviderCredentialsApiV1ProviderCredentialsGet,
createProviderCredentialApiV1ProviderCredentialsPost,
updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch,
deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete,
getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet,
} from "@/api/generated/provider-credentials/provider-credentials";
// ---------------------------------------------------------------------------
// Provider metadata
// ---------------------------------------------------------------------------
const PROVIDERS = [
{
id: "anthropic",
label: "Claude (Anthropic)",
icon: Bot,
description: "Anthropic Claude API key. Add multiple accounts to track separate keys or billing profiles.",
keyLabel: "API Key",
keyPlaceholder: "sk-ant-…",
showBaseUrl: false,
allowMultiple: true,
accountKeyDefault: "default",
},
{
id: "openai",
label: "Codex / OpenAI",
icon: Bot,
description: "OpenAI API key. Add multiple accounts to track usage separately.",
keyLabel: "API Key",
keyPlaceholder: "sk-…",
showBaseUrl: false,
allowMultiple: true,
accountKeyDefault: "",
},
{
id: "ollama",
label: "Ollama",
icon: Server,
description: "Ollama instance — local, on-prem, or cloud. Add the base URL; API key only needed for authenticated deployments.",
keyLabel: "API Key (optional)",
keyPlaceholder: "Leave blank for unauthenticated",
showBaseUrl: true,
allowMultiple: true,
accountKeyDefault: "default",
},
] as const;
type ProviderId = (typeof PROVIDERS)[number]["id"];
// ---------------------------------------------------------------------------
// Add/Edit form
// ---------------------------------------------------------------------------
interface CredentialFormProps {
providerId: ProviderId;
allowMultiple: boolean;
showBaseUrl: boolean;
keyLabel: string;
keyPlaceholder: string;
accountKeyDefault: string;
onSave: (data: {
account_key: string;
display_name: string;
api_key: string;
base_url: string;
}) => Promise<void>;
onCancel: () => void;
isSaving: boolean;
error: string | null;
}
function CredentialForm({
allowMultiple,
showBaseUrl,
keyLabel,
keyPlaceholder,
accountKeyDefault,
onSave,
onCancel,
isSaving,
error,
}: CredentialFormProps) {
const [accountKey, setAccountKey] = useState(accountKeyDefault);
const [displayName, setDisplayName] = useState("");
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
return (
<div className="mt-3 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
<div className="space-y-3">
{allowMultiple && (
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Account name <span className="text-[color:var(--danger)]">*</span>
</label>
<Input
value={accountKey}
onChange={(e) => setAccountKey(e.target.value)}
placeholder="e.g. work, personal, gpu-box"
disabled={isSaving}
/>
<p className="mt-1 text-[11px] text-muted">
Used to tell accounts apart in cost reports.
</p>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Display name (optional)
</label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={accountKey || "My account"}
disabled={isSaving}
/>
</div>
{showBaseUrl && (
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Base URL <span className="text-[color:var(--danger)]">*</span>
</label>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://localhost:11434"
disabled={isSaving}
/>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-muted">
{keyLabel}
</label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={keyPlaceholder}
disabled={isSaving}
autoComplete="new-password"
/>
</div>
{error && (
<p className="text-sm text-[color:var(--danger)]">{error}</p>
)}
<div className="flex gap-2">
<Button
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() })
}
>
{isSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
)}
{isSaving ? "Saving…" : "Save"}
</Button>
<Button variant="outline" size="sm" onClick={onCancel} disabled={isSaving}>
<X className="mr-1.5 h-3.5 w-3.5" />
Cancel
</Button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Usage strip — live token data fetched from the provider
// ---------------------------------------------------------------------------
function fmtTokens(n: number | null | undefined): string {
if (n == null) return "—";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
return n.toString();
}
function fmtResetMs(ms: number | null | undefined): string {
if (ms == null || ms <= 0) return "now";
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function UsageStrip({ credentialId, provider }: { credentialId: string; provider: string }) {
const [usage, setUsage] = useState<ProviderUsageLiveRead | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastFetched, setLastFetched] = useState<Date | null>(null);
const fetch = useCallback(async (refresh = false) => {
setLoading(true);
setError(null);
try {
const res = await getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet(
credentialId,
refresh ? { refresh: true } : undefined,
);
if (res.status === 200) {
setUsage(res.data);
setLastFetched(new Date());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch usage.");
} finally {
setLoading(false);
}
}, [credentialId]);
useEffect(() => { void fetch(); }, [fetch]);
if (loading && !usage) {
return (
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
Fetching live usage
</div>
);
}
if (!usage?.reachable) {
return (
<div className="mt-2 flex items-center gap-2 text-xs text-[color:var(--danger)]">
<span>{usage?.error ?? error ?? "Provider unreachable."}</span>
<button type="button" onClick={() => fetch(true)} className="ml-1 underline hover:opacity-70">
Retry
</button>
</div>
);
}
const tok = usage.tokens;
const req = usage.requests;
const isOllama = provider === "ollama";
return (
<div className="mt-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-2.5">
{isOllama ? (
<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)]" />
Connected
</span>
{(usage.models?.length ?? 0) > 0 && (
<span>{usage.models!.length} model{usage.models!.length !== 1 ? "s" : ""} available</span>
)}
<button type="button" onClick={() => fetch(true)} className="ml-auto text-muted hover:text-strong">
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
) : (
<div className="space-y-1.5">
{/* Tokens */}
{tok.limit != null ? (
<div>
<div className="mb-1 flex items-center justify-between text-[11px]">
<span className="font-medium text-muted">Tokens</span>
<span className="tabular-nums text-strong">
{fmtTokens(tok.remaining)} / {fmtTokens(tok.limit)} remaining
{tok.reset_in_ms != null && (
<span className="ml-2 text-muted">· resets in {fmtResetMs(tok.reset_in_ms)}</span>
)}
</span>
</div>
{tok.pct_used != null && (
<div className="h-1.5 overflow-hidden rounded-full bg-[color:var(--surface-strong)]">
<div
className={`h-full rounded-full transition-all ${
tok.pct_used > 90 ? "bg-[color:var(--danger)]" :
tok.pct_used > 75 ? "bg-[color:var(--warning)]" :
"bg-[color:var(--success)]"
}`}
style={{ width: `${Math.min(100, tok.pct_used)}%` }}
/>
</div>
)}
</div>
) : null}
{/* Requests */}
{req.limit != null ? (
<div className="flex items-center justify-between text-[11px] text-muted">
<span>Requests</span>
<span className="tabular-nums">
{req.remaining ?? "—"} / {req.limit} remaining
{req.reset_in_ms != null && (
<span className="ml-2">· resets in {fmtResetMs(req.reset_in_ms)}</span>
)}
</span>
</div>
) : null}
{tok.limit == null && req.limit == null && (
<p className="text-[11px] text-muted">
Connected no rate limit headers returned by this key tier.
</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>}
<button type="button" onClick={() => fetch(true)} className="ml-auto flex items-center gap-1 hover:text-strong">
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
Refresh
</button>
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Credential row
// ---------------------------------------------------------------------------
interface CredentialRowProps {
cred: ProviderCredentialRead;
isAdmin: boolean;
onDelete: (cred: ProviderCredentialRead) => void;
onToggle: (cred: ProviderCredentialRead) => Promise<void>;
showUsage?: boolean;
}
function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }: CredentialRowProps) {
const [toggling, setToggling] = useState(false);
return (
<div
className={`rounded-lg border px-3 py-2.5 ${
cred.active
? "border-[color:var(--border)] bg-[color:var(--surface-muted)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] opacity-60"
}`}
>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-strong">
{cred.display_name || cred.account_key}
</p>
<div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0 text-[11px] text-muted">
<span>key: {cred.account_key}</span>
{cred.has_api_key && cred.api_key_last_four ? (
<span className="flex items-center gap-1">
<KeyRound className="h-3 w-3" />
{cred.api_key_last_four}
</span>
) : cred.has_api_key ? (
<span className="flex items-center gap-1">
<KeyRound className="h-3 w-3" />
set
</span>
) : (
<span className="text-[color:var(--warning)]">no key</span>
)}
{cred.base_url && <span className="truncate max-w-[200px]">{cred.base_url}</span>}
</div>
</div>
{isAdmin && (
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={async () => {
setToggling(true);
await onToggle(cred);
setToggling(false);
}}
disabled={toggling}
className={`rounded-full px-2 py-0.5 text-[11px] font-medium transition ${
cred.active
? "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)] hover:opacity-80"
: "bg-[color:var(--surface-strong)] text-muted hover:opacity-80"
}`}
>
{toggling ? "…" : cred.active ? "Active" : "Inactive"}
</button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[color:var(--danger)] hover:bg-[color:var(--danger-soft)]"
onClick={() => onDelete(cred)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
{showUsage && cred.active && (cred.has_api_key || cred.base_url) && (
<UsageStrip credentialId={cred.id} provider={cred.provider} />
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Provider section
// ---------------------------------------------------------------------------
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>;
onDelete: (cred: ProviderCredentialRead) => void;
onToggle: (cred: ProviderCredentialRead) => Promise<void>;
}
function ProviderSection({ provider, credentials, isAdmin, onAdd, onDelete, onToggle }: ProviderSectionProps) {
const Icon = provider.icon;
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const canAdd = isAdmin && (provider.allowMultiple || credentials.length === 0);
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)] shrink-0">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold text-strong">{provider.label}</h2>
<p className="mt-0.5 text-sm text-muted">{provider.description}</p>
</div>
</div>
{canAdd && !showForm && (
<Button
size="sm"
variant="outline"
onClick={() => { setShowForm(true); setSaveError(null); }}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
)}
</div>
{showForm && (
<CredentialForm
providerId={provider.id}
allowMultiple={provider.allowMultiple}
showBaseUrl={provider.showBaseUrl}
keyLabel={provider.keyLabel}
keyPlaceholder={provider.keyPlaceholder}
accountKeyDefault={provider.accountKeyDefault}
onSave={async (data) => {
setSaving(true);
setSaveError(null);
try {
await onAdd(provider.id, data);
setShowForm(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Save failed.");
} finally {
setSaving(false);
}
}}
onCancel={() => setShowForm(false)}
isSaving={saving}
error={saveError}
/>
)}
{credentials.length > 0 && (
<div className="mt-3 space-y-2">
{credentials.map((cred) => (
<CredentialRow
key={cred.id}
cred={cred}
isAdmin={isAdmin}
onDelete={onDelete}
onToggle={onToggle}
/>
))}
</div>
)}
{credentials.length === 0 && !showForm && (
<p className="mt-3 text-sm text-muted">
No {provider.label} accounts configured.
{isAdmin ? " Click Add to set one up." : ""}
</p>
)}
</section>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function AIProvidersSettingsPage() {
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProviderCredentialRead | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setIsLoading(true);
const res = await listProviderCredentialsApiV1ProviderCredentialsGet();
if (res.status === 200) setCredentials(res.data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load provider settings.");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (isSignedIn) void load();
}, [isSignedIn, load]);
const handleAdd = async (
providerId: ProviderId,
data: { account_key: string; display_name: string; api_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,
base_url: data.base_url || undefined,
});
if (res.status === 201) {
setCredentials((prev) => [...prev, res.data]);
} else {
throw new Error("Failed to save credential.");
}
};
const handleToggle = async (cred: ProviderCredentialRead) => {
const res = await updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch(
cred.id,
{ active: !cred.active },
);
if (res.status === 200) {
setCredentials((prev) => prev.map((c) => (c.id === cred.id ? res.data : c)));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
setDeleteError(null);
try {
await deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete(deleteTarget.id);
setCredentials((prev) => prev.filter((c) => c.id !== deleteTarget.id));
setDeleteTarget(null);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Delete failed.");
} finally {
setIsDeleting(false);
}
};
return (
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to manage AI provider settings.",
forceRedirectUrl: "/settings/ai-providers",
signUpForceRedirectUrl: "/settings/ai-providers",
}}
title="AI Providers"
description="Configure API keys and endpoints for the AI providers your gateway uses."
>
{isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
Loading provider settings
</div>
) : error ? (
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-4 text-sm text-[color:var(--danger)]">
{error}
</div>
) : (
<div className="space-y-4">
{!isAdmin && (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
You can view provider settings. Only org admins can add or change credentials.
</div>
)}
{PROVIDERS.map((provider) => (
<ProviderSection
key={provider.id}
provider={provider}
credentials={credentials.filter((c) => c.provider === provider.id)}
isAdmin={isAdmin}
onAdd={handleAdd}
onDelete={setDeleteTarget}
onToggle={handleToggle}
/>
))}
</div>
)}
</DashboardPageLayout>
<ConfirmActionDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title="Remove provider account"
description={
deleteTarget
? `Remove "${deleteTarget.display_name || deleteTarget.account_key}" (${deleteTarget.provider})? The stored key will be deleted and cannot be recovered.`
: ""
}
onConfirm={handleDelete}
isConfirming={isDeleting}
errorMessage={deleteError}
confirmLabel="Remove account"
confirmingLabel="Removing…"
confirmClassName="bg-destructive text-destructive-foreground hover:bg-destructive/90"
cancelLabel="Keep it"
/>
</>
);
}

View File

@ -10,6 +10,7 @@ import { useAuth, useUser } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Bot,
GitBranch,
Globe,
Mail,
@ -251,6 +252,28 @@ export default function SettingsPage() {
</form>
</section>
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground">
<Bot className="h-4 w-4 text-muted-foreground" />
AI Providers
</h2>
<p className="mt-1 text-sm text-muted-foreground">
API keys and endpoints for Claude, Codex/OpenAI, and Ollama
(local, on-prem, or cloud). Add multiple accounts to track
usage separately.
</p>
</div>
<Link href="/settings/ai-providers">
<Button type="button" variant="outline">
Manage Providers
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">