diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index eda0c71..b9ff9f0 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -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)", diff --git a/backend/app/api/provider_credentials.py b/backend/app/api/provider_credentials.py new file mode 100644 index 0000000..2161cec --- /dev/null +++ b/backend/app/api/provider_credentials.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 90e8db7..d11fd52 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 36f8201..85f34ec 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/provider_credentials.py b/backend/app/models/provider_credentials.py new file mode 100644 index 0000000..39d32b4 --- /dev/null +++ b/backend/app/models/provider_credentials.py @@ -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) diff --git a/backend/app/schemas/provider_credentials.py b/backend/app/schemas/provider_credentials.py new file mode 100644 index 0000000..f8faba8 --- /dev/null +++ b/backend/app/schemas/provider_credentials.py @@ -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 diff --git a/backend/app/services/provider_usage.py b/backend/app/services/provider_usage.py new file mode 100644 index 0000000..6dcae63 --- /dev/null +++ b/backend/app/services/provider_usage.py @@ -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 diff --git a/backend/migrations/versions/d1e2f3a4b5c6_add_provider_credentials.py b/backend/migrations/versions/d1e2f3a4b5c6_add_provider_credentials.py new file mode 100644 index 0000000..2e989a5 --- /dev/null +++ b/backend/migrations/versions/d1e2f3a4b5c6_add_provider_credentials.py @@ -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") diff --git a/frontend/src/api/generated/gateways/gateways.ts b/frontend/src/api/generated/gateways/gateways.ts index 66f0b19..158603e 100644 --- a/frontend/src/api/generated/gateways/gateways.ts +++ b/frontend/src/api/generated/gateways/gateways.ts @@ -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 => { + return customFetch( + 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 + >, + TError = HTTPValidationError, +>( + gatewayId: string, + params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryKey(gatewayId, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getGatewayLogsApiV1GatewaysGatewayIdLogsGet(gatewayId, params, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!gatewayId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryResult = + NonNullable< + Awaited> + >; +export type GetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryError = + HTTPValidationError; + +export function useGetGatewayLogsApiV1GatewaysGatewayIdLogsGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + gatewayId: string, + params: undefined | GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetGatewayLogsApiV1GatewaysGatewayIdLogsGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + gatewayId: string, + params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetGatewayLogsApiV1GatewaysGatewayIdLogsGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + gatewayId: string, + params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Tail gateway logs (admin) + */ + +export function useGetGatewayLogsApiV1GatewaysGatewayIdLogsGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + gatewayId: string, + params?: GetGatewayLogsApiV1GatewaysGatewayIdLogsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getGetGatewayLogsApiV1GatewaysGatewayIdLogsGetQueryOptions( + gatewayId, + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + 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 => { + return customFetch( + getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetUrl(gatewayId), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryKey = ( + gatewayId: string, +) => { + return [`/api/v1/gateways/${gatewayId}/config`] as const; +}; + +export const getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + gatewayId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryKey(gatewayId); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getGatewayConfigApiV1GatewaysGatewayIdConfigGet(gatewayId, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!gatewayId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryResult = + NonNullable< + Awaited> + >; +export type GetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryError = + HTTPValidationError; + +export function useGetGatewayConfigApiV1GatewaysGatewayIdConfigGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + gatewayId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetGatewayConfigApiV1GatewaysGatewayIdConfigGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + gatewayId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetGatewayConfigApiV1GatewaysGatewayIdConfigGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + gatewayId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Read gateway config (admin) + */ + +export function useGetGatewayConfigApiV1GatewaysGatewayIdConfigGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + gatewayId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getGetGatewayConfigApiV1GatewaysGatewayIdConfigGetQueryOptions( + gatewayId, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + 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 => { + return customFetch( + getPatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchUrl(gatewayId), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify( + patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody, + ), + }, + ); +}; + +export const getPatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { + gatewayId: string; + data: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody; + }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType + >, + 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 + >, + { + gatewayId: string; + data: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody; + } + > = (props) => { + const { gatewayId, data } = props ?? {}; + + return patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch( + gatewayId, + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchMutationResult = + NonNullable< + Awaited< + ReturnType + > + >; +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 + >, + TError, + { + gatewayId: string; + data: PatchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody; + }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType + >, + 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) diff --git a/frontend/src/api/generated/model/getGatewayConfigApiV1GatewaysGatewayIdConfigGet200.ts b/frontend/src/api/generated/model/getGatewayConfigApiV1GatewaysGatewayIdConfigGet200.ts new file mode 100644 index 0000000..367900e --- /dev/null +++ b/frontend/src/api/generated/model/getGatewayConfigApiV1GatewaysGatewayIdConfigGet200.ts @@ -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; +}; diff --git a/frontend/src/api/generated/model/getGatewayLogsApiV1GatewaysGatewayIdLogsGet200.ts b/frontend/src/api/generated/model/getGatewayLogsApiV1GatewaysGatewayIdLogsGet200.ts new file mode 100644 index 0000000..05f2365 --- /dev/null +++ b/frontend/src/api/generated/model/getGatewayLogsApiV1GatewaysGatewayIdLogsGet200.ts @@ -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; +}; diff --git a/frontend/src/api/generated/model/getGatewayLogsApiV1GatewaysGatewayIdLogsGetParams.ts b/frontend/src/api/generated/model/getGatewayLogsApiV1GatewaysGatewayIdLogsGetParams.ts new file mode 100644 index 0000000..2bb372c --- /dev/null +++ b/frontend/src/api/generated/model/getGatewayLogsApiV1GatewaysGatewayIdLogsGetParams.ts @@ -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; +}; diff --git a/frontend/src/api/generated/model/getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams.ts b/frontend/src/api/generated/model/getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams.ts new file mode 100644 index 0000000..c66077a --- /dev/null +++ b/frontend/src/api/generated/model/getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams.ts @@ -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; + }; diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index 6716fb6..501c414 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -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"; diff --git a/frontend/src/api/generated/model/patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch200.ts b/frontend/src/api/generated/model/patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch200.ts new file mode 100644 index 0000000..07860ef --- /dev/null +++ b/frontend/src/api/generated/model/patchGatewayConfigApiV1GatewaysGatewayIdConfigPatch200.ts @@ -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; +}; diff --git a/frontend/src/api/generated/model/patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody.ts b/frontend/src/api/generated/model/patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody.ts new file mode 100644 index 0000000..2acb72f --- /dev/null +++ b/frontend/src/api/generated/model/patchGatewayConfigApiV1GatewaysGatewayIdConfigPatchBody.ts @@ -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; +}; diff --git a/frontend/src/api/generated/model/providerCredentialCreate.ts b/frontend/src/api/generated/model/providerCredentialCreate.ts new file mode 100644 index 0000000..e5b69f0 --- /dev/null +++ b/frontend/src/api/generated/model/providerCredentialCreate.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/providerCredentialRead.ts b/frontend/src/api/generated/model/providerCredentialRead.ts new file mode 100644 index 0000000..e343d07 --- /dev/null +++ b/frontend/src/api/generated/model/providerCredentialRead.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/providerCredentialUpdate.ts b/frontend/src/api/generated/model/providerCredentialUpdate.ts new file mode 100644 index 0000000..d051197 --- /dev/null +++ b/frontend/src/api/generated/model/providerCredentialUpdate.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/providerUsageLiveRead.ts b/frontend/src/api/generated/model/providerUsageLiveRead.ts new file mode 100644 index 0000000..23c2a4c --- /dev/null +++ b/frontend/src/api/generated/model/providerUsageLiveRead.ts @@ -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[]; +} diff --git a/frontend/src/api/generated/model/requestWindowRead.ts b/frontend/src/api/generated/model/requestWindowRead.ts new file mode 100644 index 0000000..33d6695 --- /dev/null +++ b/frontend/src/api/generated/model/requestWindowRead.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/tokenWindowRead.ts b/frontend/src/api/generated/model/tokenWindowRead.ts new file mode 100644 index 0000000..a2f1a61 --- /dev/null +++ b/frontend/src/api/generated/model/tokenWindowRead.ts @@ -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; +} diff --git a/frontend/src/api/generated/provider-credentials/provider-credentials.ts b/frontend/src/api/generated/provider-credentials/provider-credentials.ts new file mode 100644 index 0000000..49aa929 --- /dev/null +++ b/frontend/src/api/generated/provider-credentials/provider-credentials.ts @@ -0,0 +1,1317 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams, + HTTPValidationError, + ProviderCredentialCreate, + ProviderCredentialRead, + ProviderCredentialUpdate, + ProviderUsageLiveRead, +} from ".././model"; + +import { customFetch } from "../../mutator"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * List all provider credentials for the caller's organisation. + * @summary List Provider Credentials + */ +export type listProviderCredentialsApiV1ProviderCredentialsGetResponse200 = { + data: ProviderCredentialRead[]; + status: 200; +}; + +export type listProviderCredentialsApiV1ProviderCredentialsGetResponseSuccess = + listProviderCredentialsApiV1ProviderCredentialsGetResponse200 & { + headers: Headers; + }; +export type listProviderCredentialsApiV1ProviderCredentialsGetResponse = + listProviderCredentialsApiV1ProviderCredentialsGetResponseSuccess; + +export const getListProviderCredentialsApiV1ProviderCredentialsGetUrl = () => { + return `/api/v1/provider-credentials`; +}; + +export const listProviderCredentialsApiV1ProviderCredentialsGet = async ( + options?: RequestInit, +): Promise => { + return customFetch( + getListProviderCredentialsApiV1ProviderCredentialsGetUrl(), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListProviderCredentialsApiV1ProviderCredentialsGetQueryKey = + () => { + return [`/api/v1/provider-credentials`] as const; + }; + +export const getListProviderCredentialsApiV1ProviderCredentialsGetQueryOptions = + < + TData = Awaited< + ReturnType + >, + TError = unknown, + >(options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListProviderCredentialsApiV1ProviderCredentialsGetQueryKey(); + + const queryFn: QueryFunction< + Awaited< + ReturnType + > + > = ({ signal }) => + listProviderCredentialsApiV1ProviderCredentialsGet({ + signal, + ...requestOptions, + }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > & { queryKey: DataTag }; + }; + +export type ListProviderCredentialsApiV1ProviderCredentialsGetQueryResult = + NonNullable< + Awaited< + ReturnType + > + >; +export type ListProviderCredentialsApiV1ProviderCredentialsGetQueryError = + unknown; + +export function useListProviderCredentialsApiV1ProviderCredentialsGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType< + typeof listProviderCredentialsApiV1ProviderCredentialsGet + > + >, + TError, + Awaited< + ReturnType< + typeof listProviderCredentialsApiV1ProviderCredentialsGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListProviderCredentialsApiV1ProviderCredentialsGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType< + typeof listProviderCredentialsApiV1ProviderCredentialsGet + > + >, + TError, + Awaited< + ReturnType< + typeof listProviderCredentialsApiV1ProviderCredentialsGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListProviderCredentialsApiV1ProviderCredentialsGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Provider Credentials + */ + +export function useListProviderCredentialsApiV1ProviderCredentialsGet< + TData = Awaited< + ReturnType + >, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListProviderCredentialsApiV1ProviderCredentialsGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Create a new provider credential. Admin-only. + * @summary Create Provider Credential + */ +export type createProviderCredentialApiV1ProviderCredentialsPostResponse201 = { + data: ProviderCredentialRead; + status: 201; +}; + +export type createProviderCredentialApiV1ProviderCredentialsPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type createProviderCredentialApiV1ProviderCredentialsPostResponseSuccess = + createProviderCredentialApiV1ProviderCredentialsPostResponse201 & { + headers: Headers; + }; +export type createProviderCredentialApiV1ProviderCredentialsPostResponseError = + createProviderCredentialApiV1ProviderCredentialsPostResponse422 & { + headers: Headers; + }; + +export type createProviderCredentialApiV1ProviderCredentialsPostResponse = + | createProviderCredentialApiV1ProviderCredentialsPostResponseSuccess + | createProviderCredentialApiV1ProviderCredentialsPostResponseError; + +export const getCreateProviderCredentialApiV1ProviderCredentialsPostUrl = + () => { + return `/api/v1/provider-credentials`; + }; + +export const createProviderCredentialApiV1ProviderCredentialsPost = async ( + providerCredentialCreate: ProviderCredentialCreate, + options?: RequestInit, +): Promise => { + return customFetch( + getCreateProviderCredentialApiV1ProviderCredentialsPostUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(providerCredentialCreate), + }, + ); +}; + +export const getCreateProviderCredentialApiV1ProviderCredentialsPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: ProviderCredentialCreate }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: ProviderCredentialCreate }, + TContext + > => { + const mutationKey = [ + "createProviderCredentialApiV1ProviderCredentialsPost", + ]; + 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 + >, + { data: ProviderCredentialCreate } + > = (props) => { + const { data } = props ?? {}; + + return createProviderCredentialApiV1ProviderCredentialsPost( + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type CreateProviderCredentialApiV1ProviderCredentialsPostMutationResult = + NonNullable< + Awaited< + ReturnType + > + >; +export type CreateProviderCredentialApiV1ProviderCredentialsPostMutationBody = + ProviderCredentialCreate; +export type CreateProviderCredentialApiV1ProviderCredentialsPostMutationError = + HTTPValidationError; + +/** + * @summary Create Provider Credential + */ +export const useCreateProviderCredentialApiV1ProviderCredentialsPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType + >, + TError, + { data: ProviderCredentialCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType + >, + TError, + { data: ProviderCredentialCreate }, + TContext +> => { + return useMutation( + getCreateProviderCredentialApiV1ProviderCredentialsPostMutationOptions( + options, + ), + queryClient, + ); +}; +/** + * @summary Get Provider Credential + */ +export type getProviderCredentialApiV1ProviderCredentialsCredentialIdGetResponse200 = + { + data: ProviderCredentialRead; + status: 200; + }; + +export type getProviderCredentialApiV1ProviderCredentialsCredentialIdGetResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type getProviderCredentialApiV1ProviderCredentialsCredentialIdGetResponseSuccess = + getProviderCredentialApiV1ProviderCredentialsCredentialIdGetResponse200 & { + headers: Headers; + }; +export type getProviderCredentialApiV1ProviderCredentialsCredentialIdGetResponseError = + getProviderCredentialApiV1ProviderCredentialsCredentialIdGetResponse422 & { + headers: Headers; + }; + +export type getProviderCredentialApiV1ProviderCredentialsCredentialIdGetResponse = + + | getProviderCredentialApiV1ProviderCredentialsCredentialIdGetResponseSuccess + | getProviderCredentialApiV1ProviderCredentialsCredentialIdGetResponseError; + +export const getGetProviderCredentialApiV1ProviderCredentialsCredentialIdGetUrl = + (credentialId: string) => { + return `/api/v1/provider-credentials/${credentialId}`; + }; + +export const getProviderCredentialApiV1ProviderCredentialsCredentialIdGet = + async ( + credentialId: string, + options?: RequestInit, + ): Promise => { + return customFetch( + getGetProviderCredentialApiV1ProviderCredentialsCredentialIdGetUrl( + credentialId, + ), + { + ...options, + method: "GET", + }, + ); + }; + +export const getGetProviderCredentialApiV1ProviderCredentialsCredentialIdGetQueryKey = + (credentialId: string) => { + return [`/api/v1/provider-credentials/${credentialId}`] as const; + }; + +export const getGetProviderCredentialApiV1ProviderCredentialsCredentialIdGetQueryOptions = + < + TData = Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError = HTTPValidationError, + >( + credentialId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetProviderCredentialApiV1ProviderCredentialsCredentialIdGetQueryKey( + credentialId, + ); + + const queryFn: QueryFunction< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + > + > = ({ signal }) => + getProviderCredentialApiV1ProviderCredentialsCredentialIdGet( + credentialId, + { signal, ...requestOptions }, + ); + + return { + queryKey, + queryFn, + enabled: !!credentialId, + ...queryOptions, + } as UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError, + TData + > & { queryKey: DataTag }; + }; + +export type GetProviderCredentialApiV1ProviderCredentialsCredentialIdGetQueryResult = + NonNullable< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + > + >; +export type GetProviderCredentialApiV1ProviderCredentialsCredentialIdGetQueryError = + HTTPValidationError; + +export function useGetProviderCredentialApiV1ProviderCredentialsCredentialIdGet< + TData = Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError = HTTPValidationError, +>( + credentialId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError, + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetProviderCredentialApiV1ProviderCredentialsCredentialIdGet< + TData = Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError = HTTPValidationError, +>( + credentialId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError, + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetProviderCredentialApiV1ProviderCredentialsCredentialIdGet< + TData = Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError = HTTPValidationError, +>( + credentialId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get Provider Credential + */ + +export function useGetProviderCredentialApiV1ProviderCredentialsCredentialIdGet< + TData = Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError = HTTPValidationError, +>( + credentialId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderCredentialApiV1ProviderCredentialsCredentialIdGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getGetProviderCredentialApiV1ProviderCredentialsCredentialIdGetQueryOptions( + credentialId, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Update a credential. Pass api_key="" to clear it. Admin-only. + * @summary Update Provider Credential + */ +export type updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchResponse200 = + { + data: ProviderCredentialRead; + status: 200; + }; + +export type updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchResponseSuccess = + updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchResponse200 & { + headers: Headers; + }; +export type updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchResponseError = + updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchResponse422 & { + headers: Headers; + }; + +export type updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchResponse = + + | updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchResponseSuccess + | updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchResponseError; + +export const getUpdateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchUrl = + (credentialId: string) => { + return `/api/v1/provider-credentials/${credentialId}`; + }; + +export const updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch = + async ( + credentialId: string, + providerCredentialUpdate: ProviderCredentialUpdate, + options?: RequestInit, + ): Promise => { + return customFetch( + getUpdateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchUrl( + credentialId, + ), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(providerCredentialUpdate), + }, + ); + }; + +export const getUpdateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch + > + >, + TError, + { credentialId: string; data: ProviderCredentialUpdate }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType< + typeof updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch + > + >, + TError, + { credentialId: string; data: ProviderCredentialUpdate }, + TContext + > => { + const mutationKey = [ + "updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch", + ]; + 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 updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch + > + >, + { credentialId: string; data: ProviderCredentialUpdate } + > = (props) => { + const { credentialId, data } = props ?? {}; + + return updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch( + credentialId, + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type UpdateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchMutationResult = + NonNullable< + Awaited< + ReturnType< + typeof updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch + > + > + >; +export type UpdateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchMutationBody = + ProviderCredentialUpdate; +export type UpdateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchMutationError = + HTTPValidationError; + +/** + * @summary Update Provider Credential + */ +export const useUpdateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch = + ( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch + > + >, + TError, + { credentialId: string; data: ProviderCredentialUpdate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, + ): UseMutationResult< + Awaited< + ReturnType< + typeof updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch + > + >, + TError, + { credentialId: string; data: ProviderCredentialUpdate }, + TContext + > => { + return useMutation( + getUpdateProviderCredentialApiV1ProviderCredentialsCredentialIdPatchMutationOptions( + options, + ), + queryClient, + ); + }; +/** + * Delete a credential. Admin-only. + * @summary Delete Provider Credential + */ +export type deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteResponse204 = + { + data: void; + status: 204; + }; + +export type deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteResponseSuccess = + deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteResponse204 & { + headers: Headers; + }; +export type deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteResponseError = + deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteResponse422 & { + headers: Headers; + }; + +export type deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteResponse = + + | deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteResponseSuccess + | deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteResponseError; + +export const getDeleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteUrl = + (credentialId: string) => { + return `/api/v1/provider-credentials/${credentialId}`; + }; + +export const deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete = + async ( + credentialId: string, + options?: RequestInit, + ): Promise => { + return customFetch( + getDeleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteUrl( + credentialId, + ), + { + ...options, + method: "DELETE", + }, + ); + }; + +export const getDeleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete + > + >, + TError, + { credentialId: string }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType< + typeof deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete + > + >, + TError, + { credentialId: string }, + TContext + > => { + const mutationKey = [ + "deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete", + ]; + 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 deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete + > + >, + { credentialId: string } + > = (props) => { + const { credentialId } = props ?? {}; + + return deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete( + credentialId, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type DeleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteMutationResult = + NonNullable< + Awaited< + ReturnType< + typeof deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete + > + > + >; + +export type DeleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteMutationError = + HTTPValidationError; + +/** + * @summary Delete Provider Credential + */ +export const useDeleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete = + ( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete + > + >, + TError, + { credentialId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, + ): UseMutationResult< + Awaited< + ReturnType< + typeof deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete + > + >, + TError, + { credentialId: string }, + TContext + > => { + return useMutation( + getDeleteProviderCredentialApiV1ProviderCredentialsCredentialIdDeleteMutationOptions( + options, + ), + queryClient, + ); + }; +/** + * 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. + * @summary Get Provider Usage Live + */ +export type getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetResponse200 = + { + data: ProviderUsageLiveRead; + status: 200; + }; + +export type getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetResponseSuccess = + getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetResponse200 & { + headers: Headers; + }; +export type getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetResponseError = + getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetResponse422 & { + headers: Headers; + }; + +export type getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetResponse = + + | getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetResponseSuccess + | getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetResponseError; + +export const getGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetUrl = + ( + credentialId: string, + params?: GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams, + ) => { + 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/provider-credentials/${credentialId}/usage?${stringifiedParams}` + : `/api/v1/provider-credentials/${credentialId}/usage`; + }; + +export const getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet = + async ( + credentialId: string, + params?: GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams, + options?: RequestInit, + ): Promise => { + return customFetch( + getGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetUrl( + credentialId, + params, + ), + { + ...options, + method: "GET", + }, + ); + }; + +export const getGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetQueryKey = + ( + credentialId: string, + params?: GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams, + ) => { + return [ + `/api/v1/provider-credentials/${credentialId}/usage`, + ...(params ? [params] : []), + ] as const; + }; + +export const getGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetQueryOptions = + < + TData = Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError = HTTPValidationError, + >( + credentialId: string, + params?: GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetQueryKey( + credentialId, + params, + ); + + const queryFn: QueryFunction< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + > + > = ({ signal }) => + getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet( + credentialId, + params, + { signal, ...requestOptions }, + ); + + return { + queryKey, + queryFn, + enabled: !!credentialId, + ...queryOptions, + } as UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError, + TData + > & { queryKey: DataTag }; + }; + +export type GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetQueryResult = + NonNullable< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + > + >; +export type GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetQueryError = + HTTPValidationError; + +export function useGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet< + TData = Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError = HTTPValidationError, +>( + credentialId: string, + params: + | undefined + | GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError, + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet< + TData = Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError = HTTPValidationError, +>( + credentialId: string, + params?: GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError, + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet< + TData = Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError = HTTPValidationError, +>( + credentialId: string, + params?: GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get Provider Usage Live + */ + +export function useGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet< + TData = Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError = HTTPValidationError, +>( + credentialId: string, + params?: GetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getGetProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGetQueryOptions( + credentialId, + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/frontend/src/app/gateways/[gatewayId]/page.tsx b/frontend/src/app/gateways/[gatewayId]/page.tsx index 5b2cd2c..f3a9754 100644 --- a/frontend/src/app/gateways/[gatewayId]/page.tsx +++ b/frontend/src/app/gateways/[gatewayId]/page.tsx @@ -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(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(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; available: boolean }) + : null, + ), + }); + const [configEdits, setConfigEdits] = useState>({}); + const [configSaveOpen, setConfigSaveOpen] = useState(false); + const [configSaving, setConfigSaving] = useState(false); + const [configSaveError, setConfigSaveError] = useState(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={
+ ))} +
+ )} + + {/* Logs tab */} + {activeTab === "logs" && isAdmin && ( +
+
+

+ {logsQuery.data ? `${logsQuery.data.lines.length} lines` : ""} +

+ +
+ {logsQuery.isLoading ? ( +
+ Loading logs… +
+ ) : logsQuery.error ? ( +
+ + {logsQuery.error instanceof Error + ? logsQuery.error.message + : "Could not load logs."} +
+ ) : ( +
+ {(logsQuery.data?.lines ?? []).length === 0 ? ( +

No log output available.

+ ) : ( + (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 ( +
+ {line} +
+ ); + }) + )} +
+
+ )} +
+ )} + + {/* Config tab */} + {activeTab === "config" && isAdmin && ( +
+ {configQuery.isLoading ? ( +
+ Loading config… +
+ ) : configQuery.error ? ( +
+ + {configQuery.error instanceof Error + ? configQuery.error.message + : "Could not load config."} +
+ ) : !configQuery.data?.available ? ( +
+ Config data is unavailable. The gateway may not expose its configuration via this API. +
+ ) : ( + <> + {Object.keys(configEdits).length > 0 && ( +
+ {Object.keys(configEdits).length} unsaved change{Object.keys(configEdits).length !== 1 ? "s" : ""} +
+ + +
+
+ )} +
+ + + + + + + + + {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 ( + + + + + ); + })} + +
KeyValue
+ {key} + + {isMasked ? ( +
+ {String(rawValue)} + { + 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" + /> +
+ ) : ( + { + 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"}`} + /> + )} +
+
+ + )} +
+ )} + + {/* Config save confirmation */} + 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 ? (
Loading gateway…
- ) : gatewayQuery.error ? ( + ) : activeTab === "overview" && gatewayQuery.error ? (
{gatewayQuery.error.message}
- ) : gateway ? ( + ) : activeTab === "overview" && gateway ? (
diff --git a/frontend/src/app/settings/ai-providers/page.tsx b/frontend/src/app/settings/ai-providers/page.tsx new file mode 100644 index 0000000..d0f71b9 --- /dev/null +++ b/frontend/src/app/settings/ai-providers/page.tsx @@ -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; + 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 ( +
+
+ {allowMultiple && ( +
+ + setAccountKey(e.target.value)} + placeholder="e.g. work, personal, gpu-box" + disabled={isSaving} + /> +

+ Used to tell accounts apart in cost reports. +

+
+ )} +
+ + setDisplayName(e.target.value)} + placeholder={accountKey || "My account"} + disabled={isSaving} + /> +
+ {showBaseUrl && ( +
+ + setBaseUrl(e.target.value)} + placeholder="http://localhost:11434" + disabled={isSaving} + /> +
+ )} +
+ + setApiKey(e.target.value)} + placeholder={keyPlaceholder} + disabled={isSaving} + autoComplete="new-password" + /> +
+ {error && ( +

{error}

+ )} +
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastFetched, setLastFetched] = useState(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 ( +
+ + Fetching live usage… +
+ ); + } + + if (!usage?.reachable) { + return ( +
+ {usage?.error ?? error ?? "Provider unreachable."} + +
+ ); + } + + const tok = usage.tokens; + const req = usage.requests; + const isOllama = provider === "ollama"; + + return ( +
+ {isOllama ? ( +
+ + + Connected + + {(usage.models?.length ?? 0) > 0 && ( + {usage.models!.length} model{usage.models!.length !== 1 ? "s" : ""} available + )} + +
+ ) : ( +
+ {/* Tokens */} + {tok.limit != null ? ( +
+
+ Tokens + + {fmtTokens(tok.remaining)} / {fmtTokens(tok.limit)} remaining + {tok.reset_in_ms != null && ( + · resets in {fmtResetMs(tok.reset_in_ms)} + )} + +
+ {tok.pct_used != null && ( +
+
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)}%` }} + /> +
+ )} +
+ ) : null} + + {/* Requests */} + {req.limit != null ? ( +
+ Requests + + {req.remaining ?? "—"} / {req.limit} remaining + {req.reset_in_ms != null && ( + · resets in {fmtResetMs(req.reset_in_ms)} + )} + +
+ ) : null} + + {tok.limit == null && req.limit == null && ( +

+ Connected — no rate limit headers returned by this key tier. +

+ )} + +
+ {lastFetched && Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago} + +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Credential row +// --------------------------------------------------------------------------- + +interface CredentialRowProps { + cred: ProviderCredentialRead; + isAdmin: boolean; + onDelete: (cred: ProviderCredentialRead) => void; + onToggle: (cred: ProviderCredentialRead) => Promise; + showUsage?: boolean; +} + +function CredentialRow({ cred, isAdmin, onDelete, onToggle, showUsage = true }: CredentialRowProps) { + const [toggling, setToggling] = useState(false); + return ( +
+
+
+

+ {cred.display_name || cred.account_key} +

+
+ key: {cred.account_key} + {cred.has_api_key && cred.api_key_last_four ? ( + + + ••••{cred.api_key_last_four} + + ) : cred.has_api_key ? ( + + + set + + ) : ( + no key + )} + {cred.base_url && {cred.base_url}} +
+
+ {isAdmin && ( +
+ + +
+ )} +
+ {showUsage && cred.active && (cred.has_api_key || cred.base_url) && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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; + onDelete: (cred: ProviderCredentialRead) => void; + onToggle: (cred: ProviderCredentialRead) => Promise; +} + +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(null); + + const canAdd = isAdmin && (provider.allowMultiple || credentials.length === 0); + + return ( +
+
+
+
+ +
+
+

{provider.label}

+

{provider.description}

+
+
+ {canAdd && !showForm && ( + + )} +
+ + {showForm && ( + { + 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 && ( +
+ {credentials.map((cred) => ( + + ))} +
+ )} + + {credentials.length === 0 && !showForm && ( +

+ No {provider.label} accounts configured. + {isAdmin ? " Click Add to set one up." : ""} +

+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default function AIProvidersSettingsPage() { + const { isSignedIn } = useAuth(); + const { isAdmin } = useOrganizationMembership(isSignedIn); + const [credentials, setCredentials] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(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 ( + <> + + {isLoading ? ( +
+ + Loading provider settings… +
+ ) : error ? ( +
+ {error} +
+ ) : ( +
+ {!isAdmin && ( +
+ You can view provider settings. Only org admins can add or change credentials. +
+ )} + {PROVIDERS.map((provider) => ( + c.provider === provider.id)} + isAdmin={isAdmin} + onAdd={handleAdd} + onDelete={setDeleteTarget} + onToggle={handleToggle} + /> + ))} +
+ )} +
+ + { 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" + /> + + ); +} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 2c2064b..c03a762 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -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() { +
+
+
+

+ + AI Providers +

+

+ API keys and endpoints for Claude, Codex/OpenAI, and Ollama + (local, on-prem, or cloud). Add multiple accounts to track + usage separately. +

+
+ + + +
+
+