mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(models): sort catalog by listing date availability and price
This commit is contained in:
@@ -45,6 +45,7 @@ class SpaceModel(pydantic.BaseModel):
|
|||||||
is_featured: bool = False
|
is_featured: bool = False
|
||||||
featured_order: int = 0
|
featured_order: int = 0
|
||||||
status: str
|
status: str
|
||||||
|
listed_at: str | None = None
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ class SpaceModelSelection(pydantic.BaseModel):
|
|||||||
uuid: str
|
uuid: str
|
||||||
model_id: str
|
model_id: str
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
|
listed_at: str | None = None
|
||||||
input_credits: float | None = None
|
input_credits: float | None = None
|
||||||
output_credits: float | None = None
|
output_credits: float | None = None
|
||||||
availability: SpaceModelAvailability = pydantic.Field(default_factory=SpaceModelAvailability)
|
availability: SpaceModelAvailability = pydantic.Field(default_factory=SpaceModelAvailability)
|
||||||
|
|||||||
@@ -831,6 +831,7 @@ class TestSpaceServiceGetModelSelection:
|
|||||||
{
|
{
|
||||||
'uuid': 'best-model',
|
'uuid': 'best-model',
|
||||||
'model_id': 'best-chat-model',
|
'model_id': 'best-chat-model',
|
||||||
|
'listed_at': '2026-09-09T19:00:00.000929Z',
|
||||||
'provider': 'provider-1',
|
'provider': 'provider-1',
|
||||||
'category': 'chat',
|
'category': 'chat',
|
||||||
'status': 'active',
|
'status': 'active',
|
||||||
@@ -878,6 +879,8 @@ class TestSpaceServiceGetModelSelection:
|
|||||||
result = await service.get_model_selection('chat')
|
result = await service.get_model_selection('chat')
|
||||||
|
|
||||||
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
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':
|
if response_shape == 'availability-wrapper':
|
||||||
assert result[0].availability.up is True
|
assert result[0].availability.up is True
|
||||||
assert result[0].availability.last_probed_at == '2026-09-11T12:01:18Z'
|
assert result[0].availability.last_probed_at == '2026-09-11T12:01:18Z'
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import ReasoningLevelPicker, {
|
|||||||
REASONING_LEVELS,
|
REASONING_LEVELS,
|
||||||
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
|
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
|
||||||
import LangBotModelMetadata from '@/app/home/components/model-availability/LangBotModelMetadata';
|
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';
|
import { useLangBotModelAvailability } from '@/app/home/components/model-availability/useLangBotModelAvailability';
|
||||||
|
|
||||||
const MODEL_SELECT_TRIGGER_CLASS =
|
const MODEL_SELECT_TRIGGER_CLASS =
|
||||||
@@ -584,8 +585,11 @@ export default function DynamicFormItemComponent({
|
|||||||
|
|
||||||
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
||||||
// Separate space models from regular models
|
// Separate space models from regular models
|
||||||
const spaceModels = llmModels.filter(
|
const spaceModels = sortModelsByCatalog(
|
||||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
llmModels.filter(
|
||||||
|
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
),
|
||||||
|
langbotModelMetadata,
|
||||||
);
|
);
|
||||||
const regularModels = llmModels.filter(
|
const regularModels = llmModels.filter(
|
||||||
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
@@ -778,8 +782,11 @@ export default function DynamicFormItemComponent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: {
|
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: {
|
||||||
const spaceEmbeddingModels = embeddingModels.filter(
|
const spaceEmbeddingModels = sortModelsByCatalog(
|
||||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
embeddingModels.filter(
|
||||||
|
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
),
|
||||||
|
langbotModelMetadata,
|
||||||
);
|
);
|
||||||
const regularEmbeddingModels = embeddingModels.filter(
|
const regularEmbeddingModels = embeddingModels.filter(
|
||||||
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
@@ -977,6 +984,18 @@ export default function DynamicFormItemComponent({
|
|||||||
},
|
},
|
||||||
{} as Record<string, RerankModel[]>,
|
{} as Record<string, RerankModel[]>,
|
||||||
);
|
);
|
||||||
|
for (const [providerName, models] of Object.entries(
|
||||||
|
groupedRerankModels,
|
||||||
|
)) {
|
||||||
|
if (
|
||||||
|
models[0]?.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
|
) {
|
||||||
|
groupedRerankModels[providerName] = sortModelsByCatalog(
|
||||||
|
models,
|
||||||
|
langbotModelMetadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md min-w-0">
|
<div className="w-full max-w-md min-w-0">
|
||||||
@@ -1012,8 +1031,11 @@ export default function DynamicFormItemComponent({
|
|||||||
|
|
||||||
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
||||||
// Separate space models from regular models
|
// Separate space models from regular models
|
||||||
const fbSpaceModels = llmModels.filter(
|
const fbSpaceModels = sortModelsByCatalog(
|
||||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
llmModels.filter(
|
||||||
|
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
),
|
||||||
|
langbotModelMetadata,
|
||||||
);
|
);
|
||||||
const fbRegularModels = llmModels.filter(
|
const fbRegularModels = llmModels.filter(
|
||||||
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api';
|
||||||
|
|
||||||
|
type CatalogModel = { uuid: string; name: string };
|
||||||
|
type MetadataMap = Record<string, LangBotModelAvailabilityItem>;
|
||||||
|
|
||||||
|
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<T extends CatalogModel>(
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
ProviderModels,
|
ProviderModels,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import ModelItem from './ModelItem';
|
import ModelItem from './ModelItem';
|
||||||
|
import { sortModelsByCatalog } from '../../model-availability/sort-models';
|
||||||
import AddModelPopover from './AddModelPopover';
|
import AddModelPopover from './AddModelPopover';
|
||||||
|
|
||||||
interface ProviderCardProps {
|
interface ProviderCardProps {
|
||||||
@@ -425,7 +426,10 @@ export default function ProviderCard({
|
|||||||
</p>
|
</p>
|
||||||
) : models ? (
|
) : models ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{models.llm.map((model) => (
|
{(isLangBotModels
|
||||||
|
? sortModelsByCatalog(models.llm, modelMetadata)
|
||||||
|
: models.llm
|
||||||
|
).map((model) => (
|
||||||
<ModelItem
|
<ModelItem
|
||||||
key={model.uuid}
|
key={model.uuid}
|
||||||
model={model}
|
model={model}
|
||||||
@@ -480,7 +484,10 @@ export default function ProviderCard({
|
|||||||
onResetTestResult={onResetTestResult}
|
onResetTestResult={onResetTestResult}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{models.embedding.map((model) => (
|
{(isLangBotModels
|
||||||
|
? sortModelsByCatalog(models.embedding, modelMetadata)
|
||||||
|
: models.embedding
|
||||||
|
).map((model) => (
|
||||||
<ModelItem
|
<ModelItem
|
||||||
key={model.uuid}
|
key={model.uuid}
|
||||||
model={model}
|
model={model}
|
||||||
@@ -533,7 +540,10 @@ export default function ProviderCard({
|
|||||||
onResetTestResult={onResetTestResult}
|
onResetTestResult={onResetTestResult}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{models.rerank.map((model) => (
|
{(isLangBotModels
|
||||||
|
? sortModelsByCatalog(models.rerank, modelMetadata)
|
||||||
|
: models.rerank
|
||||||
|
).map((model) => (
|
||||||
<ModelItem
|
<ModelItem
|
||||||
key={model.uuid}
|
key={model.uuid}
|
||||||
model={model}
|
model={model}
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export interface LangBotModelAvailabilityItem {
|
|||||||
uuid: string;
|
uuid: string;
|
||||||
model_id: string;
|
model_id: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
|
listed_at?: string | null;
|
||||||
input_credits: number | null;
|
input_credits: number | null;
|
||||||
output_credits: number | null;
|
output_credits: number | null;
|
||||||
availability: LangBotModelAvailability;
|
availability: LangBotModelAvailability;
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../src/app/home/components/model-availability/sort-models.ts',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const exports = {};
|
||||||
|
new Function(
|
||||||
|
'exports',
|
||||||
|
ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
},
|
||||||
|
}).outputText,
|
||||||
|
)(exports);
|
||||||
|
const { sortModelsByCatalog } = exports;
|
||||||
|
const model = (name) => ({ 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user