diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index 431cf61b1ec6..add439abb49e 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -3,6 +3,7 @@ import { buildTraitsTriggerDisplay, getPromptControlledTraits, } from "@t3tools/client-runtime/model-traits"; +import { composerModelLabel } from "@t3tools/client-runtime/model-label"; import type { ModelOption } from "../lib/modelOptions"; import { resolveProviderOptionDescriptors } from "../lib/providerOptions"; import { AppText as Text } from "./ui/AppText"; @@ -70,7 +71,11 @@ export function ComposerModelControl(props: { /> ) } - label={label} + label={ + props.modelOption + ? composerModelLabel(props.modelOption.shortName ?? props.modelOption.label) + : label + } iconOnly={props.iconOnly} suffix={ summary ? ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4f1b92befaa3..281c4f461700 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -20,6 +20,7 @@ import { export type ModelOption = { readonly key: string; readonly label: string; + readonly shortName?: string; readonly subtitle: string; readonly providerKey: string; readonly providerLabel: string; @@ -326,6 +327,7 @@ export function buildModelOptions( options.set(key, { key, label: model.name, + ...(model.shortName ? { shortName: model.shortName } : {}), subtitle: model.subProvider ?? "", providerKey: provider.instanceId, providerLabel, @@ -391,6 +393,7 @@ export function buildModelOptions( options.set(key, { key, label: model?.name ?? fallbackModelSelection.model, + ...(model?.shortName ? { shortName: model.shortName } : {}), subtitle: model?.subProvider ?? "", providerKey: fallbackModelSelection.instanceId, providerLabel, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 65ceac5012ab..4a470e75a5fd 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -885,6 +885,8 @@ it.layer( }, ); const retainedModels = [defaultModel, customModel, currentModel]; + const presentedCurrentModel = { ...currentModel, shortName: "6.1 Sol" }; + const presentedRetainedModels = [defaultModel, customModel, presentedCurrentModel]; for (const restarted of [false, true]) { yield* Effect.gen(function* () { @@ -892,8 +894,13 @@ it.layer( assert.deepStrictEqual( (yield* registry.getProviders)[0]?.models, restarted - ? retainedModels - : [defaultModel, customModel, currentModel, removedModel], + ? presentedRetainedModels + : [ + defaultModel, + customModel, + presentedCurrentModel, + { ...removedModel, shortName: "Gemini 2.5 Pro" }, + ], ); yield* registry.refreshInstance(instance.instanceId); @@ -904,7 +911,7 @@ it.layer( yield* Ref.set(nextProvider, failedProvider); const afterFailure = yield* registry.refreshInstance(instance.instanceId); - assert.deepStrictEqual(afterFailure[0]?.models, retainedModels); + assert.deepStrictEqual(afterFailure[0]?.models, presentedRetainedModels); assert.deepStrictEqual( (yield* readProviderStatusCache(filePath))?.models, retainedModels, diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 97da44aa93ed..75deb49d9c85 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -24,6 +24,7 @@ import * as Semaphore from "effect/Semaphore"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; +import { withModelShortNames } from "./modelShortName.ts"; import * as ServerConfig from "../config.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import { @@ -1008,19 +1009,35 @@ export const layer = Layer.effect( ); }); + // Derived short names stay out of the registry state so cached and retained + // models never carry a value computed by an older rule. + const presentProviders = (providers: ReadonlyArray) => + providers.map(withModelShortNames); + return { - getProviders: Ref.get(providersRef), + getProviders: Ref.get(providersRef).pipe(Effect.map(presentProviders)), refresh: (provider?: ProviderDriverKind) => - refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), + refresh(provider).pipe( + Effect.catchCause(recoverRefreshFailure), + Effect.map(presentProviders), + ), refreshInstance: (instanceId: ProviderInstanceId) => - refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshInstance(instanceId).pipe( + Effect.catchCause(recoverRefreshFailure), + Effect.map(presentProviders), + ), refreshWorkspaceSnapshot: (input) => - refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), - refreshAfterUpdate, + refreshWorkspaceSnapshot(input).pipe( + Effect.catchCause(recoverRefreshFailure), + Effect.map(presentProviders), + ), + refreshAfterUpdate: (provider, selectedInstanceId) => + refreshAfterUpdate(provider, selectedInstanceId).pipe(Effect.map(presentProviders)), getProviderMaintenanceCapabilitiesForInstance, - setProviderMaintenanceActionState, + setProviderMaintenanceActionState: (input) => + setProviderMaintenanceActionState(input).pipe(Effect.map(presentProviders)), get streamChanges() { - return Stream.fromPubSub(changesPubSub); + return Stream.fromPubSub(changesPubSub).pipe(Stream.map(presentProviders)); }, } satisfies ProviderRegistry["Service"]; }), diff --git a/apps/server/src/provider/modelShortName.test.ts b/apps/server/src/provider/modelShortName.test.ts new file mode 100644 index 000000000000..27949a09cff7 --- /dev/null +++ b/apps/server/src/provider/modelShortName.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { modelShortName, withModelShortNames } from "./modelShortName.ts"; + +describe("modelShortName", () => { + it.each([ + ["Claude Opus 5.5", "claudeAgent", "Opus 5.5"], + ["GPT-5.4 Mini", "codex", "5.4 Mini"], + ["GPT-6.1 Sol", "opencode", "6.1 Sol"], + ["GPT-6.1-Sol", "opencode", "6.1 Sol"], + ["Grok 4.1 Fast Non-Reasoning", "opencode", undefined], + ["Grok 4.1 Fast Non-Reasoning", "grok", "4.1 Fast Non-Reasoning"], + ["GPT-5.3-Codex-Spark", "codex", "5.3 Codex Spark"], + ["Grok Build", "grok", "Build"], + ["Grok Build", "cursor", "Build"], + ["Grok Build", "opencode", undefined], + ["Gemini 3.1 Pro Preview Custom Tools", "opencode", undefined], + ["NVIDIA: Nemotron 3 Super", "opencode", "Nemotron 3 Super"], + ["Google: Gemini 3.1 Pro", "opencode", "Gemini 3.1 Pro"], + ["Google: 3.1 Pro", "opencode", undefined], + ["GPT-5.4 Mini", "pi", undefined], + ["GPT-5.5", "codex", undefined], + ["GPT-4o", "opencode", undefined], + ["Gemini 3 (High)", "antigravity", undefined], + ["Gemini 3.8 Flash (High)", "antigravity", "3.8 Flash (High)"], + ["gpt-5.5", "opencode", undefined], + ] as const)("shortens %s on %s to %s", (name, driver, expected) => { + expect(modelShortName(name, driver)).toBe(expected); + }); +}); + +describe("withModelShortNames", () => { + const model = (input: Partial & { name: string }): ServerProviderModel => ({ + slug: input.name.toLowerCase().replaceAll(" ", "-"), + isCustom: false, + capabilities: null, + ...input, + }); + const provider = (models: ReadonlyArray): ServerProvider => ({ + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + enabled: true, + installed: true, + version: "3.0.0", + status: "ready", + checkedAt: "2026-10-23T00:00:00Z", + auth: { status: "authenticated" }, + models, + skills: [], + slashCommands: [], + }); + + it("fills custom models and keeps an explicit shortName", () => { + const result = withModelShortNames( + provider([ + model({ name: "Claude Opus 5.5", shortName: "Opus" }), + model({ name: "Claude Sonnet 6", isCustom: true }), + ]), + ); + expect(result.models.map((entry) => entry.shortName)).toEqual(["Opus", "Sonnet 6"]); + }); +}); diff --git a/apps/server/src/provider/modelShortName.ts b/apps/server/src/provider/modelShortName.ts new file mode 100644 index 000000000000..f94a7cd67c9c --- /dev/null +++ b/apps/server/src/provider/modelShortName.ts @@ -0,0 +1,88 @@ +import type { ServerProvider } from "@t3tools/contracts"; + +const MODEL_FAMILIES: ReadonlyArray<{ + readonly prefix: string; + readonly nativeDrivers: ReadonlyArray; +}> = [ + { prefix: "Claude ", nativeDrivers: ["claudeAgent"] }, + { prefix: "GPT-", nativeDrivers: ["codex"] }, + { prefix: "Gemini ", nativeDrivers: ["antigravity"] }, + { prefix: "Muse ", nativeDrivers: ["muse"] }, + { prefix: "Grok ", nativeDrivers: ["grok", "cursor"] }, +]; + +// Words that describe a variant within a family rather than name a model. Off the +// family's native provider there is no icon saying which family "4 Pro" belongs to. +const VARIANT_WORDS = new Set([ + "pro", + "flash", + "mini", + "max", + "lite", + "nano", + "ultra", + "plus", + "fast", + "turbo", + "preview", + "latest", + "thinking", + "reasoning", + "build", + "image", + "vision", + "audio", + "live", + "custom", + "tools", + "exp", + "experimental", + "chat", + "tts", + "stt", + "non", + "transcribe", +]); + +const VERSION = /^v?\d+(?:\.\d+)*[a-z]?$/i; + +const VENDOR_PREFIX = /^[A-Z][\w.&\- ]{0,24}: (?=\S)/; + +export function modelShortName(name: string, driver: string): string | undefined { + const unprefixed = name.replace(VENDOR_PREFIX, ""); + const short = withoutFamily(unprefixed, driver) ?? (namesModel(unprefixed) ? unprefixed : name); + return short === name ? undefined : short; +} + +function withoutFamily(name: string, driver: string): string | undefined { + const family = MODEL_FAMILIES.find((candidate) => name.startsWith(candidate.prefix)); + if (!family) return undefined; + const rest = name.slice(family.prefix.length).trim(); + const named = nonVersionWords(rest); + if (named.length === 0) return undefined; + if (!family.nativeDrivers.includes(driver) && !namesModel(rest)) return undefined; + return /\s/.test(rest) ? rest : rest.replaceAll("-", " "); +} + +function nonVersionWords(text: string): ReadonlyArray { + return text + .replace(/\(.*?\)/g, " ") + .split(/[\s-]+/) + .filter((word) => word.length > 0 && !VERSION.test(word)); +} + +function namesModel(text: string): boolean { + return nonVersionWords(text).some((word) => !VARIANT_WORDS.has(word.toLowerCase())); +} + +export function withModelShortNames(provider: ServerProvider): ServerProvider { + let changed = false; + const models = provider.models.map((model) => { + if (model.shortName) return model; + const shortName = modelShortName(model.name, provider.driver); + if (!shortName) return model; + changed = true; + return { ...model, shortName }; + }); + return changed ? { ...provider, models } : provider; +} diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index 670ccd541bdb..e8d08570707b 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -99,7 +99,7 @@ describe("resolveContextWindowModelDisplayName", () => { }, modelOptionsByInstance, ), - ).toBe("5.6 Sol"); + ).toBe("GPT-5.6 Sol"); }); }); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 69c4fbbec5b6..3331e808d74f 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -8,7 +8,7 @@ import { resolveSelectableProviderInstanceEntry, type ProviderInstanceEntry, } from "../../providerInstances"; -import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; +import { getDisplayModelName, type ModelEsque } from "./providerIconUtils"; // Used when the provider did not report its cache TTL: Claude's 1h cache plus slack. const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000; @@ -96,7 +96,7 @@ export function resolveContextWindowModelDisplayName( .get(selection.instanceId) ?.find((model) => model.slug === selection.model); - return selectedModel ? getTriggerDisplayModelName(selectedModel) : selection.model; + return selectedModel ? getDisplayModelName(selectedModel) : selection.model; } export function formatContextWindowCompactionMessage( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index c169ac10e6b1..43dba71807b3 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -5,6 +5,7 @@ import { type ProviderDriverKind, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { composerModelLabel } from "@t3tools/client-runtime/model-label"; import { memo, useEffect, useState } from "react"; import { Badge } from "../ui/badge"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -13,11 +14,7 @@ import { cn } from "~/lib/utils"; import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { ChatGptSharingControl } from "./ChatGptSharingControl"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; -import { - ModelEsque, - getTriggerDisplayModelLabel, - getTriggerDisplayModelName, -} from "./providerIconUtils"; +import { ModelEsque, getDisplayModelName, getTriggerDisplayModelName } from "./providerIconUtils"; import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, @@ -74,12 +71,12 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ? undefined : selectedInstanceOptions[0]); const triggerTitle = selectedModel - ? getTriggerDisplayModelName(selectedModel) + ? composerModelLabel(getTriggerDisplayModelName(selectedModel)) : props.model === ANTIGRAVITY_DEFAULT_MODEL ? "Choose model" : props.model || "Choose model"; const triggerLabel = selectedModel - ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` + ? `${getDisplayModelName(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` : triggerTitle; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); @@ -157,12 +154,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: selection.model, options: props.modelOptionsByInstance.get(selection.instanceId) ?? [], }); + const unavailable = model?.isUnavailable ? " (Unavailable)" : ""; return { ...selection, entry, label: model - ? `${getTriggerDisplayModelName(model)}${model.isUnavailable ? " (Unavailable)" : ""}` + ? `${composerModelLabel(getTriggerDisplayModelName(model))}${unavailable}` : selection.model, + fullLabel: model ? `${getDisplayModelName(model)}${unavailable}` : selection.model, }; }); const multipleLabel = selectedEntries @@ -174,7 +173,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { .join(", ")}${selectedEntries.length > 2 ? `, ${selectedEntries.length - 2} more` : ""}` : undefined; const allModelNames = selectedEntries - ? selectedEntries.map((selection) => selection.label).join(", ") || "Choose models" + ? selectedEntries.map((selection) => selection.fullLabel).join(", ") || "Choose models" : undefined; const triggerTooltipContent = shortcutLabel ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}` @@ -194,7 +193,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { { + it.each([ + ["Nemotron 3.5 Super VL Free", "Nemotron 3.5 Super"], + ["Ox Alpha Free (Unlimited)", "Ox Alpha"], + ["DeepSeek V4 Flash Vision Exp", "DeepSeek V4 Flash"], + ["LongCat 2.5 Preview Free", "LongCat 2.5"], + ["4 Argon Contributor", "4 Argon Contributor"], + ["Nemotron 3 Nano Omni (free)", "Nemotron 3 Nano Omni"], + ["Nano Banana 2 Lite (Gemini 3.1 Flash Lite Image)", "Nano Banana 2 Lite"], + ["GPT-OSS (Vertex AI (OpenAI-compatible))", "GPT-OSS"], + ["NVIDIA: Nemotron 3.5 Lightning Free", "Nemotron 3.5 Lightning"], + ["3.1 Pro Preview Custom Tools", "3.1 Pro"], + ["Qwen3 235B-A22B Thinking 2507", "Qwen3 235B"], + ["Qwen3-Next 80B-A3B (Thinking)", "Qwen3-Next 80B"], + ["Nemotron 3 Nano Omni 30B A3B Reasoning", "Nemotron 3 Nano Omni"], + ["Qwen3 Coder 480B A35B Instruct Turbo", "Qwen3 Coder"], + ["Llama 4 Maverick 17B Instruct (US)", "Llama 4 Maverick"], + ["DeepSeek V4.1 Flash 1017 Spot", "DeepSeek V4.1 Flash"], + ["GLM Flash Latest (GLM 5.3 Flash)", "GLM Flash"], + ["Grok 4.20 (Non-Reasoning)", "Grok 4.20"], + ["4.1 Fast Non-Reasoning", "4.1 Fast"], + ["3.1 Flash Image Preview (Nano Banana 2)", "3.1 Flash Image"], + ["Gemini 2.5 Flash Preview (09/2025) – Thinking", "Gemini 2.5 Flash"], + ["Opus 5.5", "Opus 5.5"], + ["GPT-5.5", "GPT-5.5"], + ["Grok 4", "Grok 4"], + ["Thinking", "Thinking"], + ["(Preview)", "(Preview)"], + ])("labels %s as %s", (name, label) => { + expect(composerModelLabel(name)).toBe(label); + }); +}); diff --git a/packages/client-runtime/src/modelLabel.ts b/packages/client-runtime/src/modelLabel.ts new file mode 100644 index 000000000000..143c609867ee --- /dev/null +++ b/packages/client-runtime/src/modelLabel.ts @@ -0,0 +1,73 @@ +const VENDOR_PREFIX = /^[^:()]+:\s+(?=\S)/; +const PARENTHETICAL = /\s*\([^()]*\)/g; + +const VARIANT_PHRASES: ReadonlyArray> = [ + ["custom", "tools"], + ...[ + "free", + "preview", + "exp", + "experimental", + "instruct", + "chat", + "base", + "turbo", + "spot", + "latest", + "new", + "thinking", + "reasoning", + "non-reasoning", + "distilled", + "fp8", + "fp16", + "bf16", + "int4", + "int8", + "vl", + "vision", + ].map((word) => [word]), +]; + +const SEPARATOR = /^[^\p{L}\p{N}]+$/u; +const STAMP = /^\d{4,}(?:-\d{2}){0,2}$/; +const SIZE_PART = /^(?:\d+x)?a?\d+(?:\.\d+)?[bkmte]$/i; +const VERSION = /^v?\d+(?:\.\d+)*[a-z]?$/i; + +const isSize = (token: string) => token.split("-").every((part) => SIZE_PART.test(part)); + +function trailingVariantLength(tokens: ReadonlyArray): number { + const last = tokens.at(-1)!; + if (STAMP.test(last) || SEPARATOR.test(last)) return 1; + const phrase = VARIANT_PHRASES.find( + (words) => + words.length < tokens.length && + words.every( + (word, index) => tokens[tokens.length - words.length + index]!.toLowerCase() === word, + ), + ); + return phrase?.length ?? 0; +} + +export function composerModelLabel(displayName: string): string { + let name = displayName.trim().replace(VENDOR_PREFIX, ""); + for (let previous = ""; previous !== name;) { + previous = name; + name = name.replace(PARENTHETICAL, ""); + } + const tokens = name.split(/\s+/).filter(Boolean); + let size: string | undefined; + while (tokens.length > 1) { + const variantLength = trailingVariantLength(tokens); + if (variantLength > 0) { + tokens.splice(-variantLength); + } else if (isSize(tokens.at(-1)!)) { + size = tokens.pop()!.split("-")[0]; + } else { + break; + } + } + const distinguished = tokens.slice(1).some((token) => !VERSION.test(token) && !isSize(token)); + if (size && !distinguished) tokens.push(size); + return tokens.join(" ") || displayName; +}