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
8 changes: 8 additions & 0 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-fable-5",
name: "Claude Fable 5",
shortName: "Fable 5",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
Expand Down Expand Up @@ -87,6 +88,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-opus-4-8",
name: "Claude Opus 4.8",
shortName: "Opus 4.8",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
Expand Down Expand Up @@ -114,6 +116,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-opus-4-7",
name: "Claude Opus 4.7",
shortName: "Opus 4.7",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
Expand All @@ -140,6 +143,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-opus-4-6",
name: "Claude Opus 4.6",
shortName: "Opus 4.6",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
Expand Down Expand Up @@ -173,6 +177,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-opus-4-5",
name: "Claude Opus 4.5",
shortName: "Opus 4.5",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
Expand All @@ -196,6 +201,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-sonnet-5",
name: "Claude Sonnet 5",
shortName: "Sonnet 5",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
Expand Down Expand Up @@ -226,6 +232,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
shortName: "Sonnet 4.6",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
Expand Down Expand Up @@ -255,6 +262,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-haiku-4-5",
name: "Claude Haiku 4.5",
shortName: "Haiku 4.5",
isCustom: false,
capabilities: createModelCapabilities({
optionDescriptors: [
Expand Down
39 changes: 38 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,43 @@
import { assert, it } from "@effect/vitest";

import { mapCodexModelCapabilities } from "./CodexProvider.ts";
import {
mapCodexModelCapabilities,
parseCodexModelListResponse,
toCodexShortName,
} from "./CodexProvider.ts";

it("derives concise names for Codex-owned GPT models", () => {
assert.strictEqual(toCodexShortName("GPT-5.6-Sol"), "5.6 Sol");
assert.strictEqual(toCodexShortName("GPT-5.6-Terra"), "5.6 Terra");
assert.strictEqual(toCodexShortName("GPT-5.4-Mini"), "5.4 Mini");
assert.strictEqual(toCodexShortName("GPT-5.5"), "5.5");
assert.strictEqual(toCodexShortName("GPT-4o"), "4o");
assert.strictEqual(toCodexShortName("GPT-4o-Mini"), "4o Mini");
assert.strictEqual(toCodexShortName("o3"), "o3");
});

it("derives name and shortName from a raw catalog display name", () => {
const [model] = parseCodexModelListResponse({
data: [
{
additionalSpeedTiers: [],
defaultReasoningEffort: "medium",
defaultServiceTier: null,
description: "Test model",
displayName: "gpt-5.4-mini",
hidden: false,
id: "gpt-5.4-mini",
isDefault: false,
model: "gpt-5.4-mini",
serviceTiers: [],
supportedReasoningEfforts: [],
},
],
});

assert.strictEqual(model?.name, "GPT-5.4-Mini");
assert.strictEqual(model?.shortName, "5.4 Mini");
});

it("maps current Codex model capability fields", () => {
const capabilities = mapCodexModelCapabilities({
Expand Down
29 changes: 22 additions & 7 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,30 @@ const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string =>
.replace(/-([a-z])/g, (_, c) => "-" + c.toUpperCase());
};

function parseCodexModelListResponse(
export const toCodexShortName = (name: string): string => {
const match = /^GPT-(\d+(?:\.\d+)*[a-z]?)(?:(?:[-\s]+)(.+))?$/iu.exec(name.trim());
const version = match?.[1];
if (!version) {
return name;
}
const suffix = match[2]?.trim().replace(/[-_]+/gu, " ");
return suffix ? `${version} ${suffix}` : version;
};

export function parseCodexModelListResponse(
response: CodexSchema.V2ModelListResponse,
): ReadonlyArray<ServerProviderModel> {
return response.data.map((model) => ({
slug: model.model,
name: toDisplayName(model),
isCustom: false,
capabilities: mapCodexModelCapabilities(model),
}));
return response.data.map((model) => {
const name = toDisplayName(model);
const shortName = toCodexShortName(name);
return {
slug: model.model,
name,
...(shortName !== name ? { shortName } : {}),
isCustom: false,
capabilities: mapCodexModelCapabilities(model),
};
});
}

function appendCustomCodexModels(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
);
const fable5 = status.models.find((model) => model.slug === "claude-fable-5");
assert.strictEqual(fable5?.name, "Claude Fable 5");
assert.strictEqual(fable5?.shortName, "Fable 5");
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
Expand Down
30 changes: 11 additions & 19 deletions apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
import { memo } from "react";
import { CheckIcon, StarIcon } from "lucide-react";
import {
getDisplayModelName,
getTriggerDisplayModelLabel,
type ModelEsque,
PROVIDER_ICON_BY_PROVIDER,
} from "./providerIconUtils";
import { getDisplayModelName, type ModelEsque } from "./modelDisplayNames";
import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
import { ComboboxItem } from "../ui/combobox";
import { Button } from "../ui/button";
import { Kbd } from "../ui/kbd";
Expand All @@ -26,12 +22,10 @@ export const ModelListRow = memo(function ModelListRow(props: {
* "Codex Personal" render with their user-authored label.
*/
providerDisplayName: string;
providerAccentColor?: string | undefined;
isFavorite: boolean;
isSelected: boolean;
showProvider: boolean;
preferShortName?: boolean;
useTriggerLabel?: boolean;
showNewBadge?: boolean;
jumpLabel?: string | null;
disabledReason?: string | null;
Expand All @@ -50,26 +44,24 @@ export const ModelListRow = memo(function ModelListRow(props: {
disabled={Boolean(props.disabledReason)}
contentClassName="flex w-full items-center gap-3"
className={cn(
"group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]",
"data-highlighted:bg-muted/56 data-selected:bg-transparent data-selected:text-foreground data-selected:ring-0",
"group relative my-0.5 w-full !min-w-0 max-w-full cursor-pointer rounded-sm px-2 py-2 transition-[background-color,box-shadow,color]",
"data-highlighted:bg-muted/56 data-selected:bg-blue-500/10 data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-blue-500/15",
props.disabledReason &&
"data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent",
)}
>
<div className="min-w-0 flex-1 text-left">
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 truncate text-xs font-medium leading-snug">
{props.useTriggerLabel
? getTriggerDisplayModelLabel(props.model)
: getDisplayModelName(
props.model,
props.preferShortName ? { preferShortName: true } : undefined,
)}
<div className="min-w-0 truncate text-sm font-medium leading-snug">
{getDisplayModelName(
props.model,
props.preferShortName ? { preferShortName: true } : undefined,
)}
</div>
{props.isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null}
{props.showNewBadge ? (
<span
className="shrink-0 rounded border border-amber-500/35 bg-amber-500/15 px-0.5 py-px text-[10px] font-bold uppercase leading-none tracking-wide text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/12 dark:text-amber-200"
className="shrink-0 rounded-sm border border-amber-500/35 bg-amber-500/15 px-0.5 py-px text-[10px] font-bold uppercase leading-none tracking-wide text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/12 dark:text-amber-200"
aria-label="New model"
>
New
Expand Down Expand Up @@ -97,7 +89,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
size="icon-xs"
variant="ghost"
className={cn(
"-mr-1 shrink-0 text-muted-foreground/70 opacity-64 transition-[color,opacity] hover:text-foreground hover:opacity-100 group-hover:opacity-100",
"-mr-1 shrink-0 rounded-sm text-muted-foreground/70 opacity-64 transition-[color,opacity] before:rounded-[calc(var(--radius-sm)-1px)] hover:text-foreground hover:opacity-100 group-hover:opacity-100",
props.isFavorite && "text-foreground opacity-100",
)}
onClick={(event) => {
Expand Down
65 changes: 53 additions & 12 deletions apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ModelPickerSidebar } from "./ModelPickerSidebar";
import { isModelPickerNewModel } from "./modelPickerModelHighlights";
import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxListVirtualized } from "../ui/combobox";
import { ModelEsque } from "./providerIconUtils";
import { ModelEsque } from "./modelDisplayNames";
import {
modelPickerJumpCommandForIndex,
modelPickerJumpIndexFromCommand,
Expand All @@ -37,7 +37,6 @@ type ModelPickerItem = {
instanceId: ProviderInstanceId;
driverKind: ProviderDriverKind;
instanceDisplayName: string;
instanceAccentColor?: string | undefined;
continuationGroupKey?: string | undefined;
};

Expand Down Expand Up @@ -210,7 +209,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
instanceId,
driverKind: entry.driverKind,
instanceDisplayName: entry.displayName,
...(entry.accentColor ? { instanceAccentColor: entry.accentColor } : {}),
...(entry.continuationGroupKey
? { continuationGroupKey: entry.continuationGroupKey }
: {}),
Expand Down Expand Up @@ -472,6 +470,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS;
}, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]);

const showProviderForEveryRow = isSearching || selectedInstanceId === "favorites";
// Rows with a sub-provider render the taller two-line layout even in
// single-provider tabs, so size the estimate for them or the list
// mis-measures on first paint.
const hasSubProviderRows = useMemo(
() => filteredModels.some((model) => Boolean(model.subProvider)),
[filteredModels],
);

useEffect(() => {
const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented || event.repeat) {
Expand Down Expand Up @@ -504,6 +511,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
};
}, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]);

// LegendList only re-renders a row when its data item or `extraData`
// changes — a new `renderItem` closure alone is ignored. Everything the
// row render reads from surrounding state must therefore be represented
// here, or rows that keep their list position render stale output.
const listExtraData = useMemo(
() => ({
favoritesSet,
isSearching,
selectedInstanceId,
activeModelKey: `${props.activeInstanceId}:${props.model}`,
filteredModelByKey,
getModelDisabledReason,
modelJumpLabelByKey,
toggleFavorite,
}),
[
favoritesSet,
isSearching,
selectedInstanceId,
props.activeInstanceId,
props.model,
filteredModelByKey,
getModelDisabledReason,
modelJumpLabelByKey,
toggleFavorite,
],
);

useLayoutEffect(() => {
setShowTopScrollFade(false);
setShowBottomScrollFade(filteredModelKeys.length > 5);
Expand Down Expand Up @@ -619,11 +654,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {

{/* Model list */}
<div className="relative min-h-0 flex-1 overflow-hidden">
<ComboboxListVirtualized className="model-picker-list size-full min-w-0 p-0">
<ComboboxListVirtualized className="not-empty:p-0 size-full min-w-0">
<LegendList<string>
ref={modelListRef}
data={filteredModelKeys}
extraData={favoritesSet}
extraData={listExtraData}
keyExtractor={(modelKey) => modelKey}
renderItem={({ item: modelKey, index }) => {
const model = filteredModelByKey.get(modelKey);
Expand All @@ -640,26 +675,32 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
instanceId={model.instanceId}
driverKind={model.driverKind}
providerDisplayName={model.instanceDisplayName}
providerAccentColor={model.instanceAccentColor}
isFavorite={favoritesSet.has(modelKey)}
isSelected={modelKey === `${props.activeInstanceId}:${props.model}`}
showProvider
preferShortName={!isLocked}
useTriggerLabel={false}
isSelected={modelKey === listExtraData.activeModelKey}
showProvider={
// When browsing a single provider's tab the sidebar
// already says which provider these models belong
// to, so the per-row provider line is redundant.
// Keep it in mixed-provider views (favorites,
// search) and for models that carry a sub-provider
// distinction (e.g. "Claude · Bedrock").
showProviderForEveryRow || Boolean(model.subProvider)
}
preferShortName
showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)}
jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
disabledReason={disabledReason}
onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)}
/>
);
}}
estimatedItemSize={60}
estimatedItemSize={showProviderForEveryRow || hasSubProviderRows ? 60 : 40}
drawDistance={480}
recycleItems
onLayout={updateModelListScrollFades}
onScroll={updateModelListScrollFades}
className={cn(
"scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]",
"model-picker-list scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]",
showTopScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]",
showBottomScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]",
)}
Expand Down
9 changes: 3 additions & 6 deletions apps/web/src/components/chat/ModelPickerSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
data-model-picker-sidebar="true"
>
<div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div
ref={sidebarContentRef}
className="relative flex min-h-full flex-col gap-1 px-1 pb-1 pt-0.5"
>
<div ref={sidebarContentRef} className="relative flex min-h-full flex-col gap-1 p-1">
{selectedIndicatorTop !== null ? (
<div
data-model-picker-selected-indicator="true"
Expand All @@ -125,7 +122,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
render={
<button
className={cn(
"relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-muted",
"relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-sm transition-colors hover:bg-muted",
)}
onClick={() => handleSelect("favorites")}
type="button"
Expand Down Expand Up @@ -172,7 +169,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
<button
data-model-picker-provider={entry.instanceId}
className={cn(
"relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-muted",
"relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-sm transition-colors hover:bg-muted",
isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",
)}
data-provider-accent-color={entry.accentColor}
Expand Down
Loading
Loading