Refactor LLM registry to modular structure and improve admin UI

Moved LLM registry backend code into a dedicated llm_registry module with submodules for model types, notifications, schema utilities, and registry logic. Updated all backend imports to use the new structure. On the frontend, redesigned the admin LLM registry page with a dashboard layout, modularized data fetching, and improved forms for adding/editing providers and models. Updated UI components for better usability and maintainability.
This commit is contained in:
Bentlybro
2025-12-12 11:32:28 +00:00
parent b6e2f05b63
commit 8c7b1af409
20 changed files with 561 additions and 268 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"mcp__github__get_commit"
]
}
}

View File

@@ -23,7 +23,7 @@ from backend.data.block import (
BlockSchemaInput,
BlockSchemaOutput,
)
from backend.data.llm_model_types import ModelMetadata
from backend.data.llm_registry import ModelMetadata
from backend.data.model import (
APIKeyCredentials,
CredentialsField,

View File

@@ -38,7 +38,7 @@ from backend.util.exceptions import (
)
from backend.util.settings import Config
from .llm_schema_utils import update_schema_with_llm_registry
from backend.data.llm_registry import update_schema_with_llm_registry
from .model import (
ContributorDetails,
Credentials,

View File

@@ -0,0 +1,73 @@
"""
LLM Registry module for managing LLM models, providers, and costs dynamically.
This module provides a database-driven registry system for LLM models,
replacing hardcoded model configurations with a flexible admin-managed system.
"""
from backend.data.llm_registry.model_types import ModelMetadata
from backend.data.llm_registry import notifications
from backend.data.llm_registry import schema_utils
from backend.data.llm_registry.registry import (
RegistryModel,
RegistryModelCost,
get_all_model_slugs_for_validation,
get_default_model_slug,
get_dynamic_model_slugs,
get_fallback_model_for_disabled,
get_llm_discriminator_mapping,
get_llm_model_cost,
get_llm_model_metadata,
get_llm_model_schema_options,
get_model_info,
is_model_enabled,
iter_dynamic_models,
refresh_llm_registry,
register_static_costs,
register_static_metadata,
)
# Re-export for backwards compatibility
from backend.data.llm_registry.notifications import (
REGISTRY_REFRESH_CHANNEL,
publish_registry_refresh_notification,
subscribe_to_registry_refresh,
)
from backend.data.llm_registry.schema_utils import (
is_llm_model_field,
refresh_llm_discriminator_mapping,
refresh_llm_model_options,
update_schema_with_llm_registry,
)
__all__ = [
# Types
"ModelMetadata",
"RegistryModel",
"RegistryModelCost",
# Registry functions
"get_all_model_slugs_for_validation",
"get_default_model_slug",
"get_dynamic_model_slugs",
"get_fallback_model_for_disabled",
"get_llm_discriminator_mapping",
"get_llm_model_cost",
"get_llm_model_metadata",
"get_llm_model_schema_options",
"get_model_info",
"is_model_enabled",
"iter_dynamic_models",
"refresh_llm_registry",
"register_static_costs",
"register_static_metadata",
# Notifications
"REGISTRY_REFRESH_CHANNEL",
"publish_registry_refresh_notification",
"subscribe_to_registry_refresh",
# Schema utilities
"is_llm_model_field",
"refresh_llm_discriminator_mapping",
"refresh_llm_model_options",
"update_schema_with_llm_registry",
]

View File

@@ -1,7 +1,12 @@
"""Type definitions for LLM model metadata."""
from typing import NamedTuple
class ModelMetadata(NamedTuple):
"""Metadata for an LLM model."""
provider: str
context_window: int
max_output_tokens: int | None

View File

@@ -89,3 +89,4 @@ async def subscribe_to_registry_refresh(
exc_info=True,
)
raise

View File

@@ -1,3 +1,5 @@
"""Core LLM registry implementation for managing models dynamically."""
from __future__ import annotations
import asyncio
@@ -7,13 +9,15 @@ from typing import Any, Iterable
import prisma.models
from backend.data.llm_model_types import ModelMetadata
from backend.data.llm_registry.model_types import ModelMetadata
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RegistryModelCost:
"""Cost configuration for an LLM model."""
credit_cost: int
credential_provider: str
credential_id: str | None
@@ -24,6 +28,8 @@ class RegistryModelCost:
@dataclass(frozen=True)
class RegistryModel:
"""Represents a model in the LLM registry."""
slug: str
display_name: str
description: str | None
@@ -44,11 +50,13 @@ _lock = asyncio.Lock()
def register_static_metadata(metadata: dict[Any, ModelMetadata]) -> None:
"""Register static metadata for legacy models (deprecated)."""
_static_metadata.update({str(key): value for key, value in metadata.items()})
_refresh_cached_schema()
def register_static_costs(costs: dict[Any, int]) -> None:
"""Register static costs for legacy models (deprecated)."""
_static_costs.update({str(key): value for key, value in costs.items()})
@@ -148,6 +156,7 @@ async def refresh_llm_registry() -> None:
def _refresh_cached_schema() -> None:
"""Refresh cached schema options and discriminator mapping."""
new_options = _build_schema_options()
_schema_options.clear()
_schema_options.extend(new_options)
@@ -166,11 +175,8 @@ def get_llm_model_metadata(slug: str) -> ModelMetadata | None:
return _static_metadata.get(slug)
# Removed get_llm_model_metadata_async - direct database queries don't work in executor context
# The registry should be refreshed on startup via initialize_blocks() or rest_api lifespan
def get_llm_model_cost(slug: str) -> tuple[RegistryModelCost, ...]:
"""Get model cost configuration by slug."""
if slug in _dynamic_models:
return _dynamic_models[slug].costs
cost_value = _static_costs.get(slug)
@@ -209,6 +215,7 @@ def get_llm_discriminator_mapping() -> dict[str, str]:
def get_dynamic_model_slugs() -> set[str]:
"""Get all dynamic model slugs from the registry."""
return set(_dynamic_models.keys())
@@ -226,6 +233,7 @@ def get_all_model_slugs_for_validation() -> set[str]:
def iter_dynamic_models() -> Iterable[RegistryModel]:
"""Iterate over all dynamic models in the registry."""
return tuple(_dynamic_models.values())
@@ -286,7 +294,7 @@ def get_model_info(model_slug: str) -> RegistryModel | None:
def get_default_model_slug() -> str:
"""
Get the default model slug to use for block defaults.
Prefers "gpt-4o" if it exists and is enabled, otherwise returns
the first enabled model from the registry, or "gpt-4o" as fallback.
"""
@@ -295,11 +303,12 @@ def get_default_model_slug() -> str:
preferred_model = _dynamic_models.get(preferred_slug)
if preferred_model and preferred_model.is_enabled:
return preferred_slug
# Find first enabled model
for model in sorted(_dynamic_models.values(), key=lambda m: m.display_name.lower()):
if model.is_enabled:
return model.slug
# Fallback to preferred slug even if not in registry (for backwards compatibility)
return preferred_slug
return preferred_slug

View File

@@ -8,7 +8,11 @@ and model options from the LLM registry into block schemas.
import logging
from typing import Any
from backend.data import llm_registry
from backend.data.llm_registry.registry import (
get_all_model_slugs_for_validation,
get_llm_discriminator_mapping,
get_llm_model_schema_options,
)
logger = logging.getLogger(__name__)
@@ -44,7 +48,7 @@ def refresh_llm_model_options(field_schema: dict[str, Any]) -> None:
Existing graphs may have disabled models selected - they should pass validation
and the fallback logic in llm_call() will handle using an alternative model.
"""
fresh_options = llm_registry.get_llm_model_schema_options()
fresh_options = get_llm_model_schema_options()
if not fresh_options:
return
@@ -52,7 +56,7 @@ def refresh_llm_model_options(field_schema: dict[str, Any]) -> None:
if "options" in field_schema:
field_schema["options"] = fresh_options
all_known_slugs = llm_registry.get_all_model_slugs_for_validation()
all_known_slugs = get_all_model_slugs_for_validation()
if all_known_slugs and "enum" in field_schema:
existing_enum = set(field_schema.get("enum", []))
combined_enum = existing_enum | all_known_slugs
@@ -70,7 +74,7 @@ def refresh_llm_discriminator_mapping(field_schema: dict[str, Any]) -> None:
return
# Always refresh the mapping to get latest models
fresh_mapping = llm_registry.get_llm_discriminator_mapping()
fresh_mapping = get_llm_discriminator_mapping()
if fresh_mapping:
field_schema["discriminator_mapping"] = fresh_mapping
@@ -117,3 +121,4 @@ def update_schema_with_llm_registry(
field_name,
exc,
)

View File

@@ -44,7 +44,7 @@ from backend.integrations.providers import ProviderName
from backend.util.json import loads as json_loads
from backend.util.settings import Secrets
from .llm_schema_utils import update_schema_with_llm_registry
from backend.data.llm_registry import update_schema_with_llm_registry
# Type alias for any provider name (including custom ones)
AnyProviderName = str # Will be validated as ProviderName at runtime

View File

@@ -10,7 +10,7 @@ import logging
from backend.data import db, llm_registry
from backend.data.block import BlockSchema, initialize_blocks
from backend.data.block_cost_config import refresh_llm_costs
from backend.data.llm_registry_notifications import subscribe_to_registry_refresh
from backend.data.llm_registry import subscribe_to_registry_refresh
logger = logging.getLogger(__name__)

View File

@@ -51,7 +51,7 @@ async def _refresh_runtime_state() -> None:
logger.debug("Could not clear v2 builder cache: %s", e)
# Notify all executor services to refresh their registry cache
from backend.data.llm_registry_notifications import (
from backend.data.llm_registry import (
publish_registry_refresh_notification,
)

View File

@@ -79,7 +79,7 @@ async def event_broadcaster(manager: ConnectionManager):
async def registry_refresh_worker():
"""Listen for LLM registry refresh notifications and broadcast to all clients."""
from backend.data.llm_registry_notifications import REGISTRY_REFRESH_CHANNEL
from backend.data.llm_registry import REGISTRY_REFRESH_CHANNEL
from backend.data.redis_client import connect_async
redis = await connect_async()

View File

@@ -31,14 +31,10 @@ export async function createLlmProviderAction(formData: FormData) {
? String(formData.get("description"))
: undefined,
default_credential_provider: formData.get("default_credential_provider")
? String(formData.get("default_credential_provider"))
: undefined,
default_credential_id: formData.get("default_credential_id")
? String(formData.get("default_credential_id"))
: undefined,
default_credential_type: formData.get("default_credential_type")
? String(formData.get("default_credential_type"))
? String(formData.get("default_credential_provider")).trim()
: undefined,
default_credential_id: undefined, // Not needed - system uses credential_provider to lookup
default_credential_type: "api_key", // Default to api_key
supports_tools: formData.get("supports_tools") === "on",
supports_json_output: formData.get("supports_json_output") !== "off",
supports_reasoning: formData.get("supports_reasoning") === "on",
@@ -52,13 +48,24 @@ export async function createLlmProviderAction(formData: FormData) {
}
export async function createLlmModelAction(formData: FormData) {
const providerId = String(formData.get("provider_id"));
// Fetch provider to get default credentials
const api = new BackendApi();
const providersResponse = await api.listAdminLlmProviders(false);
const provider = providersResponse.providers.find((p) => p.id === providerId);
if (!provider) {
throw new Error("Provider not found");
}
const payload: CreateLlmModelRequest = {
slug: String(formData.get("slug") || "").trim(),
display_name: String(formData.get("display_name") || "").trim(),
description: formData.get("description")
? String(formData.get("description"))
: undefined,
provider_id: String(formData.get("provider_id")),
provider_id: providerId,
context_window: Number(formData.get("context_window") || 0),
max_output_tokens: formData.get("max_output_tokens")
? Number(formData.get("max_output_tokens"))
@@ -69,21 +76,15 @@ export async function createLlmModelAction(formData: FormData) {
costs: [
{
credit_cost: Number(formData.get("credit_cost") || 0),
credential_provider: String(
formData.get("credential_provider") || "",
).trim(),
credential_id: formData.get("credential_id")
? String(formData.get("credential_id"))
: undefined,
credential_type: formData.get("credential_type")
? String(formData.get("credential_type"))
: undefined,
credential_provider:
provider.default_credential_provider || provider.name,
credential_id: provider.default_credential_id || undefined,
credential_type: provider.default_credential_type || "api_key",
metadata: {},
},
],
};
const api = new BackendApi();
await api.createAdminLlmModel(payload);
revalidatePath(ADMIN_LLM_PATH);
}

View File

@@ -1,68 +1,102 @@
import type { LlmProvider } from "@/lib/autogpt-server-api/types";
import { createLlmModelAction } from "../actions";
import { Button } from "@/components/atoms/Button/Button";
export function AddModelForm({ providers }: { providers: LlmProvider[] }) {
interface Props {
providers: LlmProvider[];
}
export function AddModelForm({ providers }: Props) {
return (
<form
action={createLlmModelAction}
className="space-y-8 rounded-lg border border-border bg-card p-8 shadow-sm"
>
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">Add Model</h2>
<p className="text-sm text-muted-foreground">
<form action={createLlmModelAction} className="space-y-6">
<div className="space-y-1">
<h3 className="text-lg font-semibold">Add Model</h3>
<p className="text-sm text-gray-600">
Register a new model slug, metadata, and pricing.
</p>
</div>
<div className="space-y-8">
<div className="space-y-5">
<div className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">Basic Information</h3>
<h3 className="text-sm font-semibold text-foreground">
Basic Information
</h3>
<p className="text-xs text-muted-foreground">Core model details</p>
</div>
<div className="grid gap-5 md:grid-cols-2">
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Model Slug</span>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label
htmlFor="slug"
className="text-sm font-medium text-foreground"
>
Model Slug <span className="text-destructive">*</span>
</label>
<input
id="slug"
required
name="slug"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="gpt-4.1-mini-2025-04-14"
/>
</label>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Display Name</span>
</div>
<div className="space-y-2">
<label
htmlFor="display_name"
className="text-sm font-medium text-foreground"
>
Display Name <span className="text-destructive">*</span>
</label>
<input
id="display_name"
required
name="display_name"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="GPT 4.1 Mini"
/>
</label>
</div>
</div>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Description</span>
<div className="space-y-2">
<label
htmlFor="description"
className="text-sm font-medium text-foreground"
>
Description
</label>
<textarea
id="description"
name="description"
rows={3}
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="Optional description..."
/>
</label>
</div>
</div>
<div className="space-y-5 border-t border-border pt-6">
{/* Model Configuration */}
<div className="space-y-4 border-t border-border pt-6">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">Model Configuration</h3>
<p className="text-xs text-muted-foreground">Model capabilities and limits</p>
<h3 className="text-sm font-semibold text-foreground">
Model Configuration
</h3>
<p className="text-xs text-muted-foreground">
Model capabilities and limits
</p>
</div>
<div className="grid gap-5 md:grid-cols-3">
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Provider</span>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<label
htmlFor="provider_id"
className="text-sm font-medium text-foreground"
>
Provider <span className="text-destructive">*</span>
</label>
<select
id="provider_id"
required
name="provider_id"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
defaultValue=""
>
<option value="" disabled>
@@ -74,102 +108,100 @@ export function AddModelForm({ providers }: { providers: LlmProvider[] }) {
</option>
))}
</select>
</label>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Context Window</span>
</div>
<div className="space-y-2">
<label
htmlFor="context_window"
className="text-sm font-medium text-foreground"
>
Context Window <span className="text-destructive">*</span>
</label>
<input
id="context_window"
required
type="number"
name="context_window"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="128000"
min={1}
/>
</label>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Max Output Tokens</span>
</div>
<div className="space-y-2">
<label
htmlFor="max_output_tokens"
className="text-sm font-medium text-foreground"
>
Max Output Tokens
</label>
<input
id="max_output_tokens"
type="number"
name="max_output_tokens"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="16384"
min={1}
/>
</label>
</div>
</div>
</div>
<div className="space-y-5 border-t border-border pt-6">
{/* Pricing */}
<div className="space-y-4 border-t border-border pt-6">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">Pricing & Credentials</h3>
<p className="text-xs text-muted-foreground">Cost and credential configuration</p>
<h3 className="text-sm font-semibold text-foreground">Pricing</h3>
<p className="text-xs text-muted-foreground">
Credit cost per run (credentials are managed via the provider)
</p>
</div>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-4">
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Credit Cost</span>
<div className="grid gap-4 sm:grid-cols-1">
<div className="space-y-2">
<label
htmlFor="credit_cost"
className="text-sm font-medium text-foreground"
>
Credit Cost <span className="text-destructive">*</span>
</label>
<input
id="credit_cost"
required
type="number"
name="credit_cost"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="5"
min={0}
/>
</label>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Credential Provider</span>
<input
required
name="credential_provider"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="openai"
/>
</label>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Credential ID</span>
<input
name="credential_id"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="cred-id"
/>
</label>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Credential Type</span>
<input
name="credential_type"
defaultValue="api_key"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</label>
</div>
</div>
<p className="mt-3 text-xs text-muted-foreground">
Credit cost is always in platform credits.
<p className="text-xs text-muted-foreground">
Credit cost is always in platform credits. Credentials are inherited
from the selected provider.
</p>
</div>
<div className="space-y-3 border-t border-border pt-6">
<label className="flex items-center gap-3 text-sm font-medium">
<input type="hidden" name="is_enabled" value="off" />
<input
type="checkbox"
name="is_enabled"
defaultChecked
className="h-4 w-4 rounded border-input"
/>
{/* Enabled Toggle */}
<div className="flex items-center gap-3 border-t border-border pt-6">
<input type="hidden" name="is_enabled" value="off" />
<input
id="is_enabled"
type="checkbox"
name="is_enabled"
defaultChecked
className="h-4 w-4 rounded border-input"
/>
<label
htmlFor="is_enabled"
className="text-sm font-medium text-foreground"
>
Enabled by default
</label>
</div>
</div>
<div className="flex justify-end border-t border-border pt-6">
<button
type="submit"
className="inline-flex items-center rounded-md bg-primary px-8 py-3 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
<div className="flex justify-end border-t border-gray-200 pt-4">
<Button type="submit" variant="primary" size="small">
Save Model
</button>
</Button>
</div>
</form>
);
}

View File

@@ -1,124 +1,204 @@
import { createLlmProviderAction } from "../actions";
import { Button } from "@/components/atoms/Button/Button";
export function AddProviderForm() {
return (
<form
action={createLlmProviderAction}
className="space-y-8 rounded-lg border border-border bg-card p-8 shadow-sm"
>
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">Add Provider</h2>
<p className="text-sm text-muted-foreground">
<form action={createLlmProviderAction} className="space-y-6">
<div className="space-y-1">
<h3 className="text-lg font-semibold">Add Provider</h3>
<p className="text-sm text-gray-600">
Define a new upstream provider and default credential information.
</p>
</div>
<div className="space-y-8">
<div className="space-y-5">
{/* Setup Instructions */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="space-y-2">
<h4 className="text-sm font-semibold text-blue-900">
Before Adding a Provider
</h4>
<p className="text-xs text-blue-800">
To use a new provider, you must first configure its credentials in the
backend:
</p>
<ol className="list-inside list-decimal space-y-1 text-xs text-blue-800">
<li>
Add the credential to{" "}
<code className="rounded bg-blue-100 px-1 py-0.5 font-mono">
backend/integrations/credentials_store.py
</code>{" "}
with a UUID, provider name, and settings secret reference
</li>
<li>
Add it to the{" "}
<code className="rounded bg-blue-100 px-1 py-0.5 font-mono">
PROVIDER_CREDENTIALS
</code>{" "}
dictionary in{" "}
<code className="rounded bg-blue-100 px-1 py-0.5 font-mono">
backend/data/block_cost_config.py
</code>
</li>
<li>
Use the <strong>same provider name</strong> in the "Credential Provider"
field below that matches the key in{" "}
<code className="rounded bg-blue-100 px-1 py-0.5 font-mono">
PROVIDER_CREDENTIALS
</code>
</li>
</ol>
</div>
</div>
<div className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">Basic Information</h3>
<h3 className="text-sm font-semibold text-foreground">
Basic Information
</h3>
<p className="text-xs text-muted-foreground">Core provider details</p>
</div>
<div className="grid gap-5 md:grid-cols-2">
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Provider Slug</span>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label
htmlFor="name"
className="text-sm font-medium text-foreground"
>
Provider Slug <span className="text-destructive">*</span>
</label>
<input
id="name"
required
name="name"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="e.g. openai"
/>
</label>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Display Name</span>
</div>
<div className="space-y-2">
<label
htmlFor="display_name"
className="text-sm font-medium text-foreground"
>
Display Name <span className="text-destructive">*</span>
</label>
<input
id="display_name"
required
name="display_name"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="OpenAI"
/>
</label>
</div>
</div>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Description</span>
<div className="space-y-2">
<label
htmlFor="description"
className="text-sm font-medium text-foreground"
>
Description
</label>
<textarea
id="description"
name="description"
rows={3}
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="Optional description..."
/>
</label>
</div>
<div className="space-y-5 border-t border-border pt-6">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">Default Credentials</h3>
<p className="text-xs text-muted-foreground">Default credential configuration</p>
</div>
<div className="grid gap-5 md:grid-cols-3">
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Credential Provider</span>
<input
name="default_credential_provider"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="openai"
/>
</label>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Credential ID</span>
<input
name="default_credential_id"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="cred-id"
/>
</label>
<label className="space-y-2.5">
<span className="text-sm font-medium text-foreground">Credential Type</span>
<input
name="default_credential_type"
defaultValue="api_key"
className="w-full rounded-md border border-input bg-background px-4 py-2.5 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</label>
</div>
</div>
<div className="space-y-5 border-t border-border pt-6">
{/* Default Credentials */}
<div className="space-y-4 border-t border-border pt-6">
<div className="space-y-1">
<h3 className="text-base font-semibold text-foreground">Capabilities</h3>
<h3 className="text-sm font-semibold text-foreground">
Default Credentials
</h3>
<p className="text-xs text-muted-foreground">
Credential provider name that matches the key in{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
PROVIDER_CREDENTIALS
</code>
</p>
</div>
<div className="space-y-2">
<label
htmlFor="default_credential_provider"
className="text-sm font-medium text-foreground"
>
Credential Provider <span className="text-destructive">*</span>
</label>
<input
id="default_credential_provider"
name="default_credential_provider"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="openai"
/>
<p className="text-xs text-muted-foreground">
<strong>Important:</strong> This must exactly match the key in the{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
PROVIDER_CREDENTIALS
</code>{" "}
dictionary in{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
block_cost_config.py
</code>
. Common values: "openai", "anthropic", "groq", "open_router", etc.
</p>
</div>
</div>
{/* Capabilities */}
<div className="space-y-4 border-t border-border pt-6">
<div className="space-y-1">
<h3 className="text-sm font-semibold text-foreground">
Capabilities
</h3>
<p className="text-xs text-muted-foreground">Provider feature flags</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2">
{[
{ name: "supports_tools", label: "Supports tools" },
{ name: "supports_json_output", label: "Supports JSON output" },
{ name: "supports_reasoning", label: "Supports reasoning" },
{ name: "supports_parallel_tool", label: "Supports parallel tool calls" },
{
name: "supports_parallel_tool",
label: "Supports parallel tool calls",
},
].map(({ name, label }) => (
<label key={name} className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-4 py-3 text-sm font-medium transition-colors hover:bg-muted/50">
<div
key={name}
className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-4 py-3 transition-colors hover:bg-muted/50"
>
<input type="hidden" name={name} value="off" />
<input
id={name}
type="checkbox"
name={name}
defaultChecked={name !== "supports_reasoning" && name !== "supports_parallel_tool"}
defaultChecked={
name !== "supports_reasoning" &&
name !== "supports_parallel_tool"
}
className="h-4 w-4 rounded border-input"
/>
{label}
</label>
<label
htmlFor={name}
className="text-sm font-medium text-foreground"
>
{label}
</label>
</div>
))}
</div>
</div>
</div>
<div className="flex justify-end border-t border-border pt-6">
<button
type="submit"
className="inline-flex items-center rounded-md bg-primary px-8 py-3 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
<div className="flex justify-end border-t border-gray-200 pt-4">
<Button type="submit" variant="primary" size="small">
Save Provider
</button>
</Button>
</div>
</form>
);
}

View File

@@ -24,12 +24,9 @@ export function EditModelModal({
styling={{ maxWidth: "768px", maxHeight: "90vh", overflowY: "auto" }}
>
<Dialog.Trigger>
<button
type="button"
className="inline-flex items-center rounded border border-input px-3 py-1 text-xs font-semibold hover:bg-muted"
>
<Button variant="outline" size="small" className="min-w-0">
Edit
</button>
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div className="mb-4 text-sm text-muted-foreground">

View File

@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { CaretDown } from "@phosphor-icons/react";
import type { LlmModel, LlmProvider } from "@/lib/autogpt-server-api/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { Button } from "@/components/atoms/Button/Button";
import { AddProviderForm } from "./AddProviderForm";
import { AddModelForm } from "./AddModelForm";
import { ProviderList } from "./ProviderList";
import { ModelsTable } from "./ModelsTable";
interface Props {
providers: LlmProvider[];
models: LlmModel[];
}
type FormType = "model" | "provider" | null;
export function LlmRegistryDashboard({ providers, models }: Props) {
const [activeForm, setActiveForm] = useState<FormType>(null);
function handleFormSelect(type: FormType) {
setActiveForm(activeForm === type ? null : type);
}
return (
<div className="mx-auto p-6">
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">LLM Registry</h1>
<p className="text-gray-500">
Manage supported providers, models, and credit pricing
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="primary" size="small" className="gap-2">
Add New
<CaretDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleFormSelect("model")}>
Model
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleFormSelect("provider")}>
Provider
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Add Forms Section */}
{activeForm && (
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">
Add {activeForm === "model" ? "Model" : "Provider"}
</h2>
<button
type="button"
onClick={() => setActiveForm(null)}
className="text-sm text-gray-600 hover:text-gray-900"
>
Close
</button>
</div>
{activeForm === "model" ? (
<AddModelForm providers={providers} />
) : (
<AddProviderForm />
)}
</div>
)}
{/* Providers Section */}
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="mb-4">
<h2 className="text-xl font-semibold">Providers</h2>
<p className="mt-1 text-sm text-gray-600">
Default credentials and feature flags for upstream vendors
</p>
</div>
<ProviderList providers={providers} />
</div>
{/* Models Section */}
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="mb-4">
<h2 className="text-xl font-semibold">Models</h2>
<p className="mt-1 text-sm text-gray-600">
Toggle availability, adjust context windows, and update credit
pricing
</p>
</div>
<ModelsTable models={models} providers={providers} />
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import type { LlmModel, LlmProvider } from "@/lib/autogpt-server-api/types";
import {
Table,
TableBody,
@@ -8,7 +7,7 @@ import {
TableHeader,
TableRow,
} from "@/components/atoms/Table/Table";
import { Button } from "@/components/atoms/Button/Button";
import { toggleLlmModelAction } from "../actions";
import { DeleteModelModal } from "./DeleteModelModal";
import { EditModelModal } from "./EditModelModal";
@@ -43,7 +42,7 @@ export function ModelsTable({
<TableHead>Max Output</TableHead>
<TableHead>Cost</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -95,16 +94,16 @@ export function ModelsTable({
</TableCell>
<TableCell>
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold ${
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${
model.is_enabled
? "bg-green-100 text-green-700"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-muted text-muted-foreground"
}`}
>
{model.is_enabled ? "Enabled" : "Disabled"}
</span>
</TableCell>
<TableCell className="text-right text-sm">
<TableCell>
<div className="flex items-center justify-end gap-2">
<ToggleModelButton
modelId={model.id}
@@ -134,15 +133,17 @@ function ToggleModelButton({
isEnabled: boolean;
}) {
return (
<form action={toggleLlmModelAction}>
<form action={toggleLlmModelAction} className="inline">
<input type="hidden" name="model_id" value={modelId} />
<input type="hidden" name="is_enabled" value={(!isEnabled).toString()} />
<button
<Button
type="submit"
className="inline-flex items-center rounded border border-input px-3 py-1 text-xs font-semibold hover:bg-muted"
variant="outline"
size="small"
className="min-w-0"
>
{isEnabled ? "Disable" : "Enable"}
</button>
</Button>
</form>
);
}

View File

@@ -1,63 +1,15 @@
import { withRoleAccess } from "@/lib/withRoleAccess";
import {
fetchLlmModels,
fetchLlmProviders,
} from "./actions";
import { AddProviderForm } from "./components/AddProviderForm";
import { AddModelForm } from "./components/AddModelForm";
import { ProviderList } from "./components/ProviderList";
import { ModelsTable } from "./components/ModelsTable";
import { useLlmRegistryPage } from "./useLlmRegistryPage";
import { LlmRegistryDashboard } from "./components/LlmRegistryDashboard";
async function LlmRegistryDashboard() {
const [providersResponse, modelsResponse] = await Promise.all([
fetchLlmProviders(),
fetchLlmModels(),
]);
const providers = providersResponse.providers;
const models = modelsResponse.models;
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-12 p-8">
<div className="space-y-2">
<h1 className="text-4xl font-bold tracking-tight">LLM Registry</h1>
<p className="text-base text-muted-foreground">
Manage supported providers, models, and credit pricing
</p>
</div>
<div className="grid gap-8 lg:grid-cols-2">
<AddProviderForm />
<AddModelForm providers={providers} />
</div>
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-3xl font-semibold tracking-tight">Providers</h2>
<p className="text-sm text-muted-foreground">
Default credentials and feature flags for upstream vendors.
</p>
</div>
<ProviderList providers={providers} />
</div>
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-3xl font-semibold tracking-tight">Models</h2>
<p className="text-sm text-muted-foreground">
Toggle availability, adjust context windows, and update credit pricing.
</p>
</div>
<ModelsTable models={models} providers={providers} />
</div>
</div>
);
async function LlmRegistryPage() {
const data = await useLlmRegistryPage();
return <LlmRegistryDashboard {...data} />;
}
export default async function AdminLlmRegistryPage() {
"use server";
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedDashboard = await withAdminAccess(LlmRegistryDashboard);
return <ProtectedDashboard />;
const ProtectedLlmRegistryPage = await withAdminAccess(LlmRegistryPage);
return <ProtectedLlmRegistryPage />;
}

View File

@@ -0,0 +1,21 @@
/**
* Hook for LLM Registry page data fetching and state management.
*/
import {
fetchLlmModels,
fetchLlmProviders,
} from "./actions";
export async function useLlmRegistryPage() {
const [providersResponse, modelsResponse] = await Promise.all([
fetchLlmProviders(),
fetchLlmModels(),
]);
return {
providers: providersResponse.providers,
models: modelsResponse.models,
};
}