1 Commits

Author SHA1 Message Date
Abhimanyu Saharan
dc7906a224 feat(models): implement model routing and management pages with dynamic imports 2026-02-12 00:16:18 +05:30
52 changed files with 6470 additions and 10 deletions

View File

@@ -0,0 +1,165 @@
"""API routes for gateway model registry and provider-auth management."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from app.api.deps import require_org_admin
from app.db.session import get_session
from app.schemas.common import OkResponse
from app.schemas.llm_models import (
GatewayModelPullResult,
GatewayModelSyncResult,
LlmModelCreate,
LlmModelRead,
LlmModelUpdate,
LlmProviderAuthCreate,
LlmProviderAuthRead,
LlmProviderAuthUpdate,
)
from app.services.openclaw.model_registry_service import GatewayModelRegistryService
from app.services.organizations import OrganizationContext
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/model-registry", tags=["model-registry"])
SESSION_DEP = Depends(get_session)
ORG_ADMIN_DEP = Depends(require_org_admin)
GATEWAY_ID_QUERY = Query(default=None)
@router.get("/provider-auth", response_model=list[LlmProviderAuthRead])
async def list_provider_auth(
gateway_id: UUID | None = GATEWAY_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> list[LlmProviderAuthRead]:
"""List provider auth records for the active organization."""
service = GatewayModelRegistryService(session)
return await service.list_provider_auth(ctx=ctx, gateway_id=gateway_id)
@router.post("/provider-auth", response_model=LlmProviderAuthRead)
async def create_provider_auth(
payload: LlmProviderAuthCreate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> LlmProviderAuthRead:
"""Create a provider auth record and sync gateway config."""
service = GatewayModelRegistryService(session)
return await service.create_provider_auth(payload=payload, ctx=ctx)
@router.patch("/provider-auth/{provider_auth_id}", response_model=LlmProviderAuthRead)
async def update_provider_auth(
provider_auth_id: UUID,
payload: LlmProviderAuthUpdate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> LlmProviderAuthRead:
"""Patch a provider auth record and sync gateway config."""
service = GatewayModelRegistryService(session)
return await service.update_provider_auth(
provider_auth_id=provider_auth_id,
payload=payload,
ctx=ctx,
)
@router.delete("/provider-auth/{provider_auth_id}", response_model=OkResponse)
async def delete_provider_auth(
provider_auth_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> OkResponse:
"""Delete a provider auth record and sync gateway config."""
service = GatewayModelRegistryService(session)
await service.delete_provider_auth(provider_auth_id=provider_auth_id, ctx=ctx)
return OkResponse()
@router.get("/models", response_model=list[LlmModelRead])
async def list_models(
gateway_id: UUID | None = GATEWAY_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> list[LlmModelRead]:
"""List gateway model catalog entries for the active organization."""
service = GatewayModelRegistryService(session)
return await service.list_models(ctx=ctx, gateway_id=gateway_id)
@router.post("/models", response_model=LlmModelRead)
async def create_model(
payload: LlmModelCreate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> LlmModelRead:
"""Create a model catalog entry and sync gateway config."""
service = GatewayModelRegistryService(session)
return await service.create_model(payload=payload, ctx=ctx)
@router.patch("/models/{model_id}", response_model=LlmModelRead)
async def update_model(
model_id: UUID,
payload: LlmModelUpdate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> LlmModelRead:
"""Patch a model catalog entry and sync gateway config."""
service = GatewayModelRegistryService(session)
return await service.update_model(model_id=model_id, payload=payload, ctx=ctx)
@router.delete("/models/{model_id}", response_model=OkResponse)
async def delete_model(
model_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> OkResponse:
"""Delete a model catalog entry and sync gateway config."""
service = GatewayModelRegistryService(session)
await service.delete_model(model_id=model_id, ctx=ctx)
return OkResponse()
@router.post("/gateways/{gateway_id}/sync", response_model=GatewayModelSyncResult)
async def sync_gateway_models(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewayModelSyncResult:
"""Push provider auth + model catalog + agent model links to a gateway."""
service = GatewayModelRegistryService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
return await service.sync_gateway_config(
gateway=gateway,
organization_id=ctx.organization.id,
)
@router.post("/gateways/{gateway_id}/pull", response_model=GatewayModelPullResult)
async def pull_gateway_models(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewayModelPullResult:
"""Pull provider auth + model catalog + agent model links from a gateway."""
service = GatewayModelRegistryService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
return await service.pull_gateway_config(
gateway=gateway,
organization_id=ctx.organization.id,
)

View File

@@ -22,6 +22,7 @@ from app.api.boards import router as boards_router
from app.api.gateway import router as gateway_router
from app.api.gateways import router as gateways_router
from app.api.metrics import router as metrics_router
from app.api.model_registry import router as model_registry_router
from app.api.organizations import router as organizations_router
from app.api.souls_directory import router as souls_directory_router
from app.api.tasks import router as tasks_router
@@ -98,6 +99,7 @@ api_v1.include_router(activity_router)
api_v1.include_router(gateway_router)
api_v1.include_router(gateways_router)
api_v1.include_router(metrics_router)
api_v1.include_router(model_registry_router)
api_v1.include_router(organizations_router)
api_v1.include_router(souls_directory_router)
api_v1.include_router(board_groups_router)

View File

@@ -10,6 +10,7 @@ from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.llm import LlmModel, LlmProviderAuth
from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite
@@ -31,6 +32,8 @@ __all__ = [
"BoardGroup",
"Board",
"Gateway",
"LlmModel",
"LlmProviderAuth",
"Organization",
"OrganizationMember",
"OrganizationBoardAccess",

View File

@@ -44,5 +44,10 @@ class Agent(QueryModel, table=True):
delete_confirm_token_hash: str | None = Field(default=None, index=True)
last_seen_at: datetime | None = Field(default=None)
is_board_lead: bool = Field(default=False, index=True)
primary_model_id: UUID | None = Field(default=None, foreign_key="llm_models.id", index=True)
fallback_model_ids: list[str] | None = Field(
default=None,
sa_column=Column(JSON),
)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

46
backend/app/models/llm.py Normal file
View File

@@ -0,0 +1,46 @@
"""Models for gateway-scoped LLM provider auth and model catalog records."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlmodel import Field
from app.core.time import utcnow
from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class LlmProviderAuth(QueryModel, table=True):
"""Provider auth settings to write into a specific gateway config."""
__tablename__ = "llm_provider_auth" # pyright: ignore[reportAssignmentType]
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
gateway_id: UUID = Field(foreign_key="gateways.id", index=True)
provider: str = Field(index=True)
config_path: str
secret: str
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
class LlmModel(QueryModel, table=True):
"""Gateway model catalog entries available for agent assignment."""
__tablename__ = "llm_models" # pyright: ignore[reportAssignmentType]
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
gateway_id: UUID = Field(foreign_key="gateways.id", index=True)
provider: str = Field(index=True)
model_id: str = Field(index=True)
display_name: str
settings: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -13,6 +13,16 @@ from app.schemas.board_onboarding import (
)
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
from app.schemas.llm_models import (
GatewayModelPullResult,
GatewayModelSyncResult,
LlmModelCreate,
LlmModelRead,
LlmModelUpdate,
LlmProviderAuthCreate,
LlmProviderAuthRead,
LlmProviderAuthUpdate,
)
from app.schemas.metrics import DashboardMetrics
from app.schemas.organizations import (
OrganizationActiveUpdate,
@@ -57,6 +67,14 @@ __all__ = [
"GatewayRead",
"GatewayUpdate",
"DashboardMetrics",
"GatewayModelPullResult",
"GatewayModelSyncResult",
"LlmModelCreate",
"LlmModelRead",
"LlmModelUpdate",
"LlmProviderAuthCreate",
"LlmProviderAuthRead",
"LlmProviderAuthUpdate",
"OrganizationActiveUpdate",
"OrganizationCreate",
"OrganizationInviteAccept",

View File

@@ -39,6 +39,27 @@ def _normalize_identity_profile(
return normalized or None
def _normalize_model_ids(
model_ids: object,
) -> list[UUID] | None:
if model_ids is None:
return None
if not isinstance(model_ids, (list, tuple, set)):
raise ValueError("fallback_model_ids must be a list")
normalized: list[UUID] = []
seen: set[UUID] = set()
for raw in model_ids:
candidate = str(raw).strip()
if not candidate:
continue
model_id = UUID(candidate)
if model_id in seen:
continue
seen.add(model_id)
normalized.append(model_id)
return normalized or None
class AgentBase(SQLModel):
"""Common fields shared by agent create/read/update payloads."""
@@ -46,6 +67,8 @@ class AgentBase(SQLModel):
name: NonEmptyStr
status: str = "provisioning"
heartbeat_config: dict[str, Any] | None = None
primary_model_id: UUID | None = None
fallback_model_ids: list[UUID] | None = None
identity_profile: dict[str, Any] | None = None
identity_template: str | None = None
soul_template: str | None = None
@@ -70,6 +93,15 @@ class AgentBase(SQLModel):
"""Normalize identity-profile values into trimmed string mappings."""
return _normalize_identity_profile(value)
@field_validator("fallback_model_ids", mode="before")
@classmethod
def normalize_fallback_model_ids(
cls,
value: object,
) -> list[UUID] | None:
"""Normalize fallback model ids into ordered UUID values."""
return _normalize_model_ids(value)
class AgentCreate(AgentBase):
"""Payload for creating a new agent."""
@@ -83,6 +115,8 @@ class AgentUpdate(SQLModel):
name: NonEmptyStr | None = None
status: str | None = None
heartbeat_config: dict[str, Any] | None = None
primary_model_id: UUID | None = None
fallback_model_ids: list[UUID] | None = None
identity_profile: dict[str, Any] | None = None
identity_template: str | None = None
soul_template: str | None = None
@@ -107,6 +141,15 @@ class AgentUpdate(SQLModel):
"""Normalize identity-profile values into trimmed string mappings."""
return _normalize_identity_profile(value)
@field_validator("fallback_model_ids", mode="before")
@classmethod
def normalize_fallback_model_ids(
cls,
value: object,
) -> list[UUID] | None:
"""Normalize fallback model ids into ordered UUID values."""
return _normalize_model_ids(value)
class AgentRead(AgentBase):
"""Public agent representation returned by the API."""

View File

@@ -0,0 +1,167 @@
"""Schemas for LLM provider auth, model catalog, and gateway sync payloads."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import field_validator
from sqlmodel import Field, SQLModel
from app.schemas.common import NonEmptyStr
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr)
def _normalize_provider(value: object) -> str | object:
if isinstance(value, str):
normalized = value.strip().lower()
return normalized or value
return value
def _normalize_config_path(value: object) -> str | object:
if isinstance(value, str):
normalized = value.strip()
return normalized or value
return value
def _default_provider_config_path(provider: str) -> str:
return f"providers.{provider}.apiKey"
class LlmProviderAuthBase(SQLModel):
"""Shared provider auth fields."""
gateway_id: UUID
provider: NonEmptyStr
config_path: NonEmptyStr | None = None
@field_validator("provider", mode="before")
@classmethod
def normalize_provider(cls, value: object) -> str | object:
return _normalize_provider(value)
@field_validator("config_path", mode="before")
@classmethod
def normalize_config_path(cls, value: object) -> str | object:
return _normalize_config_path(value)
class LlmProviderAuthCreate(LlmProviderAuthBase):
"""Payload used to create a provider auth record."""
secret: NonEmptyStr
class LlmProviderAuthUpdate(SQLModel):
"""Payload used to patch an existing provider auth record."""
provider: NonEmptyStr | None = None
config_path: NonEmptyStr | None = None
secret: NonEmptyStr | None = None
@field_validator("provider", mode="before")
@classmethod
def normalize_provider(cls, value: object) -> str | object:
return _normalize_provider(value)
@field_validator("config_path", mode="before")
@classmethod
def normalize_config_path(cls, value: object) -> str | object:
return _normalize_config_path(value)
class LlmProviderAuthRead(SQLModel):
"""Public provider auth payload (secret value is never returned)."""
id: UUID
organization_id: UUID
gateway_id: UUID
provider: str
config_path: str
has_secret: bool = True
created_at: datetime
updated_at: datetime
class LlmModelBase(SQLModel):
"""Shared gateway model catalog fields."""
gateway_id: UUID
provider: NonEmptyStr
model_id: NonEmptyStr
display_name: NonEmptyStr
settings: dict[str, Any] | None = None
@field_validator("provider", mode="before")
@classmethod
def normalize_provider(cls, value: object) -> str | object:
return _normalize_provider(value)
class LlmModelCreate(LlmModelBase):
"""Payload used to create a model catalog entry."""
class LlmModelUpdate(SQLModel):
"""Payload used to patch an existing model catalog entry."""
provider: NonEmptyStr | None = None
model_id: NonEmptyStr | None = None
display_name: NonEmptyStr | None = None
settings: dict[str, Any] | None = None
@field_validator("provider", mode="before")
@classmethod
def normalize_provider(cls, value: object) -> str | object:
return _normalize_provider(value)
class LlmModelRead(SQLModel):
"""Public model catalog entry payload."""
id: UUID
organization_id: UUID
gateway_id: UUID
provider: str
model_id: str
display_name: str
settings: dict[str, Any] | None = None
created_at: datetime
updated_at: datetime
class GatewayModelSyncResult(SQLModel):
"""Summary of model/provider config sync operations for a gateway."""
gateway_id: UUID
provider_auth_patched: int
model_catalog_patched: int
agent_models_patched: int
sessions_patched: int
errors: list[str] = Field(default_factory=list)
class GatewayModelPullResult(SQLModel):
"""Summary of model/provider config pull operations for a gateway."""
gateway_id: UUID
provider_auth_imported: int
model_catalog_imported: int
agent_models_imported: int
errors: list[str] = Field(default_factory=list)
__all__ = [
"GatewayModelPullResult",
"GatewayModelSyncResult",
"LlmModelCreate",
"LlmModelRead",
"LlmModelUpdate",
"LlmProviderAuthCreate",
"LlmProviderAuthRead",
"LlmProviderAuthUpdate",
]

File diff suppressed because it is too large Load Diff

View File

@@ -492,10 +492,32 @@ async def _gateway_config_agent_list(
msg = "config.get returned invalid payload"
raise OpenClawGatewayError(msg)
data = cfg.get("config") or cfg.get("parsed") or {}
if not isinstance(data, dict):
msg = "config.get returned invalid config"
raise OpenClawGatewayError(msg)
# Prefer parsed object over raw serialized config to support older gateways.
raw_parsed = cfg.get("parsed")
raw_config = cfg.get("config")
data: dict[str, Any]
def _parse_json_config(raw: str) -> dict[str, Any]:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
msg = "config.get returned invalid config"
raise OpenClawGatewayError(msg) from exc
if not isinstance(parsed, dict):
msg = "config.get returned invalid config"
raise OpenClawGatewayError(msg)
return parsed
if isinstance(raw_parsed, dict):
data = raw_parsed
elif isinstance(raw_config, dict):
data = raw_config
elif isinstance(raw_parsed, str) and raw_parsed.strip():
data = _parse_json_config(raw_parsed)
elif isinstance(raw_config, str) and raw_config.strip():
data = _parse_json_config(raw_config)
else:
data = {}
agents_section = data.get("agents") or {}
agents_list = agents_section.get("list") or []

View File

@@ -32,6 +32,7 @@ from app.models.agents import Agent
from app.models.board_memory import BoardMemory
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.llm import LlmModel
from app.models.organizations import Organization
from app.models.tasks import Task
from app.schemas.agents import (
@@ -72,6 +73,7 @@ from app.services.openclaw.internal.session_keys import (
board_agent_session_key,
board_lead_session_key,
)
from app.services.openclaw.model_registry_service import GatewayModelRegistryService
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
from app.services.openclaw.provisioning import (
OpenClawGatewayControlPlane,
@@ -959,6 +961,71 @@ class AgentLifecycleService(OpenClawDBService):
detail="An agent with this name already exists in this gateway workspace.",
)
@staticmethod
def _normalized_fallback_ids(value: object) -> list[UUID]:
if not isinstance(value, list):
return []
normalized: list[UUID] = []
seen: set[UUID] = set()
for raw in value:
candidate = str(raw).strip()
if not candidate:
continue
try:
model_id = UUID(candidate)
except ValueError:
continue
if model_id in seen:
continue
seen.add(model_id)
normalized.append(model_id)
return normalized
async def normalize_agent_model_assignments(
self,
*,
gateway_id: UUID,
primary_model_id: UUID | None,
fallback_model_ids: list[UUID],
) -> tuple[UUID | None, list[str] | None]:
if primary_model_id is None and not fallback_model_ids:
return None, None
candidate_ids: list[UUID] = []
if primary_model_id is not None:
candidate_ids.append(primary_model_id)
candidate_ids.extend(fallback_model_ids)
unique_ids = list(dict.fromkeys(candidate_ids))
statement = (
select(LlmModel.id)
.where(col(LlmModel.gateway_id) == gateway_id)
.where(col(LlmModel.id).in_(unique_ids))
)
with self.session.no_autoflush:
valid_ids = set(await self.session.exec(statement))
missing = [value for value in unique_ids if value not in valid_ids]
if missing:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Model assignment includes model ids not in the agent gateway catalog.",
)
filtered_fallback: list[str] = []
for fallback_id in fallback_model_ids:
if primary_model_id is not None and fallback_id == primary_model_id:
continue
value = str(fallback_id)
if value in filtered_fallback:
continue
filtered_fallback.append(value)
return primary_model_id, (filtered_fallback or None)
async def sync_gateway_agent_models(self, *, gateway: Gateway) -> None:
await GatewayModelRegistryService(self.session).sync_gateway_config(
gateway=gateway,
organization_id=gateway.organization_id,
)
async def persist_new_agent(
self,
*,
@@ -1177,6 +1244,13 @@ class AgentLifecycleService(OpenClawDBService):
detail="Board gateway_id is required",
)
updates["gateway_id"] = board.gateway_id
if "primary_model_id" in updates:
primary_value = updates["primary_model_id"]
if primary_value is not None and not isinstance(primary_value, UUID):
updates["primary_model_id"] = UUID(str(primary_value))
if "fallback_model_ids" in updates:
normalized_fallback = self._normalized_fallback_ids(updates["fallback_model_ids"])
updates["fallback_model_ids"] = [str(model_id) for model_id in normalized_fallback] or None
for key, value in updates.items():
setattr(agent, key, value)
@@ -1192,6 +1266,19 @@ class AgentLifecycleService(OpenClawDBService):
detail="Board gateway_id is required",
)
agent.gateway_id = board.gateway_id
primary_model_id = agent.primary_model_id
if primary_model_id is not None and not isinstance(primary_model_id, UUID):
primary_model_id = UUID(str(primary_model_id))
fallback_model_ids = self._normalized_fallback_ids(agent.fallback_model_ids)
normalized_primary, normalized_fallback = await self.normalize_agent_model_assignments(
gateway_id=agent.gateway_id,
primary_model_id=primary_model_id if isinstance(primary_model_id, UUID) else None,
fallback_model_ids=fallback_model_ids,
)
agent.primary_model_id = normalized_primary
agent.fallback_model_ids = normalized_fallback
agent.updated_at = utcnow()
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
@@ -1487,6 +1574,17 @@ class AgentLifecycleService(OpenClawDBService):
gateway, _client_config = await self.require_gateway(board)
data = payload.model_dump()
data["gateway_id"] = gateway.id
primary_model_id = data.get("primary_model_id")
if primary_model_id is not None and not isinstance(primary_model_id, UUID):
primary_model_id = UUID(str(primary_model_id))
fallback_model_ids = self._normalized_fallback_ids(data.get("fallback_model_ids"))
normalized_primary, normalized_fallback = await self.normalize_agent_model_assignments(
gateway_id=gateway.id,
primary_model_id=primary_model_id if isinstance(primary_model_id, UUID) else None,
fallback_model_ids=fallback_model_ids,
)
data["primary_model_id"] = normalized_primary
data["fallback_model_ids"] = normalized_fallback
requested_name = (data.get("name") or "").strip()
await self.ensure_unique_agent_name(
board=board,
@@ -1502,6 +1600,8 @@ class AgentLifecycleService(OpenClawDBService):
user=actor.user if actor.actor_type == "user" else None,
force_bootstrap=False,
)
if agent.primary_model_id is not None or agent.fallback_model_ids:
await self.sync_gateway_agent_models(gateway=gateway)
self.logger.info("agent.create.success agent_id=%s board_id=%s", agent.id, board.id)
return self.to_agent_read(self.with_computed_status(agent))
@@ -1535,6 +1635,7 @@ class AgentLifecycleService(OpenClawDBService):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await self.require_agent_access(agent=agent, ctx=options.context, write=True)
updates = payload.model_dump(exclude_unset=True)
sync_model_assignments = "primary_model_id" in updates or "fallback_model_ids" in updates
make_main = updates.pop("is_gateway_main", None)
await self.validate_agent_update_inputs(
ctx=options.context,
@@ -1568,6 +1669,8 @@ class AgentLifecycleService(OpenClawDBService):
agent=agent,
request=provision_request,
)
if sync_model_assignments or agent.primary_model_id is not None or agent.fallback_model_ids:
await self.sync_gateway_agent_models(gateway=target.gateway)
self.logger.info("agent.update.success agent_id=%s", agent.id)
return self.to_agent_read(self.with_computed_status(agent))

View File

@@ -0,0 +1,285 @@
"""add llm model registry
Revision ID: 9a3fb1158c2d
Revises: f4d2b649e93a
Create Date: 2026-02-11 21:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "9a3fb1158c2d"
down_revision = "f4d2b649e93a"
branch_labels = None
depends_on = None
def _has_index(inspector: sa.Inspector, table: str, index_name: str) -> bool:
return any(item.get("name") == index_name for item in inspector.get_indexes(table))
def _has_unique(
inspector: sa.Inspector,
table: str,
*,
name: str | None = None,
columns: tuple[str, ...] | None = None,
) -> bool:
unique_constraints = inspector.get_unique_constraints(table)
for item in unique_constraints:
if name and item.get("name") == name:
return True
if columns and tuple(item.get("column_names") or ()) == columns:
return True
return False
def _column_names(inspector: sa.Inspector, table: str) -> set[str]:
return {item["name"] for item in inspector.get_columns(table)}
def _has_foreign_key(
inspector: sa.Inspector,
table: str,
*,
constrained_columns: tuple[str, ...],
referred_table: str,
) -> bool:
for item in inspector.get_foreign_keys(table):
if tuple(item.get("constrained_columns") or ()) != constrained_columns:
continue
if item.get("referred_table") != referred_table:
continue
return True
return False
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("llm_provider_auth"):
op.create_table(
"llm_provider_auth",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("gateway_id", sa.Uuid(), nullable=False),
sa.Column("provider", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("config_path", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("secret", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["gateway_id"], ["gateways.id"]),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"gateway_id",
"provider",
"config_path",
name="uq_llm_provider_auth_gateway_provider_path",
),
)
inspector = sa.inspect(bind)
else:
existing_columns = _column_names(inspector, "llm_provider_auth")
if "config_path" not in existing_columns:
op.add_column(
"llm_provider_auth",
sa.Column(
"config_path",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default="providers.openai.apiKey",
),
)
op.alter_column("llm_provider_auth", "config_path", server_default=None)
inspector = sa.inspect(bind)
if not _has_unique(
inspector,
"llm_provider_auth",
name="uq_llm_provider_auth_gateway_provider_path",
columns=("gateway_id", "provider", "config_path"),
):
op.create_unique_constraint(
"uq_llm_provider_auth_gateway_provider_path",
"llm_provider_auth",
["gateway_id", "provider", "config_path"],
)
inspector = sa.inspect(bind)
if not _has_index(inspector, "llm_provider_auth", op.f("ix_llm_provider_auth_gateway_id")):
op.create_index(
op.f("ix_llm_provider_auth_gateway_id"),
"llm_provider_auth",
["gateway_id"],
unique=False,
)
inspector = sa.inspect(bind)
if not _has_index(
inspector,
"llm_provider_auth",
op.f("ix_llm_provider_auth_organization_id"),
):
op.create_index(
op.f("ix_llm_provider_auth_organization_id"),
"llm_provider_auth",
["organization_id"],
unique=False,
)
inspector = sa.inspect(bind)
if not _has_index(inspector, "llm_provider_auth", op.f("ix_llm_provider_auth_provider")):
op.create_index(
op.f("ix_llm_provider_auth_provider"),
"llm_provider_auth",
["provider"],
unique=False,
)
inspector = sa.inspect(bind)
if not inspector.has_table("llm_models"):
op.create_table(
"llm_models",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("gateway_id", sa.Uuid(), nullable=False),
sa.Column("provider", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("model_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("display_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["gateway_id"], ["gateways.id"]),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("gateway_id", "model_id", name="uq_llm_models_gateway_model_id"),
)
inspector = sa.inspect(bind)
elif not _has_unique(
inspector,
"llm_models",
name="uq_llm_models_gateway_model_id",
columns=("gateway_id", "model_id"),
):
op.create_unique_constraint(
"uq_llm_models_gateway_model_id",
"llm_models",
["gateway_id", "model_id"],
)
inspector = sa.inspect(bind)
if not _has_index(inspector, "llm_models", op.f("ix_llm_models_gateway_id")):
op.create_index(
op.f("ix_llm_models_gateway_id"),
"llm_models",
["gateway_id"],
unique=False,
)
inspector = sa.inspect(bind)
if not _has_index(inspector, "llm_models", op.f("ix_llm_models_model_id")):
op.create_index(
op.f("ix_llm_models_model_id"),
"llm_models",
["model_id"],
unique=False,
)
inspector = sa.inspect(bind)
if not _has_index(inspector, "llm_models", op.f("ix_llm_models_organization_id")):
op.create_index(
op.f("ix_llm_models_organization_id"),
"llm_models",
["organization_id"],
unique=False,
)
inspector = sa.inspect(bind)
if not _has_index(inspector, "llm_models", op.f("ix_llm_models_provider")):
op.create_index(
op.f("ix_llm_models_provider"),
"llm_models",
["provider"],
unique=False,
)
inspector = sa.inspect(bind)
agent_columns = _column_names(inspector, "agents")
if "primary_model_id" not in agent_columns:
op.add_column("agents", sa.Column("primary_model_id", sa.Uuid(), nullable=True))
inspector = sa.inspect(bind)
if "fallback_model_ids" not in agent_columns:
op.add_column("agents", sa.Column("fallback_model_ids", sa.JSON(), nullable=True))
inspector = sa.inspect(bind)
if not _has_index(inspector, "agents", op.f("ix_agents_primary_model_id")):
op.create_index(op.f("ix_agents_primary_model_id"), "agents", ["primary_model_id"], unique=False)
inspector = sa.inspect(bind)
if not _has_foreign_key(
inspector,
"agents",
constrained_columns=("primary_model_id",),
referred_table="llm_models",
):
op.create_foreign_key(
"fk_agents_primary_model_id_llm_models",
"agents",
"llm_models",
["primary_model_id"],
["id"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if inspector.has_table("agents"):
for fk in inspector.get_foreign_keys("agents"):
if tuple(fk.get("constrained_columns") or ()) != ("primary_model_id",):
continue
if fk.get("referred_table") != "llm_models":
continue
fk_name = fk.get("name")
if fk_name:
op.drop_constraint(fk_name, "agents", type_="foreignkey")
inspector = sa.inspect(bind)
if _has_index(inspector, "agents", op.f("ix_agents_primary_model_id")):
op.drop_index(op.f("ix_agents_primary_model_id"), table_name="agents")
agent_columns = _column_names(inspector, "agents")
if "fallback_model_ids" in agent_columns:
op.drop_column("agents", "fallback_model_ids")
if "primary_model_id" in agent_columns:
op.drop_column("agents", "primary_model_id")
inspector = sa.inspect(bind)
if inspector.has_table("llm_models"):
if _has_index(inspector, "llm_models", op.f("ix_llm_models_provider")):
op.drop_index(op.f("ix_llm_models_provider"), table_name="llm_models")
if _has_index(inspector, "llm_models", op.f("ix_llm_models_organization_id")):
op.drop_index(op.f("ix_llm_models_organization_id"), table_name="llm_models")
if _has_index(inspector, "llm_models", op.f("ix_llm_models_model_id")):
op.drop_index(op.f("ix_llm_models_model_id"), table_name="llm_models")
if _has_index(inspector, "llm_models", op.f("ix_llm_models_gateway_id")):
op.drop_index(op.f("ix_llm_models_gateway_id"), table_name="llm_models")
op.drop_table("llm_models")
inspector = sa.inspect(bind)
if inspector.has_table("llm_provider_auth"):
if _has_index(inspector, "llm_provider_auth", op.f("ix_llm_provider_auth_provider")):
op.drop_index(op.f("ix_llm_provider_auth_provider"), table_name="llm_provider_auth")
if _has_index(
inspector,
"llm_provider_auth",
op.f("ix_llm_provider_auth_organization_id"),
):
op.drop_index(
op.f("ix_llm_provider_auth_organization_id"),
table_name="llm_provider_auth",
)
if _has_index(inspector, "llm_provider_auth", op.f("ix_llm_provider_auth_gateway_id")):
op.drop_index(
op.f("ix_llm_provider_auth_gateway_id"),
table_name="llm_provider_auth",
)
op.drop_table("llm_provider_auth")

View File

@@ -0,0 +1,105 @@
# ruff: noqa: S101
"""Regression tests for agent model-assignment update normalization."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from uuid import UUID, uuid4
import pytest
from unittest.mock import AsyncMock
from app.services.openclaw.provisioning_db import AgentLifecycleService
class _NoAutoflush:
def __init__(self, session: "_SessionStub") -> None:
self._session = session
def __enter__(self) -> None:
self._session.in_no_autoflush = True
return None
def __exit__(self, exc_type, exc, tb) -> bool:
self._session.in_no_autoflush = False
return False
class _SessionStub:
def __init__(self, valid_ids: set[UUID]) -> None:
self.valid_ids = valid_ids
self.in_no_autoflush = False
self.commits = 0
@property
def no_autoflush(self) -> _NoAutoflush:
return _NoAutoflush(self)
async def exec(self, _statement: Any) -> list[UUID]:
if not self.in_no_autoflush:
raise AssertionError("Expected normalize query to run under no_autoflush.")
return list(self.valid_ids)
def add(self, _model: Any) -> None:
return None
async def commit(self) -> None:
self.commits += 1
async def refresh(self, _model: Any) -> None:
return None
@dataclass
class _AgentStub:
gateway_id: UUID
board_id: UUID | None = None
is_board_lead: bool = False
openclaw_session_id: str | None = None
primary_model_id: UUID | None = None
fallback_model_ids: list[str] | None = None
updated_at: datetime | None = None
heartbeat_config: dict[str, Any] | None = field(
default_factory=lambda: {"interval_seconds": 5},
)
@pytest.mark.asyncio
async def test_normalize_agent_model_assignments_uses_no_autoflush() -> None:
primary = uuid4()
fallback = uuid4()
session = _SessionStub({primary, fallback})
service = AgentLifecycleService(session) # type: ignore[arg-type]
normalized_primary, normalized_fallback = await service.normalize_agent_model_assignments(
gateway_id=uuid4(),
primary_model_id=primary,
fallback_model_ids=[primary, fallback],
)
assert normalized_primary == primary
assert normalized_fallback == [str(fallback)]
@pytest.mark.asyncio
async def test_apply_agent_update_mutations_coerces_fallback_ids_to_strings(monkeypatch) -> None:
primary = uuid4()
fallback = uuid4()
session = _SessionStub({primary, fallback})
service = AgentLifecycleService(session) # type: ignore[arg-type]
monkeypatch.setattr(service, "get_main_agent_gateway", AsyncMock(return_value=None))
agent = _AgentStub(gateway_id=uuid4())
updates: dict[str, Any] = {
"primary_model_id": primary,
"fallback_model_ids": [primary, fallback, fallback],
}
await service.apply_agent_update_mutations(agent=agent, updates=updates, make_main=None) # type: ignore[arg-type]
assert updates["fallback_model_ids"] == [str(primary), str(fallback)]
assert agent.primary_model_id == primary
assert agent.fallback_model_ids == [str(fallback)]
assert session.commits == 1

View File

@@ -0,0 +1,28 @@
# ruff: noqa: S101
"""Tests for agent model-assignment schema normalization."""
from __future__ import annotations
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.schemas.agents import AgentCreate
def test_agent_create_normalizes_fallback_model_ids() -> None:
model_a = uuid4()
model_b = uuid4()
payload = AgentCreate(
name="Worker",
fallback_model_ids=[str(model_a), str(model_b), str(model_a)],
)
assert payload.fallback_model_ids == [model_a, model_b]
def test_agent_create_rejects_non_list_fallback_model_ids() -> None:
with pytest.raises(ValidationError):
AgentCreate(name="Worker", fallback_model_ids="not-a-list")

View File

@@ -0,0 +1,110 @@
# ruff: noqa: S101
"""Tests for gateway model-registry pull helper normalization."""
from __future__ import annotations
from app.services.openclaw.model_registry_service import (
_extract_config_data,
_get_nested_path,
_infer_provider_for_model,
_model_config,
_model_settings,
_normalize_provider,
_parse_agent_model_value,
)
def test_get_nested_path_resolves_existing_value() -> None:
source = {"providers": {"openai": {"apiKey": "sk-test"}}}
assert _get_nested_path(source, ["providers", "openai", "apiKey"]) == "sk-test"
assert _get_nested_path(source, ["providers", "anthropic", "apiKey"]) is None
def test_normalize_provider_trims_and_lowercases() -> None:
assert _normalize_provider(" OpenAI ") == "openai"
assert _normalize_provider("") is None
assert _normalize_provider(123) is None
def test_infer_provider_for_model_prefers_prefix_delimiter() -> None:
assert _infer_provider_for_model("openai/gpt-5") == "openai"
assert _infer_provider_for_model("anthropic:claude-sonnet") == "anthropic"
assert _infer_provider_for_model("gpt-5") == "unknown"
def test_model_settings_only_accepts_dict_payloads() -> None:
settings = _model_settings({"provider": "openai", "temperature": 0.2})
assert settings == {"provider": "openai", "temperature": 0.2}
assert _model_settings("not-a-dict") is None
def test_parse_agent_model_value_normalizes_primary_and_fallbacks() -> None:
primary, fallback = _parse_agent_model_value(
{
"primary": " openai/gpt-5 ",
"fallbacks": [
"openai/gpt-4.1",
"openai/gpt-5",
"openai/gpt-4.1",
" ",
123,
],
},
)
assert primary == "openai/gpt-5"
assert fallback == ["openai/gpt-4.1"]
def test_parse_agent_model_value_accepts_legacy_fallback_key() -> None:
primary, fallback = _parse_agent_model_value(
{
"primary": "openai/gpt-5",
"fallback": ["openai/gpt-4.1", "openai/gpt-4.1"],
},
)
assert primary == "openai/gpt-5"
assert fallback == ["openai/gpt-4.1"]
def test_parse_agent_model_value_accepts_string_primary() -> None:
primary, fallback = _parse_agent_model_value(" openai/gpt-5 ")
assert primary == "openai/gpt-5"
assert fallback == []
def test_model_config_uses_fallbacks_key() -> None:
assert _model_config("openai/gpt-5", ["openai/gpt-4.1"]) == {
"primary": "openai/gpt-5",
"fallbacks": ["openai/gpt-4.1"],
}
def test_extract_config_data_prefers_parsed_when_config_is_raw_string() -> None:
config_data, base_hash = _extract_config_data(
{
"config": '{"agents":{"list":[{"id":"a1"}]}}',
"parsed": {"agents": {"list": [{"id": "a1"}]}},
"hash": "abc123",
},
)
assert isinstance(config_data, dict)
assert config_data.get("agents") == {"list": [{"id": "a1"}]}
assert base_hash == "abc123"
def test_extract_config_data_parses_json_string_when_parsed_absent() -> None:
config_data, base_hash = _extract_config_data(
{
"config": '{"providers":{"openai":{"apiKey":"sk-test"}}}',
"hash": "def456",
},
)
assert config_data.get("providers") == {"openai": {"apiKey": "sk-test"}}
assert base_hash == "def456"

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,8 @@ export interface AgentCreate {
name: string;
status?: string;
heartbeat_config?: AgentCreateHeartbeatConfig;
primary_model_id?: string | null;
fallback_model_ids?: string[] | null;
identity_profile?: AgentCreateIdentityProfile;
identity_template?: string | null;
soul_template?: string | null;

View File

@@ -16,6 +16,8 @@ export interface AgentRead {
name: string;
status?: string;
heartbeat_config?: AgentReadHeartbeatConfig;
primary_model_id?: string | null;
fallback_model_ids?: string[] | null;
identity_profile?: AgentReadIdentityProfile;
identity_template?: string | null;
soul_template?: string | null;

View File

@@ -16,6 +16,8 @@ export interface AgentUpdate {
name?: string | null;
status?: string | null;
heartbeat_config?: AgentUpdateHeartbeatConfig;
primary_model_id?: string | null;
fallback_model_ids?: string[] | null;
identity_profile?: AgentUpdateIdentityProfile;
identity_template?: string | null;
soul_template?: string | null;

View File

@@ -14,6 +14,7 @@ import type { ApprovalCreateStatus } from "./approvalCreateStatus";
export interface ApprovalCreate {
action_type: string;
task_id?: string | null;
task_ids?: string[];
payload?: ApprovalCreatePayload;
confidence: number;
rubric_scores?: ApprovalCreateRubricScores;

View File

@@ -14,6 +14,7 @@ import type { ApprovalReadStatus } from "./approvalReadStatus";
export interface ApprovalRead {
action_type: string;
task_id?: string | null;
task_ids?: string[];
payload?: ApprovalReadPayload;
confidence: number;
rubric_scores?: ApprovalReadRubricScores;

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Summary of model/provider config sync operations for a gateway.
*/
export interface GatewayModelSyncResult {
gateway_id: string;
provider_auth_patched: number;
model_catalog_patched: number;
agent_models_patched: number;
sessions_patched: number;
errors?: string[];
}

View File

@@ -101,6 +101,7 @@ export * from "./gatewayLeadMessageRequestKind";
export * from "./gatewayLeadMessageResponse";
export * from "./gatewayMainAskUserRequest";
export * from "./gatewayMainAskUserResponse";
export * from "./gatewayModelSyncResult";
export * from "./gatewayRead";
export * from "./gatewayResolveQuery";
export * from "./gatewaySessionHistoryResponse";
@@ -152,8 +153,10 @@ export * from "./listBoardsApiV1AgentBoardsGetParams";
export * from "./listBoardsApiV1BoardsGetParams";
export * from "./listGatewaysApiV1GatewaysGetParams";
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
export * from "./listModelsApiV1ModelRegistryModelsGetParams";
export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams";
export * from "./listOrgMembersApiV1OrganizationsMeMembersGetParams";
export * from "./listProviderAuthApiV1ModelRegistryProviderAuthGetParams";
export * from "./listSessionsApiV1GatewaySessionsGet200";
export * from "./listSessionsApiV1GatewaySessionsGetParams";
export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams";
@@ -161,6 +164,15 @@ export * from "./listTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetPa
export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams";
export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams";
export * from "./listTasksApiV1BoardsBoardIdTasksGetParams";
export * from "./llmModelCreate";
export * from "./llmModelCreateSettings";
export * from "./llmModelRead";
export * from "./llmModelReadSettings";
export * from "./llmModelUpdate";
export * from "./llmModelUpdateSettings";
export * from "./llmProviderAuthCreate";
export * from "./llmProviderAuthRead";
export * from "./llmProviderAuthUpdate";
export * from "./okResponse";
export * from "./organizationActiveUpdate";
export * from "./organizationBoardAccessRead";

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type ListModelsApiV1ModelRegistryModelsGetParams = {
gateway_id?: string | null;
};

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type ListProviderAuthApiV1ModelRegistryProviderAuthGetParams = {
gateway_id?: string | null;
};

View File

@@ -0,0 +1,21 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { LlmModelCreateSettings } from "./llmModelCreateSettings";
/**
* Payload used to create a model catalog entry.
*/
export interface LlmModelCreate {
gateway_id: string;
/** @minLength 1 */
provider: string;
/** @minLength 1 */
model_id: string;
/** @minLength 1 */
display_name: string;
settings?: LlmModelCreateSettings;
}

View File

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

View File

@@ -0,0 +1,22 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { LlmModelReadSettings } from "./llmModelReadSettings";
/**
* Public model catalog entry payload.
*/
export interface LlmModelRead {
id: string;
organization_id: string;
gateway_id: string;
provider: string;
model_id: string;
display_name: string;
settings?: LlmModelReadSettings;
created_at: string;
updated_at: string;
}

View File

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

View File

@@ -0,0 +1,17 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { LlmModelUpdateSettings } from "./llmModelUpdateSettings";
/**
* Payload used to patch an existing model catalog entry.
*/
export interface LlmModelUpdate {
provider?: string | null;
model_id?: string | null;
display_name?: string | null;
settings?: LlmModelUpdateSettings;
}

View File

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

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Payload used to create a provider auth record.
*/
export interface LlmProviderAuthCreate {
gateway_id: string;
/** @minLength 1 */
provider: string;
config_path?: string | null;
/** @minLength 1 */
secret: string;
}

View File

@@ -0,0 +1,20 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Public provider auth payload (secret value is never returned).
*/
export interface LlmProviderAuthRead {
id: string;
organization_id: string;
gateway_id: string;
provider: string;
config_path: string;
has_secret?: boolean;
created_at: string;
updated_at: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* Payload used to patch an existing provider auth record.
*/
export interface LlmProviderAuthUpdate {
provider?: string | null;
config_path?: string | null;
secret?: string | null;
}

View File

@@ -17,6 +17,10 @@ import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type listModelsApiV1ModelRegistryModelsGetResponse,
useListModelsApiV1ModelRegistryModelsGet,
} from "@/api/generated/model-registry/model-registry";
import type { AgentRead, AgentUpdate, BoardRead } from "@/api/generated/model";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
@@ -118,6 +122,12 @@ export default function EditAgentPage() {
const [heartbeatTarget, setHeartbeatTarget] = useState<string | undefined>(
undefined,
);
const [primaryModelId, setPrimaryModelId] = useState<string | undefined>(
undefined,
);
const [fallbackModelIds, setFallbackModelIds] = useState<string[] | undefined>(
undefined,
);
const [identityProfile, setIdentityProfile] = useState<
IdentityProfile | undefined
>(undefined);
@@ -136,6 +146,16 @@ export default function EditAgentPage() {
retry: false,
},
});
const modelsQuery = useListModelsApiV1ModelRegistryModelsGet<
listModelsApiV1ModelRegistryModelsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const agentQuery = useGetAgentApiV1AgentsAgentIdGet<
getAgentApiV1AgentsAgentIdGetResponse,
@@ -165,6 +185,10 @@ export default function EditAgentPage() {
if (boardsQuery.data?.status !== 200) return [];
return boardsQuery.data.data.items ?? [];
}, [boardsQuery.data]);
const models = useMemo(() => {
if (modelsQuery.data?.status !== 200) return [];
return modelsQuery.data.data;
}, [modelsQuery.data]);
const loadedAgent: AgentRead | null =
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
@@ -201,17 +225,30 @@ export default function EditAgentPage() {
const loadedSoulTemplate = useMemo(() => {
return loadedAgent?.soul_template?.trim() || DEFAULT_SOUL_TEMPLATE;
}, [loadedAgent?.soul_template]);
const loadedFallbackModelIds = useMemo(
() => loadedAgent?.fallback_model_ids ?? [],
[loadedAgent?.fallback_model_ids],
);
const isLoading =
boardsQuery.isLoading || agentQuery.isLoading || updateMutation.isPending;
boardsQuery.isLoading ||
modelsQuery.isLoading ||
agentQuery.isLoading ||
updateMutation.isPending;
const errorMessage =
error ?? agentQuery.error?.message ?? boardsQuery.error?.message ?? null;
error ??
agentQuery.error?.message ??
boardsQuery.error?.message ??
modelsQuery.error?.message ??
null;
const resolvedName = name ?? loadedAgent?.name ?? "";
const resolvedIsGatewayMain =
isGatewayMain ?? Boolean(loadedAgent?.is_gateway_main);
const resolvedHeartbeatEvery = heartbeatEvery ?? loadedHeartbeat.every;
const resolvedHeartbeatTarget = heartbeatTarget ?? loadedHeartbeat.target;
const resolvedPrimaryModelId = primaryModelId ?? loadedAgent?.primary_model_id ?? "";
const resolvedFallbackModelIds = fallbackModelIds ?? loadedFallbackModelIds;
const resolvedIdentityProfile = identityProfile ?? loadedIdentityProfile;
const resolvedSoulTemplate = soulTemplate ?? loadedSoulTemplate;
@@ -219,6 +256,40 @@ export default function EditAgentPage() {
if (resolvedIsGatewayMain) return boardId ?? "";
return boardId ?? loadedAgent?.board_id ?? boards[0]?.id ?? "";
}, [boardId, boards, loadedAgent?.board_id, resolvedIsGatewayMain]);
const targetGatewayId = useMemo(() => {
if (!loadedAgent) return null;
if (resolvedBoardId) {
const selectedBoard = boards.find((board) => board.id === resolvedBoardId);
if (selectedBoard?.gateway_id) {
return selectedBoard.gateway_id;
}
}
return loadedAgent.gateway_id;
}, [boards, loadedAgent, resolvedBoardId]);
const availableModels = useMemo(
() => models.filter((model) => model.gateway_id === targetGatewayId),
[models, targetGatewayId],
);
const modelOptions = useMemo<SearchableSelectOption[]>(
() =>
availableModels.map((model) => ({
value: model.id,
label: `${model.display_name} (${model.model_id})`,
})),
[availableModels],
);
const availableModelIds = useMemo(
() => new Set(availableModels.map((model) => model.id)),
[availableModels],
);
const effectivePrimaryModelId = availableModelIds.has(resolvedPrimaryModelId)
? resolvedPrimaryModelId
: "";
const effectiveFallbackModelIds = resolvedFallbackModelIds.filter(
(modelIdValue) =>
modelIdValue !== effectivePrimaryModelId &&
availableModelIds.has(modelIdValue),
);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -250,6 +321,11 @@ export default function EditAgentPage() {
typeof loadedAgent.heartbeat_config === "object"
? (loadedAgent.heartbeat_config as Record<string, unknown>)
: {};
const normalizedFallbackModelIds = effectiveFallbackModelIds.filter(
(modelIdValue) =>
modelIdValue !== effectivePrimaryModelId &&
availableModelIds.has(modelIdValue),
);
const payload: AgentUpdate = {
name: trimmed,
@@ -266,6 +342,14 @@ export default function EditAgentPage() {
loadedAgent.identity_profile,
resolvedIdentityProfile,
) as unknown as Record<string, unknown> | null,
primary_model_id:
effectivePrimaryModelId && availableModelIds.has(effectivePrimaryModelId)
? effectivePrimaryModelId
: null,
fallback_model_ids:
normalizedFallbackModelIds.length > 0
? normalizedFallbackModelIds
: null,
soul_template: resolvedSoulTemplate.trim() || null,
};
if (!resolvedIsGatewayMain) {
@@ -469,6 +553,78 @@ export default function EditAgentPage() {
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
LLM routing
</p>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Primary model
</label>
<SearchableSelect
ariaLabel="Select primary model"
value={effectivePrimaryModelId}
onValueChange={setPrimaryModelId}
options={modelOptions}
placeholder="Select model"
searchPlaceholder="Search models..."
emptyMessage="No matching models."
disabled={isLoading || modelOptions.length === 0}
/>
{modelOptions.length === 0 ? (
<p className="text-xs text-slate-500">
No models found for this agent&apos;s gateway. Configure models
in the Models page.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Fallback models
</label>
{modelOptions.length === 0 ? (
<p className="text-xs text-slate-500">No fallback models yet.</p>
) : (
<div className="grid gap-2 md:grid-cols-2">
{modelOptions.map((option) => (
<label
key={option.value}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700"
>
<input
type="checkbox"
checked={effectiveFallbackModelIds.includes(option.value)}
disabled={
isLoading || option.value === effectivePrimaryModelId
}
onChange={(event) => {
if (event.target.checked) {
setFallbackModelIds((current) => {
const value = current ?? loadedFallbackModelIds;
return value.includes(option.value)
? value
: [...value, option.value];
});
return;
}
setFallbackModelIds((current) => {
const value = current ?? loadedFallbackModelIds;
return value.filter(
(modelIdValue) => modelIdValue !== option.value,
);
});
}}
/>
<span>{option.label}</span>
</label>
))}
</div>
)}
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Schedule & notifications

View File

@@ -2,7 +2,7 @@
export const dynamic = "force-dynamic";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/auth/clerk";
@@ -12,9 +12,13 @@ import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type listModelsApiV1ModelRegistryModelsGetResponse,
useListModelsApiV1ModelRegistryModelsGet,
} from "@/api/generated/model-registry/model-registry";
import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import type { BoardRead } from "@/api/generated/model";
import type { BoardRead, LlmModelRead } from "@/api/generated/model";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -86,6 +90,8 @@ export default function NewAgentPage() {
const [boardId, setBoardId] = useState<string>("");
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
const [primaryModelId, setPrimaryModelId] = useState("");
const [fallbackModelIds, setFallbackModelIds] = useState<string[]>([]);
const [identityProfile, setIdentityProfile] = useState<IdentityProfile>({
...DEFAULT_IDENTITY_PROFILE,
});
@@ -114,12 +120,48 @@ export default function NewAgentPage() {
},
},
});
const modelsQuery = useListModelsApiV1ModelRegistryModelsGet<
listModelsApiV1ModelRegistryModelsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const boards =
boardsQuery.data?.status === 200 ? (boardsQuery.data.data.items ?? []) : [];
const models: LlmModelRead[] =
modelsQuery.data?.status === 200 ? modelsQuery.data.data : [];
const displayBoardId = boardId || boards[0]?.id || "";
const selectedBoard = boards.find((board) => board.id === displayBoardId) ?? null;
const availableModels = models.filter(
(model) => model.gateway_id === selectedBoard?.gateway_id,
);
const modelOptions = availableModels.map((model) => ({
value: model.id,
label: `${model.display_name} (${model.model_id})`,
}));
const availableModelIds = useMemo(
() => new Set(availableModels.map((model) => model.id)),
[availableModels],
);
const effectivePrimaryModelId = availableModelIds.has(primaryModelId)
? primaryModelId
: "";
const effectiveFallbackModelIds = fallbackModelIds.filter(
(modelId) =>
modelId !== effectivePrimaryModelId && availableModelIds.has(modelId),
);
const isLoading = boardsQuery.isLoading || createAgentMutation.isPending;
const errorMessage = error ?? boardsQuery.error?.message ?? null;
const errorMessage =
error ?? boardsQuery.error?.message ?? modelsQuery.error?.message ?? null;
const normalizedFallbackModelIds = fallbackModelIds.filter(
(modelId) =>
modelId !== effectivePrimaryModelId && availableModelIds.has(modelId),
);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -147,6 +189,11 @@ export default function NewAgentPage() {
identity_profile: normalizeIdentityProfile(
identityProfile,
) as unknown as Record<string, unknown> | null,
primary_model_id: effectivePrimaryModelId || null,
fallback_model_ids:
normalizedFallbackModelIds.length > 0
? normalizedFallbackModelIds
: null,
soul_template: soulTemplate.trim() || null,
},
});
@@ -290,6 +337,74 @@ export default function NewAgentPage() {
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
LLM routing
</p>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Primary model
</label>
<SearchableSelect
ariaLabel="Select primary model"
value={effectivePrimaryModelId}
onValueChange={setPrimaryModelId}
options={modelOptions}
placeholder="Select model"
searchPlaceholder="Search models..."
emptyMessage="No matching models."
disabled={isLoading || availableModels.length === 0}
/>
{availableModels.length === 0 ? (
<p className="text-xs text-slate-500">
No models found for this board&apos;s gateway. Configure models
in the Models page first.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Fallback models
</label>
{modelOptions.length === 0 ? (
<p className="text-xs text-slate-500">No fallback models yet.</p>
) : (
<div className="grid gap-2 md:grid-cols-2">
{modelOptions.map((option) => (
<label
key={option.value}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700"
>
<input
type="checkbox"
checked={effectiveFallbackModelIds.includes(option.value)}
disabled={
isLoading || option.value === effectivePrimaryModelId
}
onChange={(event) => {
if (event.target.checked) {
setFallbackModelIds((current) =>
current.includes(option.value)
? current
: [...current, option.value],
);
return;
}
setFallbackModelIds((current) =>
current.filter((value) => value !== option.value),
);
}}
/>
<span>{option.label}</span>
</label>
))}
</div>
)}
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Schedule & notifications

View File

@@ -0,0 +1,493 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import {
type listAgentsApiV1AgentsGetResponse,
getListAgentsApiV1AgentsGetQueryKey,
useListAgentsApiV1AgentsGet,
useUpdateAgentApiV1AgentsAgentIdPatch,
} from "@/api/generated/agents/agents";
import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { AgentRead, BoardRead, LlmModelRead } from "@/api/generated/model";
import {
type listModelsApiV1ModelRegistryModelsGetResponse,
useListModelsApiV1ModelRegistryModelsGet,
} from "@/api/generated/model-registry/model-registry";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import SearchableSelect, {
type SearchableSelectOption,
} from "@/components/ui/searchable-select";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
type AgentRoutingEditPageProps = {
agentId: string;
};
type RoutingStatus = "override" | "default" | "unconfigured";
const routingStatusLabel = (status: RoutingStatus): string => {
if (status === "override") return "Primary override";
if (status === "default") return "Using default";
return "No primary";
};
const routingStatusVariant = (
status: RoutingStatus,
): "success" | "accent" | "warning" => {
if (status === "override") return "success";
if (status === "default") return "accent";
return "warning";
};
const agentRoleLabel = (agent: AgentRead): string | null => {
const role = agent.identity_profile?.role;
if (typeof role !== "string") return null;
const normalized = role.trim();
return normalized || null;
};
const modelOptionLabel = (model: LlmModelRead): string =>
`${model.display_name} (${model.model_id})`;
const stringListsMatch = (left: string[], right: string[]): boolean => {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
};
const withGatewayQuery = (path: string, gatewayId: string): string => {
if (!gatewayId) return path;
return `${path}?gateway=${encodeURIComponent(gatewayId)}`;
};
export default function AgentRoutingEditPage({ agentId }: AgentRoutingEditPageProps) {
const { isSignedIn } = useAuth();
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [primaryModelDraft, setPrimaryModelDraft] = useState<string | null>(null);
const [fallbackModelDraft, setFallbackModelDraft] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
const agentsKey = getListAgentsApiV1AgentsGetQueryKey();
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const modelsQuery = useListModelsApiV1ModelRegistryModelsGet<
listModelsApiV1ModelRegistryModelsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const updateAgentMutation = useUpdateAgentApiV1AgentsAgentIdPatch<ApiError>({
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: agentsKey });
if (agent?.gateway_id) {
router.push(withGatewayQuery("/models/routing", agent.gateway_id));
return;
}
router.push("/models/routing");
},
onError: (updateError) => {
setError(updateError.message || "Unable to save agent routing.");
},
},
});
const agents = useMemo<AgentRead[]>(() => {
if (agentsQuery.data?.status !== 200) return [];
return agentsQuery.data.data.items ?? [];
}, [agentsQuery.data]);
const boards = useMemo<BoardRead[]>(() => {
if (boardsQuery.data?.status !== 200) return [];
return boardsQuery.data.data.items ?? [];
}, [boardsQuery.data]);
const gateways = useMemo(() => {
if (gatewaysQuery.data?.status !== 200) return [];
return gatewaysQuery.data.data.items ?? [];
}, [gatewaysQuery.data]);
const models = useMemo<LlmModelRead[]>(() => {
if (modelsQuery.data?.status !== 200) return [];
return modelsQuery.data.data;
}, [modelsQuery.data]);
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board] as const)), [boards]);
const gatewaysById = useMemo(
() => new Map(gateways.map((gateway) => [gateway.id, gateway] as const)),
[gateways],
);
const agent = agents.find((item) => item.id === agentId) ?? null;
const agentBoard = agent?.board_id ? (boardsById.get(agent.board_id) ?? null) : null;
const modelsForGateway = agent?.gateway_id
? models.filter((model) => model.gateway_id === agent.gateway_id)
: [];
const modelsById = new Map(modelsForGateway.map((item) => [item.id, item] as const));
const availableModelIds = new Set(modelsForGateway.map((model) => model.id));
const defaultPrimaryModel = modelsForGateway[0] ?? null;
const baselinePrimaryModelId = agent?.primary_model_id ?? "";
const baselineFallbackModelIds = agent?.fallback_model_ids ?? [];
const primaryModelIdCandidate = primaryModelDraft ?? baselinePrimaryModelId;
const primaryModelId = availableModelIds.has(primaryModelIdCandidate)
? primaryModelIdCandidate
: "";
const fallbackModelIds = (() => {
const source = fallbackModelDraft ?? baselineFallbackModelIds;
return source.filter(
(modelIdValue, index, list) =>
modelIdValue !== primaryModelId &&
availableModelIds.has(modelIdValue) &&
list.indexOf(modelIdValue) === index,
);
})();
const selectedPrimary = primaryModelId ? (modelsById.get(primaryModelId) ?? null) : null;
const effectivePrimaryModel = selectedPrimary ?? defaultPrimaryModel;
const status: RoutingStatus = primaryModelId
? "override"
: effectivePrimaryModel
? "default"
: "unconfigured";
const selectedFallbackModels = fallbackModelIds
.map((id) => modelsById.get(id) ?? null)
.filter((model): model is LlmModelRead => model !== null);
const modelOptions: SearchableSelectOption[] = modelsForGateway.map((model) => ({
value: model.id,
label: modelOptionLabel(model),
}));
const hasUnsavedChanges = (() => {
if (!agent) return false;
return (
baselinePrimaryModelId !== primaryModelId ||
!stringListsMatch(baselineFallbackModelIds, fallbackModelIds)
);
})();
const handleSave = () => {
if (!agent) {
setError("Agent not found.");
return;
}
if (primaryModelId && !availableModelIds.has(primaryModelId)) {
setError("Primary model must belong to this gateway catalog.");
return;
}
setError(null);
updateAgentMutation.mutate({
agentId: agent.id,
params: { force: true },
data: {
primary_model_id: primaryModelId || null,
fallback_model_ids: fallbackModelIds.length > 0 ? fallbackModelIds : null,
},
});
};
const handleRevert = () => {
setError(null);
setPrimaryModelDraft(null);
setFallbackModelDraft(null);
};
const requestedGateway = searchParams.get("gateway")?.trim() ?? "";
const backGatewayId = agent?.gateway_id ?? requestedGateway;
const gatewayName = agent?.gateway_id ? (gatewaysById.get(agent.gateway_id)?.name ?? null) : null;
const pageError =
agentsQuery.error?.message ??
boardsQuery.error?.message ??
gatewaysQuery.error?.message ??
modelsQuery.error?.message ??
null;
const missingAgent =
!agentsQuery.isLoading &&
agentsQuery.data?.status === 200 &&
!agent;
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to edit agent routing.",
forceRedirectUrl: "/models/routing",
signUpForceRedirectUrl: "/models/routing",
}}
title="Edit agent routing"
description="Set primary override and fallback models for this agent."
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can edit agent routing."
>
<div className="space-y-4">
{missingAgent ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Agent not found.
</div>
) : (
<div className="space-y-4">
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-sm font-semibold text-slate-900">Agent details</h2>
{!agent ? (
<p className="mt-3 text-sm text-slate-500">Loading agent...</p>
) : (
<table className="mt-3 min-w-full border-collapse text-sm">
<tbody>
<tr className="border-t border-slate-200">
<th className="w-44 px-3 py-3 text-left font-medium text-slate-600">Agent</th>
<td className="px-3 py-3">
<Link
href={`/agents/${agent.id}`}
className="font-semibold text-blue-700 underline-offset-2 hover:underline"
>
{agent.name}
</Link>
</td>
</tr>
<tr className="border-t border-slate-200">
<th className="px-3 py-3 text-left font-medium text-slate-600">Role</th>
<td className="px-3 py-3 text-slate-700">{agentRoleLabel(agent) ?? "Unspecified"}</td>
</tr>
<tr className="border-t border-slate-200">
<th className="px-3 py-3 text-left font-medium text-slate-600">Board</th>
<td className="px-3 py-3 text-slate-700">
{agent.board_id ? (
<Link
href={`/boards/${agent.board_id}`}
className="text-blue-700 underline-offset-2 hover:underline"
>
{agentBoard?.name ?? "Open board"}
</Link>
) : (
"Gateway main"
)}
</td>
</tr>
<tr className="border-t border-slate-200">
<th className="px-3 py-3 text-left font-medium text-slate-600">Gateway</th>
<td className="px-3 py-3 text-slate-700">
{gatewayName ? `${gatewayName} (${agent.gateway_id})` : (agent.gateway_id || "Unknown gateway")}
</td>
</tr>
<tr className="border-t border-slate-200">
<th className="px-3 py-3 text-left font-medium text-slate-600">Status</th>
<td className="px-3 py-3">
<Badge variant={routingStatusVariant(status)}>{routingStatusLabel(status)}</Badge>
</td>
</tr>
<tr className="border-t border-slate-200">
<th className="px-3 py-3 text-left font-medium text-slate-600">Effective primary</th>
<td className="px-3 py-3 text-slate-700">
{effectivePrimaryModel ? modelOptionLabel(effectivePrimaryModel) : "None"}
{!selectedPrimary && effectivePrimaryModel ? " (inherited from default)" : ""}
</td>
</tr>
</tbody>
</table>
)}
</section>
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-slate-900">Routing assignment</h2>
{!agent ? (
<Badge variant="outline">Loading</Badge>
) : hasUnsavedChanges ? (
<Badge variant="warning">Unsaved changes</Badge>
) : (
<Badge variant="outline">Saved</Badge>
)}
</div>
<p className="mt-1 text-xs text-slate-600">
Primary override is optional. Empty primary inherits the gateway default.
</p>
<div className="mt-4 space-y-4">
<div>
<p className="mb-2 text-sm font-medium text-slate-700">Primary model override</p>
<SearchableSelect
ariaLabel="Select primary model"
value={primaryModelId}
onValueChange={(value) => {
setPrimaryModelDraft(value);
setFallbackModelDraft((current) => {
const source = current ?? baselineFallbackModelIds;
return source.filter((item) => item !== value);
});
}}
options={modelOptions}
placeholder="Use gateway default (no override)"
searchPlaceholder="Search models..."
emptyMessage="No matching models."
triggerClassName="w-full"
disabled={!agent || updateAgentMutation.isPending || modelOptions.length === 0}
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-slate-700">
Fallback models ({fallbackModelIds.length})
</p>
{selectedFallbackModels.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedFallbackModels.map((model) => (
<Badge key={model.id} variant="outline" className="normal-case tracking-normal">
{model.display_name}
</Badge>
))}
</div>
) : null}
</div>
{modelOptions.length === 0 ? (
<p className="text-sm text-slate-500">No catalog models available for this gateway yet.</p>
) : (
<div className="max-h-72 space-y-2 overflow-y-auto pr-1">
{modelOptions.map((option) => {
const checked = fallbackModelIds.includes(option.value);
const disabled = option.value === primaryModelId || !agent;
const model = modelsById.get(option.value) ?? null;
return (
<label
key={option.value}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2"
>
<span className="flex min-w-0 items-center gap-2">
<input
type="checkbox"
checked={checked}
disabled={disabled || updateAgentMutation.isPending}
onChange={(event) => {
if (event.target.checked) {
setFallbackModelDraft((current) => {
const source = current ?? baselineFallbackModelIds;
return source.includes(option.value)
? source
: [...source, option.value];
});
return;
}
setFallbackModelDraft((current) => {
const source = current ?? baselineFallbackModelIds;
return source.filter((value) => value !== option.value);
});
}}
/>
<span className="min-w-0 truncate text-sm text-slate-700">{option.label}</span>
</span>
{model ? <Badge variant="outline">{model.provider}</Badge> : null}
</label>
);
})}
</div>
)}
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Button
type="button"
onClick={handleSave}
disabled={!agent || updateAgentMutation.isPending || !hasUnsavedChanges}
>
{updateAgentMutation.isPending ? "Saving..." : "Save routing"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleRevert}
disabled={!agent || updateAgentMutation.isPending || !hasUnsavedChanges}
>
Revert
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setError(null);
setPrimaryModelDraft("");
setFallbackModelDraft([]);
}}
disabled={!agent || updateAgentMutation.isPending}
>
Clear override
</Button>
<Link
href={withGatewayQuery("/models/routing", backGatewayId)}
className={buttonVariants({ variant: "outline", size: "md" })}
>
Back to routing table
</Link>
</div>
</section>
</div>
)}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{pageError ? <p className="text-sm text-red-600">{pageError}</p> : null}
</div>
</DashboardPageLayout>
);
}

View File

@@ -0,0 +1,344 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { GatewayRead, LlmModelRead } from "@/api/generated/model";
import {
type listModelsApiV1ModelRegistryModelsGetResponse,
getListModelsApiV1ModelRegistryModelsGetQueryKey,
useCreateModelApiV1ModelRegistryModelsPost,
useListModelsApiV1ModelRegistryModelsGet,
useUpdateModelApiV1ModelRegistryModelsModelIdPatch,
} from "@/api/generated/model-registry/model-registry";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import SearchableSelect, {
type SearchableSelectOption,
} from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
type CatalogModelFormPageProps =
| { mode: "create" }
| { mode: "edit"; modelId: string };
const toGatewayOptions = (gateways: GatewayRead[]): SearchableSelectOption[] =>
gateways.map((gateway) => ({ value: gateway.id, label: gateway.name }));
const withGatewayQuery = (path: string, gatewayId: string): string => {
if (!gatewayId) return path;
return `${path}?gateway=${encodeURIComponent(gatewayId)}`;
};
export default function CatalogModelFormPage(props: CatalogModelFormPageProps) {
const { isSignedIn } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const modelIdParam = props.mode === "edit" ? props.modelId : null;
const [gatewayDraft, setGatewayDraft] = useState<string | null>(null);
const [providerDraft, setProviderDraft] = useState<string | null>(null);
const [modelIdDraft, setModelIdDraft] = useState<string | null>(null);
const [displayNameDraft, setDisplayNameDraft] = useState<string | null>(null);
const [settingsDraft, setSettingsDraft] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const modelsKey = getListModelsApiV1ModelRegistryModelsGetQueryKey();
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const modelsQuery = useListModelsApiV1ModelRegistryModelsGet<
listModelsApiV1ModelRegistryModelsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const createMutation = useCreateModelApiV1ModelRegistryModelsPost<ApiError>({
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: modelsKey });
router.push(withGatewayQuery("/models/catalog", gatewayId));
},
onError: (err) => {
setError(err.message || "Unable to create model.");
},
},
});
const updateMutation = useUpdateModelApiV1ModelRegistryModelsModelIdPatch<ApiError>({
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: modelsKey });
router.push(withGatewayQuery("/models/catalog", gatewayId));
},
onError: (err) => {
setError(err.message || "Unable to update model.");
},
},
});
const gateways = useMemo(() => {
if (gatewaysQuery.data?.status !== 200) return [];
return gatewaysQuery.data.data.items ?? [];
}, [gatewaysQuery.data]);
const models = useMemo<LlmModelRead[]>(() => {
if (modelsQuery.data?.status !== 200) return [];
return modelsQuery.data.data;
}, [modelsQuery.data]);
const currentItem = useMemo(() => {
if (props.mode !== "edit" || !modelIdParam) return null;
return models.find((item) => item.id === modelIdParam) ?? null;
}, [modelIdParam, models, props.mode]);
const gatewayOptions = useMemo(() => toGatewayOptions(gateways), [gateways]);
const requestedGateway = searchParams.get("gateway")?.trim() ?? "";
const gatewayId = (() => {
if (gateways.length === 0) return "";
if (props.mode === "edit" && currentItem?.gateway_id) {
return currentItem.gateway_id;
}
if (gatewayDraft && gateways.some((gateway) => gateway.id === gatewayDraft)) {
return gatewayDraft;
}
if (requestedGateway && gateways.some((gateway) => gateway.id === requestedGateway)) {
return requestedGateway;
}
return gateways[0].id;
})();
const provider = providerDraft ?? (props.mode === "edit" ? (currentItem?.provider ?? "") : "");
const modelId = modelIdDraft ?? (props.mode === "edit" ? (currentItem?.model_id ?? "") : "");
const displayName =
displayNameDraft ?? (props.mode === "edit" ? (currentItem?.display_name ?? "") : "");
const settingsText =
settingsDraft ??
(props.mode === "edit" && currentItem?.settings
? JSON.stringify(currentItem.settings, null, 2)
: "");
const isBusy = createMutation.isPending || updateMutation.isPending;
const pageError = gatewaysQuery.error?.message ?? modelsQuery.error?.message ?? null;
const title = props.mode === "create" ? "Add catalog model" : "Edit catalog model";
const description =
props.mode === "create"
? "Create a gateway model catalog entry for agent routing."
: "Update model metadata and settings for this catalog entry.";
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!gatewayId) {
setError("Select a gateway first.");
return;
}
const normalizedProvider = provider.trim().toLowerCase();
const normalizedModelId = modelId.trim();
const normalizedDisplayName = displayName.trim();
if (!normalizedProvider || !normalizedModelId || !normalizedDisplayName) {
setError("Provider, model ID, and display name are required.");
return;
}
let settings: Record<string, unknown> | undefined;
const normalizedSettings = settingsText.trim();
if (normalizedSettings) {
try {
const parsed = JSON.parse(normalizedSettings) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("settings must be an object");
}
settings = parsed as Record<string, unknown>;
} catch {
setError("Settings must be a valid JSON object.");
return;
}
} else if (props.mode === "edit") {
settings = {};
}
setError(null);
if (props.mode === "create") {
createMutation.mutate({
data: {
gateway_id: gatewayId,
provider: normalizedProvider,
model_id: normalizedModelId,
display_name: normalizedDisplayName,
settings,
},
});
return;
}
if (!modelIdParam) {
setError("Missing model identifier.");
return;
}
updateMutation.mutate({
modelId: modelIdParam,
data: {
provider: normalizedProvider,
model_id: normalizedModelId,
display_name: normalizedDisplayName,
settings,
},
});
};
const missingEditItem =
props.mode === "edit" &&
!modelsQuery.isLoading &&
modelsQuery.data?.status === 200 &&
!currentItem;
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to manage catalog models.",
forceRedirectUrl: "/models/catalog",
signUpForceRedirectUrl: "/models/catalog",
}}
title={title}
description={description}
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can manage model catalog entries."
>
<div className="space-y-4">
{missingEditItem ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Catalog model entry not found.
</div>
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-4">
<h2 className="text-sm font-semibold text-slate-900">Model details</h2>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-slate-700">
<span className="font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</span>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
onValueChange={setGatewayDraft}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No matching gateways."
triggerClassName="w-full"
disabled={props.mode === "edit" || isBusy}
/>
</label>
<label className="space-y-2 text-sm text-slate-700">
<span className="font-medium text-slate-900">
Provider <span className="text-red-500">*</span>
</span>
<Input
value={provider}
onChange={(event) => setProviderDraft(event.target.value)}
placeholder="openai"
disabled={isBusy}
/>
</label>
<label className="space-y-2 text-sm text-slate-700">
<span className="font-medium text-slate-900">
Model ID <span className="text-red-500">*</span>
</span>
<Input
value={modelId}
onChange={(event) => setModelIdDraft(event.target.value)}
placeholder="openai-codex/gpt-5.3"
disabled={isBusy}
/>
</label>
<label className="space-y-2 text-sm text-slate-700">
<span className="font-medium text-slate-900">
Display name <span className="text-red-500">*</span>
</span>
<Input
value={displayName}
onChange={(event) => setDisplayNameDraft(event.target.value)}
placeholder="GPT-5.3 (Codex)"
disabled={isBusy}
/>
</label>
<label className="space-y-2 text-sm text-slate-700 md:col-span-2">
<span>Settings JSON (optional)</span>
<Textarea
value={settingsText}
onChange={(event) => setSettingsDraft(event.target.value)}
rows={8}
placeholder='{"temperature": 0.2}'
disabled={isBusy}
/>
</label>
</div>
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{pageError ? <p className="text-sm text-red-500">{pageError}</p> : null}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={isBusy || missingEditItem}>
{props.mode === "create"
? createMutation.isPending
? "Creating..."
: "Create model"
: updateMutation.isPending
? "Saving..."
: "Save changes"}
</Button>
<Link
href={withGatewayQuery("/models/catalog", gatewayId || requestedGateway)}
className={buttonVariants({ variant: "outline", size: "md" })}
>
Back to model catalog
</Link>
</div>
</form>
)}
</div>
</DashboardPageLayout>
);
}

View File

@@ -0,0 +1,778 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import {
type listAgentsApiV1AgentsGetResponse,
getListAgentsApiV1AgentsGetQueryKey,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { AgentRead, BoardRead, LlmModelRead, LlmProviderAuthRead } from "@/api/generated/model";
import {
type listModelsApiV1ModelRegistryModelsGetResponse,
type listProviderAuthApiV1ModelRegistryProviderAuthGetResponse,
getListModelsApiV1ModelRegistryModelsGetQueryKey,
getListProviderAuthApiV1ModelRegistryProviderAuthGetQueryKey,
useDeleteModelApiV1ModelRegistryModelsModelIdDelete,
useDeleteProviderAuthApiV1ModelRegistryProviderAuthProviderAuthIdDelete,
useListModelsApiV1ModelRegistryModelsGet,
useListProviderAuthApiV1ModelRegistryProviderAuthGet,
} from "@/api/generated/model-registry/model-registry";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
const agentRoleLabel = (agent: AgentRead): string | null => {
const role = agent.identity_profile?.role;
if (typeof role !== "string") return null;
const value = role.trim();
return value || null;
};
type RoutingStatus = "override" | "default" | "unconfigured";
type ProviderSortKey = "gateway" | "provider" | "config_path" | "secret" | "updated";
type ModelSortKey = "gateway" | "display_name" | "model_id" | "provider" | "settings" | "updated";
type RoutingSortKey = "agent" | "role" | "gateway" | "board" | "primary" | "status";
type SortDirection = "asc" | "desc";
export type ModelsView = "provider-auth" | "catalog" | "routing";
const routingStatusLabel = (status: RoutingStatus): string => {
if (status === "override") return "Primary override";
if (status === "default") return "Using default";
return "No primary";
};
const routingStatusVariant = (
status: RoutingStatus,
): "success" | "accent" | "warning" => {
if (status === "override") return "success";
if (status === "default") return "accent";
return "warning";
};
const formatTimestamp = (value: unknown): string => {
if (!value) return "-";
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return "-";
return parsed.toLocaleString();
};
const VIEW_META: Record<
ModelsView,
{ title: string; description: string; addLabel?: string; addHref?: string }
> = {
"provider-auth": {
title: "Provider Auth",
description: "Gateway provider credentials managed by Mission Control.",
addLabel: "Add provider auth",
addHref: "/models/provider-auth/new",
},
catalog: {
title: "Model Catalog",
description: "Gateway model catalog used for agent routing assignments.",
addLabel: "Add model",
addHref: "/models/catalog/new",
},
routing: {
title: "Agent Routing",
description: "Per-agent primary and fallback model assignments across gateways.",
},
};
const withGatewayQuery = (href: string, gatewayId: string): string => {
if (!gatewayId) return href;
return `${href}?gateway=${encodeURIComponent(gatewayId)}`;
};
export default function ModelsWorkspace({ view }: { view: ModelsView }) {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [providerSortKey, setProviderSortKey] = useState<ProviderSortKey>("gateway");
const [providerSortDirection, setProviderSortDirection] = useState<SortDirection>("asc");
const [modelSortKey, setModelSortKey] = useState<ModelSortKey>("gateway");
const [modelSortDirection, setModelSortDirection] = useState<SortDirection>("asc");
const [routingSortKey, setRoutingSortKey] = useState<RoutingSortKey>("agent");
const [routingSortDirection, setRoutingSortDirection] = useState<SortDirection>("asc");
const modelsKey = getListModelsApiV1ModelRegistryModelsGetQueryKey();
const providerAuthKey = getListProviderAuthApiV1ModelRegistryProviderAuthGetQueryKey();
const agentsKey = getListAgentsApiV1AgentsGetQueryKey();
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const modelsQuery = useListModelsApiV1ModelRegistryModelsGet<
listModelsApiV1ModelRegistryModelsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const providerAuthQuery = useListProviderAuthApiV1ModelRegistryProviderAuthGet<
listProviderAuthApiV1ModelRegistryProviderAuthGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const deleteProviderMutation =
useDeleteProviderAuthApiV1ModelRegistryProviderAuthProviderAuthIdDelete<ApiError>(
{
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: providerAuthKey });
},
},
},
);
const deleteModelMutation =
useDeleteModelApiV1ModelRegistryModelsModelIdDelete<ApiError>({
mutation: {
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: modelsKey }),
queryClient.invalidateQueries({ queryKey: agentsKey }),
]);
},
},
});
const gateways =
gatewaysQuery.data?.status === 200 ? (gatewaysQuery.data.data.items ?? []) : [];
const models: LlmModelRead[] =
modelsQuery.data?.status === 200 ? modelsQuery.data.data : [];
const boards: BoardRead[] =
boardsQuery.data?.status === 200 ? (boardsQuery.data.data.items ?? []) : [];
const providerAuth: LlmProviderAuthRead[] =
providerAuthQuery.data?.status === 200 ? providerAuthQuery.data.data : [];
const agents: AgentRead[] =
agentsQuery.data?.status === 200 ? (agentsQuery.data.data.items ?? []) : [];
const gatewaysById = new Map(gateways.map((gateway) => [gateway.id, gateway] as const));
const boardsById = new Map(boards.map((board) => [board.id, board] as const));
const modelsById = new Map(models.map((model) => [model.id, model] as const));
const gatewayDefaultById = (() => {
const grouped = new Map<string, LlmModelRead[]>();
for (const model of models) {
const bucket = grouped.get(model.gateway_id) ?? [];
bucket.push(model);
grouped.set(model.gateway_id, bucket);
}
const defaults = new Map<string, LlmModelRead>();
for (const [gatewayId, bucket] of grouped.entries()) {
const sorted = [...bucket].sort((a, b) =>
String(a.created_at).localeCompare(String(b.created_at)),
);
if (sorted[0]) {
defaults.set(gatewayId, sorted[0]);
}
}
return defaults;
})();
const providerRows = [...providerAuth].sort((left, right) => {
const resolveString = (row: LlmProviderAuthRead): string => {
if (providerSortKey === "gateway") {
return gatewaysById.get(row.gateway_id)?.name ?? "Unknown gateway";
}
if (providerSortKey === "provider") return row.provider;
if (providerSortKey === "config_path") return row.config_path;
return "";
};
const resolveNumber = (row: LlmProviderAuthRead): number => {
if (providerSortKey === "secret") return row.has_secret ? 1 : 0;
if (providerSortKey === "updated") return new Date(String(row.updated_at)).getTime() || 0;
return 0;
};
if (providerSortKey === "secret" || providerSortKey === "updated") {
const a = resolveNumber(left);
const b = resolveNumber(right);
const baseCompare = a - b;
if (baseCompare !== 0) {
return providerSortDirection === "asc" ? baseCompare : -baseCompare;
}
} else {
const a = resolveString(left).toLowerCase();
const b = resolveString(right).toLowerCase();
const baseCompare = a.localeCompare(b);
if (baseCompare !== 0) {
return providerSortDirection === "asc" ? baseCompare : -baseCompare;
}
}
const fallbackCompare = left.provider.localeCompare(right.provider);
return providerSortDirection === "asc" ? fallbackCompare : -fallbackCompare;
});
const modelRows = [...models].sort((left, right) => {
const resolveString = (row: LlmModelRead): string => {
if (modelSortKey === "gateway") {
return gatewaysById.get(row.gateway_id)?.name ?? "Unknown gateway";
}
if (modelSortKey === "display_name") return row.display_name;
if (modelSortKey === "model_id") return row.model_id;
if (modelSortKey === "provider") return row.provider;
return "";
};
const resolveNumber = (row: LlmModelRead): number => {
if (modelSortKey === "settings") return row.settings ? Object.keys(row.settings).length : 0;
if (modelSortKey === "updated") return new Date(String(row.updated_at)).getTime() || 0;
return 0;
};
if (modelSortKey === "settings" || modelSortKey === "updated") {
const a = resolveNumber(left);
const b = resolveNumber(right);
const baseCompare = a - b;
if (baseCompare !== 0) {
return modelSortDirection === "asc" ? baseCompare : -baseCompare;
}
} else {
const a = resolveString(left).toLowerCase();
const b = resolveString(right).toLowerCase();
const baseCompare = a.localeCompare(b);
if (baseCompare !== 0) {
return modelSortDirection === "asc" ? baseCompare : -baseCompare;
}
}
const fallbackCompare = left.display_name.localeCompare(right.display_name);
return modelSortDirection === "asc" ? fallbackCompare : -fallbackCompare;
});
const routingRows = agents
.map((agent) => {
const primary = agent.primary_model_id ? modelsById.get(agent.primary_model_id) : null;
const defaultPrimary = gatewayDefaultById.get(agent.gateway_id) ?? null;
const effectivePrimary = primary ?? defaultPrimary;
const board = agent.board_id ? (boardsById.get(agent.board_id) ?? null) : null;
const role = agentRoleLabel(agent) ?? "Unspecified";
const fallbackCount = (agent.fallback_model_ids ?? []).length;
const status: RoutingStatus = primary
? "override"
: effectivePrimary
? "default"
: "unconfigured";
return {
agent,
board,
role,
primary,
effectivePrimary,
fallbackCount,
gatewayName: gatewaysById.get(agent.gateway_id)?.name ?? "Unknown gateway",
status,
};
});
const sortedRoutingRows = [...routingRows].sort((left, right) => {
const resolveValue = (row: (typeof routingRows)[number]): string => {
if (routingSortKey === "agent") return row.agent.name;
if (routingSortKey === "role") return row.role;
if (routingSortKey === "gateway") return row.gatewayName;
if (routingSortKey === "board") return row.board?.name ?? "Gateway main";
if (routingSortKey === "primary") {
return row.primary?.display_name ?? row.effectivePrimary?.display_name ?? "";
}
return routingStatusLabel(row.status);
};
const a = resolveValue(left).toLowerCase();
const b = resolveValue(right).toLowerCase();
const baseCompare = a.localeCompare(b);
if (baseCompare !== 0) {
return routingSortDirection === "asc" ? baseCompare : -baseCompare;
}
const fallbackCompare = left.agent.name.localeCompare(right.agent.name);
return routingSortDirection === "asc" ? fallbackCompare : -fallbackCompare;
});
const setRoutingSort = (key: RoutingSortKey) => {
if (routingSortKey === key) {
setRoutingSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setRoutingSortKey(key);
setRoutingSortDirection("asc");
};
const routingSortLabel = (key: RoutingSortKey): string => {
if (routingSortKey !== key) return "";
return routingSortDirection === "asc" ? " ▲" : " ▼";
};
const setProviderSort = (key: ProviderSortKey) => {
if (providerSortKey === key) {
setProviderSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setProviderSortKey(key);
setProviderSortDirection("asc");
};
const providerSortLabel = (key: ProviderSortKey): string => {
if (providerSortKey !== key) return "";
return providerSortDirection === "asc" ? " ▲" : " ▼";
};
const setModelSort = (key: ModelSortKey) => {
if (modelSortKey === key) {
setModelSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setModelSortKey(key);
setModelSortDirection("asc");
};
const modelSortLabel = (key: ModelSortKey): string => {
if (modelSortKey !== key) return "";
return modelSortDirection === "asc" ? " ▲" : " ▼";
};
const pageError =
gatewaysQuery.error?.message ??
modelsQuery.error?.message ??
boardsQuery.error?.message ??
providerAuthQuery.error?.message ??
agentsQuery.error?.message ??
null;
const activeViewMeta = VIEW_META[view];
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to manage gateway models.",
forceRedirectUrl: "/models/routing",
signUpForceRedirectUrl: "/models/routing",
}}
title={activeViewMeta.title}
description={activeViewMeta.description}
headerActions={
activeViewMeta.addHref && activeViewMeta.addLabel ? (
<Link href={activeViewMeta.addHref} className={buttonVariants()}>
{activeViewMeta.addLabel}
</Link>
) : null
}
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can access model management."
>
<div className="space-y-6">
{view === "provider-auth" ? (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="max-h-[760px] overflow-auto">
<table className="min-w-full border-collapse text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-100 text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setProviderSort("gateway")}
className="text-left text-inherit hover:text-slate-700"
>
Gateway{providerSortLabel("gateway")}
</button>
</th>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setProviderSort("provider")}
className="text-left text-inherit hover:text-slate-700"
>
Provider{providerSortLabel("provider")}
</button>
</th>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setProviderSort("config_path")}
className="text-left text-inherit hover:text-slate-700"
>
Config path{providerSortLabel("config_path")}
</button>
</th>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setProviderSort("secret")}
className="text-left text-inherit hover:text-slate-700"
>
Secret{providerSortLabel("secret")}
</button>
</th>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setProviderSort("updated")}
className="text-left text-inherit hover:text-slate-700"
>
Updated{providerSortLabel("updated")}
</button>
</th>
<th className="px-4 py-3 font-medium text-right">Action</th>
</tr>
</thead>
<tbody>
{providerRows.length === 0 ? (
<tr className="border-t border-slate-200">
<td colSpan={6} className="px-4 py-8 text-center text-sm text-slate-500">
No provider auth entries yet.
</td>
</tr>
) : (
providerRows.map((item) => (
<tr key={item.id} className="border-t border-slate-200 bg-white">
<td className="px-4 py-3 align-top text-slate-700">
{gatewaysById.get(item.gateway_id)?.name ?? "Unknown gateway"}
</td>
<td className="px-4 py-3 align-top">
<Badge variant="accent">{item.provider}</Badge>
</td>
<td className="px-4 py-3 align-top text-slate-700">
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs">
{item.config_path}
</code>
</td>
<td className="px-4 py-3 align-top text-slate-700">
{item.has_secret ? "Configured" : "Missing"}
</td>
<td className="px-4 py-3 align-top text-slate-600">
{formatTimestamp(item.updated_at)}
</td>
<td className="px-4 py-3 text-right align-top">
<div className="flex justify-end gap-2">
<Link
href={withGatewayQuery(
`/models/provider-auth/${item.id}/edit`,
item.gateway_id,
)}
className={buttonVariants({ size: "sm", variant: "outline" })}
>
Edit
</Link>
<Button
variant="outline"
size="sm"
onClick={() => deleteProviderMutation.mutate({ providerAuthId: item.id })}
disabled={deleteProviderMutation.isPending}
className="border-red-300 text-red-700 hover:border-red-500 hover:text-red-800"
>
Delete
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
) : null}
{view === "catalog" ? (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="max-h-[760px] overflow-auto">
<table className="min-w-full border-collapse text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-100 text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setModelSort("gateway")}
className="text-left text-inherit hover:text-slate-700"
>
Gateway{modelSortLabel("gateway")}
</button>
</th>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setModelSort("display_name")}
className="text-left text-inherit hover:text-slate-700"
>
Display name{modelSortLabel("display_name")}
</button>
</th>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setModelSort("model_id")}
className="text-left text-inherit hover:text-slate-700"
>
Model ID{modelSortLabel("model_id")}
</button>
</th>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setModelSort("provider")}
className="text-left text-inherit hover:text-slate-700"
>
Provider{modelSortLabel("provider")}
</button>
</th>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setModelSort("settings")}
className="text-left text-inherit hover:text-slate-700"
>
Settings{modelSortLabel("settings")}
</button>
</th>
<th className="px-4 py-3 font-medium">
<button
type="button"
onClick={() => setModelSort("updated")}
className="text-left text-inherit hover:text-slate-700"
>
Updated{modelSortLabel("updated")}
</button>
</th>
<th className="px-4 py-3 font-medium text-right">Action</th>
</tr>
</thead>
<tbody>
{modelRows.length === 0 ? (
<tr className="border-t border-slate-200">
<td colSpan={7} className="px-4 py-8 text-center text-sm text-slate-500">
No models in catalog yet.
</td>
</tr>
) : (
modelRows.map((item) => (
<tr key={item.id} className="border-t border-slate-200 bg-white">
<td className="px-4 py-3 align-top text-slate-700">
{gatewaysById.get(item.gateway_id)?.name ?? "Unknown gateway"}
</td>
<td className="px-4 py-3 align-top font-medium text-slate-800">{item.display_name}</td>
<td className="px-4 py-3 align-top text-slate-700">{item.model_id}</td>
<td className="px-4 py-3 align-top">
<Badge variant="accent">{item.provider}</Badge>
</td>
<td className="px-4 py-3 align-top text-slate-600">
{item.settings ? `${Object.keys(item.settings).length} keys` : "-"}
</td>
<td className="px-4 py-3 align-top text-slate-600">
{formatTimestamp(item.updated_at)}
</td>
<td className="px-4 py-3 text-right align-top">
<div className="flex justify-end gap-2">
<Link
href={withGatewayQuery(`/models/catalog/${item.id}/edit`, item.gateway_id)}
className={buttonVariants({ size: "sm", variant: "outline" })}
>
Edit
</Link>
<Button
variant="outline"
size="sm"
onClick={() => deleteModelMutation.mutate({ modelId: item.id })}
disabled={deleteModelMutation.isPending}
className="border-red-300 text-red-700 hover:border-red-500 hover:text-red-800"
>
Delete
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
) : null}
{view === "routing" ? (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="max-h-[780px] overflow-auto">
{routingRows.length === 0 ? (
<p className="mx-4 my-4 rounded-lg border border-dashed border-slate-300 px-3 py-5 text-sm text-slate-500">
No agents found.
</p>
) : (
<table className="min-w-full table-fixed border-collapse text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-100 text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="w-[18%] px-4 py-3 font-medium">
<button
type="button"
onClick={() => setRoutingSort("agent")}
className="text-left text-inherit hover:text-slate-700"
>
Agent{routingSortLabel("agent")}
</button>
</th>
<th className="w-[14%] px-4 py-3 font-medium">
<button
type="button"
onClick={() => setRoutingSort("role")}
className="text-left text-inherit hover:text-slate-700"
>
Role{routingSortLabel("role")}
</button>
</th>
<th className="w-[16%] px-4 py-3 font-medium">
<button
type="button"
onClick={() => setRoutingSort("gateway")}
className="text-left text-inherit hover:text-slate-700"
>
Gateway{routingSortLabel("gateway")}
</button>
</th>
<th className="w-[16%] px-4 py-3 font-medium">
<button
type="button"
onClick={() => setRoutingSort("board")}
className="text-left text-inherit hover:text-slate-700"
>
Board{routingSortLabel("board")}
</button>
</th>
<th className="w-[24%] px-4 py-3 font-medium">
<button
type="button"
onClick={() => setRoutingSort("primary")}
className="text-left text-inherit hover:text-slate-700"
>
Primary{routingSortLabel("primary")}
</button>
</th>
<th className="w-[12%] px-4 py-3 font-medium">
<button
type="button"
onClick={() => setRoutingSort("status")}
className="text-left text-inherit hover:text-slate-700"
>
Status{routingSortLabel("status")}
</button>
</th>
<th className="px-4 py-3 font-medium text-right">Action</th>
</tr>
</thead>
<tbody>
{sortedRoutingRows.map((row) => (
<tr key={row.agent.id} className="border-t border-slate-200 bg-white hover:bg-slate-50">
<td className="px-4 py-3 align-top">
<Link
href={`/agents/${row.agent.id}`}
className="block truncate font-semibold text-blue-700 underline-offset-2 hover:underline"
>
{row.agent.name}
</Link>
<p className="mt-1 truncate text-xs text-slate-500">
Fallbacks: {row.fallbackCount}
</p>
</td>
<td className="px-4 py-3 align-top text-slate-700">{row.role}</td>
<td className="px-4 py-3 align-top text-slate-700">{row.gatewayName}</td>
<td className="px-4 py-3 align-top">
{row.agent.board_id ? (
<div>
<Link
href={`/boards/${row.agent.board_id}`}
className="block truncate text-blue-700 underline-offset-2 hover:underline"
>
{row.board?.name ?? "Open board"}
</Link>
</div>
) : (
<span className="text-slate-600">Gateway main</span>
)}
</td>
<td className="px-4 py-3 align-top text-slate-700">
{row.primary ? (
<p className="truncate font-medium text-slate-800">{row.primary.display_name}</p>
) : row.effectivePrimary ? (
<p className="truncate font-medium text-slate-800">
{row.effectivePrimary.display_name}
</p>
) : (
"None"
)}
</td>
<td className="px-4 py-3 align-top">
<Badge variant={routingStatusVariant(row.status)}>
{routingStatusLabel(row.status)}
</Badge>
</td>
<td className="px-4 py-3 text-right align-top">
<div className="flex justify-end gap-2">
<Link
href={withGatewayQuery(`/models/routing/${row.agent.id}/edit`, row.agent.gateway_id)}
className={buttonVariants({ size: "sm", variant: "outline" })}
>
Edit
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
) : null}
{pageError ? <p className="text-sm text-red-500">{pageError}</p> : null}
</div>
</DashboardPageLayout>
);
}

View File

@@ -0,0 +1,322 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { GatewayRead, LlmProviderAuthRead } from "@/api/generated/model";
import {
type listProviderAuthApiV1ModelRegistryProviderAuthGetResponse,
getListProviderAuthApiV1ModelRegistryProviderAuthGetQueryKey,
useCreateProviderAuthApiV1ModelRegistryProviderAuthPost,
useListProviderAuthApiV1ModelRegistryProviderAuthGet,
useUpdateProviderAuthApiV1ModelRegistryProviderAuthProviderAuthIdPatch,
} from "@/api/generated/model-registry/model-registry";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import SearchableSelect, {
type SearchableSelectOption,
} from "@/components/ui/searchable-select";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
const PROVIDER_PLACEHOLDER = "openai";
type ProviderAuthFormPageProps =
| { mode: "create" }
| { mode: "edit"; providerAuthId: string };
const toGatewayOptions = (gateways: GatewayRead[]): SearchableSelectOption[] =>
gateways.map((gateway) => ({ value: gateway.id, label: gateway.name }));
const withGatewayQuery = (path: string, gatewayId: string): string => {
if (!gatewayId) return path;
return `${path}?gateway=${encodeURIComponent(gatewayId)}`;
};
export default function ProviderAuthFormPage(props: ProviderAuthFormPageProps) {
const { isSignedIn } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const providerAuthId = props.mode === "edit" ? props.providerAuthId : null;
const [gatewayDraft, setGatewayDraft] = useState<string | null>(null);
const [providerDraft, setProviderDraft] = useState<string | null>(null);
const [configPathDraft, setConfigPathDraft] = useState<string | null>(null);
const [secret, setSecret] = useState("");
const [error, setError] = useState<string | null>(null);
const providerAuthKey = getListProviderAuthApiV1ModelRegistryProviderAuthGetQueryKey();
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const providerAuthQuery = useListProviderAuthApiV1ModelRegistryProviderAuthGet<
listProviderAuthApiV1ModelRegistryProviderAuthGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin && props.mode === "edit"),
refetchOnMount: "always",
},
});
const createMutation = useCreateProviderAuthApiV1ModelRegistryProviderAuthPost<ApiError>({
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: providerAuthKey });
router.push(withGatewayQuery("/models/provider-auth", gatewayId));
},
onError: (err) => {
setError(err.message || "Unable to create provider auth entry.");
},
},
});
const updateMutation =
useUpdateProviderAuthApiV1ModelRegistryProviderAuthProviderAuthIdPatch<ApiError>({
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: providerAuthKey });
router.push(withGatewayQuery("/models/provider-auth", gatewayId));
},
onError: (err) => {
setError(err.message || "Unable to update provider auth entry.");
},
},
});
const gateways = useMemo(() => {
if (gatewaysQuery.data?.status !== 200) return [];
return gatewaysQuery.data.data.items ?? [];
}, [gatewaysQuery.data]);
const providerAuthItems = useMemo<LlmProviderAuthRead[]>(() => {
if (providerAuthQuery.data?.status !== 200) return [];
return providerAuthQuery.data.data;
}, [providerAuthQuery.data]);
const currentItem = useMemo(() => {
if (props.mode !== "edit" || !providerAuthId) return null;
return providerAuthItems.find((item) => item.id === providerAuthId) ?? null;
}, [props.mode, providerAuthId, providerAuthItems]);
const gatewayOptions = useMemo(() => toGatewayOptions(gateways), [gateways]);
const requestedGateway = searchParams.get("gateway")?.trim() ?? "";
const gatewayId = (() => {
if (gateways.length === 0) return "";
if (props.mode === "edit" && currentItem?.gateway_id) {
return currentItem.gateway_id;
}
if (gatewayDraft && gateways.some((gateway) => gateway.id === gatewayDraft)) {
return gatewayDraft;
}
if (requestedGateway && gateways.some((gateway) => gateway.id === requestedGateway)) {
return requestedGateway;
}
return gateways[0].id;
})();
const provider = providerDraft ?? (props.mode === "edit" ? (currentItem?.provider ?? "") : "");
const configPath =
configPathDraft ?? (props.mode === "edit" ? (currentItem?.config_path ?? "") : "");
const isBusy = createMutation.isPending || updateMutation.isPending;
const pageError =
gatewaysQuery.error?.message ??
(props.mode === "edit" ? providerAuthQuery.error?.message : null) ??
null;
const title = props.mode === "create" ? "Add provider auth" : "Edit provider auth";
const description =
props.mode === "create"
? "Create provider credentials for a gateway config path."
: "Update provider credentials and config path for this gateway entry.";
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!gatewayId) {
setError("Select a gateway first.");
return;
}
const normalizedProvider = provider.trim().toLowerCase();
const normalizedConfigPath = configPath.trim() || `providers.${normalizedProvider}.apiKey`;
if (!normalizedProvider) {
setError("Provider is required.");
return;
}
if (props.mode === "create") {
const normalizedSecret = secret.trim();
if (!normalizedSecret) {
setError("Secret is required.");
return;
}
setError(null);
createMutation.mutate({
data: {
gateway_id: gatewayId,
provider: normalizedProvider,
config_path: normalizedConfigPath,
secret: normalizedSecret,
},
});
return;
}
if (!providerAuthId) {
setError("Missing provider auth identifier.");
return;
}
setError(null);
updateMutation.mutate({
providerAuthId,
data: {
provider: normalizedProvider,
config_path: normalizedConfigPath,
secret: secret.trim() ? secret.trim() : undefined,
},
});
};
const missingEditItem =
props.mode === "edit" &&
!providerAuthQuery.isLoading &&
providerAuthQuery.data?.status === 200 &&
!currentItem;
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to manage provider auth.",
forceRedirectUrl: "/models/provider-auth",
signUpForceRedirectUrl: "/models/provider-auth",
}}
title={title}
description={description}
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can manage provider auth."
>
<div className="space-y-4">
{missingEditItem ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Provider auth entry not found.
</div>
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-4">
<h2 className="text-sm font-semibold text-slate-900">Credentials</h2>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-slate-700">
<span className="font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</span>
<SearchableSelect
ariaLabel="Select gateway"
value={gatewayId}
onValueChange={setGatewayDraft}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No matching gateways."
triggerClassName="w-full"
disabled={props.mode === "edit" || isBusy}
/>
</label>
<label className="space-y-2 text-sm text-slate-700">
<span className="font-medium text-slate-900">
Provider <span className="text-red-500">*</span>
</span>
<Input
value={provider}
onChange={(event) => setProviderDraft(event.target.value)}
placeholder={PROVIDER_PLACEHOLDER}
disabled={isBusy}
/>
</label>
<label className="space-y-2 text-sm text-slate-700 md:col-span-2">
<span className="font-medium text-slate-900">Config path</span>
<Input
value={configPath}
onChange={(event) => setConfigPathDraft(event.target.value)}
placeholder="providers.openai.apiKey"
disabled={isBusy}
/>
</label>
<label className="space-y-2 text-sm text-slate-700 md:col-span-2">
<span className="font-medium text-slate-900">
{props.mode === "create" ? (
<>
Secret <span className="text-red-500">*</span>
</>
) : (
"Secret (leave blank to keep current)"
)}
</span>
<Input
value={secret}
onChange={(event) => setSecret(event.target.value)}
type="password"
placeholder="sk-..."
disabled={isBusy}
/>
</label>
</div>
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{pageError ? <p className="text-sm text-red-500">{pageError}</p> : null}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={isBusy || missingEditItem}>
{props.mode === "create"
? createMutation.isPending
? "Creating..."
: "Create provider auth"
: updateMutation.isPending
? "Saving..."
: "Save changes"}
</Button>
<Link
href={withGatewayQuery("/models/provider-auth", gatewayId || requestedGateway)}
className={buttonVariants({ variant: "outline", size: "md" })}
>
Back to provider auth
</Link>
</div>
</form>
)}
</div>
</DashboardPageLayout>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
export const dynamic = "force-dynamic";
import { useParams } from "next/navigation";
import CatalogModelFormPage from "../../../_components/CatalogModelFormPage";
export default function ModelsCatalogEditPage() {
const params = useParams();
const modelIdParam = params?.modelId;
const modelId = Array.isArray(modelIdParam) ? modelIdParam[0] : modelIdParam;
return <CatalogModelFormPage mode="edit" modelId={modelId ?? ""} />;
}

View File

@@ -0,0 +1,7 @@
export const dynamic = "force-dynamic";
import CatalogModelFormPage from "../../_components/CatalogModelFormPage";
export default function ModelsCatalogNewPage() {
return <CatalogModelFormPage mode="create" />;
}

View File

@@ -0,0 +1,7 @@
export const dynamic = "force-dynamic";
import ModelsWorkspace from "../_components/ModelsWorkspace";
export default function ModelsCatalogPage() {
return <ModelsWorkspace view="catalog" />;
}

View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default function ModelsPage() {
redirect("/models/routing");
}

View File

@@ -0,0 +1,17 @@
"use client";
export const dynamic = "force-dynamic";
import { useParams } from "next/navigation";
import ProviderAuthFormPage from "../../../_components/ProviderAuthFormPage";
export default function ModelsProviderAuthEditPage() {
const params = useParams();
const providerAuthIdParam = params?.providerAuthId;
const providerAuthId = Array.isArray(providerAuthIdParam)
? providerAuthIdParam[0]
: providerAuthIdParam;
return <ProviderAuthFormPage mode="edit" providerAuthId={providerAuthId ?? ""} />;
}

View File

@@ -0,0 +1,7 @@
export const dynamic = "force-dynamic";
import ProviderAuthFormPage from "../../_components/ProviderAuthFormPage";
export default function ModelsProviderAuthNewPage() {
return <ProviderAuthFormPage mode="create" />;
}

View File

@@ -0,0 +1,7 @@
export const dynamic = "force-dynamic";
import ModelsWorkspace from "../_components/ModelsWorkspace";
export default function ModelsProviderAuthPage() {
return <ModelsWorkspace view="provider-auth" />;
}

View File

@@ -0,0 +1,15 @@
"use client";
export const dynamic = "force-dynamic";
import { useParams } from "next/navigation";
import AgentRoutingEditPage from "../../../_components/AgentRoutingEditPage";
export default function ModelsRoutingEditPageWrapper() {
const params = useParams();
const agentIdParam = params?.agentId;
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
return <AgentRoutingEditPage agentId={agentId ?? ""} />;
}

View File

@@ -0,0 +1,7 @@
export const dynamic = "force-dynamic";
import ModelsWorkspace from "../_components/ModelsWorkspace";
export default function ModelsRoutingPage() {
return <ModelsWorkspace view="routing" />;
}

View File

@@ -0,0 +1,296 @@
"use client";
export const dynamic = "force-dynamic";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/auth/clerk";
import { ApiError, customFetch } from "@/api/mutator";
import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import {
type listAgentsApiV1AgentsGetResponse,
getListAgentsApiV1AgentsGetQueryKey,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
import {
type listModelsApiV1ModelRegistryModelsGetResponse,
type listProviderAuthApiV1ModelRegistryProviderAuthGetResponse,
getListModelsApiV1ModelRegistryModelsGetQueryKey,
getListProviderAuthApiV1ModelRegistryProviderAuthGetQueryKey,
useListModelsApiV1ModelRegistryModelsGet,
useListProviderAuthApiV1ModelRegistryProviderAuthGet,
useSyncGatewayModelsApiV1ModelRegistryGatewaysGatewayIdSyncPost,
} from "@/api/generated/model-registry/model-registry";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import SearchableSelect, {
type SearchableSelectOption,
} from "@/components/ui/searchable-select";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
type GatewayModelPullResult = {
gateway_id: string;
provider_auth_imported: number;
model_catalog_imported: number;
agent_models_imported: number;
errors?: string[];
};
const toGatewayOptions = (gateways: { id: string; name: string }[]): SearchableSelectOption[] =>
gateways.map((gateway) => ({ value: gateway.id, label: gateway.name }));
export default function ModelsSyncPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [activeGatewayDraft, setActiveGatewayDraft] = useState<string | null>(null);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const modelsKey = getListModelsApiV1ModelRegistryModelsGetQueryKey();
const providerAuthKey = getListProviderAuthApiV1ModelRegistryProviderAuthGetQueryKey();
const agentsKey = getListAgentsApiV1AgentsGetQueryKey();
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const modelsQuery = useListModelsApiV1ModelRegistryModelsGet<
listModelsApiV1ModelRegistryModelsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const providerAuthQuery = useListProviderAuthApiV1ModelRegistryProviderAuthGet<
listProviderAuthApiV1ModelRegistryProviderAuthGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
const syncMutation =
useSyncGatewayModelsApiV1ModelRegistryGatewaysGatewayIdSyncPost<ApiError>({
mutation: {
onSuccess: async (response) => {
if (response.status === 200) {
const result = response.data;
const syncErrors = result.errors ?? [];
const suffix =
syncErrors.length > 0
? ` Completed with ${syncErrors.length} warning(s).`
: " Synced cleanly.";
setSyncMessage(
`Patched ${result.provider_auth_patched} provider auth, ${result.model_catalog_patched} catalog models, ${result.agent_models_patched} agent assignments, and ${result.sessions_patched} sessions.${suffix}`,
);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: modelsKey }),
queryClient.invalidateQueries({ queryKey: providerAuthKey }),
queryClient.invalidateQueries({ queryKey: agentsKey }),
]);
},
onError: (error) => {
setSyncMessage(error.message || "Gateway sync failed.");
},
},
});
const pullMutation = useMutation<
{ data: GatewayModelPullResult; status: number; headers: Headers },
ApiError,
string
>({
mutationFn: async (gatewayId: string) =>
customFetch<{ data: GatewayModelPullResult; status: number; headers: Headers }>(
`/api/v1/model-registry/gateways/${gatewayId}/pull`,
{ method: "POST" },
),
onSuccess: async (response) => {
if (response.status === 200) {
const result = response.data;
const pullErrors = result.errors ?? [];
const suffix =
pullErrors.length > 0
? ` Imported with ${pullErrors.length} warning(s).`
: " Imported cleanly.";
setSyncMessage(
`Imported ${result.provider_auth_imported} provider auth entries, ${result.model_catalog_imported} catalog models, and ${result.agent_models_imported} agent assignments.${suffix}`,
);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: modelsKey }),
queryClient.invalidateQueries({ queryKey: providerAuthKey }),
queryClient.invalidateQueries({ queryKey: agentsKey }),
]);
},
onError: (error) => {
setSyncMessage(error.message || "Gateway pull failed.");
},
});
const gateways =
gatewaysQuery.data?.status === 200 ? (gatewaysQuery.data.data.items ?? []) : [];
const models =
modelsQuery.data?.status === 200 ? modelsQuery.data.data : [];
const providerAuth =
providerAuthQuery.data?.status === 200 ? providerAuthQuery.data.data : [];
const agents =
agentsQuery.data?.status === 200 ? (agentsQuery.data.data.items ?? []) : [];
const activeGatewayId =
activeGatewayDraft && gateways.some((gateway) => gateway.id === activeGatewayDraft)
? activeGatewayDraft
: (gateways[0]?.id ?? "");
const activeGateway = gateways.find((gateway) => gateway.id === activeGatewayId) ?? null;
const providerCount = providerAuth.filter((item) => item.gateway_id === activeGatewayId).length;
const modelCount = models.filter((item) => item.gateway_id === activeGatewayId).length;
const agentCount = agents.filter((item) => item.gateway_id === activeGatewayId).length;
const primaryOverrideCount = agents.filter(
(item) => item.gateway_id === activeGatewayId && Boolean(item.primary_model_id),
).length;
const isBusy = pullMutation.isPending || syncMutation.isPending;
const pageError =
gatewaysQuery.error?.message ??
modelsQuery.error?.message ??
providerAuthQuery.error?.message ??
agentsQuery.error?.message ??
null;
const runGatewayPull = () => {
if (!activeGatewayId) {
setSyncMessage("Select a gateway first.");
return;
}
setSyncMessage(null);
pullMutation.mutate(activeGatewayId);
};
const runGatewaySync = () => {
if (!activeGatewayId) {
setSyncMessage("Select a gateway first.");
return;
}
setSyncMessage(null);
syncMutation.mutate({ gatewayId: activeGatewayId });
};
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to sync models with a gateway.",
forceRedirectUrl: "/models/sync",
signUpForceRedirectUrl: "/models/sync",
}}
title="Gateway Sync"
description="Pull and push provider auth, catalog models, and agent routing per gateway."
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can sync models."
>
<div className="space-y-6">
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-end gap-3">
<div className="w-full max-w-md">
<p className="mb-2 text-sm font-medium text-slate-900">Gateway</p>
<SearchableSelect
ariaLabel="Select gateway"
value={activeGatewayId}
onValueChange={setActiveGatewayDraft}
options={toGatewayOptions(gateways)}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No matching gateways."
triggerClassName="w-full"
disabled={gateways.length === 0 || isBusy}
/>
</div>
<Button
type="button"
variant="outline"
onClick={runGatewayPull}
disabled={!activeGatewayId || isBusy}
>
{pullMutation.isPending ? "Pulling..." : "Pull from gateway"}
</Button>
<Button
type="button"
onClick={runGatewaySync}
disabled={!activeGatewayId || isBusy}
>
{syncMutation.isPending ? "Pushing..." : "Push to gateway"}
</Button>
</div>
{syncMessage ? (
<p className="mt-4 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
{syncMessage}
</p>
) : null}
{pageError ? <p className="mt-4 text-sm text-red-500">{pageError}</p> : null}
</section>
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-900">
{activeGateway ? `${activeGateway.name} summary` : "Gateway summary"}
</h3>
<Badge variant="outline">{activeGatewayId ? "Selected" : "No gateway"}</Badge>
</div>
</div>
<div className="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wider text-slate-500">Provider auth</p>
<p className="mt-1 text-xl font-semibold text-slate-900">{providerCount}</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wider text-slate-500">Catalog models</p>
<p className="mt-1 text-xl font-semibold text-slate-900">{modelCount}</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wider text-slate-500">Agents</p>
<p className="mt-1 text-xl font-semibold text-slate-900">{agentCount}</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wider text-slate-500">Primary overrides</p>
<p className="mt-1 text-xl font-semibold text-slate-900">{primaryOverrideCount}</p>
</div>
</div>
</section>
</div>
</DashboardPageLayout>
);
}

View File

@@ -6,9 +6,14 @@ import {
Activity,
BarChart3,
Bot,
Cpu,
Database,
CheckCircle2,
Folder,
GitBranch,
RefreshCw,
Building2,
KeyRound,
LayoutGrid,
Network,
} from "lucide-react";
@@ -158,7 +163,70 @@ export function DashboardSidebar() {
>
<Bot className="h-4 w-4" />
Agents
</Link>
</Link>
) : null}
{isAdmin ? (
<div className="space-y-1">
<div
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-semibold uppercase tracking-wider",
pathname.startsWith("/models")
? "text-blue-800"
: "text-slate-500",
)}
>
<Cpu className="h-4 w-4" />
Models
</div>
<Link
href="/models/provider-auth"
className={cn(
"ml-7 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition",
pathname.startsWith("/models/provider-auth")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<KeyRound className="h-4 w-4" />
Provider auth
</Link>
<Link
href="/models/catalog"
className={cn(
"ml-7 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition",
pathname.startsWith("/models/catalog")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Database className="h-4 w-4" />
Model catalog
</Link>
<Link
href="/models/routing"
className={cn(
"ml-7 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition",
pathname.startsWith("/models/routing") || pathname === "/models"
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<GitBranch className="h-4 w-4" />
Agent routing
</Link>
<Link
href="/models/sync"
className={cn(
"ml-7 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 transition",
pathname.startsWith("/models/sync")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<RefreshCw className="h-4 w-4" />
Gateway sync
</Link>
</div>
) : null}
</nav>
</div>

View File

@@ -9,6 +9,7 @@ import {
Activity,
Bot,
ChevronDown,
Cpu,
LayoutDashboard,
LogOut,
Plus,
@@ -154,6 +155,7 @@ export function UserMenu({
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/activity", label: "Activity", icon: Activity },
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/models/routing", label: "Models", icon: Cpu },
{ href: "/gateways", label: "Gateways", icon: Server },
{ href: "/settings", label: "Settings", icon: Settings },
] as const