Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'])
})
})
Original file line number Diff line number Diff line change
@@ -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<LMStudioProbeModel[]>({
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)
},
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -285,7 +286,15 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
}
}, [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(
() =>
Expand Down Expand Up @@ -329,15 +338,14 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
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) {
Expand Down Expand Up @@ -1004,18 +1012,29 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
<FormItem className="flex flex-col">
<FormLabel>Model *</FormLabel>
{modelInfoList.length === 0 ? (
<FormControl>
<Input
placeholder={
watchedType === 'azure'
? 'Enter your deployment name'
: watchedType === 'bedrock'
? 'e.g., anthropic.claude-3-5-sonnet-20241022-v2:0'
: 'Enter model ID'
}
{...field}
/>
</FormControl>
<>
<FormControl>
<Input
placeholder={
watchedType === 'azure'
? 'Enter your deployment name'
: watchedType === 'bedrock'
? 'e.g., anthropic.claude-3-5-sonnet-20241022-v2:0'
: 'Enter model ID'
}
{...field}
/>
</FormControl>
{watchedType === 'lmstudio' && (
<FormDescription>
{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.'}
</FormDescription>
)}
</>
) : (
<Popover
open={modelPickerOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const CUSTOM_PROVIDER_MODELS: Partial<Record<ProviderType, ModelInfo[]>> = {
browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }],
'openai-compatible': [],
ollama: [],
lmstudio: [],
'chatgpt-pro': [
{ modelId: 'gpt-5.5', contextLength: 1050000 },
{ modelId: 'gpt-5.4', contextLength: 1050000 },
Expand Down
Loading