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
@@ -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}