diff --git a/src/apps/chat/components/layout-bar/useLLMDropdown.tsx b/src/apps/chat/components/layout-bar/useLLMDropdown.tsx index 182684e73..e848b7794 100644 --- a/src/apps/chat/components/layout-bar/useLLMDropdown.tsx +++ b/src/apps/chat/components/layout-bar/useLLMDropdown.tsx @@ -15,6 +15,7 @@ import { KeyStroke } from '~/common/components/KeyStroke'; import { OptimaBarControlMethods, OptimaBarDropdownMemo, OptimaDropdownItems } from '~/common/layout/optima/bar/OptimaBarDropdown'; import { findModelsServiceOrNull } from '~/common/stores/llms/store-llms'; import { isDeepEqual } from '~/common/util/hooks/useDeep'; +import { sortLLMsByServiceLabel } from '~/common/stores/llms/components/llms.dropdown.utils'; import { optimaActions, optimaOpenModels } from '~/common/layout/optima/useOptima'; import { useAllLLMs } from '~/common/stores/llms/hooks/useAllLLMs'; import { useModelDomain } from '~/common/stores/llms/hooks/useModelDomain'; @@ -72,7 +73,10 @@ function LLMDropdown(props: { return lcFilterString ? true : isLLMVisible(llm); }); - for (const llm of filteredLLMs) { + // sort by service label so vendor groups appear alphabetically (groups remain contiguous because sort is stable on equal keys) + const sortedLLMs = sortLLMsByServiceLabel(filteredLLMs); + + for (const llm of sortedLLMs) { // add separators when changing services if (!prevServiceId || llm.sId !== prevServiceId) { const vendor = findModelVendor(llm.vId); diff --git a/src/common/components/forms/useLLMSelect.tsx b/src/common/components/forms/useLLMSelect.tsx index 394d00afa..c665b611e 100644 --- a/src/common/components/forms/useLLMSelect.tsx +++ b/src/common/components/forms/useLLMSelect.tsx @@ -19,6 +19,7 @@ import { StarIconUnstyled, StarredNoXL2 } from '~/common/components/StarIcons'; import { TooltipOutlined } from '~/common/components/TooltipOutlined'; import { findModelsServiceOrNull, getChatLLMId, llmsStoreActions } from '~/common/stores/llms/store-llms'; import { optimaActions, optimaOpenModels } from '~/common/layout/optima/useOptima'; +import { sortLLMsByServiceLabel } from '~/common/stores/llms/components/llms.dropdown.utils'; import { useToggleableStringSet } from '~/common/util/hooks/useToggleableStringSet'; import { useUIPreferencesStore } from '~/common/stores/store-ui'; import { useVisibleLLMs } from '~/common/stores/llms/llms.hooks'; @@ -202,12 +203,15 @@ export function useLLMSelect( const optimizeToSingleVisibleId = (!controlledOpen && _filteredLLMs.length > LLM_SELECT_REDUCE_OPTIONS) ? llmId : null; // id to keep visible when optimizing const optionsArray = React.useMemo(() => { + // sort LLMs alphabetically by service label so vendor groups appear in a stable order (groups remain contiguous because sort is stable on equal keys) + const sortedLLMs = sortLLMsByServiceLabel(_filteredLLMs); + // check if we have multiple services (to show collapsible headers) - const hasMultipleServices = _filteredLLMs.some((llm, i, arr) => i > 0 && llm.sId !== arr[i - 1].sId); + const hasMultipleServices = sortedLLMs.some((llm, i, arr) => i > 0 && llm.sId !== arr[i - 1].sId); // create the option items let prevServiceId: DModelsServiceId | null = null; - return _filteredLLMs.reduce((acc, llm, _index) => { + return sortedLLMs.reduce((acc, llm, _index) => { if (optimizeToSingleVisibleId && llm.id !== optimizeToSingleVisibleId) return acc; diff --git a/src/common/stores/llms/components/llms.dropdown.utils.tsx b/src/common/stores/llms/components/llms.dropdown.utils.tsx index 988d7658d..3df98dd46 100644 --- a/src/common/stores/llms/components/llms.dropdown.utils.tsx +++ b/src/common/stores/llms/components/llms.dropdown.utils.tsx @@ -42,17 +42,44 @@ export interface LLMServiceGroup { } /** - * Group LLMs by service, resolving service display labels. + * Resolve display label for each unique service in the input. + * Fallback chain: service.label -> vendor.name -> service.id. + */ +function _resolveServiceLabels(llms: ReadonlyArray): Map { + const labelById = new Map(); + for (const llm of llms) { + if (labelById.has(llm.sId)) continue; + const vendor = findModelVendor(llm.vId); + labelById.set(llm.sId, findModelsServiceOrNull(llm.sId)?.label || vendor?.name || llm.sId); + } + return labelById; +} + +/** + * Stably sort LLMs by their service label (alphabetical, locale-aware). + * Preserves intra-service order (e.g. starred-first), since JS sort is stable. + */ +export function sortLLMsByServiceLabel(llms: ReadonlyArray): T[] { + if (llms.length < 2) return [...llms]; + const labelById = _resolveServiceLabels(llms); + return [...llms].sort((a, b) => labelById.get(a.sId)!.localeCompare(labelById.get(b.sId)!)); +} + +/** + * Group LLMs by service, alphabetically sorted by service label. + * Preserves intra-service order. */ export function groupLLMsByService(llms: ReadonlyArray): LLMServiceGroup[] { + const labelById = _resolveServiceLabels(llms); + if (llms.length >= 2) + llms = [...llms].sort((a, b) => labelById.get(a.sId)!.localeCompare(labelById.get(b.sId)!)); + const groups: LLMServiceGroup[] = []; let currentGroup: LLMServiceGroup | null = null; for (const llm of llms) { if (!currentGroup || currentGroup.serviceId !== llm.sId) { - const vendor = findModelVendor(llm.vId); - const serviceLabel = findModelsServiceOrNull(llm.sId)?.label || vendor?.name || llm.sId; - currentGroup = { serviceId: llm.sId, serviceLabel, models: [] }; + currentGroup = { serviceId: llm.sId, serviceLabel: labelById.get(llm.sId)!, models: [] }; groups.push(currentGroup); } currentGroup.models.push(llm); diff --git a/src/modules/llms/models-modal/ModelsList.tsx b/src/modules/llms/models-modal/ModelsList.tsx index 4c1430fc5..c3ed54f2b 100644 --- a/src/modules/llms/models-modal/ModelsList.tsx +++ b/src/modules/llms/models-modal/ModelsList.tsx @@ -14,6 +14,7 @@ import { GoodTooltip } from '~/common/components/GoodTooltip'; import { PhGearSixIcon } from '~/common/components/icons/phosphor/PhGearSixIcon'; import { STAR_EMOJI, StarredToggle, starredToggleStyle } from '~/common/components/StarIcons'; import { findModelsServiceOrNull, llmsStoreActions } from '~/common/stores/llms/store-llms'; +import { sortLLMsByServiceLabel } from '~/common/stores/llms/components/llms.dropdown.utils'; import { useLLMsByService } from '~/common/stores/llms/llms.hooks'; import { useIsMobile } from '~/common/components/useMatchMedia'; import { useModelDomains } from '~/common/stores/llms/hooks/useModelDomains'; @@ -283,7 +284,9 @@ export function ModelsList(props: { // are we showing multiple services const showAllServices = !props.filterServiceId; - const hasManyServices = llms.length >= 2 && llms.some(llm => llm.sId !== llms[0].sId); + // sort by service label so vendor groups appear alphabetically when showing all services (single-service view keeps existing order) + const orderedLLMs = showAllServices ? sortLLMsByServiceLabel(llms) : llms; + const hasManyServices = orderedLLMs.length >= 2 && orderedLLMs.some(llm => llm.sId !== orderedLLMs[0].sId); let lastGroupLabel = ''; // derived @@ -293,7 +296,7 @@ export function ModelsList(props: { // generate the list items, prepending headers when necessary const items: React.JSX.Element[] = []; - for (const llm of llms) { + for (const llm of orderedLLMs) { // skip hidden models if requested if (!props.showHiddenModels && isLLMHidden(llm))