Compare commits
1 Commits
mobile/age
...
feat/model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7906a224 |
165
backend/app/api/model_registry.py
Normal file
165
backend/app/api/model_registry.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
46
backend/app/models/llm.py
Normal 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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
167
backend/app/schemas/llm_models.py
Normal file
167
backend/app/schemas/llm_models.py
Normal 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",
|
||||
]
|
||||
1002
backend/app/services/openclaw/model_registry_service.py
Normal file
1002
backend/app/services/openclaw/model_registry_service.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 []
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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")
|
||||
105
backend/tests/test_agent_model_assignment_updates.py
Normal file
105
backend/tests/test_agent_model_assignment_updates.py
Normal 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
|
||||
28
backend/tests/test_agent_schema_models.py
Normal file
28
backend/tests/test_agent_schema_models.py
Normal 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")
|
||||
110
backend/tests/test_model_registry_pull_helpers.py
Normal file
110
backend/tests/test_model_registry_pull_helpers.py
Normal 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"
|
||||
1498
frontend/src/api/generated/model-registry/model-registry.ts
Normal file
1498
frontend/src/api/generated/model-registry/model-registry.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
18
frontend/src/api/generated/model/gatewayModelSyncResult.ts
Normal file
18
frontend/src/api/generated/model/gatewayModelSyncResult.ts
Normal 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[];
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
21
frontend/src/api/generated/model/llmModelCreate.ts
Normal file
21
frontend/src/api/generated/model/llmModelCreate.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
22
frontend/src/api/generated/model/llmModelRead.ts
Normal file
22
frontend/src/api/generated/model/llmModelRead.ts
Normal 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;
|
||||
}
|
||||
8
frontend/src/api/generated/model/llmModelReadSettings.ts
Normal file
8
frontend/src/api/generated/model/llmModelReadSettings.ts
Normal 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;
|
||||
17
frontend/src/api/generated/model/llmModelUpdate.ts
Normal file
17
frontend/src/api/generated/model/llmModelUpdate.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
18
frontend/src/api/generated/model/llmProviderAuthCreate.ts
Normal file
18
frontend/src/api/generated/model/llmProviderAuthCreate.ts
Normal 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;
|
||||
}
|
||||
20
frontend/src/api/generated/model/llmProviderAuthRead.ts
Normal file
20
frontend/src/api/generated/model/llmProviderAuthRead.ts
Normal 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;
|
||||
}
|
||||
15
frontend/src/api/generated/model/llmProviderAuthUpdate.ts
Normal file
15
frontend/src/api/generated/model/llmProviderAuthUpdate.ts
Normal 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;
|
||||
}
|
||||
@@ -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'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
|
||||
|
||||
@@ -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'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
|
||||
|
||||
493
frontend/src/app/models/_components/AgentRoutingEditPage.tsx
Normal file
493
frontend/src/app/models/_components/AgentRoutingEditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
344
frontend/src/app/models/_components/CatalogModelFormPage.tsx
Normal file
344
frontend/src/app/models/_components/CatalogModelFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
778
frontend/src/app/models/_components/ModelsWorkspace.tsx
Normal file
778
frontend/src/app/models/_components/ModelsWorkspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
frontend/src/app/models/_components/ProviderAuthFormPage.tsx
Normal file
322
frontend/src/app/models/_components/ProviderAuthFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/app/models/catalog/[modelId]/edit/page.tsx
Normal file
15
frontend/src/app/models/catalog/[modelId]/edit/page.tsx
Normal 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 ?? ""} />;
|
||||
}
|
||||
7
frontend/src/app/models/catalog/new/page.tsx
Normal file
7
frontend/src/app/models/catalog/new/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import CatalogModelFormPage from "../../_components/CatalogModelFormPage";
|
||||
|
||||
export default function ModelsCatalogNewPage() {
|
||||
return <CatalogModelFormPage mode="create" />;
|
||||
}
|
||||
7
frontend/src/app/models/catalog/page.tsx
Normal file
7
frontend/src/app/models/catalog/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import ModelsWorkspace from "../_components/ModelsWorkspace";
|
||||
|
||||
export default function ModelsCatalogPage() {
|
||||
return <ModelsWorkspace view="catalog" />;
|
||||
}
|
||||
7
frontend/src/app/models/page.tsx
Normal file
7
frontend/src/app/models/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function ModelsPage() {
|
||||
redirect("/models/routing");
|
||||
}
|
||||
@@ -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 ?? ""} />;
|
||||
}
|
||||
7
frontend/src/app/models/provider-auth/new/page.tsx
Normal file
7
frontend/src/app/models/provider-auth/new/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import ProviderAuthFormPage from "../../_components/ProviderAuthFormPage";
|
||||
|
||||
export default function ModelsProviderAuthNewPage() {
|
||||
return <ProviderAuthFormPage mode="create" />;
|
||||
}
|
||||
7
frontend/src/app/models/provider-auth/page.tsx
Normal file
7
frontend/src/app/models/provider-auth/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import ModelsWorkspace from "../_components/ModelsWorkspace";
|
||||
|
||||
export default function ModelsProviderAuthPage() {
|
||||
return <ModelsWorkspace view="provider-auth" />;
|
||||
}
|
||||
15
frontend/src/app/models/routing/[agentId]/edit/page.tsx
Normal file
15
frontend/src/app/models/routing/[agentId]/edit/page.tsx
Normal 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 ?? ""} />;
|
||||
}
|
||||
7
frontend/src/app/models/routing/page.tsx
Normal file
7
frontend/src/app/models/routing/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import ModelsWorkspace from "../_components/ModelsWorkspace";
|
||||
|
||||
export default function ModelsRoutingPage() {
|
||||
return <ModelsWorkspace view="routing" />;
|
||||
}
|
||||
296
frontend/src/app/models/sync/page.tsx
Normal file
296
frontend/src/app/models/sync/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user