From fa15f48fd7d7e59fc00ab37a9e9ed3f7907fdc5d Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sat, 12 Sep 2026 02:07:33 +0800 Subject: [PATCH] feat(models): sort catalog by listing date availability and price --- src/langbot/pkg/entity/dto/space_model.py | 2 + .../api/service/test_space_service.py | 3 + .../dynamic-form/DynamicFormItemComponent.tsx | 34 +++++- .../model-availability/sort-models.ts | 48 ++++++++ .../models-dialog/components/ProviderCard.tsx | 16 ++- web/src/app/infra/entities/api/index.ts | 1 + web/tests/unit/model-catalog-sort.test.mjs | 108 ++++++++++++++++++ 7 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 web/src/app/home/components/model-availability/sort-models.ts create mode 100644 web/tests/unit/model-catalog-sort.test.mjs diff --git a/src/langbot/pkg/entity/dto/space_model.py b/src/langbot/pkg/entity/dto/space_model.py index c4e1b2970..845a55256 100644 --- a/src/langbot/pkg/entity/dto/space_model.py +++ b/src/langbot/pkg/entity/dto/space_model.py @@ -45,6 +45,7 @@ class SpaceModel(pydantic.BaseModel): is_featured: bool = False featured_order: int = 0 status: str + listed_at: str | None = None created_at: str | None = None updated_at: str | None = None @@ -64,6 +65,7 @@ class SpaceModelSelection(pydantic.BaseModel): uuid: str model_id: str category: str | None = None + listed_at: str | None = None input_credits: float | None = None output_credits: float | None = None availability: SpaceModelAvailability = pydantic.Field(default_factory=SpaceModelAvailability) diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index 6ed80f4ed..9b9e58a1d 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -831,6 +831,7 @@ class TestSpaceServiceGetModelSelection: { 'uuid': 'best-model', 'model_id': 'best-chat-model', + 'listed_at': '2026-09-09T19:00:00.000929Z', 'provider': 'provider-1', 'category': 'chat', 'status': 'active', @@ -878,6 +879,8 @@ class TestSpaceServiceGetModelSelection: result = await service.get_model_selection('chat') assert [model.uuid for model in result] == ['best-model', 'fallback-model'] + assert result[0].model_dump()['listed_at'] == '2026-09-09T19:00:00.000929Z' + assert result[1].listed_at is None if response_shape == 'availability-wrapper': assert result[0].availability.up is True assert result[0].availability.last_probed_at == '2026-09-11T12:01:18Z' diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 3d966ee05..bef437c04 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -73,6 +73,7 @@ import ReasoningLevelPicker, { REASONING_LEVELS, } from '@/app/home/components/reasoning/ReasoningLevelPicker'; import LangBotModelMetadata from '@/app/home/components/model-availability/LangBotModelMetadata'; +import { sortModelsByCatalog } from '@/app/home/components/model-availability/sort-models'; import { useLangBotModelAvailability } from '@/app/home/components/model-availability/useLangBotModelAvailability'; const MODEL_SELECT_TRIGGER_CLASS = @@ -584,8 +585,11 @@ export default function DynamicFormItemComponent({ case DynamicFormItemType.LLM_MODEL_SELECTOR: // Separate space models from regular models - const spaceModels = llmModels.filter( - (m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, + const spaceModels = sortModelsByCatalog( + llmModels.filter( + (m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, + ), + langbotModelMetadata, ); const regularModels = llmModels.filter( (m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, @@ -778,8 +782,11 @@ export default function DynamicFormItemComponent({ ); case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: { - const spaceEmbeddingModels = embeddingModels.filter( - (m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, + const spaceEmbeddingModels = sortModelsByCatalog( + embeddingModels.filter( + (m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, + ), + langbotModelMetadata, ); const regularEmbeddingModels = embeddingModels.filter( (m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, @@ -977,6 +984,18 @@ export default function DynamicFormItemComponent({ }, {} as Record, ); + for (const [providerName, models] of Object.entries( + groupedRerankModels, + )) { + if ( + models[0]?.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER + ) { + groupedRerankModels[providerName] = sortModelsByCatalog( + models, + langbotModelMetadata, + ); + } + } return (
@@ -1012,8 +1031,11 @@ export default function DynamicFormItemComponent({ case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: { // Separate space models from regular models - const fbSpaceModels = llmModels.filter( - (m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, + const fbSpaceModels = sortModelsByCatalog( + llmModels.filter( + (m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, + ), + langbotModelMetadata, ); const fbRegularModels = llmModels.filter( (m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, diff --git a/web/src/app/home/components/model-availability/sort-models.ts b/web/src/app/home/components/model-availability/sort-models.ts new file mode 100644 index 000000000..777f065a9 --- /dev/null +++ b/web/src/app/home/components/model-availability/sort-models.ts @@ -0,0 +1,48 @@ +import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api'; + +type CatalogModel = { uuid: string; name: string }; +type MetadataMap = Record; + +function listingDay(value?: string | null): number { + const timestamp = value ? Date.parse(value) : NaN; + // Use UTC calendar days so list order is consistent across user time zones. + return Number.isFinite(timestamp) ? Math.floor(timestamp / 86_400_000) : -1; +} + +function availabilityRank(up?: boolean | null): number { + return up === true ? 0 : up == null ? 1 : 2; +} + +function price(value?: number | null): number { + return value != null && Number.isFinite(value) && value >= 0 + ? value + : Infinity; +} + +/** Sort one LangBot Models group without changing the source array. */ +export function sortModelsByCatalog( + models: readonly T[], + metadata: MetadataMap, +): T[] { + // Keep the existing order until catalog metadata is available. + if (Object.keys(metadata).length === 0) return [...models]; + + return [...models].sort((left, right) => { + const a = metadata[left.uuid] ?? metadata[left.name]; + const b = metadata[right.uuid] ?? metadata[right.name]; + const dateOrder = listingDay(b?.listed_at) - listingDay(a?.listed_at); + if (dateOrder) return dateOrder; + + const statusOrder = + availabilityRank(a?.availability?.up) - + availabilityRank(b?.availability?.up); + if (statusOrder) return statusOrder; + + for (const key of ['input_credits', 'output_credits'] as const) { + const aPrice = price(a?.[key]); + const bPrice = price(b?.[key]); + if (aPrice !== bPrice) return aPrice < bPrice ? -1 : 1; + } + return left.name < right.name ? -1 : left.name > right.name ? 1 : 0; + }); +} diff --git a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx index d47bfb332..ac522f258 100644 --- a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx +++ b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx @@ -38,6 +38,7 @@ import { ProviderModels, } from '../types'; import ModelItem from './ModelItem'; +import { sortModelsByCatalog } from '../../model-availability/sort-models'; import AddModelPopover from './AddModelPopover'; interface ProviderCardProps { @@ -425,7 +426,10 @@ export default function ProviderCard({

) : models ? (
- {models.llm.map((model) => ( + {(isLangBotModels + ? sortModelsByCatalog(models.llm, modelMetadata) + : models.llm + ).map((model) => ( ))} - {models.embedding.map((model) => ( + {(isLangBotModels + ? sortModelsByCatalog(models.embedding, modelMetadata) + : models.embedding + ).map((model) => ( ))} - {models.rerank.map((model) => ( + {(isLangBotModels + ? sortModelsByCatalog(models.rerank, modelMetadata) + : models.rerank + ).map((model) => ( ({ uuid: name, name }); +const metadata = (listed_at, up, input_credits = 10, output_credits = 20) => ({ + listed_at, + availability: { up }, + input_credits, + output_credits, +}); +const names = (models) => models.map((m) => m.name); + +test('newest UTC day precedes availability; same-day time is ignored', () => { + const items = ['old-up', 'new-down', 'new-up'].map(model); + const catalog = { + 'old-up': metadata('2026-09-08T23:59:59Z', true), + 'new-down': metadata('2026-09-09T23:59:59Z', false), + 'new-up': metadata('2026-09-10T01:00:00+08:00', true), + }; + assert.deepEqual(names(sortModelsByCatalog(items, catalog)), [ + 'new-up', + 'new-down', + 'old-up', + ]); + assert.deepEqual(names(items), ['old-up', 'new-down', 'new-up']); +}); + +test('same-day availability ranks up, unknown, down before prices', () => { + const catalog = { + down: metadata('2026-09-09', false, 0, 0), + unknown: metadata('2026-09-09', null, 1, 1), + up: metadata('2026-09-09', true, 100, 100), + }; + assert.deepEqual( + names(sortModelsByCatalog(Object.keys(catalog).map(model), catalog)), + ['up', 'unknown', 'down'], + ); +}); + +test('prices compare input then output; free prices remain valid', () => { + const catalog = { + expensive: metadata('2026-09-09', true, 20, 1), + 'output-high': metadata('2026-09-09', true, 10, 50), + 'output-low': metadata('2026-09-09', true, 10, 20), + free: metadata('2026-09-09', true, 0, 0), + missing: metadata('2026-09-09', true, null, null), + invalid: metadata('2026-09-09', true, NaN, -1), + }; + assert.deepEqual( + names(sortModelsByCatalog(Object.keys(catalog).map(model), catalog)), + ['free', 'output-low', 'output-high', 'expensive', 'invalid', 'missing'], + ); +}); + +test('unknown dates sort after known dates and missing catalog entries are safe', () => { + const catalog = { + known: metadata('2026-01-01', false, 100, 100), + invalid: metadata('invalid', true), + missing: metadata(null, true), + }; + assert.deepEqual( + names( + sortModelsByCatalog( + ['missing', 'absent', 'invalid', 'known'].map(model), + catalog, + ), + ), + ['known', 'invalid', 'missing', 'absent'], + ); +}); + +test('UUID lookup takes precedence, name lookup works, empty metadata preserves order', () => { + const items = [{ uuid: 'local-id', name: 'alias' }, model('other')]; + const catalog = { + alias: metadata('2026-09-09', true), + other: metadata('2026-09-08', true), + }; + assert.deepEqual(names(sortModelsByCatalog(items, catalog)), [ + 'alias', + 'other', + ]); + catalog['local-id'] = metadata('2026-09-07', true); + assert.deepEqual(names(sortModelsByCatalog(items, catalog)), [ + 'other', + 'alias', + ]); + assert.deepEqual(sortModelsByCatalog(items, {}), items); +});