diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index cd5c9657d6..0c560ebad4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -231,6 +231,13 @@ label. A management client can set or reset one label with `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }`; send `displayName: null` to reset it. Provider `PATCH` does not edit this map. Use this dedicated `PUT` endpoint to change or remove labels. +The dashboard exposes the same durable setting on **Models**. Expand the provider, find a +discovered model, and choose **Name**. The dialog keeps the exact `provider/model` selector visible +while you save a friendly label. Choose **Reset name** to return to provider metadata or the normal +selector fallback. **Name** changes presentation only; the separate alias pencil changes the +short routing alias and is not a display name editor. Native OpenAI and custom model rows keep their +existing controls. + ## Codex catalog and root `config.toml` settings These settings belong in the root of `$CODEX_HOME/config.toml`, alongside diff --git a/gui/src/components/ModelDisplayNameDialog.tsx b/gui/src/components/ModelDisplayNameDialog.tsx new file mode 100644 index 0000000000..86a7b48c75 --- /dev/null +++ b/gui/src/components/ModelDisplayNameDialog.tsx @@ -0,0 +1,158 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { useT, type TKey } from "../i18n/shared"; +import { + modelDisplayNameValidationKey, + type ModelRow, +} from "../pages/models-shared"; + +interface ModelDisplayNameDialogProps { + model: ModelRow; + saving: boolean; + requestError: string | null; + onSave: (displayName: string) => void; + onReset: () => void; + onClose: () => void; +} + +const SOURCE_LABEL_KEYS: Record, TKey> = { + operator: "models.displayNameSourceOperator", + provider: "models.displayNameSourceProvider", + fallback: "models.displayNameSourceFallback", +}; + +export default function ModelDisplayNameDialog({ + model, + saving, + requestError, + onSave, + onReset, + onClose, +}: ModelDisplayNameDialogProps) { + const t = useT(); + const dialogRef = useRef(null); + const inputRef = useRef(null); + const wasSavingRef = useRef(saving); + const titleId = useId(); + const helpId = useId(); + const errorId = useId(); + const [draft, setDraft] = useState(model.displayNameOverride ?? ""); + const [validationKey, setValidationKey] = useState(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + inputRef.current?.focus(); + return () => { if (dialog?.open) dialog.close(); }; + }, []); + + useEffect(() => { + const saveFailed = wasSavingRef.current && !saving && Boolean(requestError); + wasSavingRef.current = saving; + if (saveFailed) inputRef.current?.focus(); + }, [requestError, saving]); + + const validationError = validationKey ? t(validationKey) : null; + const visibleError = validationError ?? requestError; + const sourceKey = model.displayNameSource + ? SOURCE_LABEL_KEYS[model.displayNameSource] + : "models.displayNameSourceFallback"; + + const requestClose = () => { + if (!saving) onClose(); + }; + + return ( + { + event.preventDefault(); + requestClose(); + }} + > + + + +
+ {t("models.displayNameModelId")} + {model.namespaced} +
+ +
+ {t("models.displayNameCurrent")} + {model.displayName ?? model.namespaced} + {t(sourceKey)} +
+ + + { + setDraft(event.target.value); + setValidationKey(null); + }} + /> +

+ {t("models.displayNameHelp", { model: model.namespaced })} +

+ {visibleError && ( + + )} + +
+ + + +
+ +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 9086c9bf42..a2e305516c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2548,4 +2548,23 @@ export const de: Record = { "integrations.cursor.colReasoning": "Reasoning-Aufwand", "integrations.cursor.colContext": "Kontext", "integrations.cursor.guide": "Anleitung zu Cursor Private Inference öffnen", + "models.displayNameAction": "Name", + "models.displayNameActionLabel": "Anzeigenamen für {model} bearbeiten", + "models.displayNameTitle": "Anzeigename", + "models.displayNameModelId": "Modell-ID", + "models.displayNameCurrent": "Aktueller Name", + "models.displayNameSourceOperator": "Ihr Name", + "models.displayNameSourceProvider": "Anbietername", + "models.displayNameSourceFallback": "Modell-ID als Ersatz", + "models.displayNameField": "Anzeigename", + "models.displayNamePlaceholder": "z. B. Grok 4.6", + "models.displayNameHelp": "Ändert nur die Anzeige. Das Routing bleibt {model}.", + "models.displayNameReset": "Name zurücksetzen", + "models.displayNameSaved": "Anzeigename gespeichert", + "models.displayNameResetDone": "Anzeigename zurückgesetzt", + "models.displayNameSaveFailed": "Anzeigename konnte nicht gespeichert werden", + "models.displayNameRequired": "Geben Sie einen Anzeigenamen ein oder verwenden Sie Name zurücksetzen.", + "models.displayNameTooLong": "Der Anzeigename darf höchstens 128 Zeichen lang sein.", + "models.displayNameNoSlash": "Der Anzeigename darf kein / enthalten.", + "models.displayNameNoControl": "Der Anzeigename darf keine Steuerzeichen enthalten.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0d2f1d05d4..a4b87e3e9f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2582,6 +2582,25 @@ export const en = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "models.displayNameAction": "Name", + "models.displayNameActionLabel": "Edit friendly name for {model}", + "models.displayNameTitle": "Friendly name", + "models.displayNameModelId": "Model ID", + "models.displayNameCurrent": "Current name", + "models.displayNameSourceOperator": "Your name", + "models.displayNameSourceProvider": "Provider name", + "models.displayNameSourceFallback": "Model ID fallback", + "models.displayNameField": "Friendly name", + "models.displayNamePlaceholder": "e.g. Grok 4.6", + "models.displayNameHelp": "Changes presentation only. Routing remains {model}.", + "models.displayNameReset": "Reset name", + "models.displayNameSaved": "Display name saved", + "models.displayNameResetDone": "Display name reset", + "models.displayNameSaveFailed": "Failed to save display name", + "models.displayNameRequired": "Enter a friendly name, or use Reset name.", + "models.displayNameTooLong": "Friendly name must be 128 characters or fewer.", + "models.displayNameNoSlash": "Friendly name cannot contain /.", + "models.displayNameNoControl": "Friendly name cannot contain control characters.", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b6eb03f0b0..3357d2cc75 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2535,4 +2535,23 @@ export const fr: Record = { "integrations.cursor.colReasoning": "Raisonnement", "integrations.cursor.colContext": "Contexte", "integrations.cursor.guide": "Ouvrir le guide de Cursor Private Inference", + "models.displayNameAction": "Nom", + "models.displayNameActionLabel": "Modifier le nom d’affichage de {model}", + "models.displayNameTitle": "Nom d’affichage", + "models.displayNameModelId": "ID du modèle", + "models.displayNameCurrent": "Nom actuel", + "models.displayNameSourceOperator": "Votre nom d’affichage", + "models.displayNameSourceProvider": "Nom du fournisseur", + "models.displayNameSourceFallback": "ID du modèle par défaut", + "models.displayNameField": "Nom d’affichage", + "models.displayNamePlaceholder": "p. ex. Grok 4.6", + "models.displayNameHelp": "Modifie uniquement l’affichage. Le routage reste {model}.", + "models.displayNameReset": "Réinitialiser le nom", + "models.displayNameSaved": "Nom d’affichage enregistré", + "models.displayNameResetDone": "Nom d’affichage réinitialisé", + "models.displayNameSaveFailed": "Impossible d’enregistrer le nom d’affichage", + "models.displayNameRequired": "Saisissez un nom d’affichage ou utilisez Réinitialiser le nom.", + "models.displayNameTooLong": "Le nom d’affichage doit contenir au maximum 128 caractères.", + "models.displayNameNoSlash": "Le nom d’affichage ne peut pas contenir /.", + "models.displayNameNoControl": "Le nom d’affichage ne peut pas contenir de caractères de contrôle.", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4b16912324..10b3380a92 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2569,4 +2569,23 @@ export const ja: Record = { "integrations.cursor.colReasoning": "推論", "integrations.cursor.colContext": "コンテキスト", "integrations.cursor.guide": "Cursor Private Inference のガイドを開く", + "models.displayNameAction": "名前", + "models.displayNameActionLabel": "{model} の表示名を編集", + "models.displayNameTitle": "表示名", + "models.displayNameModelId": "モデル ID", + "models.displayNameCurrent": "現在の名前", + "models.displayNameSourceOperator": "設定した名前", + "models.displayNameSourceProvider": "プロバイダー名", + "models.displayNameSourceFallback": "モデル ID の既定値", + "models.displayNameField": "表示名", + "models.displayNamePlaceholder": "例: Grok 4.6", + "models.displayNameHelp": "表示だけを変更します。ルーティングは {model} のままです。", + "models.displayNameReset": "名前をリセット", + "models.displayNameSaved": "表示名を保存しました", + "models.displayNameResetDone": "表示名をリセットしました", + "models.displayNameSaveFailed": "表示名を保存できませんでした", + "models.displayNameRequired": "表示名を入力するか、名前をリセットしてください。", + "models.displayNameTooLong": "表示名は 128 文字以内にしてください。", + "models.displayNameNoSlash": "表示名に / は使用できません。", + "models.displayNameNoControl": "表示名に制御文字は使用できません。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a87bf608e5..a8a01870a8 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2570,4 +2570,23 @@ export const ko: Record = { "integrations.cursor.colReasoning": "추론", "integrations.cursor.colContext": "컨텍스트", "integrations.cursor.guide": "Cursor Private Inference 가이드 열기", + "models.displayNameAction": "이름", + "models.displayNameActionLabel": "{model}의 표시 이름 편집", + "models.displayNameTitle": "표시 이름", + "models.displayNameModelId": "모델 ID", + "models.displayNameCurrent": "현재 이름", + "models.displayNameSourceOperator": "운영자 지정 이름", + "models.displayNameSourceProvider": "프로바이더 제공 이름", + "models.displayNameSourceFallback": "모델 ID 기본값", + "models.displayNameField": "표시 이름", + "models.displayNamePlaceholder": "예: Grok 4.6", + "models.displayNameHelp": "표시 방식만 변경합니다. 라우팅은 {model}로 유지됩니다.", + "models.displayNameReset": "이름 초기화", + "models.displayNameSaved": "표시 이름이 저장되었습니다", + "models.displayNameResetDone": "표시 이름이 초기화되었습니다", + "models.displayNameSaveFailed": "표시 이름을 저장하지 못했습니다", + "models.displayNameRequired": "표시 이름을 입력하거나 이름 초기화를 사용하세요.", + "models.displayNameTooLong": "표시 이름은 128자 이하여야 합니다.", + "models.displayNameNoSlash": "표시 이름에 /를 사용할 수 없습니다.", + "models.displayNameNoControl": "표시 이름에 제어 문자를 사용할 수 없습니다.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 70eb364002..52eb0dcb87 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2571,4 +2571,23 @@ export const ru: Record = { "integrations.cursor.colReasoning": "Рассуждения", "integrations.cursor.colContext": "Контекст", "integrations.cursor.guide": "Открыть руководство по Cursor Private Inference", + "models.displayNameAction": "Имя", + "models.displayNameActionLabel": "Изменить понятное имя для {model}", + "models.displayNameTitle": "Понятное имя", + "models.displayNameModelId": "ID модели", + "models.displayNameCurrent": "Текущее имя", + "models.displayNameSourceOperator": "Ваше имя", + "models.displayNameSourceProvider": "Имя провайдера", + "models.displayNameSourceFallback": "ID модели по умолчанию", + "models.displayNameField": "Понятное имя", + "models.displayNamePlaceholder": "например, Grok 4.6", + "models.displayNameHelp": "Меняет только отображение. Маршрут остаётся {model}.", + "models.displayNameReset": "Сбросить имя", + "models.displayNameSaved": "Понятное имя сохранено", + "models.displayNameResetDone": "Понятное имя сброшено", + "models.displayNameSaveFailed": "Не удалось сохранить понятное имя", + "models.displayNameRequired": "Введите понятное имя или используйте Сбросить имя.", + "models.displayNameTooLong": "Понятное имя должно содержать не более 128 символов.", + "models.displayNameNoSlash": "Понятное имя не может содержать /.", + "models.displayNameNoControl": "Понятное имя не может содержать управляющие символы.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ca233f452e..e02e10ef49 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2571,4 +2571,23 @@ export const tr: Record = { "integrations.cursor.colReasoning": "Akıl yürütme", "integrations.cursor.colContext": "Bağlam", "integrations.cursor.guide": "Cursor Private Inference kılavuzunu aç", + "models.displayNameAction": "Ad", + "models.displayNameActionLabel": "{model} için görünen adı düzenle", + "models.displayNameTitle": "Görünen ad", + "models.displayNameModelId": "Model kimliği", + "models.displayNameCurrent": "Geçerli ad", + "models.displayNameSourceOperator": "Sizin adınız", + "models.displayNameSourceProvider": "Sağlayıcı adı", + "models.displayNameSourceFallback": "Model kimliği varsayılanı", + "models.displayNameField": "Görünen ad", + "models.displayNamePlaceholder": "örn. Grok 4.6", + "models.displayNameHelp": "Yalnızca görünümü değiştirir. Yönlendirme {model} olarak kalır.", + "models.displayNameReset": "Adı sıfırla", + "models.displayNameSaved": "Görünen ad kaydedildi", + "models.displayNameResetDone": "Görünen ad sıfırlandı", + "models.displayNameSaveFailed": "Görünen ad kaydedilemedi", + "models.displayNameRequired": "Bir görünen ad girin veya Adı sıfırla seçeneğini kullanın.", + "models.displayNameTooLong": "Görünen ad en fazla 128 karakter olabilir.", + "models.displayNameNoSlash": "Görünen ad / içeremez.", + "models.displayNameNoControl": "Görünen ad denetim karakterleri içeremez.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2b7e6ac6ba..4a48dcffe6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2533,4 +2533,23 @@ export const zhTW: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "開啟 Cursor Private Inference 指南", + "models.displayNameAction": "名稱", + "models.displayNameActionLabel": "編輯 {model} 的友善名稱", + "models.displayNameTitle": "友善名稱", + "models.displayNameModelId": "模型 ID", + "models.displayNameCurrent": "目前名稱", + "models.displayNameSourceOperator": "你的名稱", + "models.displayNameSourceProvider": "供應商名稱", + "models.displayNameSourceFallback": "模型 ID 預設值", + "models.displayNameField": "友善名稱", + "models.displayNamePlaceholder": "例如 Grok 4.6", + "models.displayNameHelp": "只變更顯示方式。路由仍為 {model}。", + "models.displayNameReset": "重設名稱", + "models.displayNameSaved": "友善名稱已儲存", + "models.displayNameResetDone": "友善名稱已重設", + "models.displayNameSaveFailed": "無法儲存友善名稱", + "models.displayNameRequired": "請輸入友善名稱,或使用重設名稱。", + "models.displayNameTooLong": "友善名稱不能超過 128 個字元。", + "models.displayNameNoSlash": "友善名稱不能包含 /。", + "models.displayNameNoControl": "友善名稱不能包含控制字元。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 42ac3941d4..3c1042099c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2569,4 +2569,23 @@ export const zh: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "打开 Cursor Private Inference 指南", + "models.displayNameAction": "名称", + "models.displayNameActionLabel": "编辑 {model} 的友好名称", + "models.displayNameTitle": "友好名称", + "models.displayNameModelId": "模型 ID", + "models.displayNameCurrent": "当前名称", + "models.displayNameSourceOperator": "你的名称", + "models.displayNameSourceProvider": "提供商名称", + "models.displayNameSourceFallback": "模型 ID 默认值", + "models.displayNameField": "友好名称", + "models.displayNamePlaceholder": "例如 Grok 4.6", + "models.displayNameHelp": "仅更改显示方式。路由仍为 {model}。", + "models.displayNameReset": "重置名称", + "models.displayNameSaved": "友好名称已保存", + "models.displayNameResetDone": "友好名称已重置", + "models.displayNameSaveFailed": "无法保存友好名称", + "models.displayNameRequired": "请输入友好名称,或使用重置名称。", + "models.displayNameTooLong": "友好名称不能超过 128 个字符。", + "models.displayNameNoSlash": "友好名称不能包含 /。", + "models.displayNameNoControl": "友好名称不能包含控制字符。", }; diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 22ea51bc20..e3fe0d66ca 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1,4 +1,5 @@ import { CodexStaleBanner } from "../components/codex-stale-banner"; +import ModelDisplayNameDialog from "../components/ModelDisplayNameDialog"; import { fetchCodexAppServerState } from "../codex-app-server-state"; import type { AppServerStateOutcome } from "../codex-app-server-state"; import { useCodexRestart } from "../use-codex-restart"; @@ -8,7 +9,7 @@ import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconRefresh, Ic import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; import { modelLabel } from "../model-display"; -import { formatNamespacedModelId, formatProviderDisplayName, providerDisplaySlug } from "../provider-icons"; +import { formatProviderDisplayName, providerDisplaySlug } from "../provider-icons"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { describeIntegrationRefusalParts } from "./integrations/refusal-copy"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; @@ -328,6 +329,11 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [showThreadsCustom, setShowThreadsCustom] = useState(false); const [v2HelpOpen, setV2HelpOpen] = useState(false); const [customModalOpen, setCustomModalOpen] = useState(false); + const [displayNameModel, setDisplayNameModel] = useState(null); + const [displayNameSaving, setDisplayNameSaving] = useState(false); + const [displayNameRequestError, setDisplayNameRequestError] = useState(null); + const displayNameSavingRef = useRef(false); + const displayNameTriggerRef = useRef(null); const reloadAliases = useCallback(async (signal?: AbortSignal) => { const response = await fetch(`${apiBase}/api/aliases`, { signal }); @@ -557,6 +563,65 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } }, [applyCatalog, cacheKey, fetchCatalog, pickerResource.refresh]); + const finishDisplayNameEdit = useCallback(() => { + const trigger = displayNameTriggerRef.current; + setDisplayNameModel(null); + setDisplayNameRequestError(null); + window.setTimeout(() => { + if (trigger?.isConnected) trigger.focus(); + }, 0); + }, []); + + const closeDisplayNameEdit = useCallback(() => { + if (!displayNameSavingRef.current) finishDisplayNameEdit(); + }, [finishDisplayNameEdit]); + + const saveDisplayName = useCallback(async (displayName: string | null) => { + const model = displayNameModel; + if (!model || displayNameSavingRef.current) return; + displayNameSavingRef.current = true; + setDisplayNameSaving(true); + setDisplayNameRequestError(null); + try { + const response = await fetch( + `${apiBase}/api/providers/${encodeURIComponent(model.provider)}/model-display-names`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ modelId: model.id, displayName }), + }, + ); + const result = await readJsonOrThrow<{ + displayName?: string; + displayNameOverride?: string | null; + displayNameSource?: ModelRow["displayNameSource"]; + }>(response, t("models.displayNameSaveFailed")); + if (result) { + setModels(current => current.map(row => row.namespaced !== model.namespaced ? row : { + ...row, + displayName: result.displayName ?? row.displayName, + displayNameOverride: result.displayNameOverride ?? undefined, + displayNameSource: result.displayNameSource ?? row.displayNameSource, + })); + } + if (!await load(true)) { + throw new Error(t("models.loadFail")); + } + displayNameSavingRef.current = false; + setDisplayNameSaving(false); + publishFeedback(true, displayName === null + ? t("models.displayNameResetDone") + : t("models.displayNameSaved")); + finishDisplayNameEdit(); + } catch (error) { + displayNameSavingRef.current = false; + setDisplayNameSaving(false); + setDisplayNameRequestError(error instanceof Error && error.message + ? error.message + : t("models.displayNameSaveFailed")); + } + }, [apiBase, displayNameModel, finishDisplayNameEdit, load, t]); + // Shadow/v2 controls must not wait on the models catalog (live discovery can be slow). useEffect(() => { // Both belong to the catalog tab; a hidden panel polling /api/v2 every ten seconds @@ -1537,9 +1602,29 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> {m.initialSelectionPending && {t("models.initialSelectionPending")}} {aliases.models[provider]?.[m.id] && {aliases.models[provider][m.id].alias}} - {m.native ? modelLabel(m.id) : formatNamespacedModelId(m.namespaced, t)} + + {m.native ? modelLabel(m.id) : m.namespaced} + {!m.native && m.displayName?.trim() && m.displayName.trim() !== m.namespaced && ( + {m.displayName.trim()} + )} + {aliases.models[provider]?.[m.id]?.source === "builtin" && {t("models.aliasAuto")}} + {!m.native && !m.custom && ( + + )} {m.custom && ( {t("models.customBadge")} @@ -2485,6 +2570,17 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; )} + + {displayNameModel && ( + void saveDisplayName(value)} + onReset={() => void saveDisplayName(null)} + onClose={closeDisplayNameEdit} + /> + )} ); diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index fdc487301c..1f5ef7786b 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -1,4 +1,4 @@ -import type { TFn } from "../i18n/shared"; +import type { TFn, TKey } from "../i18n/shared"; import type { ProviderDiscoverySummary } from "../models-groups"; import { modelVisible, type ProviderModelMap } from "../model-visibility"; import { formatNamespacedModelId } from "../provider-icons"; @@ -35,6 +35,8 @@ export interface ModelRow { custom?: boolean; customId?: string; displayName?: string; + displayNameOverride?: string; + displayNameSource?: "operator" | "provider" | "fallback"; inputModalities?: string[]; contextWindow?: number; contextCap?: number; @@ -43,6 +45,26 @@ export interface ModelRow { reasoningEfforts?: string[]; } +function containsDisplayNameControlCharacter(value: string): boolean { + return [...value].some(character => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f + || (codePoint >= 0x7f && codePoint <= 0x9f) + || codePoint === 0x2028 + || codePoint === 0x2029; + }); +} + +/** Mirror the server display-name contract for immediate form feedback. */ +export function modelDisplayNameValidationKey(value: string): TKey | null { + const trimmed = value.trim(); + if (!trimmed) return "models.displayNameRequired"; + if (trimmed.length > 128) return "models.displayNameTooLong"; + if (trimmed.includes("/")) return "models.displayNameNoSlash"; + if (containsDisplayNameControlCharacter(trimmed)) return "models.displayNameNoControl"; + return null; +} + /** * Reasoning-effort labels offered in the custom-model dialog. The full set of real * `reasoning_effort` values (none, minimal, low, medium, high, xhigh, max). Deliberately diff --git a/gui/src/styles.css b/gui/src/styles.css index b0bbc0a6ff..630882006f 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2639,6 +2639,52 @@ button.prov-account-row.active { cursor: default; } /* ---- model row hover tooltip ---- */ .model-row-wrap { position: relative; } +.models-model-identity { + display: inline-flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 1px; +} +.models-model-friendly { + max-width: min(42vw, 420px); + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} +.models-display-name-trigger { flex-shrink: 0; } +.model-display-name-dialog { max-width: 460px; } +.model-display-name-identity, +.model-display-name-current { + display: grid; + gap: 5px; + margin-bottom: 16px; +} +.model-display-name-identity code { + overflow-wrap: anywhere; + color: var(--text); +} +.model-display-name-current { + grid-template-columns: 1fr auto; + align-items: center; +} +.model-display-name-current > .text-label { grid-column: 1 / -1; } +.model-display-name-current strong { min-width: 0; overflow-wrap: anywhere; } +.model-display-name-dialog > .input { margin-bottom: 6px; } +.model-display-name-error { + margin-top: 8px; + color: var(--red); + font-size: var(--text-label); + line-height: var(--leading-body); +} +@media (max-width: 560px) { + .models-model-friendly { max-width: 58vw; } + .model-display-name-current { grid-template-columns: 1fr; } + .model-display-name-current > .text-label { grid-column: auto; } + .model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column; } + .model-display-name-dialog .modal-actions .btn { width: 100%; } +} .model-tip { z-index: 10; background: var(--surface); diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx new file mode 100644 index 0000000000..6cdb169999 --- /dev/null +++ b/gui/tests/models-display-name-editor.test.tsx @@ -0,0 +1,468 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import ModelDisplayNameDialog from "../src/components/ModelDisplayNameDialog"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import Models from "../src/pages/Models"; +import type { ModelRow } from "../src/pages/models-shared"; +import { modelDisplayNameValidationKey } from "../src/pages/models-shared"; + +describe("discovered model display name validation", () => { + test("accepts a safe label at both ordinary and maximum length", () => { + expect(modelDisplayNameValidationKey("Grok 4.6")).toBeNull(); + expect(modelDisplayNameValidationKey("A".repeat(128))).toBeNull(); + }); + + test("rejects values that the management API cannot persist", () => { + expect(modelDisplayNameValidationKey(" ")).toBe("models.displayNameRequired"); + expect(modelDisplayNameValidationKey("Grok/4.6")).toBe("models.displayNameNoSlash"); + expect(modelDisplayNameValidationKey("Grok\n4.6")).toBe("models.displayNameNoControl"); + expect(modelDisplayNameValidationKey("A".repeat(129))).toBe("models.displayNameTooLong"); + }); +}); + +describe("discovered model display name responsive styles", () => { + test("keeps the narrow action order aligned with keyboard navigation", async () => { + const styles = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + + expect(styles).toContain( + ".model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column; }", + ); + expect(styles).not.toContain( + ".model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column-reverse; }", + ); + }); +}); + +describe("Models dashboard discovered display name integration", () => { + const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", + "IS_REACT_ACT_ENVIRONMENT", "fetch", "setInterval", "clearInterval", + ] as const; + let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; + let testWindow: Window; + let container: HTMLElement; + let root: Root | null; + let mutationBodies: Array<{ modelId: string; displayName: string | null }>; + let mutationFailure: string | null; + let mutationGate: Promise | null; + let modelFetches: number; + let modelFetchFailure: string | null; + let currentModels: ModelRow[]; + + const routedModel = (): ModelRow => ({ + provider: "xai-demo", + id: "grok-4.6", + namespaced: "xai-demo/grok-4.6", + disabled: false, + displayName: "Grok 4.6", + displayNameOverride: "Grok 4.6", + displayNameSource: "operator", + }); + + beforeEach(() => { + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#models" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + setInterval: { configurable: true, value: () => 1 }, + clearInterval: { configurable: true, value: () => {} }, + }); + currentModels = [ + routedModel(), + { + provider: "command-code", + id: "deepseek-deepseek-v4-flash", + namespaced: "command-code/deepseek-deepseek-v4-flash", + disabled: false, + displayName: "DeepSeek V4 Flash", + displayNameSource: "provider", + }, + { provider: "openai", id: "gpt-5.5", namespaced: "openai/gpt-5.5", disabled: false, native: true }, + { + provider: "xai-demo", id: "custom-one", namespaced: "xai-demo/custom-one", + disabled: false, custom: true, customId: "custom-1", displayName: "Custom One", + }, + ]; + mutationBodies = []; + mutationFailure = null; + mutationGate = null; + modelFetches = 0; + modelFetchFailure = null; + testWindow.localStorage.setItem("ocx-models-collapsed:v2", JSON.stringify([])); + testWindow.sessionStorage.setItem("ocx.models.catalog.v1:http://localhost", JSON.stringify({ + models: currentModels, + providers: [ + { name: "xai-demo", liveModels: false, models: ["grok-4.6", "custom-one"] }, + { name: "command-code", liveModels: false, models: ["deepseek-deepseek-v4-flash"] }, + { name: "openai", liveModels: false, models: ["gpt-5.5"] }, + ], + selectedModels: {}, + disabled: [], + contextCaps: {}, + contextCapValue: 350_000, + })); + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/api/models")) { + modelFetches += 1; + if (modelFetchFailure) { + return Response.json({ error: modelFetchFailure }, { status: 500 }); + } + return Response.json(currentModels); + } + if (url.endsWith("/api/providers")) return Response.json([ + { name: "xai-demo", liveModels: false, models: ["grok-4.6", "custom-one"] }, + { name: "command-code", liveModels: false, models: ["deepseek-deepseek-v4-flash"] }, + { name: "openai", liveModels: false, models: ["gpt-5.5"] }, + ]); + if (url.endsWith("/api/selected-models")) return Response.json({ selected: {} }); + if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); + if (url.endsWith("/api/aliases")) return Response.json({ providers: {}, models: {}, defaults: { global: false, providers: {} } }); + if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); + if (url.endsWith("/api/shadow-call-settings")) return Response.json({ enabled: false, model: "" }); + if (url.endsWith("/api/v2")) return Response.json({ enabled: false, agentsMaxThreadsConflict: false, multiAgentMode: "default" }); + if (url.includes("/api/providers/xai-demo/model-display-names") && init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as { modelId: string; displayName: string | null }; + mutationBodies.push(body); + if (mutationGate) await mutationGate; + if (mutationFailure) return Response.json({ error: mutationFailure }, { status: 500 }); + currentModels = currentModels.map(row => row.namespaced !== "xai-demo/grok-4.6" ? row : { + ...row, + displayName: body.displayName ?? "xai-demo/grok-4.6", + displayNameOverride: body.displayName ?? undefined, + displayNameSource: body.displayName ? "operator" : "fallback", + }); + return Response.json({ ok: true }); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + + container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container as never); + root = null; + }); + + afterEach(async () => { + clearClientResourceStoresForTests(); + if (root) { + const mounted = root; + await act(async () => mounted.unmount()); + } + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + + async function flush() { + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); + }); + } + + async function mountModels() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await flush(); + } + + function nameTrigger(): HTMLButtonElement { + return container.querySelector( + '[aria-label="Edit friendly name for xai-demo/grok-4.6"]', + )!; + } + + function dialogInput(): HTMLInputElement { + return container.querySelector("dialog")! + .querySelector("input")!; + } + + function setInputValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! + .set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + } + + function dialogButton(label: string): HTMLButtonElement { + return [...container.querySelectorAll("dialog button")] + .find(button => button.textContent === label)!; + } + + test("only discovered rows expose Name while showing friendly and exact identities", async () => { + await mountModels(); + + expect(nameTrigger()).not.toBeNull(); + expect(container.querySelectorAll('[aria-label^="Edit friendly name for "]')).toHaveLength(2); + expect(container.querySelector('[aria-label="Edit friendly name for openai/gpt-5.5"]')).toBeNull(); + expect(container.querySelector('[aria-label="Edit friendly name for xai-demo/custom-one"]')).toBeNull(); + expect(container.textContent).toContain("Grok 4.6"); + expect(container.textContent).toContain("xai-demo/grok-4.6"); + expect([...container.querySelectorAll("code")].some(code => + code.textContent === "command-code/deepseek-deepseek-v4-flash" + )).toBe(true); + expect(container.textContent).toContain("Custom One"); + }); + + test("save and reset send exact payloads, reload the catalog, and restore trigger focus", async () => { + await mountModels(); + const trigger = nameTrigger(); + const fetchesBeforeSave = modelFetches; + + await act(async () => trigger.click()); + await act(async () => { + setInputValue(dialogInput(), " Grok Fast "); + dialogButton("Save").click(); + }); + await flush(); + + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Grok Fast" }]); + expect(modelFetches).toBeGreaterThan(fetchesBeforeSave); + expect(container.querySelector("dialog")).toBeNull(); + expect(container.textContent).toContain("Grok Fast"); + expect(testWindow.document.activeElement).toBe(trigger); + + await act(async () => nameTrigger().click()); + await act(async () => dialogButton("Reset name").click()); + await flush(); + + expect(mutationBodies[1]).toEqual({ modelId: "grok-4.6", displayName: null }); + expect(container.querySelector("dialog")).toBeNull(); + expect(container.textContent).toContain("xai-demo/grok-4.6"); + }); + + test("a server failure keeps the dialog and edited draft available for retry", async () => { + mutationFailure = "Catalog refresh failed"; + await mountModels(); + await act(async () => nameTrigger().click()); + await act(async () => { + setInputValue(dialogInput(), "Retry Name"); + dialogButton("Save").click(); + }); + await flush(); + + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Retry Name" }]); + expect(container.querySelector("dialog")).not.toBeNull(); + expect(dialogInput().value).toBe("Retry Name"); + expect(container.textContent).toContain("Catalog refresh failed"); + expect(testWindow.document.activeElement).toBe(dialogInput()); + }); + + test("a failed catalog reload after save keeps the dialog available for retry", async () => { + await mountModels(); + modelFetchFailure = "Catalog reload failed"; + await act(async () => nameTrigger().click()); + await act(async () => { + setInputValue(dialogInput(), "Retry Reload"); + dialogButton("Save").click(); + }); + await flush(); + + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Retry Reload" }]); + expect(container.querySelector("dialog")).not.toBeNull(); + expect(dialogInput().value).toBe("Retry Reload"); + expect(container.textContent).toContain("Failed to load models"); + expect(testWindow.document.activeElement).toBe(dialogInput()); + }); + + test("a pending save blocks duplicate mutations", async () => { + let releaseMutation!: () => void; + mutationGate = new Promise(resolve => { releaseMutation = resolve; }); + await mountModels(); + await act(async () => nameTrigger().click()); + const save = dialogButton("Save"); + + await act(async () => { + setInputValue(dialogInput(), "Grok Once"); + save.click(); + save.click(); + await Promise.resolve(); + }); + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Grok Once" }]); + expect(save.disabled).toBe(true); + + releaseMutation(); + await flush(); + expect(container.querySelector("dialog")).toBeNull(); + }); + + test("Cancel closes without mutation and restores focus to Name", async () => { + await mountModels(); + const trigger = nameTrigger(); + await act(async () => trigger.click()); + await act(async () => dialogButton("Cancel").click()); + await flush(); + + expect(mutationBodies).toHaveLength(0); + expect(container.querySelector("dialog")).toBeNull(); + expect(testWindow.document.activeElement).toBe(trigger); + }); +}); + +describe("discovered model display name dialog", () => { + const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; + let testWindow: Window; + let container: HTMLElement; + let root: Root | null; + + const model: ModelRow = { + provider: "xai-demo", + id: "grok-4.6", + namespaced: "xai-demo/grok-4.6", + disabled: false, + displayName: "Grok 4.6", + displayNameOverride: "Grok 4.6", + displayNameSource: "operator", + }; + + beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container as never); + root = null; + }); + + afterEach(async () => { + if (root) { + const mounted = root; + await act(async () => mounted.unmount()); + } + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + + async function renderDialog(options: { + saving?: boolean; + requestError?: string | null; + onSave?: (value: string) => void; + onReset?: () => void; + onClose?: () => void; + } = {}) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root ??= createRoot(container); + root.render( + + {})} + onReset={options.onReset ?? (() => {})} + onClose={options.onClose ?? (() => {})} + /> + , + ); + }); + } + + function setInputValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! + .set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + } + + test("opens with immutable identity and only the operator override in the input", async () => { + await renderDialog(); + + const dialog = container.querySelector("dialog")!; + const input = container.querySelector("input")!; + expect(dialog.open).toBe(true); + expect(dialog.textContent).toContain("xai-demo/grok-4.6"); + expect(dialog.textContent).toContain("Grok 4.6"); + expect(dialog.textContent).toContain("Your name"); + expect(input.value).toBe("Grok 4.6"); + expect(testWindow.document.activeElement).toBe(input); + }); + + test("validates before save and sends the trimmed safe draft", async () => { + const onSave = jest.fn(); + await renderDialog({ onSave }); + const input = container.querySelector("input")!; + const save = [...container.querySelectorAll("button")] + .find(button => button.textContent === "Save")!; + + await act(async () => { + setInputValue(input, "Bad/Name"); + save.click(); + }); + expect(container.textContent).toContain("Friendly name cannot contain /."); + expect(onSave).not.toHaveBeenCalled(); + + await act(async () => { + setInputValue(input, " Grok Fast "); + save.click(); + }); + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith("Grok Fast"); + }); + + test("keeps request errors visible and locks every closing action while saving", async () => { + const onClose = jest.fn(); + const onReset = jest.fn(); + await renderDialog({ saving: true, requestError: "Catalog refresh failed", onClose, onReset }); + + expect(container.textContent).toContain("Catalog refresh failed"); + const actionButtons = [...container.querySelectorAll("button")]; + expect(actionButtons.filter(button => button.tabIndex !== -1).every(button => button.disabled)).toBe(true); + + const dialog = container.querySelector("dialog")!; + await act(async () => { + dialog.dispatchEvent(new testWindow.Event("cancel", { bubbles: false, cancelable: true })); + container.querySelector(".modal-backdrop-dismiss")!.click(); + }); + expect(onClose).not.toHaveBeenCalled(); + expect(onReset).not.toHaveBeenCalled(); + }); + + test("a request failure does not mark a valid display name as invalid", async () => { + await renderDialog({ requestError: "Catalog refresh failed" }); + + const input = container.querySelector("input")!; + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(testWindow.document.activeElement).toBe(input); + }); + + test("focus returns to the editable name after a pending save fails", async () => { + await renderDialog({ saving: true }); + testWindow.document.body.tabIndex = -1; + testWindow.document.body.focus(); + expect(testWindow.document.activeElement).toBe(testWindow.document.body); + + await renderDialog({ requestError: "Catalog refresh failed" }); + + expect(testWindow.document.activeElement).toBe(container.querySelector("input")); + }); +});