feat(models): show LangBot Models pricing

This commit is contained in:
RockChinQ
2026-09-12 00:53:47 +08:00
parent a3509b3626
commit 33b4035140
17 changed files with 233 additions and 103 deletions
+3 -1
View File
@@ -59,9 +59,11 @@ class SpaceModelAvailability(pydantic.BaseModel):
class SpaceModelSelection(pydantic.BaseModel):
"""Model identity and the latest persisted probe from Space."""
"""Model identity, pricing, and latest persisted probe from Space."""
uuid: str
model_id: str
category: str | None = None
input_credits: float | None = None
output_credits: float | None = None
availability: SpaceModelAvailability = pydantic.Field(default_factory=SpaceModelAvailability)
@@ -899,6 +899,8 @@ class TestSpaceServiceGetModelSelection:
'uuid': 'embedding-model',
'model_id': 'text-embedding',
'category': 'embedding',
'input_credits': 20,
'output_credits': 40,
},
'availability': {'up': None, 'last_probed_at': None},
}
@@ -922,6 +924,8 @@ class TestSpaceServiceGetModelSelection:
result = await service.get_model_selection()
assert result[0].category == 'embedding'
assert result[0].input_credits == 20
assert result[0].output_credits == 40
assert result[0].availability.up is None
session.get.assert_called_once_with(
'https://space.langbot.app/api/v1/models/selection',
@@ -71,7 +71,7 @@ import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-
import ReasoningLevelPicker, {
REASONING_LEVELS,
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
import ModelAvailabilityIndicator from '@/app/home/components/model-availability/ModelAvailabilityIndicator';
import LangBotModelMetadata from '@/app/home/components/model-availability/LangBotModelMetadata';
import { useLangBotModelAvailability } from '@/app/home/components/model-availability/useLangBotModelAvailability';
function hasUsableUuid<T extends { uuid?: string | null }>(
@@ -162,26 +162,39 @@ export default function DynamicFormItemComponent({
DynamicFormItemType.MODEL_FALLBACK_SELECTOR,
].includes(config.type);
const {
availability: langbotModelAvailability,
metadata: langbotModelMetadata,
loaded: langbotModelAvailabilityLoaded,
} = useLangBotModelAvailability(
isModelSelector && !systemInfo.disable_models_service,
);
const renderModelAvailability = (model: {
const renderModelOption = (model: {
uuid: string;
name: string;
abilities?: string[];
provider?: { requester?: string };
}) =>
model.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER ? (
<ModelAvailabilityIndicator
availability={
langbotModelAvailability[model.uuid] ??
langbotModelAvailability[model.name]
}
show={langbotModelAvailabilityLoaded}
/>
) : null;
}) => (
<span className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<span className="inline-flex min-w-0 items-center gap-1">
<span className="truncate">{model.name}</span>
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
{model.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER && (
<LangBotModelMetadata
metadata={
langbotModelMetadata[model.uuid] ?? langbotModelMetadata[model.name]
}
loaded={langbotModelAvailabilityLoaded}
compact
/>
)}
</span>
);
const fetchLlmModels = () => {
httpClient
@@ -611,16 +624,7 @@ export default function DynamicFormItemComponent({
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{renderModelAvailability(model)}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -716,16 +720,7 @@ export default function DynamicFormItemComponent({
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{renderModelAvailability(model)}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -814,8 +809,7 @@ export default function DynamicFormItemComponent({
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
{renderModelAvailability(model)}
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -907,8 +901,7 @@ export default function DynamicFormItemComponent({
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
{renderModelAvailability(model)}
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -973,8 +966,7 @@ export default function DynamicFormItemComponent({
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
{renderModelAvailability(model)}
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -1092,16 +1084,7 @@ export default function DynamicFormItemComponent({
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{renderModelAvailability(model)}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -1198,16 +1181,7 @@ export default function DynamicFormItemComponent({
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{renderModelAvailability(model)}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -0,0 +1,87 @@
import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Coins } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ModelAvailabilityIndicator from './ModelAvailabilityIndicator';
interface LangBotModelMetadataProps {
metadata?: LangBotModelAvailabilityItem;
loaded: boolean;
compact?: boolean;
}
function formatCredits(value: number, locale: string): string {
if (value >= 1000) {
const thousands = value / 1000;
return `${thousands.toFixed(thousands >= 10 ? 0 : 1).replace(/\.0$/, '')}K`;
}
return new Intl.NumberFormat(locale, {
maximumFractionDigits: 2,
}).format(value);
}
export default function LangBotModelMetadata({
metadata,
loaded,
compact = false,
}: LangBotModelMetadataProps) {
const { t, i18n } = useTranslation();
if (!loaded) return null;
const inputCredits = metadata?.input_credits;
const outputCredits = metadata?.output_credits;
const hasPricing = inputCredits != null && outputCredits != null;
const input =
inputCredits != null ? formatCredits(inputCredits, i18n.language) : '';
const output =
outputCredits != null ? formatCredits(outputCredits, i18n.language) : '';
return (
<span className="ml-auto inline-flex shrink-0 items-center gap-2 pl-3">
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex items-center gap-1 text-xs tabular-nums text-muted-foreground"
onMouseDown={(event) => event.preventDefault()}
>
<Coins className="size-3" />
{hasPricing
? compact
? t('models.pricing.compact', { input, output })
: t('models.pricing.inline', { input, output })
: compact
? '—'
: t('models.pricing.unavailable')}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
{hasPricing ? (
<div className="space-y-0.5">
<p>{t('models.pricing.title')}</p>
<p className="text-xs text-muted-foreground">
{t('models.pricing.input', {
credits: inputCredits,
})}
</p>
<p className="text-xs text-muted-foreground">
{t('models.pricing.output', {
credits: outputCredits,
})}
</p>
</div>
) : (
<p>{t('models.pricing.unavailable')}</p>
)}
</TooltipContent>
</Tooltip>
<ModelAvailabilityIndicator
availability={metadata?.availability}
show={loaded}
/>
</span>
);
}
@@ -1,30 +1,30 @@
import { useEffect, useState } from 'react';
import type { LangBotModelAvailability } from '@/app/infra/entities/api';
import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
const CACHE_TTL_MS = 60_000;
type AvailabilityMap = Record<string, LangBotModelAvailability>;
type ModelMetadataMap = Record<string, LangBotModelAvailabilityItem>;
let cachedAvailability: AvailabilityMap | null = null;
let cachedMetadata: ModelMetadataMap | null = null;
let cacheExpiresAt = 0;
let pendingRequest: Promise<AvailabilityMap> | null = null;
let pendingRequest: Promise<ModelMetadataMap> | null = null;
async function loadAvailability(): Promise<AvailabilityMap> {
if (cachedAvailability && Date.now() < cacheExpiresAt) {
return cachedAvailability;
async function loadMetadata(): Promise<ModelMetadataMap> {
if (cachedMetadata && Date.now() < cacheExpiresAt) {
return cachedMetadata;
}
if (pendingRequest) return pendingRequest;
pendingRequest = httpClient
.getLangBotModelAvailability()
.then((response) => {
const next: AvailabilityMap = {};
const next: ModelMetadataMap = {};
for (const item of response.models) {
next[item.uuid] = item.availability;
next[item.model_id] = item.availability;
next[item.uuid] = item;
next[item.model_id] = item;
}
cachedAvailability = next;
cachedMetadata = next;
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return next;
})
@@ -35,29 +35,29 @@ async function loadAvailability(): Promise<AvailabilityMap> {
}
export function useLangBotModelAvailability(enabled = true) {
const [availability, setAvailability] = useState<AvailabilityMap>(
cachedAvailability ?? {},
const [metadata, setMetadata] = useState<ModelMetadataMap>(
cachedMetadata ?? {},
);
const [loaded, setLoaded] = useState(
cachedAvailability !== null && Date.now() < cacheExpiresAt,
cachedMetadata !== null && Date.now() < cacheExpiresAt,
);
useEffect(() => {
if (!enabled) return;
let active = true;
loadAvailability()
loadMetadata()
.then((result) => {
if (!active) return;
setAvailability(result);
setMetadata(result);
setLoaded(true);
})
.catch(() => {
// Availability is supplementary; model configuration remains usable.
// Catalog metadata is supplementary; model configuration remains usable.
});
return () => {
active = false;
};
}, [enabled]);
return { availability, loaded };
return { metadata, loaded };
}
@@ -91,7 +91,7 @@ export default function ModelsPanel({
const canManage =
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
const {
availability: langbotModelAvailability,
metadata: langbotModelMetadata,
loaded: langbotModelAvailabilityLoaded,
} = useLangBotModelAvailability(active && !systemInfo.disable_models_service);
@@ -559,7 +559,7 @@ export default function ModelsPanel({
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
spaceCredits={spaceBilling?.credits ?? null}
modelAvailability={langbotModelAvailability}
modelMetadata={langbotModelMetadata}
modelAvailabilityLoaded={langbotModelAvailabilityLoaded}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next';
import {
LLMModel,
EmbeddingModel,
LangBotModelAvailability,
LangBotModelAvailabilityItem,
ReasoningConfig,
} from '@/app/infra/entities/api';
import {
@@ -25,14 +25,14 @@ import {
} from '../types';
import ExtraArgsEditor from './ExtraArgsEditor';
import { userInfo } from '@/app/infra/http';
import ModelAvailabilityIndicator from '../../model-availability/ModelAvailabilityIndicator';
import LangBotModelMetadata from '../../model-availability/LangBotModelMetadata';
interface ModelItemProps {
model: LLMModel | EmbeddingModel;
canManage: boolean;
modelType: ModelType;
isLangBotModels: boolean;
availability?: LangBotModelAvailability;
metadata?: LangBotModelAvailabilityItem;
availabilityLoaded: boolean;
editModelPopoverOpen: string | null;
deleteConfirmOpen: string | null;
@@ -90,7 +90,7 @@ export default function ModelItem({
canManage,
modelType,
isLangBotModels,
availability,
metadata,
availabilityLoaded,
editModelPopoverOpen,
deleteConfirmOpen,
@@ -203,14 +203,8 @@ export default function ModelItem({
: 'hover:bg-accent cursor-pointer'
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex min-w-0 items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{model.name}</span>
{isLangBotModels && (
<ModelAvailabilityIndicator
availability={availability}
show={availabilityLoaded}
/>
)}
<Badge variant="secondary" className="text-xs">
{modelType === 'llm'
? t('models.chat')
@@ -237,6 +231,12 @@ export default function ModelItem({
</Badge>
)}
</div>
{isLangBotModels && (
<LangBotModelMetadata
metadata={metadata}
loaded={availabilityLoaded}
/>
)}
{canManage && !isLangBotModels && (
<Popover
open={isDeleteOpen}
@@ -10,7 +10,7 @@ import {
} from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import {
LangBotModelAvailability,
LangBotModelAvailabilityItem,
ModelProvider,
ReasoningConfig,
} from '@/app/infra/entities/api';
@@ -51,7 +51,7 @@ interface ProviderCardProps {
isWorkspaceOwner: boolean;
ownerSpaceBound: boolean;
spaceCredits: number | null;
modelAvailability: Record<string, LangBotModelAvailability>;
modelMetadata: Record<string, LangBotModelAvailabilityItem>;
modelAvailabilityLoaded: boolean;
// Popover states
addModelPopoverOpen: string | null;
@@ -121,7 +121,7 @@ export default function ProviderCard({
isWorkspaceOwner,
ownerSpaceBound,
spaceCredits,
modelAvailability,
modelMetadata,
modelAvailabilityLoaded,
addModelPopoverOpen,
editModelPopoverOpen,
@@ -432,9 +432,8 @@ export default function ProviderCard({
canManage={canManage}
modelType="llm"
isLangBotModels={isLangBotModels}
availability={
modelAvailability[model.uuid] ??
modelAvailability[model.name]
metadata={
modelMetadata[model.uuid] ?? modelMetadata[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
@@ -488,9 +487,8 @@ export default function ProviderCard({
canManage={canManage}
modelType="embedding"
isLangBotModels={isLangBotModels}
availability={
modelAvailability[model.uuid] ??
modelAvailability[model.name]
metadata={
modelMetadata[model.uuid] ?? modelMetadata[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
@@ -542,9 +540,8 @@ export default function ProviderCard({
canManage={canManage}
modelType="rerank"
isLangBotModels={isLangBotModels}
availability={
modelAvailability[model.uuid] ??
modelAvailability[model.name]
metadata={
modelMetadata[model.uuid] ?? modelMetadata[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
+2
View File
@@ -169,6 +169,8 @@ export interface LangBotModelAvailabilityItem {
uuid: string;
model_id: string;
category: string | null;
input_credits: number | null;
output_credits: number | null;
availability: LangBotModelAvailability;
}
+8
View File
@@ -310,6 +310,14 @@ const enUS = {
notChecked: 'No check result',
lastChecked: 'Checked {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: '{{input}} input · {{output}} output',
title: 'Credits per 1M tokens',
input: 'Input: {{credits}} credits',
output: 'Output: {{credits}} credits',
unavailable: 'No current price',
},
langbotModels: 'LangBot Models',
spaceTrialTooltip:
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
+8
View File
@@ -312,6 +312,14 @@ const esES = {
notChecked: 'Sin resultado de comprobación',
lastChecked: 'Comprobado {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: 'Entrada {{input}} · salida {{output}}',
title: 'Créditos por 1 M de tokens',
input: 'Entrada: {{credits}} créditos',
output: 'Salida: {{credits}} créditos',
unavailable: 'Sin precio actual',
},
langbotModels: 'Modelos LangBot',
spaceTrialTooltip:
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con una cuenta de LangBot para acceder a modelos en la nube sin configuración.',
+8
View File
@@ -315,6 +315,14 @@ const jaJP = {
notChecked: 'チェック結果なし',
lastChecked: '{{time}} にチェック',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: '入力 {{input}} · 出力 {{output}}',
title: '100万トークンあたりのクレジット',
input: '入力:{{credits}} クレジット',
output: '出力:{{credits}} クレジット',
unavailable: '現在の価格なし',
},
langbotModels: 'LangBot モデル',
spaceTrialTooltip:
'無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
+8
View File
@@ -309,6 +309,14 @@ const ruRU = {
notChecked: 'Нет результата проверки',
lastChecked: 'Проверено {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: 'Ввод {{input}} · вывод {{output}}',
title: 'Кредиты за 1 млн токенов',
input: 'Ввод: {{credits}} кредитов',
output: 'Вывод: {{credits}} кредитов',
unavailable: 'Нет актуальной цены',
},
langbotModels: 'Модели LangBot',
spaceTrialTooltip:
'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
+8
View File
@@ -298,6 +298,14 @@ const thTH = {
notChecked: 'ไม่มีผลการตรวจสอบ',
lastChecked: 'ตรวจสอบเมื่อ {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: 'อินพุต {{input}} · เอาต์พุต {{output}}',
title: 'เครดิตต่อ 1 ล้านโทเค็น',
input: 'อินพุต: {{credits}} เครดิต',
output: 'เอาต์พุต: {{credits}} เครดิต',
unavailable: 'ไม่มีราคาปัจจุบัน',
},
langbotModels: 'โมเดล LangBot',
spaceTrialTooltip:
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
+8
View File
@@ -306,6 +306,14 @@ const viVN = {
notChecked: 'Chưa có kết quả kiểm tra',
lastChecked: 'Đã kiểm tra {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: 'Đầu vào {{input}} · đầu ra {{output}}',
title: 'Tín dụng trên 1 triệu token',
input: 'Đầu vào: {{credits}} tín dụng',
output: 'Đầu ra: {{credits}} tín dụng',
unavailable: 'Chưa có giá hiện tại',
},
langbotModels: 'Mô hình LangBot',
spaceTrialTooltip:
'Có tín dụng dùng thử miễn phí! Đăng nhập bằng tài khoản LangBot để truy cập mô hình đám mây không cần cấu hình.',
+8
View File
@@ -296,6 +296,14 @@ const zhHans = {
notChecked: '暂无检测结果',
lastChecked: '检测于 {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: '输入 {{input}} · 输出 {{output}}',
title: '每 1M tokens 消耗积分',
input: '输入:{{credits}} 积分',
output: '输出:{{credits}} 积分',
unavailable: '暂无价格',
},
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。',
+8
View File
@@ -287,6 +287,14 @@ const zhHant = {
notChecked: '暫無檢測結果',
lastChecked: '檢測於 {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: '輸入 {{input}} · 輸出 {{output}}',
title: '每 1M tokens 消耗積分',
input: '輸入:{{credits}} 積分',
output: '輸出:{{credits}} 積分',
unavailable: '暫無價格',
},
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。',