Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion apps/mobile/src/components/ComposerModelControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ? (
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,15 +885,22 @@ 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* () {
const registry = yield* ProviderRegistry.ProviderRegistry;
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);
Expand All @@ -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,
Expand Down
31 changes: 24 additions & 7 deletions apps/server/src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ServerProvider>) =>
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"];
}),
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/provider/modelShortName.test.ts
Original file line number Diff line number Diff line change
@@ -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<ServerProviderModel> & { name: string }): ServerProviderModel => ({
slug: input.name.toLowerCase().replaceAll(" ", "-"),
isCustom: false,
capabilities: null,
...input,
});
const provider = (models: ReadonlyArray<ServerProviderModel>): 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"]);
});
});
88 changes: 88 additions & 0 deletions apps/server/src/provider/modelShortName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { ServerProvider } from "@t3tools/contracts";

const MODEL_FAMILIES: ReadonlyArray<{
readonly prefix: string;
readonly nativeDrivers: ReadonlyArray<string>;
}> = [
{ 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<string> {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe("resolveContextWindowModelDisplayName", () => {
},
modelOptionsByInstance,
),
).toBe("5.6 Sol");
).toBe("GPT-5.6 Sol");
});
});

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/ContextWindowMeter.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 11 additions & 10 deletions apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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}`
Expand All @@ -194,7 +193,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
<PopoverTrigger
render={
<ComposerControl
aria-label={props.triggerAriaLabel ?? allModelNames}
aria-label={
props.triggerAriaLabel ?? props.triggerLabel ?? allModelNames ?? triggerLabel
}
size={size}
data-chat-provider-model-picker="true"
className={cn(
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,10 @@
"./model-traits": {
"types": "./src/modelTraits.ts",
"default": "./src/modelTraits.ts"
},
"./model-label": {
"types": "./src/modelLabel.ts",
"default": "./src/modelLabel.ts"
}
},
"scripts": {
Expand Down
Loading
Loading