feat(admin): gateway restart, config edit, logs, provider credentials (batch 6, #35)
This commit is contained in:
parent
4e40323e71
commit
4c5264d2ed
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 & 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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue