From 5245dec563f4ee614038c1f42cfbccc73252c57d Mon Sep 17 00:00:00 2001 From: GriggsRice Date: Sat, 11 Jul 2026 00:49:23 -0500 Subject: [PATCH] feat(browseros-agent): fetch LM Studio models live instead of a stale static list The Model picker for LM Studio providers sourced from a manually-run script snapshotting models.dev's generic registry, last refreshed weeks ago and never matching what's actually loaded on a user's machine. Picking a stale suggestion sent a model ID LM Studio didn't recognize, breaking chat and the Test Connection button. Add a live probe (useLMStudioProbe) against LM Studio's native /api/v0/models endpoint, which reports per-model load state and context length. The picker now shows real local models sorted with the loaded one first, and the existing context-window auto-fill picks up accurate values instead of stale/missing ones. Falls back to manual entry with a status hint when LM Studio is unreachable. Co-Authored-By: Claude Sonnet 5 --- .../lmstudio-probe.hooks.test.ts | 96 +++++++++++++++++++ .../llm-providers/lmstudio-probe.hooks.ts | 82 ++++++++++++++++ .../screens/ai-settings/NewProviderDialog.tsx | 57 +++++++---- .../apps/app/screens/ai-settings/models.ts | 1 + 4 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 packages/browseros-agent/apps/app/modules/llm-providers/lmstudio-probe.hooks.test.ts create mode 100644 packages/browseros-agent/apps/app/modules/llm-providers/lmstudio-probe.hooks.ts diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/lmstudio-probe.hooks.test.ts b/packages/browseros-agent/apps/app/modules/llm-providers/lmstudio-probe.hooks.test.ts new file mode 100644 index 0000000000..a359219afe --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/llm-providers/lmstudio-probe.hooks.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'bun:test' +import { + isLMStudioProbeEnabled, + parseLMStudioModels, + toLMStudioOrigin, +} from './lmstudio-probe.hooks' + +describe('toLMStudioOrigin', () => { + it('derives the origin from a base URL with a path', () => { + expect(toLMStudioOrigin('http://localhost:1234/v1')).toBe( + 'http://localhost:1234', + ) + }) + + it('returns undefined for an empty base URL', () => { + expect(toLMStudioOrigin('')).toBeUndefined() + expect(toLMStudioOrigin(undefined)).toBeUndefined() + }) + + it('returns undefined for an unparsable base URL', () => { + expect(toLMStudioOrigin('not a url')).toBeUndefined() + }) +}) + +describe('isLMStudioProbeEnabled', () => { + it('disables for non-lmstudio provider types', () => { + expect( + isLMStudioProbeEnabled({ + providerType: 'ollama', + baseUrl: 'http://localhost:11434/v1', + }), + ).toBe(false) + }) + + it('disables when baseUrl is missing', () => { + expect( + isLMStudioProbeEnabled({ providerType: 'lmstudio', baseUrl: undefined }), + ).toBe(false) + }) + + it('disables when explicit enabled flag is false', () => { + expect( + isLMStudioProbeEnabled({ + providerType: 'lmstudio', + baseUrl: 'http://localhost:1234/v1', + enabled: false, + }), + ).toBe(false) + }) + + it('enables for lmstudio with a parsable baseUrl', () => { + expect( + isLMStudioProbeEnabled({ + providerType: 'lmstudio', + baseUrl: 'http://localhost:1234/v1', + }), + ).toBe(true) + }) +}) + +describe('parseLMStudioModels', () => { + it('excludes embedding models', () => { + const result = parseLMStudioModels([ + { id: 'chat-model', type: 'llm', max_context_length: 8192 }, + { id: 'embed-model', type: 'embeddings', max_context_length: 2048 }, + ]) + expect(result.map((m) => m.modelId)).toEqual(['chat-model']) + }) + + it('prefers loaded_context_length over max_context_length', () => { + const result = parseLMStudioModels([ + { + id: 'chat-model', + type: 'llm', + state: 'loaded', + max_context_length: 131072, + loaded_context_length: 32768, + }, + ]) + expect(result[0]?.contextLength).toBe(32768) + }) + + it('falls back to 0 when no context length is reported', () => { + const result = parseLMStudioModels([{ id: 'chat-model', type: 'llm' }]) + expect(result[0]?.contextLength).toBe(0) + }) + + it('sorts loaded models before not-loaded, then alphabetically', () => { + const result = parseLMStudioModels([ + { id: 'zebra', type: 'llm', state: 'not-loaded' }, + { id: 'apple', type: 'llm', state: 'loaded' }, + { id: 'mango', type: 'vlm', state: 'not-loaded' }, + ]) + expect(result.map((m) => m.modelId)).toEqual(['apple', 'mango', 'zebra']) + }) +}) diff --git a/packages/browseros-agent/apps/app/modules/llm-providers/lmstudio-probe.hooks.ts b/packages/browseros-agent/apps/app/modules/llm-providers/lmstudio-probe.hooks.ts new file mode 100644 index 0000000000..3e060ca0dc --- /dev/null +++ b/packages/browseros-agent/apps/app/modules/llm-providers/lmstudio-probe.hooks.ts @@ -0,0 +1,82 @@ +import { useQuery } from '@tanstack/react-query' +import type { ProviderType } from '@/lib/llm-providers/types' + +export interface LMStudioProbeModel { + modelId: string + contextLength: number +} + +export interface LMStudioApiModel { + id: string + type?: string + state?: string + max_context_length?: number + loaded_context_length?: number +} + +export interface UseLMStudioProbeOptions { + providerType: ProviderType | undefined + baseUrl: string | undefined + enabled?: boolean +} + +// LM Studio's model list (and which model is loaded) changes underfoot as +// the user loads/unloads models in the app, so trust a fresh probe on every +// dialog open instead of a cached one (mirrors acp-probe.hooks.ts). +const PROBE_STALE_TIME_MS = 0 + +export function toLMStudioOrigin( + baseUrl: string | undefined, +): string | undefined { + if (!baseUrl) return undefined + try { + return new URL(baseUrl).origin + } catch { + return undefined + } +} + +export function isLMStudioProbeEnabled(opts: UseLMStudioProbeOptions): boolean { + if (!(opts.enabled ?? true)) return false + if (opts.providerType !== 'lmstudio') return false + return Boolean(toLMStudioOrigin(opts.baseUrl)) +} + +// LM Studio's native /api/v0/models (not the OpenAI-compatible /v1/models) +// reports type, load state, and context length per model. Embeddings models +// are excluded since they can't serve chat completions. +export function parseLMStudioModels( + data: LMStudioApiModel[], +): LMStudioProbeModel[] { + return data + .filter((m) => m.type === 'llm' || m.type === 'vlm') + .sort((a, b) => { + const loadedDiff = + Number(b.state === 'loaded') - Number(a.state === 'loaded') + return loadedDiff !== 0 ? loadedDiff : a.id.localeCompare(b.id) + }) + .map((m) => ({ + modelId: m.id, + contextLength: m.loaded_context_length ?? m.max_context_length ?? 0, + })) +} + +export function useLMStudioProbe(opts: UseLMStudioProbeOptions) { + const origin = toLMStudioOrigin(opts.baseUrl) + const enabled = isLMStudioProbeEnabled(opts) + + return useQuery({ + queryKey: ['lmstudio-probe', origin], + enabled, + staleTime: PROBE_STALE_TIME_MS, + retry: false, + queryFn: async () => { + const res = await fetch(`${origin}/api/v0/models`) + if (!res.ok) { + throw new Error(`LM Studio returned ${res.status}`) + } + const body = (await res.json()) as { data: LMStudioApiModel[] } + return parseLMStudioModels(body.data) + }, + }) +} diff --git a/packages/browseros-agent/apps/app/screens/ai-settings/NewProviderDialog.tsx b/packages/browseros-agent/apps/app/screens/ai-settings/NewProviderDialog.tsx index eaf6b8e520..b1427a1dc3 100644 --- a/packages/browseros-agent/apps/app/screens/ai-settings/NewProviderDialog.tsx +++ b/packages/browseros-agent/apps/app/screens/ai-settings/NewProviderDialog.tsx @@ -79,7 +79,8 @@ import { cn } from '@/lib/utils' import { useAgentServerUrl } from '@/modules/browseros/agent-server-url.hooks' import { useCapabilities } from '@/modules/browseros/capabilities.hooks' import { useAcpProbe } from '@/modules/llm-providers/acp-probe.hooks' -import { getModelContextLength, getModelsForProvider } from './models' +import { useLMStudioProbe } from '@/modules/llm-providers/lmstudio-probe.hooks' +import { getModelsForProvider } from './models' import { isCredentiallessProviderType, normalizeProviderFormValues, @@ -285,7 +286,15 @@ export const NewProviderDialog: FC = ({ } }, [acpProbe.data, watchedType, form]) - const modelInfoList = getModelsForProvider(watchedType as ProviderType) + const lmStudioProbe = useLMStudioProbe({ + providerType: watchedType as ProviderType, + baseUrl: watchedBaseUrl, + }) + + const modelInfoList = + watchedType === 'lmstudio' && lmStudioProbe.data + ? lmStudioProbe.data + : getModelsForProvider(watchedType as ProviderType) const modelFuse = useMemo( () => @@ -329,15 +338,14 @@ export const NewProviderDialog: FC = ({ if (initialValues?.id) return if (watchedModelId) { - const contextLength = getModelContextLength( - watchedType as ProviderType, - watchedModelId, - ) + const contextLength = modelInfoList.find( + (m) => m.modelId === watchedModelId, + )?.contextLength if (contextLength) { form.setValue('contextWindow', contextLength) } } - }, [watchedModelId, watchedType, form, initialValues?.id]) + }, [watchedModelId, form, initialValues?.id, modelInfoList]) useEffect(() => { if (initialValues) { @@ -1004,18 +1012,29 @@ export const NewProviderDialog: FC = ({ Model * {modelInfoList.length === 0 ? ( - - - + <> + + + + {watchedType === 'lmstudio' && ( + + {lmStudioProbe.isFetching + ? 'Fetching models from LM Studio…' + : lmStudioProbe.isError + ? `Could not reach LM Studio at ${watchedBaseUrl}. Make sure the local server is running.` + : 'No models loaded in LM Studio. Load a model, then reopen this dialog.'} + + )} + ) : ( > = { browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }], 'openai-compatible': [], ollama: [], + lmstudio: [], 'chatgpt-pro': [ { modelId: 'gpt-5.5', contextLength: 1050000 }, { modelId: 'gpt-5.4', contextLength: 1050000 },