Add migration management for LLM models

Introduced a new LlmModelMigration model to track migrations when disabling LLM models, allowing for revert capability. Updated the toggle model API to create migration records with optional reason and custom pricing. Added endpoints for listing and reverting migrations, along with corresponding frontend actions and UI components to manage migrations effectively. Enhanced the admin dashboard to display active migrations, improving overall usability and tracking of model changes.
This commit is contained in:
Bently
2025-12-19 00:06:03 +00:00
parent 24d86fde30
commit 52c7b223df
12 changed files with 698 additions and 25 deletions

View File

@@ -143,21 +143,29 @@ async def toggle_llm_model(
If disabling a model and `migrate_to_slug` is provided, all workflows using
this model will be migrated to the specified replacement model before disabling.
A migration record is created which can be reverted later using the revert endpoint.
Optional fields:
- `migration_reason`: Reason for the migration (e.g., "Provider outage")
- `custom_credit_cost`: Custom pricing during the migration period
"""
try:
result = await llm_db.toggle_model(
model_id=model_id,
is_enabled=request.is_enabled,
migrate_to_slug=request.migrate_to_slug,
migration_reason=request.migration_reason,
custom_credit_cost=request.custom_credit_cost,
)
await _refresh_runtime_state()
if result.nodes_migrated > 0:
logger.info(
"Toggled model '%s' to %s and migrated %d nodes to '%s'",
"Toggled model '%s' to %s and migrated %d nodes to '%s' (migration_id=%s)",
result.model.slug,
"enabled" if request.is_enabled else "disabled",
result.nodes_migrated,
result.migrated_to_slug,
result.migration_id,
)
return result
except ValueError as exc:
@@ -235,3 +243,97 @@ async def delete_llm_model(
status_code=500,
detail="Failed to delete model and migrate workflows",
) from exc
# ============================================================================
# Migration Management Endpoints
# ============================================================================
@router.get(
"/migrations",
summary="List model migrations",
response_model=llm_model.LlmMigrationsResponse,
)
async def list_llm_migrations(
include_reverted: bool = fastapi.Query(
default=False, description="Include reverted migrations in the list"
),
):
"""
List all model migrations.
Migrations are created when disabling a model with the migrate_to_slug option.
They can be reverted to restore the original model configuration.
"""
try:
migrations = await llm_db.list_migrations(include_reverted=include_reverted)
return llm_model.LlmMigrationsResponse(migrations=migrations)
except Exception as exc:
logger.exception("Failed to list migrations: %s", exc)
raise fastapi.HTTPException(
status_code=500,
detail="Failed to list migrations",
) from exc
@router.get(
"/migrations/{migration_id}",
summary="Get migration details",
response_model=llm_model.LlmModelMigration,
)
async def get_llm_migration(migration_id: str):
"""Get details of a specific migration."""
try:
migration = await llm_db.get_migration(migration_id)
if not migration:
raise fastapi.HTTPException(
status_code=404, detail=f"Migration '{migration_id}' not found"
)
return migration
except fastapi.HTTPException:
raise
except Exception as exc:
logger.exception("Failed to get migration %s: %s", migration_id, exc)
raise fastapi.HTTPException(
status_code=500,
detail="Failed to get migration",
) from exc
@router.post(
"/migrations/{migration_id}/revert",
summary="Revert a model migration",
response_model=llm_model.RevertMigrationResponse,
)
async def revert_llm_migration(migration_id: str):
"""
Revert a model migration, restoring affected workflows to their original model.
This only reverts the specific nodes that were part of the migration.
The source model must exist and be enabled for the revert to succeed.
Requirements:
- Migration must not already be reverted
- Source model must exist and be enabled
"""
try:
result = await llm_db.revert_migration(migration_id)
await _refresh_runtime_state()
logger.info(
"Reverted migration '%s': %d nodes restored from '%s' to '%s'",
migration_id,
result.nodes_reverted,
result.target_model_slug,
result.source_model_slug,
)
return result
except ValueError as exc:
logger.warning("Migration revert validation failed: %s", exc)
raise fastapi.HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Failed to revert migration %s: %s", migration_id, exc)
raise fastapi.HTTPException(
status_code=500,
detail="Failed to revert migration",
) from exc

View File

@@ -220,7 +220,11 @@ async def update_model(
async def toggle_model(
model_id: str, is_enabled: bool, migrate_to_slug: str | None = None
model_id: str,
is_enabled: bool,
migrate_to_slug: str | None = None,
migration_reason: str | None = None,
custom_credit_cost: int | None = None,
) -> llm_model.ToggleLlmModelResponse:
"""
Toggle a model's enabled status, optionally migrating workflows when disabling.
@@ -230,10 +234,14 @@ async def toggle_model(
is_enabled: New enabled status
migrate_to_slug: If disabling and this is provided, migrate all workflows
using this model to the specified replacement model
migration_reason: Optional reason for the migration (e.g., "Provider outage")
custom_credit_cost: Optional custom pricing during the migration period
Returns:
ToggleLlmModelResponse with the updated model and optional migration stats
"""
import json
import prisma as prisma_module
# Get the model being toggled
@@ -244,6 +252,7 @@ async def toggle_model(
raise ValueError(f"Model with id '{model_id}' not found")
nodes_migrated = 0
migration_id: str | None = None
# If disabling with migration, perform migration first
if not is_enabled and migrate_to_slug:
@@ -259,16 +268,17 @@ async def toggle_model(
f"Please enable it before using it as a replacement."
)
# Count affected nodes
count_result = await prisma_module.get_client().query_raw(
# Get the IDs of nodes that will be migrated (for revert capability)
node_ids_result = await prisma_module.get_client().query_raw(
"""
SELECT COUNT(*) as count
SELECT id
FROM "AgentNode"
WHERE "constantInput"::jsonb->>'model' = $1
""",
model.slug,
)
nodes_migrated = int(count_result[0]["count"]) if count_result else 0
migrated_node_ids = [row["id"] for row in node_ids_result] if node_ids_result else []
nodes_migrated = len(migrated_node_ids)
# Perform migration and toggle atomically
async with transaction() as tx:
@@ -286,11 +296,26 @@ async def toggle_model(
migrate_to_slug,
model.slug,
)
record = await tx.llmmodel.update(
where={"id": model_id},
data={"isEnabled": is_enabled},
include={"Costs": True},
)
# Create migration record for revert capability
if nodes_migrated > 0:
migration_record = await tx.llmmodelmigration.create(
data={
"sourceModelSlug": model.slug,
"targetModelSlug": migrate_to_slug,
"reason": migration_reason,
"migratedNodeIds": json.dumps(migrated_node_ids),
"nodeCount": nodes_migrated,
"customCreditCost": custom_credit_cost,
}
)
migration_id = migration_record.id
else:
# Simple toggle without migration
record = await prisma.models.LlmModel.prisma().update(
@@ -303,6 +328,7 @@ async def toggle_model(
model=_map_model(record),
nodes_migrated=nodes_migrated,
migrated_to_slug=migrate_to_slug if nodes_migrated > 0 else None,
migration_id=migration_id,
)
@@ -416,3 +442,161 @@ async def delete_model(
f"and migrated {nodes_affected} workflow node(s) to '{replacement_model_slug}'."
),
)
def _map_migration(record: prisma.models.LlmModelMigration) -> llm_model.LlmModelMigration:
return llm_model.LlmModelMigration(
id=record.id,
source_model_slug=record.sourceModelSlug,
target_model_slug=record.targetModelSlug,
reason=record.reason,
node_count=record.nodeCount,
custom_credit_cost=record.customCreditCost,
is_reverted=record.isReverted,
created_at=record.createdAt.isoformat(),
reverted_at=record.revertedAt.isoformat() if record.revertedAt else None,
)
async def list_migrations(
include_reverted: bool = False,
) -> list[llm_model.LlmModelMigration]:
"""
List model migrations, optionally including reverted ones.
Args:
include_reverted: If True, include reverted migrations. Default is False.
Returns:
List of LlmModelMigration records
"""
where = None if include_reverted else {"isReverted": False}
records = await prisma.models.LlmModelMigration.prisma().find_many(
where=where,
order={"createdAt": "desc"},
)
return [_map_migration(record) for record in records]
async def get_migration(migration_id: str) -> llm_model.LlmModelMigration | None:
"""Get a specific migration by ID."""
record = await prisma.models.LlmModelMigration.prisma().find_unique(
where={"id": migration_id}
)
return _map_migration(record) if record else None
async def revert_migration(migration_id: str) -> llm_model.RevertMigrationResponse:
"""
Revert a model migration, restoring affected nodes to their original model.
This only reverts the specific nodes that were migrated, not all nodes
currently using the target model.
Args:
migration_id: UUID of the migration to revert
Returns:
RevertMigrationResponse with revert stats
Raises:
ValueError: If migration not found, already reverted, or source model not available
"""
import json
from datetime import datetime, timezone
# Get the migration record
migration = await prisma.models.LlmModelMigration.prisma().find_unique(
where={"id": migration_id}
)
if not migration:
raise ValueError(f"Migration with id '{migration_id}' not found")
if migration.isReverted:
raise ValueError(
f"Migration '{migration_id}' has already been reverted "
f"on {migration.revertedAt.isoformat() if migration.revertedAt else 'unknown date'}"
)
# Check if source model exists
source_model = await prisma.models.LlmModel.prisma().find_unique(
where={"slug": migration.sourceModelSlug}
)
if not source_model:
raise ValueError(
f"Source model '{migration.sourceModelSlug}' no longer exists. "
f"Cannot revert migration."
)
# Get the migrated node IDs (Prisma auto-parses JSONB to list)
migrated_node_ids: list[str] = (
migration.migratedNodeIds
if isinstance(migration.migratedNodeIds, list)
else json.loads(migration.migratedNodeIds) # type: ignore
)
if not migrated_node_ids:
raise ValueError("No nodes to revert in this migration")
# Track if we need to re-enable the source model
source_model_was_disabled = not source_model.isEnabled
# Perform revert atomically
async with transaction() as tx:
# Re-enable the source model if it was disabled
if source_model_was_disabled:
await tx.llmmodel.update(
where={"id": source_model.id},
data={"isEnabled": True},
)
# Update only the specific nodes that were migrated
# We need to check that they still have the target model (haven't been changed since)
# Use a single batch update for efficiency
# Format node IDs as PostgreSQL text array literal for comparison
node_ids_pg_array = "{" + ",".join(migrated_node_ids) + "}"
result = await tx.execute_raw(
"""
UPDATE "AgentNode"
SET "constantInput" = JSONB_SET(
"constantInput"::jsonb,
'{model}',
to_jsonb($1::text)
)
WHERE id::text = ANY($2::text[])
AND "constantInput"::jsonb->>'model' = $3
""",
migration.sourceModelSlug,
node_ids_pg_array,
migration.targetModelSlug,
)
nodes_reverted = result if result else 0
# Mark migration as reverted
await tx.llmmodelmigration.update(
where={"id": migration_id},
data={
"isReverted": True,
"revertedAt": datetime.now(timezone.utc),
},
)
# Build appropriate message
if source_model_was_disabled:
message = (
f"Successfully reverted migration: {nodes_reverted} node(s) restored "
f"from '{migration.targetModelSlug}' to '{migration.sourceModelSlug}'. "
f"Model '{migration.sourceModelSlug}' has been re-enabled."
)
else:
message = (
f"Successfully reverted migration: {nodes_reverted} node(s) restored "
f"from '{migration.targetModelSlug}' to '{migration.sourceModelSlug}'."
)
return llm_model.RevertMigrationResponse(
migration_id=migration_id,
source_model_slug=migration.sourceModelSlug,
target_model_slug=migration.targetModelSlug,
nodes_reverted=nodes_reverted,
message=message,
)

View File

@@ -107,12 +107,15 @@ class UpdateLlmModelRequest(pydantic.BaseModel):
class ToggleLlmModelRequest(pydantic.BaseModel):
is_enabled: bool
migrate_to_slug: Optional[str] = None
migration_reason: Optional[str] = None # e.g., "Provider outage"
custom_credit_cost: Optional[int] = None # Custom pricing during migration
class ToggleLlmModelResponse(pydantic.BaseModel):
model: LlmModel
nodes_migrated: int = 0
migrated_to_slug: Optional[str] = None
migration_id: Optional[str] = None # ID of the migration record for revert
class DeleteLlmModelResponse(pydantic.BaseModel):
@@ -126,3 +129,28 @@ class DeleteLlmModelResponse(pydantic.BaseModel):
class LlmModelUsageResponse(pydantic.BaseModel):
model_slug: str
node_count: int
# Migration tracking models
class LlmModelMigration(pydantic.BaseModel):
id: str
source_model_slug: str
target_model_slug: str
reason: Optional[str] = None
node_count: int
custom_credit_cost: Optional[int] = None
is_reverted: bool = False
created_at: str # ISO datetime string
reverted_at: Optional[str] = None
class LlmMigrationsResponse(pydantic.BaseModel):
migrations: list[LlmModelMigration]
class RevertMigrationResponse(pydantic.BaseModel):
migration_id: str
source_model_slug: str
target_model_slug: str
nodes_reverted: int
message: str

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "LlmModelMigration" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sourceModelSlug" TEXT NOT NULL,
"targetModelSlug" TEXT NOT NULL,
"reason" TEXT,
"migratedNodeIds" JSONB NOT NULL DEFAULT '[]',
"nodeCount" INTEGER NOT NULL,
"customCreditCost" INTEGER,
"isReverted" BOOLEAN NOT NULL DEFAULT false,
"revertedAt" TIMESTAMP(3),
CONSTRAINT "LlmModelMigration_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "LlmModelMigration_sourceModelSlug_idx" ON "LlmModelMigration"("sourceModelSlug");
-- CreateIndex
CREATE INDEX "LlmModelMigration_targetModelSlug_idx" ON "LlmModelMigration"("targetModelSlug");
-- CreateIndex
CREATE INDEX "LlmModelMigration_isReverted_idx" ON "LlmModelMigration"("isReverted");

View File

@@ -1034,4 +1034,35 @@ model LlmModelCost {
@@index([llmModelId])
@@index([credentialProvider])
}
// Tracks model migrations for revert capability
// When a model is disabled with migration, we record which nodes were affected
// so they can be reverted when the original model is back online
model LlmModelMigration {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sourceModelSlug String // The original model that was disabled
targetModelSlug String // The model workflows were migrated to
reason String? // Why the migration happened (e.g., "Provider outage")
// Track affected nodes as JSON array of node IDs
// Format: ["node-uuid-1", "node-uuid-2", ...]
migratedNodeIds Json @default("[]")
nodeCount Int // Number of nodes migrated
// Optional custom pricing during the migration period
// If set, this cost should be applied instead of the target model's cost
// Note: Requires billing system integration to fully work
customCreditCost Int?
// Revert tracking
isReverted Boolean @default(false)
revertedAt DateTime?
@@index([sourceModelSlug])
@@index([targetModelSlug])
@@index([isReverted])
}

View File

@@ -3,6 +3,7 @@
import BackendApi from "@/lib/autogpt-server-api";
import type {
CreateLlmModelRequest,
LlmMigrationsResponse,
LlmModelsResponse,
LlmProvidersResponse,
ToggleLlmModelRequest,
@@ -138,10 +139,14 @@ export async function toggleLlmModelAction(formData: FormData): Promise<void> {
const modelId = String(formData.get("model_id"));
const shouldEnable = formData.get("is_enabled") === "true";
const migrateToSlug = formData.get("migrate_to_slug");
const migrationReason = formData.get("migration_reason");
const customCreditCost = formData.get("custom_credit_cost");
const payload: ToggleLlmModelRequest = {
is_enabled: shouldEnable,
migrate_to_slug: migrateToSlug ? String(migrateToSlug) : undefined,
migration_reason: migrationReason ? String(migrationReason) : undefined,
custom_credit_cost: customCreditCost ? Number(customCreditCost) : undefined,
};
const api = new BackendApi();
await api.toggleAdminLlmModel(modelId, payload);
@@ -167,3 +172,27 @@ export async function deleteLlmModelAction(formData: FormData) {
}
}
// Migration management actions
export async function fetchLlmMigrations(
includeReverted: boolean = false
): Promise<LlmMigrationsResponse> {
const api = new BackendApi();
return await api.listAdminLlmMigrations(includeReverted);
}
export async function revertLlmMigrationAction(
formData: FormData
): Promise<void> {
try {
const migrationId = String(formData.get("migration_id"));
const api = new BackendApi();
await api.revertAdminLlmMigration(migrationId);
revalidatePath(ADMIN_LLM_PATH);
} catch (error) {
console.error("Revert migration error:", error);
throw error instanceof Error
? error
: new Error("Failed to revert migration");
}
}

View File

@@ -19,6 +19,8 @@ export function DisableModelModal({
const [usageCount, setUsageCount] = useState<number | null>(null);
const [selectedMigration, setSelectedMigration] = useState<string>("");
const [wantsMigration, setWantsMigration] = useState(false);
const [migrationReason, setMigrationReason] = useState("");
const [customCreditCost, setCustomCreditCost] = useState<string>("");
// Filter out the current model and disabled models from replacement options
const migrationOptions = availableModels.filter(
@@ -53,6 +55,8 @@ export function DisableModelModal({
setError(null);
setSelectedMigration("");
setWantsMigration(false);
setMigrationReason("");
setCustomCreditCost("");
}
const hasUsage = usageCount !== null && usageCount > 0;
@@ -71,7 +75,7 @@ export function DisableModelModal({
}
},
}}
styling={{ maxWidth: "550px" }}
styling={{ maxWidth: "600px" }}
>
<Dialog.Trigger>
<Button type="button" variant="outline" size="small" className="min-w-0">
@@ -114,7 +118,7 @@ export function DisableModelModal({
</div>
{hasUsage && (
<div className="rounded-lg border border-border bg-muted/50 p-4">
<div className="rounded-lg border border-border bg-muted/50 p-4 space-y-4">
<label className="flex items-start gap-3">
<input
type="checkbox"
@@ -132,18 +136,18 @@ export function DisableModelModal({
Migrate existing workflows to another model
</span>
<p className="mt-1 text-muted-foreground">
If unchecked, existing workflows will use automatic fallback
to an enabled model from the same provider.
Creates a revertible migration record. If unchecked, existing
workflows will use automatic fallback to an enabled model from
the same provider.
</p>
</div>
</label>
{wantsMigration && (
<div className="mt-4">
<label className="text-sm font-medium">
<div className="space-y-4 border-t border-border pt-4">
<label className="text-sm font-medium block">
<span className="mb-2 block">
Select Replacement Model{" "}
<span className="text-red-500">*</span>
Replacement Model <span className="text-red-500">*</span>
</span>
<select
required
@@ -164,6 +168,46 @@ export function DisableModelModal({
</p>
)}
</label>
<label className="text-sm font-medium block">
<span className="mb-2 block">
Migration Reason{" "}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</span>
<input
type="text"
value={migrationReason}
onChange={(e) => setMigrationReason(e.target.value)}
placeholder="e.g., Provider outage, Cost reduction"
className="w-full rounded border border-input bg-background p-2 text-sm"
/>
<p className="mt-1 text-xs text-muted-foreground">
Helps track why the migration was made
</p>
</label>
<label className="text-sm font-medium block">
<span className="mb-2 block">
Custom Credit Cost{" "}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</span>
<input
type="number"
min="0"
value={customCreditCost}
onChange={(e) => setCustomCreditCost(e.target.value)}
placeholder="Leave blank to use target model's cost"
className="w-full rounded border border-input bg-background p-2 text-sm"
/>
<p className="mt-1 text-xs text-muted-foreground">
Override pricing during this migration (for billing
adjustments)
</p>
</label>
</div>
)}
</div>
@@ -173,11 +217,27 @@ export function DisableModelModal({
<input type="hidden" name="model_id" value={model.id} />
<input type="hidden" name="is_enabled" value="false" />
{wantsMigration && selectedMigration && (
<input
type="hidden"
name="migrate_to_slug"
value={selectedMigration}
/>
<>
<input
type="hidden"
name="migrate_to_slug"
value={selectedMigration}
/>
{migrationReason && (
<input
type="hidden"
name="migration_reason"
value={migrationReason}
/>
)}
{customCreditCost && (
<input
type="hidden"
name="custom_credit_cost"
value={customCreditCost}
/>
)}
</>
)}
{error && (

View File

@@ -1,17 +1,23 @@
"use client";
import type { LlmModel, LlmProvider } from "@/lib/autogpt-server-api/types";
import type {
LlmModel,
LlmModelMigration,
LlmProvider,
} from "@/lib/autogpt-server-api/types";
import { AddProviderModal } from "./AddProviderModal";
import { AddModelModal } from "./AddModelModal";
import { ProviderList } from "./ProviderList";
import { ModelsTable } from "./ModelsTable";
import { MigrationsTable } from "./MigrationsTable";
interface Props {
providers: LlmProvider[];
models: LlmModel[];
migrations: LlmModelMigration[];
}
export function LlmRegistryDashboard({ providers, models }: Props) {
export function LlmRegistryDashboard({ providers, models, migrations }: Props) {
return (
<div className="mx-auto p-6">
<div className="flex flex-col gap-6">
@@ -29,11 +35,25 @@ export function LlmRegistryDashboard({ providers, models }: Props) {
</div>
</div>
{/* Active Migrations Section - Only show if there are migrations */}
{migrations.length > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-6 shadow-sm dark:border-blue-900 dark:bg-blue-950">
<div className="mb-4">
<h2 className="text-xl font-semibold">Active Migrations</h2>
<p className="mt-1 text-sm text-muted-foreground">
These migrations can be reverted to restore workflows to their
original model
</p>
</div>
<MigrationsTable migrations={migrations} />
</div>
)}
{/* Providers Section */}
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="rounded-lg border bg-white p-6 shadow-sm dark:bg-background">
<div className="mb-4">
<h2 className="text-xl font-semibold">Providers</h2>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Default credentials and feature flags for upstream vendors
</p>
</div>
@@ -41,10 +61,10 @@ export function LlmRegistryDashboard({ providers, models }: Props) {
</div>
{/* Models Section */}
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="rounded-lg border bg-white p-6 shadow-sm dark:bg-background">
<div className="mb-4">
<h2 className="text-xl font-semibold">Models</h2>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Toggle availability, adjust context windows, and update credit
pricing
</p>

View File

@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import type { LlmModelMigration } from "@/lib/autogpt-server-api/types";
import { Button } from "@/components/atoms/Button/Button";
import { revertLlmMigrationAction } from "../actions";
export function MigrationsTable({
migrations,
}: {
migrations: LlmModelMigration[];
}) {
if (!migrations.length) {
return (
<div className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No active migrations. Migrations are created when you disable a model
with the &quot;Migrate existing workflows&quot; option.
</div>
);
}
return (
<div className="rounded-lg border">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left text-sm font-medium">
Migration
</th>
<th className="px-4 py-3 text-left text-sm font-medium">Reason</th>
<th className="px-4 py-3 text-left text-sm font-medium">
Nodes Affected
</th>
<th className="px-4 py-3 text-left text-sm font-medium">
Custom Cost
</th>
<th className="px-4 py-3 text-left text-sm font-medium">Created</th>
<th className="px-4 py-3 text-right text-sm font-medium">
Actions
</th>
</tr>
</thead>
<tbody>
{migrations.map((migration) => (
<MigrationRow key={migration.id} migration={migration} />
))}
</tbody>
</table>
</div>
);
}
function MigrationRow({ migration }: { migration: LlmModelMigration }) {
const [isReverting, setIsReverting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleRevert(formData: FormData) {
setIsReverting(true);
setError(null);
try {
await revertLlmMigrationAction(formData);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to revert migration"
);
} finally {
setIsReverting(false);
}
}
const createdDate = new Date(migration.created_at);
return (
<>
<tr className="border-b last:border-0">
<td className="px-4 py-3">
<div className="text-sm">
<span className="font-medium">{migration.source_model_slug}</span>
<span className="mx-2 text-muted-foreground"></span>
<span className="font-medium">{migration.target_model_slug}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm text-muted-foreground">
{migration.reason || "—"}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm">{migration.node_count}</div>
</td>
<td className="px-4 py-3">
<div className="text-sm">
{migration.custom_credit_cost !== null
? `${migration.custom_credit_cost} credits`
: "—"}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm text-muted-foreground">
{createdDate.toLocaleDateString()}{" "}
{createdDate.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</td>
<td className="px-4 py-3 text-right">
<form action={handleRevert} className="inline">
<input type="hidden" name="migration_id" value={migration.id} />
<Button
type="submit"
variant="outline"
size="small"
disabled={isReverting}
>
{isReverting ? "Reverting..." : "Revert"}
</Button>
</form>
</td>
</tr>
{error && (
<tr>
<td colSpan={6} className="px-4 py-2">
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
{error}
</div>
</td>
</tr>
)}
</>
);
}

View File

@@ -3,19 +3,33 @@
*/
import {
fetchLlmMigrations,
fetchLlmModels,
fetchLlmProviders,
} from "./actions";
export async function useLlmRegistryPage() {
// Fetch providers and models (required)
const [providersResponse, modelsResponse] = await Promise.all([
fetchLlmProviders(),
fetchLlmModels(),
]);
// Fetch migrations separately with fallback (table might not exist yet)
let migrations: Awaited<ReturnType<typeof fetchLlmMigrations>>["migrations"] =
[];
try {
const migrationsResponse = await fetchLlmMigrations(false);
migrations = migrationsResponse.migrations;
} catch {
// Migrations table might not exist yet - that's ok, just show empty list
console.warn("Could not fetch migrations - table may not exist yet");
}
return {
providers: providersResponse.providers,
models: modelsResponse.models,
migrations,
};
}

View File

@@ -57,6 +57,9 @@ import type {
UpdateLlmModelRequest,
ToggleLlmModelRequest,
ToggleLlmModelResponse,
LlmModelMigration,
LlmMigrationsResponse,
RevertMigrationResponse,
UpsertLlmProviderRequest,
LlmModelsResponse,
LlmProvider,
@@ -495,6 +498,23 @@ export default class BackendAPI {
);
}
// Migration management
listAdminLlmMigrations(
includeReverted: boolean = false,
): Promise<LlmMigrationsResponse> {
return this._get(
`/llm/admin/llm/migrations?include_reverted=${includeReverted}`,
);
}
getAdminLlmMigration(migrationId: string): Promise<LlmModelMigration> {
return this._get(`/llm/admin/llm/migrations/${migrationId}`);
}
revertAdminLlmMigration(migrationId: string): Promise<RevertMigrationResponse> {
return this._request("POST", `/llm/admin/llm/migrations/${migrationId}/revert`);
}
// API Key related requests
async createAPIKey(
name: string,

View File

@@ -350,12 +350,40 @@ export type UpdateLlmModelRequest = {
export type ToggleLlmModelRequest = {
is_enabled: boolean;
migrate_to_slug?: string;
migration_reason?: string;
custom_credit_cost?: number;
};
export type ToggleLlmModelResponse = {
model: LlmModel;
nodes_migrated: number;
migrated_to_slug?: string | null;
migration_id?: string | null;
};
// Migration tracking types
export type LlmModelMigration = {
id: string;
source_model_slug: string;
target_model_slug: string;
reason?: string | null;
node_count: number;
custom_credit_cost?: number | null;
is_reverted: boolean;
created_at: string;
reverted_at?: string | null;
};
export type LlmMigrationsResponse = {
migrations: LlmModelMigration[];
};
export type RevertMigrationResponse = {
migration_id: string;
source_model_slug: string;
target_model_slug: string;
nodes_reverted: number;
message: string;
};
export type BlockIOOneOfSubSchema = {