diff --git a/src/langbot/pkg/entity/dto/space_model.py b/src/langbot/pkg/entity/dto/space_model.py index f6d171522..c4e1b2970 100644 --- a/src/langbot/pkg/entity/dto/space_model.py +++ b/src/langbot/pkg/entity/dto/space_model.py @@ -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) diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index 9d19af710..6ed80f4ed 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -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', diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 8f1e1886c..e450af978 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -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( @@ -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 ? ( - - ) : null; + }) => ( + + + {model.name} + {model.abilities?.includes('vision') && ( + + )} + {model.abilities?.includes('func_call') && ( + + )} + + {model.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER && ( + + )} + + ); const fetchLlmModels = () => { httpClient @@ -611,16 +624,7 @@ export default function DynamicFormItemComponent({ {providerName} {models.map((model) => ( - - {model.name} - {renderModelAvailability(model)} - {model.abilities?.includes('vision') && ( - - )} - {model.abilities?.includes('func_call') && ( - - )} - + {renderModelOption(model)} ))} @@ -716,16 +720,7 @@ export default function DynamicFormItemComponent({ {models.map((model) => ( - - {model.name} - {renderModelAvailability(model)} - {model.abilities?.includes('vision') && ( - - )} - {model.abilities?.includes('func_call') && ( - - )} - + {renderModelOption(model)} ))} @@ -814,8 +809,7 @@ export default function DynamicFormItemComponent({ {providerName} {models.map((model) => ( - {model.name} - {renderModelAvailability(model)} + {renderModelOption(model)} ))} @@ -907,8 +901,7 @@ export default function DynamicFormItemComponent({ {models.map((model) => ( - {model.name} - {renderModelAvailability(model)} + {renderModelOption(model)} ))} @@ -973,8 +966,7 @@ export default function DynamicFormItemComponent({ {providerName} {models.map((model) => ( - {model.name} - {renderModelAvailability(model)} + {renderModelOption(model)} ))} @@ -1092,16 +1084,7 @@ export default function DynamicFormItemComponent({ {providerName} {models.map((model) => ( - - {model.name} - {renderModelAvailability(model)} - {model.abilities?.includes('vision') && ( - - )} - {model.abilities?.includes('func_call') && ( - - )} - + {renderModelOption(model)} ))} @@ -1198,16 +1181,7 @@ export default function DynamicFormItemComponent({ {models.map((model) => ( - - {model.name} - {renderModelAvailability(model)} - {model.abilities?.includes('vision') && ( - - )} - {model.abilities?.includes('func_call') && ( - - )} - + {renderModelOption(model)} ))} diff --git a/web/src/app/home/components/model-availability/LangBotModelMetadata.tsx b/web/src/app/home/components/model-availability/LangBotModelMetadata.tsx new file mode 100644 index 000000000..127acde54 --- /dev/null +++ b/web/src/app/home/components/model-availability/LangBotModelMetadata.tsx @@ -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 ( + + + + event.preventDefault()} + > + + {hasPricing + ? compact + ? t('models.pricing.compact', { input, output }) + : t('models.pricing.inline', { input, output }) + : compact + ? '—' + : t('models.pricing.unavailable')} + + + + {hasPricing ? ( +
+

{t('models.pricing.title')}

+

+ {t('models.pricing.input', { + credits: inputCredits, + })} +

+

+ {t('models.pricing.output', { + credits: outputCredits, + })} +

+
+ ) : ( +

{t('models.pricing.unavailable')}

+ )} +
+
+ +
+ ); +} diff --git a/web/src/app/home/components/model-availability/useLangBotModelAvailability.ts b/web/src/app/home/components/model-availability/useLangBotModelAvailability.ts index e5d8575ae..32574e7db 100644 --- a/web/src/app/home/components/model-availability/useLangBotModelAvailability.ts +++ b/web/src/app/home/components/model-availability/useLangBotModelAvailability.ts @@ -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; +type ModelMetadataMap = Record; -let cachedAvailability: AvailabilityMap | null = null; +let cachedMetadata: ModelMetadataMap | null = null; let cacheExpiresAt = 0; -let pendingRequest: Promise | null = null; +let pendingRequest: Promise | null = null; -async function loadAvailability(): Promise { - if (cachedAvailability && Date.now() < cacheExpiresAt) { - return cachedAvailability; +async function loadMetadata(): Promise { + 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 { } export function useLangBotModelAvailability(enabled = true) { - const [availability, setAvailability] = useState( - cachedAvailability ?? {}, + const [metadata, setMetadata] = useState( + 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 }; } diff --git a/web/src/app/home/components/models-dialog/ModelsPanel.tsx b/web/src/app/home/components/models-dialog/ModelsPanel.tsx index 5a5521d49..f08a99e1b 100644 --- a/web/src/app/home/components/models-dialog/ModelsPanel.tsx +++ b/web/src/app/home/components/models-dialog/ModelsPanel.tsx @@ -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} diff --git a/web/src/app/home/components/models-dialog/components/ModelItem.tsx b/web/src/app/home/components/models-dialog/components/ModelItem.tsx index 8a3c83d0c..f1cbfa77f 100644 --- a/web/src/app/home/components/models-dialog/components/ModelItem.tsx +++ b/web/src/app/home/components/models-dialog/components/ModelItem.tsx @@ -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' }`} > -
+
{model.name} - {isLangBotModels && ( - - )} {modelType === 'llm' ? t('models.chat') @@ -237,6 +231,12 @@ export default function ModelItem({ )}
+ {isLangBotModels && ( + + )} {canManage && !isLangBotModels && ( ; + modelMetadata: Record; 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} diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index f3f08d488..1617d7acf 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -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; } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 3c9d93ae0..f96ea8681 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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.', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 6253ce590..2b3271920 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -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.', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 4f6f24fc9..e6c6de951 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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 アカウントでログインして、設定不要でクラウドモデルを使用できます。', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index a28d883f5..480f1dfc9 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -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, чтобы получить доступ к облачным моделям без настройки.', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 52223fa99..3f4912c61 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -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 เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 7889a36c2..1c7b7163e 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -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.', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 5bf58364e..7d4dade7f 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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 账号登录即可零配置使用云端模型。', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 9402ef3ef..b39ad4d6e 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -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 帳號登入即可零設定使用雲端模型。',