🏷️ fix: Model Spec Menu Label and Agent Avatar Fallbacks (#14887)

* 🏷️ fix: Model Spec Menu Label and Agent Avatar Fallbacks

The selector's header already falls back from a spec's `label` to its
`name` (`getSelectedValueText`), but the menu item and search result
render `spec.label` bare. A spec persisted without a label therefore
shows its name in the header while its row in the list is blank —
selectable, but unlabeled.

Specs targeting an agent had a related gap: the chat landing resolves
the agent and shows its avatar, while the selector resolved icons from
the spec/preset/endpoint only. An agent with an avatar still rendered a
generic endpoint icon in the menu unless `iconURL` was set by hand.

- Fall back to `spec.name` when `label` is absent or empty, matching the
  header.
- Resolve the target agent's avatar as the icon when the spec defines
  none of its own; an explicit `iconURL` still wins.

`getSpecAgentAvatarURL` returns the avatar as a primitive so the
memoized `SpecIcon` compares a string rather than the identity of the
agents map, and both call sites already consume `useModelSelectorContext`,
so reading `agentsMap` adds no new subscription.

* 🔁 refactor: Normalize Spec Labels on Ingest, Harden Agent Avatar Lookup

Addresses the review findings, which fell into two patterns rather than
four independent bugs.

The label fallback was applied per render site, but the spec list feeds
five consumers: the menu row, the search row, `filterItems`, the
spec/endpoint discriminator in `SearchResults`, and `getSelectedIcon`.
Guarding two of them left `filterItems` throwing on `label.toLowerCase()`
and the discriminator misclassifying a label-less spec as an endpoint —
reproducing, inside the fix, the exact inconsistency the fix targeted.

Normalize once on ingest instead: `normalizeModelSpecs` fills a missing
label from the name where the selector derives its spec list, so every
consumer works from a complete spec and the per-site guards are removed.
It returns the original array and the original spec objects when nothing
needs filling in, so memoized consumers see no new identities.

The avatar helper had the same shape of problem — a second resolution
path that skipped what the existing one already handled:

- Resolve via `getAgentAvatarUrl`, which supports agents persisting
  `avatar` as a URL string rather than an object.
- Gate on `isAgentsEndpoint`. `tModelSpecPresetSchema` permits `agent_id`
  alongside any endpoint, so a leftover id on a non-agent spec would
  otherwise surface an unrelated agent's identity.
- Thread the avatar through `getSelectedIcon`, so the selector trigger
  keeps the agent avatar after selection instead of reverting to the
  generic endpoint icon.

* 🩹 fix: Treat Empty Spec Icon Fields as Unset

`??` only skips null and undefined, so an `iconURL: ''` — what a form-backed
config writer persists for an untouched field — stopped the chain and
suppressed both the agent avatar and the endpoint icon, leaving the generic
fallback.

Resolve the first non-empty candidate instead. This matches
`applyModelSpecPreset`, which already ignores an empty `iconURL`, and keeps
`showIconInMenu` as the explicit way to render no icon.

* 🔁 refactor: Normalize Spec Labels at the Query Boundary, Cover Favorites

The context-level normalization missed a parallel consumer: the
favorites sidebar reads `startupConfig.modelSpecs.list` directly, so a
pinned label-less spec rendered a blank row and its icon ignored the
agent avatar — the same two gaps this PR fixes in the selector.

Move normalization to the true shared boundary instead of adding
another per-consumer call: `useGetStartupConfig` normalizes in its query
function, once per fetch, cached — so the selector, search, mentions,
favorites, and provider-key reachability all read complete specs. The
`ModelSelectorContext` call is removed as redundant.

- `normalizeModelSpecs` is now a single pass with a lazy copy (allocated
  only on the first incomplete spec), replacing the `some` + `map`
  double scan.
- `FavoriteItem` threads `agentAvatarURL` into `SpecIcon`; the list
  resolves it per spec from the agents map it already holds, passed as a
  primitive so memoization is unaffected.
This commit is contained in:
Danny Avila 2026-08-16 11:04:25 -04:00 • committed by GitHub
parent 06bf324cf0
commit ed081964d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 291 additions and 16 deletions

View file

@ -49,8 +49,9 @@ function ModelSelectorContent() {
selectedValues,
modelSpecs,
endpointsConfig,
agentsMap,
}),
[mappedEndpoints, selectedValues, modelSpecs, endpointsConfig],
[mappedEndpoints, selectedValues, modelSpecs, endpointsConfig, agentsMap],
);
const selectedDisplayValue = useMemo(
() =>

View file

@ -68,6 +68,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
const localize = useLocalize();
const { announcePolite } = useLiveAnnouncer();
const modelSpecs = useMemo(() => {
/** Labels are normalized at the startup-config query boundary. */
const specs = startupConfig?.modelSpecs?.list ?? [];
if (!agentsMap) {
return specs;

View file

@ -5,9 +5,9 @@ import type { TModelSpec } from 'librechat-data-provider';
import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import { cn, getSpecAgentAvatarURL } from '~/utils';
import SpecDescription from './SpecDescription';
import SpecIcon from './SpecIcon';
import { cn } from '~/utils';
interface ModelSpecItemProps {
spec: TModelSpec;
@ -19,9 +19,10 @@ interface ModelSpecItemProps {
export function ModelSpecItem({ spec, isSelected, posInSet, setSize }: ModelSpecItemProps) {
const localize = useLocalize();
const { handleSelectSpec, endpointsConfig } = useModelSelectorContext();
const { handleSelectSpec, endpointsConfig, agentsMap } = useModelSelectorContext();
const { isFavoriteSpec, toggleFavoriteSpec } = useFavorites();
const { showIconInMenu = true } = spec;
const agentAvatarURL = getSpecAgentAvatarURL(spec, agentsMap);
const { ref: itemRef, isActive } = useIsActiveItem<HTMLDivElement>();
@ -49,7 +50,11 @@ export function ModelSpecItem({ spec, isSelected, posInSet, setSize }: ModelSpec
>
{showIconInMenu && (
<div className="flex-shrink-0">
<SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />
<SpecIcon
currentSpec={spec}
endpointsConfig={endpointsConfig}
agentAvatarURL={agentAvatarURL}
/>
</div>
)}
<div className="flex min-w-0 flex-col gap-1">

View file

@ -8,9 +8,9 @@ import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import { shouldRenderEndpointOption } from '../utils';
import { cn, getSpecAgentAvatarURL } from '~/utils';
import SpecDescription from './SpecDescription';
import SpecIcon from './SpecIcon';
import { cn } from '~/utils';
interface SearchResultsProps {
results: (TModelSpec | Endpoint)[] | null;
@ -25,6 +25,7 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
handleSelectModel,
handleSelectEndpoint,
endpointsConfig,
agentsMap,
} = useModelSelectorContext();
const {
@ -78,7 +79,11 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
>
{(spec.showIconInMenu ?? true) && (
<div className="flex-shrink-0">
<SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />
<SpecIcon
currentSpec={spec}
endpointsConfig={endpointsConfig}
agentAvatarURL={getSpecAgentAvatarURL(spec, agentsMap)}
/>
</div>
)}
<div className="flex min-w-0 flex-col gap-1">

View file

@ -10,12 +10,14 @@ import { isImageURL } from '~/utils/icons';
interface SpecIconProps {
currentSpec: TModelSpec;
endpointsConfig: TEndpointsConfig;
/** Avatar of the agent this spec targets, used when the spec defines no icon of its own. */
agentAvatarURL?: string;
}
type IconType = (props: IconMapProps) => React.JSX.Element;
const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) => {
const iconURL = getModelSpecIconURL(currentSpec);
const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig, agentAvatarURL }) => {
const iconURL = getModelSpecIconURL(currentSpec, agentAvatarURL);
const endpoint = currentSpec.preset?.endpoint;
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });

View file

@ -87,4 +87,73 @@ describe('SpecIcon', () => {
expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown');
});
it("renders the agent's avatar when the spec defines no icon of its own", () => {
const currentSpec = {
name: 'agent-spec',
label: 'Agent Spec',
preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_abc' },
} as TModelSpec;
render(
<SpecIcon
currentSpec={currentSpec}
endpointsConfig={endpointsConfig}
agentAvatarURL="/images/agent-avatar.png"
/>,
);
expect(screen.getByTestId('url-icon')).toHaveAttribute(
'data-icon-url',
'/images/agent-avatar.png',
);
});
/**
* Form-authored specs persist untouched icon fields as empty strings, which
* must not suppress the avatar the way a real icon would.
*/
it('treats empty icon fields as unset and still uses the agent avatar', () => {
const currentSpec = {
name: 'agent-spec',
label: 'Agent Spec',
iconURL: '',
preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_abc', iconURL: '' },
} as unknown as TModelSpec;
render(
<SpecIcon
currentSpec={currentSpec}
endpointsConfig={endpointsConfig}
agentAvatarURL="/images/agent-avatar.png"
/>,
);
expect(screen.getByTestId('url-icon')).toHaveAttribute(
'data-icon-url',
'/images/agent-avatar.png',
);
});
it('prefers an explicit spec icon over the agent avatar', () => {
const currentSpec = {
name: 'agent-spec',
label: 'Agent Spec',
iconURL: '/assets/explicit-logo.svg',
preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_abc' },
} as TModelSpec;
render(
<SpecIcon
currentSpec={currentSpec}
endpointsConfig={endpointsConfig}
agentAvatarURL="/images/agent-avatar.png"
/>,
);
expect(screen.getByTestId('url-icon')).toHaveAttribute(
'data-icon-url',
'/assets/explicit-logo.svg',
);
});
});

View file

@ -10,6 +10,7 @@ import type {
import type { useLocalize } from '~/hooks';
import SpecIcon from '~/components/Chat/Menus/Endpoints/components/SpecIcon';
import { Endpoint, SelectedValues } from '~/common';
import { getSpecAgentAvatarURL } from '~/utils';
export function filterItems<
T extends {
@ -124,11 +125,13 @@ export function getSelectedIcon({
selectedValues,
modelSpecs,
endpointsConfig,
agentsMap,
}: {
mappedEndpoints: Endpoint[];
selectedValues: SelectedValues;
modelSpecs: TModelSpec[];
endpointsConfig: TEndpointsConfig;
agentsMap?: TAgentsMap;
}): React.ReactNode | null {
const { endpoint, model, modelSpec } = selectedValues;
@ -144,6 +147,7 @@ export function getSelectedIcon({
return React.createElement(SpecIcon, {
currentSpec: spec,
endpointsConfig,
agentAvatarURL: getSpecAgentAvatarURL(spec, agentsMap),
});
}

View file

@ -37,6 +37,8 @@ type SpecFavoriteProps = FavoriteItemBaseProps & {
item: TModelSpec;
onSelectSpec?: (spec: TModelSpec) => void;
endpointsConfig?: TEndpointsConfig;
/** Avatar of the agent the spec targets, used when the spec defines no icon of its own. */
agentAvatarURL?: string;
};
type FavoriteItemProps = AgentFavoriteProps | ModelFavoriteProps | SpecFavoriteProps;
@ -91,7 +93,11 @@ export default function FavoriteItem(props: FavoriteItemProps) {
if (props.type === 'spec') {
return (
<div className="mr-2 h-5 w-5">
<SpecIcon currentSpec={props.item} endpointsConfig={props.endpointsConfig} />
<SpecIcon
currentSpec={props.item}
endpointsConfig={props.endpointsConfig}
agentAvatarURL={props.agentAvatarURL}
/>
</div>
);
}

View file

@ -17,6 +17,7 @@ import {
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
import useSelectMention from '~/hooks/Input/useSelectMention';
import { getSpecAgentAvatarURL } from '~/utils';
import FavoriteItem from './FavoriteItem';
import store from '~/store';
@ -458,6 +459,7 @@ function FavoritesList({
onSelectSpec={onSelectSpec}
onRemoveFocus={handleRemoveFocus}
endpointsConfig={endpointsConfig}
agentAvatarURL={getSpecAgentAvatarURL(spec, agentsMap)}
/>
</DraggableFavoriteItem>
);

View file

@ -18,7 +18,9 @@ jest.mock('~/hooks', () => ({
jest.mock('~/components/Chat/Menus/Endpoints/components/SpecIcon', () => ({
__esModule: true,
default: () => <span data-testid="spec-icon" />,
default: ({ agentAvatarURL }: { agentAvatarURL?: string }) => (
<span data-testid="spec-icon" data-agent-avatar-url={agentAvatarURL ?? ''} />
),
}));
jest.mock('~/components/Endpoints/MinimalIcon', () => ({
@ -108,6 +110,17 @@ describe('FavoriteItem', () => {
expect(screen.getByTestId('spec-icon')).toBeInTheDocument();
});
/** The pinned row must show the same agent avatar as the selector row. */
it('threads the agent avatar through to SpecIcon', () => {
render(
<FavoriteItem type="spec" item={baseSpec} agentAvatarURL="/images/agent-avatar.png" />,
);
expect(screen.getByTestId('spec-icon')).toHaveAttribute(
'data-agent-avatar-url',
'/images/agent-avatar.png',
);
});
it('has aria-label formatted as "<label> (com_ui_model_spec)"', () => {
render(<FavoriteItem type="spec" item={baseSpec} />);
expect(

View file

@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { normalizeStartupConfigModelSpecs } from '~/utils';
import store from '~/store';
export const useGetEndpointsQuery = <TData = t.TEndpointsConfig>(
@ -60,7 +61,14 @@ export const useGetStartupConfig = (
const user = useRecoilValue<t.TUser | undefined>(store.user);
return useQuery<t.TStartupConfig>(
startupConfigKey(!!user, options?.context),
() => dataService.getStartupConfig({ context: options?.context }),
/**
* Normalized at the query boundary — once per fetch, cached — so every
* consumer of `modelSpecs.list` reads complete specs without per-site guards.
*/
() =>
dataService
.getStartupConfig({ context: options?.context })
.then(normalizeStartupConfigModelSpecs),
{
staleTime: Infinity,
refetchOnWindowFocus: false,

View file

@ -1,6 +1,12 @@
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
import type { TEndpointsConfig, TConfig } from 'librechat-data-provider';
import { getAvailableEndpoints, getEndpointsFilter, mapEndpoints } from './endpoints';
import type { TEndpointsConfig, TConfig, TModelSpec } from 'librechat-data-provider';
import {
getAvailableEndpoints,
getEndpointsFilter,
getSpecAgentAvatarURL,
mapEndpoints,
normalizeModelSpecs,
} from './endpoints';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { type: undefined, iconURL: 'openAI_icon.png', order: 0 },
@ -83,3 +89,62 @@ describe('mapEndpoints', () => {
expect(mapEndpoints(mockEndpointsConfig)).toEqual(expectedOrder);
});
});
describe('normalizeModelSpecs', () => {
const spec = (overrides: Partial<TModelSpec>): TModelSpec =>
({ name: 'spec-name', label: 'Spec Label', preset: {}, ...overrides }) as TModelSpec;
it('returns the same array reference when every spec has a label', () => {
const specs = [spec({}), spec({ name: 'other', label: 'Other' })];
expect(normalizeModelSpecs(specs)).toBe(specs);
});
it('fills a missing label from the name', () => {
const specs = [spec({ label: undefined as unknown as string })];
expect(normalizeModelSpecs(specs)[0].label).toBe('spec-name');
});
it('fills an empty label from the name', () => {
expect(normalizeModelSpecs([spec({ label: '' })])[0].label).toBe('spec-name');
});
/** Memoized consumers must not see a new identity for specs that were already valid. */
it('preserves the identity of specs that already have a label', () => {
const valid = spec({});
const invalid = spec({ name: 'blank', label: '' });
const result = normalizeModelSpecs([valid, invalid]);
expect(result[0]).toBe(valid);
expect(result[1]).not.toBe(invalid);
});
});
describe('getSpecAgentAvatarURL', () => {
const agentsMap = {
agent_obj: { id: 'agent_obj', avatar: { filepath: '/images/obj.png', source: 'local' } },
agent_str: { id: 'agent_str', avatar: '/images/legacy.png' },
} as unknown as Parameters<typeof getSpecAgentAvatarURL>[1];
const agentSpec = (agent_id?: string, endpoint: string = EModelEndpoint.agents): TModelSpec =>
({ name: 'n', label: 'l', preset: { endpoint, agent_id } }) as TModelSpec;
it('resolves an object avatar', () => {
expect(getSpecAgentAvatarURL(agentSpec('agent_obj'), agentsMap)).toBe('/images/obj.png');
});
/** Agents persisted before the object format still store the URL as a string. */
it('resolves a legacy string avatar', () => {
expect(getSpecAgentAvatarURL(agentSpec('agent_str'), agentsMap)).toBe('/images/legacy.png');
});
/** A leftover agent_id must not surface an unrelated agent on a non-agent spec. */
it('ignores agent_id when the spec targets another endpoint', () => {
expect(
getSpecAgentAvatarURL(agentSpec('agent_obj', EModelEndpoint.openAI), agentsMap),
).toBeUndefined();
});
it('returns undefined for an unknown or absent agent', () => {
expect(getSpecAgentAvatarURL(agentSpec('missing'), agentsMap)).toBeUndefined();
expect(getSpecAgentAvatarURL(agentSpec(undefined), agentsMap)).toBeUndefined();
});
});

View file

@ -12,6 +12,7 @@ import {
import type * as t from 'librechat-data-provider';
import type { LocalizeFunction, IconsRecord } from '~/common';
import { getTimestampedValue } from './timestamps';
import { getAgentAvatarUrl } from './agents';
/**
* Clears model for non-ephemeral agent conversations.
@ -542,9 +543,102 @@ export function mergeQuerySettingsWithSpec(
};
}
/** Gets the model spec iconURL by explicit icon, preset icon, then preset endpoint. */
export function getModelSpecIconURL(modelSpec: t.TModelSpec) {
return modelSpec.iconURL ?? modelSpec.preset?.iconURL ?? modelSpec.preset?.endpoint ?? '';
/**
* Config authored through a form stores an untouched icon field as an empty
* string rather than omitting it, so `??` would stop on it and suppress every
* later candidate. `applyModelSpecPreset` already treats an empty `iconURL` as
* unset; `showIconInMenu` is the explicit way to render no icon.
*/
function firstPresentIcon(...candidates: Array<string | null | undefined>): string {
for (const candidate of candidates) {
if (candidate != null && candidate !== '') {
return candidate;
}
}
return '';
}
/** Gets the model spec iconURL by explicit icon, preset icon, agent avatar, then preset endpoint. */
export function getModelSpecIconURL(modelSpec: t.TModelSpec, agentAvatarURL?: string) {
return firstPresentIcon(
modelSpec.iconURL,
modelSpec.preset?.iconURL,
agentAvatarURL,
modelSpec.preset?.endpoint,
);
}
/**
* Resolves the avatar of the agent a spec targets. Returns a primitive so callers
* can hand it to memoized icon components without widening their comparison to
* the identity of the whole agents map.
*/
export function getSpecAgentAvatarURL(
modelSpec: t.TModelSpec,
agentsMap?: t.TAgentsMap,
): string | undefined {
const preset = modelSpec.preset;
/**
* `tModelSpecPresetSchema` permits `agent_id` alongside any endpoint, so a
* leftover id on a non-agent spec would otherwise surface an unrelated
* agent's identity ahead of that spec's own endpoint icon.
*/
if (!isAgentsEndpoint(preset?.endpoint)) {
return undefined;
}
const agentId = preset?.agent_id;
if (agentId == null) {
return undefined;
}
/** Agents persist `avatar` as either a URL string or an object. */
return getAgentAvatarUrl(agentsMap?.[agentId]) ?? undefined;
}
/**
* `label` is required by `tModelSpecSchema`, yet specs can still reach the
* client without one when an external writer persists an incomplete config.
* Normalizing on ingest keeps every consumer — row rendering, search
* filtering, and the spec/endpoint discriminator — working from a complete
* spec, rather than each guarding separately and one inevitably being missed.
*
* Returns the original array, and the original spec objects, when nothing
* needs filling in, so memoized consumers see no new identities.
*/
export function normalizeModelSpecs(specs: t.TModelSpec[]): t.TModelSpec[] {
let normalized: t.TModelSpec[] | null = null;
for (let i = 0; i < specs.length; i++) {
const spec = specs[i];
if (spec.label) {
normalized?.push(spec);
continue;
}
/** Lazy copy: allocated only when the first incomplete spec is found. */
normalized ??= specs.slice(0, i);
normalized.push({ ...spec, label: spec.name });
}
return normalized ?? specs;
}
/**
* Applies `normalizeModelSpecs` to a fetched startup config, so normalization
* happens once at the query boundary and every consumer of
* `startupConfig.modelSpecs.list` — the selector, mentions, favorites,
* provider-key reachability — reads complete specs. Identity-preserving when
* nothing needs filling in.
*/
export function normalizeStartupConfigModelSpecs(config: t.TStartupConfig): t.TStartupConfig {
const modelSpecs = config?.modelSpecs;
if (!modelSpecs?.list?.length) {
return config;
}
const normalized = normalizeModelSpecs(modelSpecs.list);
if (normalized === modelSpecs.list) {
return config;
}
return { ...config, modelSpecs: { ...modelSpecs, list: normalized } };
}
/** Gets the default frontend-facing endpoint, dependent on iconURL definition.