From 91a8ae6c63e0c1159c3a9b06d5b56821ce398f4d Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 16:54:33 +0330 Subject: [PATCH] fix(admin/models): collapse agy effort variants into one clean option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model dropdown labeled each option as "{display} ({id})", and since agy's display name already bakes in the effort, entries rendered like "Gemini 3.8 Flash (High) (gemini-3.8-flash-high)" — the name, its effort, AND the raw id all in one, redundant with the separate effort field below. Group the live catalog into families by base name (effort qualifier stripped): "Gemini 3.8 Flash" is now one option, and the reasoning effort is chosen in the existing effort dropdown. On create, the family + chosen effort resolves back to the concrete agy catalog entry (gemini-3.8-flash-medium etc.), so the saved internal_name and reasoning_effort are byte-for-byte what a direct pick produced before. Frontend-only; no backend/catalog change. tsc --noEmit clean. --- apps/web/src/app/(app)/admin/models/page.tsx | 114 ++++++++++++++----- 1 file changed, 87 insertions(+), 27 deletions(-) diff --git a/apps/web/src/app/(app)/admin/models/page.tsx b/apps/web/src/app/(app)/admin/models/page.tsx index 87cb417..c143dee 100644 --- a/apps/web/src/app/(app)/admin/models/page.tsx +++ b/apps/web/src/app/(app)/admin/models/page.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { - effortsForModel, effortLabel, type AdminModel, type AgyAccount, @@ -23,6 +22,65 @@ import { LoadingState, Select, } from "@agy/ui"; + +/** Effort tokens an agy model id/name can encode, weakest -> strongest. */ +const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh"]; + +/** The single effort one agy catalog entry encodes, or ``null`` for a model + * that is not effort-specific (e.g. Claude "(Thinking)"). Detected from the + * id's dash-separated parts to avoid substring accidents ("xhigh" not "high"). */ +function entryEffort(m: AgyAccountModel): string | null { + const parts = m.id.toLowerCase().split(/[-_]/); + for (const token of ["xhigh", "high", "medium", "low", "minimal"]) { + if (parts.includes(token)) return token; + } + const supported = m.supported_efforts ?? []; + return supported.length === 1 ? supported[0] : null; +} + +/** Base label with any single-effort qualifier (" (High)") removed so effort + * variants of one family collapse into a single dropdown option. */ +function baseModelName(m: AgyAccountModel): string { + const stripped = m.display_name + .replace(/\s*\((high|medium|low|minimal|xhigh)\)/i, "") + .trim(); + return stripped || m.display_name; +} + +/** A model family: N agy catalog entries that share a base name and differ only + * by reasoning effort (e.g. gemini-3.8-flash-high/-medium/-low). */ +interface ModelFamily { + base: string; + entries: AgyAccountModel[]; + efforts: string[]; + supportsThinking: boolean; + supportsImages: boolean; +} + +/** Group the live catalog into families for the model dropdown. */ +function groupCatalog(catalog: AgyAccountModel[] | null): ModelFamily[] { + const groups = new Map(); + for (const m of catalog ?? []) { + const base = baseModelName(m); + let g = groups.get(base); + if (!g) { + g = { base, entries: [], efforts: [], supportsThinking: false, supportsImages: false }; + groups.set(base, g); + } + g.entries.push(m); + g.supportsThinking = g.supportsThinking || !!m.supports_thinking; + g.supportsImages = g.supportsImages || !!m.supports_images; + const own = entryEffort(m); + if (own && !g.efforts.includes(own)) g.efforts.push(own); + for (const e of m.supported_efforts ?? []) { + if (!g.efforts.includes(e)) g.efforts.push(e); + } + } + for (const g of groups.values()) { + g.efforts.sort((a, b) => EFFORT_ORDER.indexOf(a) - EFFORT_ORDER.indexOf(b)); + } + return [...groups.values()]; +} import { ApiError, api } from "@/lib/api"; /** @@ -115,6 +173,10 @@ export default function AdminModelsPage() { } }, []); + // The model dropdown shows one option per family (base name), not one per + // effort variant; the effort is chosen separately in the field below. + const groupedModels = React.useMemo(() => groupCatalog(catalog), [catalog]); + const onProviderChange = (value: string) => { setProviderId(value); // One provider exists, but the flow is wired for real: picking it reloads @@ -136,24 +198,19 @@ export default function AdminModelsPage() { const onModelChange = (value: string) => { setModelId(value); - const selected = (catalog ?? []).find((m) => m.id === value); - const supported = selected ? selected.supported_efforts : effortsForModel(value); + const family = groupedModels.find((g) => g.base === value); + const supported = family ? family.efforts : []; - // Pick default effort if supported by this model - if (selected?.default_effort && supported.includes(selected.default_effort)) { - setEffort(selected.default_effort); - } else if (supported.length > 0) { - setEffort(supported.includes("high") ? "high" : supported[0]); - } else { - setEffort(""); - } + // Default to the strongest effort the family offers (matches agy's default); + // a family with no efforts (e.g. Claude) leaves the field at its placeholder. + setEffort(supported.includes("high") ? "high" : (supported[0] ?? "")); - // Auto-detect capabilities based on agy model metadata + // Auto-detect capabilities from the family metadata. const nextCaps: Capability[] = ["text"]; - if (selected?.supports_thinking || supported.length > 0) { + if (family?.supportsThinking || supported.length > 0) { nextCaps.push("reasoning"); } - if (selected?.supports_images) { + if (family?.supportsImages) { nextCaps.push("vision"); } setCaps(nextCaps); @@ -176,12 +233,20 @@ export default function AdminModelsPage() { setFormError("حداقل یک توانایی انتخاب کنید."); return; } - const selectedModel = (catalog ?? []).find((m) => m.id === modelId); + const family = groupedModels.find((g) => g.base === modelId); + // Resolve the family + chosen effort back to the concrete agy catalog entry + // (e.g. "Gemini 3.8 Flash" + medium -> gemini-3.8-flash-medium), so the saved + // internal_name and reasoning_effort are exactly what a direct pick produced. + const resolved = family + ? (family.entries.find((e) => entryEffort(e) === effort) ?? + family.entries.find((e) => entryEffort(e) === "high") ?? + family.entries[0]) + : undefined; setCreating(true); try { const created = await api.adminCreateModel({ - internal_name: modelId, - display_name_for_admin: selectedModel?.display_name || modelId, + internal_name: resolved?.id ?? modelId, + display_name_for_admin: resolved?.display_name || modelId, capabilities: caps, input_price_per_1m: Number(inputPrice) || 0, output_price_per_1m: Number(outputPrice) || 0, @@ -200,20 +265,15 @@ export default function AdminModelsPage() { } }; - const selectedModel = (catalog ?? []).find((m) => m.id === modelId); - const efforts: ReasoningEffort[] = selectedModel - ? selectedModel.supported_efforts - : modelId - ? effortsForModel(modelId) - : []; + const selectedFamily = groupedModels.find((g) => g.base === modelId); + const efforts: ReasoningEffort[] = selectedFamily + ? (selectedFamily.efforts as ReasoningEffort[]) + : []; const accountOptions = accounts.map((a) => ({ value: a.id, label: a.email || a.name || a.id, })); - const modelOptions = (catalog ?? []).map((m) => ({ - value: m.id, - label: m.display_name && m.display_name !== m.id ? `${m.display_name} (${m.id})` : m.id, - })); + const modelOptions = groupedModels.map((g) => ({ value: g.base, label: g.base })); const effortOptions = [ { value: "", label: "پیش‌فرض سامانه" }, ...efforts.map((e) => ({ value: e, label: effortLabel(e) })),