Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ async def add_server_timing_header(request: Request, call_next):
github_webhooks,
imports,
load,
model_display_names,
notifications,
orgs,
prompts,
Expand Down Expand Up @@ -365,6 +366,7 @@ async def add_server_timing_header(request: Request, call_next):
api.include_router(slack.router)
api.include_router(admin.router)
api.include_router(cost_excluded_keys.router)
api.include_router(model_display_names.router)
api.include_router(tags.router)
api.include_router(reports.router)
api.include_router(qa.router)
Expand Down
160 changes: 160 additions & 0 deletions backend/api/routers/model_display_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError, ProgrammingError

from auth import AuthContext, can_manage_api_keys, require_admin
from auth.permissions import require_operator_org
from oddish.core.model_display_names import canonical_model_key
from oddish.db import ModelDisplayNameModel, get_session, utcnow
from pg_errors import is_undefined_table_error

router = APIRouter(prefix="/admin/model-display-names", tags=["Admin"])


class ModelDisplayNameResponse(BaseModel):
id: str
model_name: str
display_name: str
created_by: str | None
created_at: str
updated_at: str


class ModelDisplayNameRequest(BaseModel):
model_name: str
display_name: str


class UpdateModelDisplayNameRequest(BaseModel):
display_name: str


def _response(row: ModelDisplayNameModel) -> ModelDisplayNameResponse:
return ModelDisplayNameResponse(
id=row.id,
model_name=row.model_name,
display_name=row.display_name,
created_by=row.created_by_user_id,
created_at=row.created_at.isoformat(),
updated_at=row.updated_at.isoformat(),
)


def _require_manage(auth: AuthContext) -> None:
require_operator_org(auth)
if not can_manage_api_keys(auth):
raise HTTPException(
403, "Only organization admins may edit model display names"
)


def _unavailable(exc: ProgrammingError) -> HTTPException:
if is_undefined_table_error(exc):
return HTTPException(
503,
"Model display names are not available yet (schema is still "
"migrating). Try again shortly.",
)
raise exc


@router.get("", response_model=list[ModelDisplayNameResponse])
async def list_model_display_names(
auth: Annotated[AuthContext, Depends(require_admin)],
) -> list[ModelDisplayNameResponse]:
require_operator_org(auth)
try:
async with get_session() as session:
rows = await session.scalars(
select(ModelDisplayNameModel).order_by(ModelDisplayNameModel.model_name)
)
return [_response(row) for row in rows]
except ProgrammingError as exc:
raise _unavailable(exc) from exc


@router.post("", response_model=ModelDisplayNameResponse)
async def set_model_display_name(
request: ModelDisplayNameRequest,
auth: Annotated[AuthContext, Depends(require_admin)],
) -> ModelDisplayNameResponse:
_require_manage(auth)
# Store the canonical spelling: the live UNIQUE index is case-sensitive but
# the public lookup is not, so raw input would let "Spiffy-Balloon" and
# "spiffy-balloon" coexist as two live rows with only one of them applying.
model_name = canonical_model_key(request.model_name)
display_name = request.display_name.strip()
if not model_name:
raise HTTPException(400, "model_name must not be empty")
if not display_name:
raise HTTPException(400, "display_name must not be empty")

try:
async with get_session() as session:
existing = await session.scalar(
select(ModelDisplayNameModel).where(
ModelDisplayNameModel.model_name == model_name
)
)
Comment thread
cursor[bot] marked this conversation as resolved.
if existing is not None:
existing.display_name = display_name
await session.commit()
return _response(existing)

row = ModelDisplayNameModel(
model_name=model_name,
display_name=display_name,
created_by_user_id=auth.user_id,
)
session.add(row)
try:
await session.commit()
except IntegrityError:
raise HTTPException(409, "that model already has a display name")
return _response(row)
except ProgrammingError as exc:
raise _unavailable(exc) from exc


@router.put("/{name_id}", response_model=ModelDisplayNameResponse)
async def update_model_display_name(
name_id: str,
request: UpdateModelDisplayNameRequest,
auth: Annotated[AuthContext, Depends(require_admin)],
) -> ModelDisplayNameResponse:
_require_manage(auth)
display_name = request.display_name.strip()
if not display_name:
raise HTTPException(400, "display_name must not be empty")

try:
async with get_session() as session:
row = await session.get(ModelDisplayNameModel, name_id)
if row is None:
raise HTTPException(404, "display name not found")
row.display_name = display_name
await session.commit()
return _response(row)
except ProgrammingError as exc:
raise _unavailable(exc) from exc


@router.delete("/{name_id}")
async def remove_model_display_name(
name_id: str,
auth: Annotated[AuthContext, Depends(require_admin)],
) -> dict:
_require_manage(auth)
try:
async with get_session() as session:
row = await session.get(ModelDisplayNameModel, name_id)
if row is None:
raise HTTPException(404, "display name not found")
row.deleted_at = utcnow()
await session.commit()
except ProgrammingError as exc:
raise _unavailable(exc) from exc
return {"deleted": name_id}
19 changes: 18 additions & 1 deletion frontend/src/app/(app)/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { UsagePanel } from "@/components/usage-panel";
import { QueueHealthOverviewCard } from "@/components/queue-health-overview-card";
import { CostBreakdownCard } from "@/components/cost-breakdown-card";
import { CostExcludedKeysCard } from "@/components/cost-excluded-keys-card";
import { ModelDisplayNamesCard } from "@/components/model-display-names-card";
import { SlackAlertSettingsForm } from "@/components/slack-alert-settings-form";
import { RefreshCw, Server, Clock, AlertCircle } from "lucide-react";

Expand Down Expand Up @@ -661,8 +662,15 @@ const ADMIN_TABS = [
"concurrency",
"tags",
"quotas",
"model-names",
] as const;

// Platform-wide config: hidden unless the caller is in the operator org.
const OPERATOR_ONLY_TABS: ReadonlySet<(typeof ADMIN_TABS)[number]> = new Set([
"concurrency",
"model-names",
]);

function AdminPageContent() {
// The active tab lives in the URL (?tab=usage) so tabs are deep-linkable
// (e.g. from the dashboard usage card) and browser back/forward moves
Expand All @@ -678,7 +686,7 @@ function AdminPageContent() {
const canManagePlatform = operatorAccess?.allowed === true;
const allowedTabs = canManagePlatform
? ADMIN_TABS
: ADMIN_TABS.filter((tab) => tab !== "concurrency");
: ADMIN_TABS.filter((tab) => !OPERATOR_ONLY_TABS.has(tab));
const requestedTab = searchParams.get("tab") ?? "";
const activeTab = (allowedTabs as readonly string[]).includes(requestedTab)
? requestedTab
Expand Down Expand Up @@ -739,6 +747,9 @@ function AdminPageContent() {
)}
<TabsTrigger value="tags">Tag Policy</TabsTrigger>
<TabsTrigger value="quotas">Quotas</TabsTrigger>
{canManagePlatform && (
<TabsTrigger value="model-names">Model Names</TabsTrigger>
)}
</TabsList>

<TabsContent value="overview" className="space-y-4">
Expand Down Expand Up @@ -801,6 +812,12 @@ function AdminPageContent() {
</CardContent>
</Card>
</TabsContent>

{canManagePlatform && (
<TabsContent value="model-names" className="space-y-4">
<ModelDisplayNamesCard />
</TabsContent>
)}
</Tabs>
</div>
);
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/app/api/admin/model-display-names/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextRequest } from "next/server";
import { proxyBackendJson, proxyJsonRequest } from "@/lib/backend-response";

const PATH = "admin/model-display-names";

export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return proxyJsonRequest(request, `${PATH}/${encodeURIComponent(id)}`, "PUT");
}

export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const encoded = encodeURIComponent(id);
return proxyBackendJson({ path: `${PATH}/${encoded}`, method: "DELETE" });
}
9 changes: 9 additions & 0 deletions frontend/src/app/api/admin/model-display-names/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyBackendJson, proxyJsonRequest } from "@/lib/backend-response";

const PATH = "admin/model-display-names";

export const GET = () => proxyBackendJson({ path: PATH });

export const POST = (request: NextRequest) =>
proxyJsonRequest(request, PATH, "POST");
Loading