feat(models): show LangBot Models availability

This commit is contained in:
RockChinQ
2026-09-11 21:17:01 +08:00
parent e631da0073
commit a3509b3626
20 changed files with 375 additions and 6 deletions
@@ -220,6 +220,21 @@ class SystemRouterGroup(group.RouterGroup):
return self.http_status(503, -1, str(exc))
return self.success(data=model)
@self.route(
'/model-availability',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
"""Expose Space's latest persisted model probes to the WebUI."""
try:
models = await self.ap.space_service.get_model_selection()
except Exception as exc:
self.ap.logger.warning(f'Failed to load LangBot Models availability: {exc}')
return self.http_status(503, -1, 'Model availability is unavailable')
return self.success(data={'models': [model.model_dump(mode='json') for model in models]})
@self.route(
'/tasks',
methods=['GET'],
+14 -3
View File
@@ -242,13 +242,14 @@ class SpaceService:
models_data = data.get('data', {}).get('models', [])
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
async def get_model_selection(self, category: str | None = None) -> typing.List[SpaceModelSelection]:
"""Return Space models in the availability-ranked selection order."""
space_url = self._get_space_config()['url']
session = httpclient.get_session()
params = {'category': category} if category else None
async with session.get(
f'{space_url}/api/v1/models/selection',
params={'category': category},
params=params,
) as response:
if response.status != 200:
error = await httpclient.read_text_limited(response)
@@ -266,7 +267,17 @@ class SpaceService:
models = []
for selection in data:
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
models.append(selection['model'])
model = dict(selection['model'])
availability = selection.get('availability')
if not isinstance(availability, dict):
# Accept the short-lived pre-release response shape.
availability = {
key: selection[key]
for key in ('up', 'last_probed_at', 'latency_ms', 'http_code')
if key in selection
}
model['availability'] = availability
models.append(model)
else:
models.append(selection)
return [SpaceModelSelection.model_validate(model) for model in models]
+12 -1
View File
@@ -49,8 +49,19 @@ class SpaceModel(pydantic.BaseModel):
updated_at: str | None = None
class SpaceModelAvailability(pydantic.BaseModel):
"""Latest availability probe. ``up`` is None when no probe exists."""
up: bool | None = None
last_probed_at: str | None = None
latency_ms: int = 0
http_code: int = 0
class SpaceModelSelection(pydantic.BaseModel):
"""Minimal model identity returned by the ranked selection endpoint."""
"""Model identity and the latest persisted probe from Space."""
uuid: str
model_id: str
category: str | None = None
availability: SpaceModelAvailability = pydantic.Field(default_factory=SpaceModelAvailability)
@@ -847,7 +847,15 @@ class TestSpaceServiceGetModelSelection:
data = {'models': models}
elif response_shape == 'availability-wrapper':
data = [
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
{
'model': model,
'availability': {
'up': True,
'last_probed_at': '2026-09-11T12:01:18Z',
'latency_ms': index + 10,
'http_code': 200,
},
}
for index, model in enumerate(models)
]
else:
@@ -870,11 +878,56 @@ class TestSpaceServiceGetModelSelection:
result = await service.get_model_selection('chat')
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
if response_shape == 'availability-wrapper':
assert result[0].availability.up is True
assert result[0].availability.last_probed_at == '2026-09-11T12:01:18Z'
assert result[0].availability.latency_ms == 10
session.get.assert_called_once_with(
'https://space.langbot.app/api/v1/models/selection',
params={'category': 'chat'},
)
async def test_selection_without_category_fetches_all_model_statuses(self):
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
service = SpaceService(ap)
payload = {
'code': 0,
'data': {
'models': [
{
'model': {
'uuid': 'embedding-model',
'model_id': 'text-embedding',
'category': 'embedding',
},
'availability': {'up': None, 'last_probed_at': None},
}
]
},
}
mock_response = MagicMock(status=200)
with (
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
patch(
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
new=AsyncMock(return_value=payload),
),
):
session = MagicMock()
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
get_session.return_value = session
result = await service.get_model_selection()
assert result[0].category == 'embedding'
assert result[0].availability.up is None
session.get.assert_called_once_with(
'https://space.langbot.app/api/v1/models/selection',
params=None,
)
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
persistence = SimpleNamespace(
@@ -71,6 +71,8 @@ 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 { useLangBotModelAvailability } from '@/app/home/components/model-availability/useLangBotModelAvailability';
function hasUsableUuid<T extends { uuid?: string | null }>(
item: T,
@@ -153,6 +155,33 @@ export default function DynamicFormItemComponent({
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
const isModelSelector = [
DynamicFormItemType.LLM_MODEL_SELECTOR,
DynamicFormItemType.EMBEDDING_MODEL_SELECTOR,
DynamicFormItemType.RERANK_MODEL_SELECTOR,
DynamicFormItemType.MODEL_FALLBACK_SELECTOR,
].includes(config.type);
const {
availability: langbotModelAvailability,
loaded: langbotModelAvailabilityLoaded,
} = useLangBotModelAvailability(
isModelSelector && !systemInfo.disable_models_service,
);
const renderModelAvailability = (model: {
uuid: string;
name: string;
provider?: { requester?: string };
}) =>
model.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER ? (
<ModelAvailabilityIndicator
availability={
langbotModelAvailability[model.uuid] ??
langbotModelAvailability[model.name]
}
show={langbotModelAvailabilityLoaded}
/>
) : null;
const fetchLlmModels = () => {
httpClient
@@ -584,6 +613,7 @@ export default function DynamicFormItemComponent({
<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" />
)}
@@ -688,6 +718,7 @@ export default function DynamicFormItemComponent({
<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" />
)}
@@ -784,6 +815,7 @@ export default function DynamicFormItemComponent({
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
{renderModelAvailability(model)}
</SelectItem>
))}
</SelectGroup>
@@ -876,6 +908,7 @@ export default function DynamicFormItemComponent({
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
{renderModelAvailability(model)}
</SelectItem>
))}
</SelectGroup>
@@ -941,6 +974,7 @@ export default function DynamicFormItemComponent({
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
{renderModelAvailability(model)}
</SelectItem>
))}
</SelectGroup>
@@ -1060,6 +1094,7 @@ export default function DynamicFormItemComponent({
<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" />
)}
@@ -1165,6 +1200,7 @@ export default function DynamicFormItemComponent({
<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" />
)}
@@ -0,0 +1,67 @@
import type { LangBotModelAvailability } from '@/app/infra/entities/api';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useTranslation } from 'react-i18next';
interface ModelAvailabilityIndicatorProps {
availability?: LangBotModelAvailability;
show: boolean;
}
export default function ModelAvailabilityIndicator({
availability,
show,
}: ModelAvailabilityIndicatorProps) {
const { t, i18n } = useTranslation();
if (!show) return null;
const state = availability?.up;
const label =
state === true
? t('models.availability.available')
: state === false
? t('models.availability.unavailable')
: t('models.availability.notChecked');
const dotClass =
state === true
? 'bg-emerald-500'
: state === false
? 'bg-destructive'
: 'bg-muted-foreground/50';
const checkedAt = availability?.last_probed_at
? new Intl.DateTimeFormat(i18n.language, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(availability.last_probed_at))
: null;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex size-4 shrink-0 items-center justify-center"
aria-label={label}
onMouseDown={(event) => event.preventDefault()}
>
<span className={`size-1.5 rounded-full ${dotClass}`} />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
<div className="space-y-0.5">
<p>{label}</p>
{checkedAt && (
<p className="text-xs text-muted-foreground">
{t('models.availability.lastChecked', { time: checkedAt })}
{availability && availability.latency_ms > 0
? ` · ${availability.latency_ms} ms`
: ''}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import type { LangBotModelAvailability } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
const CACHE_TTL_MS = 60_000;
type AvailabilityMap = Record<string, LangBotModelAvailability>;
let cachedAvailability: AvailabilityMap | null = null;
let cacheExpiresAt = 0;
let pendingRequest: Promise<AvailabilityMap> | null = null;
async function loadAvailability(): Promise<AvailabilityMap> {
if (cachedAvailability && Date.now() < cacheExpiresAt) {
return cachedAvailability;
}
if (pendingRequest) return pendingRequest;
pendingRequest = httpClient
.getLangBotModelAvailability()
.then((response) => {
const next: AvailabilityMap = {};
for (const item of response.models) {
next[item.uuid] = item.availability;
next[item.model_id] = item.availability;
}
cachedAvailability = next;
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return next;
})
.finally(() => {
pendingRequest = null;
});
return pendingRequest;
}
export function useLangBotModelAvailability(enabled = true) {
const [availability, setAvailability] = useState<AvailabilityMap>(
cachedAvailability ?? {},
);
const [loaded, setLoaded] = useState(
cachedAvailability !== null && Date.now() < cacheExpiresAt,
);
useEffect(() => {
if (!enabled) return;
let active = true;
loadAvailability()
.then((result) => {
if (!active) return;
setAvailability(result);
setLoaded(true);
})
.catch(() => {
// Availability is supplementary; model configuration remains usable.
});
return () => {
active = false;
};
}, [enabled]);
return { availability, loaded };
}
@@ -27,6 +27,7 @@ import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
import { useCurrentWorkspace } from '@/app/infra/http';
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
import { useLangBotModelAvailability } from '../model-availability/useLangBotModelAvailability';
interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open.
@@ -89,6 +90,10 @@ export default function ModelsPanel({
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
const {
availability: langbotModelAvailability,
loaded: langbotModelAvailabilityLoaded,
} = useLangBotModelAvailability(active && !systemInfo.disable_models_service);
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [spaceBilling, setSpaceBilling] =
@@ -554,6 +559,8 @@ export default function ModelsPanel({
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
spaceCredits={spaceBilling?.credits ?? null}
modelAvailability={langbotModelAvailability}
modelAvailabilityLoaded={langbotModelAvailabilityLoaded}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import {
LLMModel,
EmbeddingModel,
LangBotModelAvailability,
ReasoningConfig,
} from '@/app/infra/entities/api';
import {
@@ -24,12 +25,15 @@ import {
} from '../types';
import ExtraArgsEditor from './ExtraArgsEditor';
import { userInfo } from '@/app/infra/http';
import ModelAvailabilityIndicator from '../../model-availability/ModelAvailabilityIndicator';
interface ModelItemProps {
model: LLMModel | EmbeddingModel;
canManage: boolean;
modelType: ModelType;
isLangBotModels: boolean;
availability?: LangBotModelAvailability;
availabilityLoaded: boolean;
editModelPopoverOpen: string | null;
deleteConfirmOpen: string | null;
onOpenEditModel: (modelId: string) => void;
@@ -86,6 +90,8 @@ export default function ModelItem({
canManage,
modelType,
isLangBotModels,
availability,
availabilityLoaded,
editModelPopoverOpen,
deleteConfirmOpen,
onOpenEditModel,
@@ -199,6 +205,12 @@ export default function ModelItem({
>
<div className="flex 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')
@@ -9,7 +9,11 @@ import {
Radar,
} from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
import {
LangBotModelAvailability,
ModelProvider,
ReasoningConfig,
} from '@/app/infra/entities/api';
import { Button } from '@/components/ui/button';
import {
Collapsible,
@@ -47,6 +51,8 @@ interface ProviderCardProps {
isWorkspaceOwner: boolean;
ownerSpaceBound: boolean;
spaceCredits: number | null;
modelAvailability: Record<string, LangBotModelAvailability>;
modelAvailabilityLoaded: boolean;
// Popover states
addModelPopoverOpen: string | null;
editModelPopoverOpen: string | null;
@@ -115,6 +121,8 @@ export default function ProviderCard({
isWorkspaceOwner,
ownerSpaceBound,
spaceCredits,
modelAvailability,
modelAvailabilityLoaded,
addModelPopoverOpen,
editModelPopoverOpen,
deleteConfirmOpen,
@@ -424,6 +432,11 @@ export default function ProviderCard({
canManage={canManage}
modelType="llm"
isLangBotModels={isLangBotModels}
availability={
modelAvailability[model.uuid] ??
modelAvailability[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
@@ -475,6 +488,11 @@ export default function ProviderCard({
canManage={canManage}
modelType="embedding"
isLangBotModels={isLangBotModels}
availability={
modelAvailability[model.uuid] ??
modelAvailability[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
@@ -524,6 +542,11 @@ export default function ProviderCard({
canManage={canManage}
modelType="rerank"
isLangBotModels={isLangBotModels}
availability={
modelAvailability[model.uuid] ??
modelAvailability[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
+18
View File
@@ -158,6 +158,24 @@ export interface RerankModel {
extra_args?: object;
}
export interface LangBotModelAvailability {
up: boolean | null;
last_probed_at: string | null;
latency_ms: number;
http_code: number;
}
export interface LangBotModelAvailabilityItem {
uuid: string;
model_id: string;
category: string | null;
availability: LangBotModelAvailability;
}
export interface ApiRespLangBotModelAvailability {
models: LangBotModelAvailabilityItem[];
}
export interface ApiRespPipelines {
pipelines: Pipeline[];
}
+5
View File
@@ -63,6 +63,7 @@ import {
BotRouteDryRunRequest,
BotRouteDryRunResult,
BotEventRouteStatusResponse,
ApiRespLangBotModelAvailability,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import type { PluginLogEntry } from '@/app/infra/entities/plugin';
@@ -1250,6 +1251,10 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/system/wizard/recommended-model');
}
public getLangBotModelAvailability(): Promise<ApiRespLangBotModelAvailability> {
return this.get('/api/v1/system/model-availability');
}
public getAsyncTasks(params?: {
type?: string;
kind?: string;
+6
View File
@@ -304,6 +304,12 @@ const enUS = {
usesOwnerSpaceBilling:
"Uses the Workspace owner's LangBot Account billing and credits.",
noModels: 'No models configured',
availability: {
available: 'Available at last check',
unavailable: 'Unavailable at last check',
notChecked: 'No check result',
lastChecked: 'Checked {{time}}',
},
langbotModels: 'LangBot Models',
spaceTrialTooltip:
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
+6
View File
@@ -306,6 +306,12 @@ const esES = {
loginToUseModels:
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
noModels: 'No hay modelos configurados',
availability: {
available: 'Disponible en la última comprobación',
unavailable: 'No disponible en la última comprobación',
notChecked: 'Sin resultado de comprobación',
lastChecked: 'Comprobado {{time}}',
},
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.',
+6
View File
@@ -309,6 +309,12 @@ const jaJP = {
usesOwnerSpaceBilling:
'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。',
noModels: 'モデルがありません',
availability: {
available: '前回のチェックで利用可能',
unavailable: '前回のチェックで利用不可',
notChecked: 'チェック結果なし',
lastChecked: '{{time}} にチェック',
},
langbotModels: 'LangBot モデル',
spaceTrialTooltip:
'無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
+6
View File
@@ -303,6 +303,12 @@ const ruRU = {
loginToUseModels:
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
noModels: 'Модели не настроены',
availability: {
available: 'Доступна при последней проверке',
unavailable: 'Недоступна при последней проверке',
notChecked: 'Нет результата проверки',
lastChecked: 'Проверено {{time}}',
},
langbotModels: 'Модели LangBot',
spaceTrialTooltip:
'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
+6
View File
@@ -292,6 +292,12 @@ const thTH = {
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
availability: {
available: 'พร้อมใช้งานในการตรวจสอบล่าสุด',
unavailable: 'ไม่พร้อมใช้งานในการตรวจสอบล่าสุด',
notChecked: 'ไม่มีผลการตรวจสอบ',
lastChecked: 'ตรวจสอบเมื่อ {{time}}',
},
langbotModels: 'โมเดล LangBot',
spaceTrialTooltip:
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
+6
View File
@@ -300,6 +300,12 @@ const viVN = {
loginToUseModels:
'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây',
noModels: 'Chưa cấu hình mô hình nào',
availability: {
available: 'Khả dụng ở lần kiểm tra gần nhất',
unavailable: 'Không khả dụng ở lần kiểm tra gần nhất',
notChecked: 'Chưa có kết quả kiểm tra',
lastChecked: 'Đã kiểm tra {{time}}',
},
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.',
+6
View File
@@ -290,6 +290,12 @@ const zhHans = {
'工作区所有者需要绑定 LangBot 账号才能使用 LangBot 模型。',
usesOwnerSpaceBilling: '使用工作区所有者的 LangBot 账号计费与积分。',
noModels: '暂无模型',
availability: {
available: '上次检测可用',
unavailable: '上次检测不可用',
notChecked: '暂无检测结果',
lastChecked: '检测于 {{time}}',
},
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。',
+6
View File
@@ -281,6 +281,12 @@ const zhHant = {
loginWithSpace: '使用 LangBot 帳號登入',
loginToUseModels: '使用 LangBot 帳號登入以使用雲端模型',
noModels: '暫無模型',
availability: {
available: '上次檢測可用',
unavailable: '上次檢測不可用',
notChecked: '暫無檢測結果',
lastChecked: '檢測於 {{time}}',
},
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。',