Skip to content

Commit 505b840

Browse files
authored
Merge pull request #43 from hbmartin/lmstudio
Add LMStudio
2 parents d8ae0bf + e32585e commit 505b840

10 files changed

Lines changed: 384 additions & 171 deletions

File tree

package.json

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -131,32 +131,33 @@
131131
}
132132
},
133133
"devDependencies": {
134-
"@ai-sdk/amazon-bedrock": "^3.0.28",
134+
"@ai-sdk/amazon-bedrock": "^3.0.29",
135135
"@ai-sdk/anthropic": "^2.0.22",
136-
"@ai-sdk/azure": "^2.0.40",
136+
"@ai-sdk/azure": "^2.0.42",
137137
"@ai-sdk/cohere": "^2.0.12",
138138
"@ai-sdk/deepseek": "^1.0.20",
139139
"@ai-sdk/google": "^2.0.17",
140140
"@ai-sdk/mistral": "^2.0.17",
141-
"@ai-sdk/openai": "^2.0.40",
141+
"@ai-sdk/openai": "^2.0.42",
142+
"@ai-sdk/openai-compatible": "^1.0.19",
142143
"@ai-sdk/provider": "^2.0.0",
143144
"@arethetypeswrong/cli": "^0.18.2",
144145
"@eslint/js": "^9.36.0",
145146
"@openrouter/ai-sdk-provider": "^1.2.0",
146-
"@storybook/addon-docs": "^9.1.8",
147-
"@storybook/addon-onboarding": "^9.1.8",
148-
"@storybook/addon-vitest": "^9.1.8",
149-
"@storybook/react-vite": "^9.1.8",
147+
"@storybook/addon-docs": "^9.1.9",
148+
"@storybook/addon-onboarding": "^9.1.9",
149+
"@storybook/addon-vitest": "^9.1.9",
150+
"@storybook/react-vite": "^9.1.9",
150151
"@testing-library/dom": "^10.4.1",
151152
"@testing-library/jest-dom": "^6.8.0",
152153
"@testing-library/react": "^16.3.0",
153154
"@testing-library/user-event": "^14.6.1",
154155
"@types/node": "20.19.1",
155-
"@types/react": "^19.1.15",
156+
"@types/react": "^19.1.16",
156157
"@types/react-dom": "^19.1.9",
157158
"@vitejs/plugin-react": "^5.0.4",
158159
"@vitest/ui": "^3.2.4",
159-
"ai": "^5.0.57",
160+
"ai": "^5.0.59",
160161
"autoprefixer": "^10.4.21",
161162
"eslint": "^9.36.0",
162163
"eslint-config-prettier": "^10.1.8",
@@ -168,7 +169,7 @@
168169
"eslint-plugin-react-hooks": "^5.2.0",
169170
"eslint-plugin-react-refresh": "^0.4.22",
170171
"eslint-plugin-sonarjs": "^3.0.5",
171-
"eslint-plugin-storybook": "^9.1.8",
172+
"eslint-plugin-storybook": "^9.1.9",
172173
"eslint-plugin-unicorn": "^61.0.2",
173174
"eslint-plugin-unused-imports": "^4.2.0",
174175
"globals": "^16.4.0",
@@ -180,7 +181,7 @@
180181
"react": "^19.1.1",
181182
"react-dom": "^19.1.1",
182183
"react-hook-form": "^7.63.0",
183-
"storybook": "^9.1.8",
184+
"storybook": "^9.1.9",
184185
"tailwindcss": "^3.4.17",
185186
"tslib": "^2.8.1",
186187
"typescript": "~5.9.2",

pnpm-lock.yaml

Lines changed: 152 additions & 141 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/catalog/ModelCatalog.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,12 @@ export class ModelCatalog {
196196
);
197197

198198
if (prefetch) {
199-
const providerIdsWithCreds = await getProvidersWithCredentials(this.providerStorage);
199+
const providerIdsWithCreds = [
200+
...(await getProvidersWithCredentials(this.providerStorage)),
201+
...this.providerRegistry
202+
.getProvidersNotRequiringCredentials()
203+
.map((provider) => provider.metadata.id),
204+
];
200205
for (const providerId of providerIdsWithCreds) {
201206
try {
202207
await this.refresh(providerId);
@@ -311,7 +316,7 @@ export class ModelCatalog {
311316
return true;
312317
}
313318

314-
async refresh(providerId: ProviderId, opts?: { force?: boolean }): Promise<void> {
319+
async refresh(providerId: ProviderId): Promise<void> {
315320
if (this.ensureProviderState(providerId) === undefined) {
316321
return;
317322
}
@@ -327,15 +332,9 @@ export class ModelCatalog {
327332
const run = (async () => {
328333
try {
329334
const config = await getProviderConfiguration(this.providerStorage, providerId);
330-
const valid = config ? provider.configuration.validateConfig(config).ok : false;
331-
if (!valid && opts?.force !== true) {
332-
this.telemetry?.onProviderInvalidConfig?.(providerId);
333-
this.setStatus(providerId, 'missing-config');
334-
return;
335-
}
336335
this.setStatus(providerId, 'refreshing');
337336
this.telemetry?.onFetchStart?.(providerId);
338-
const models = await provider.fetchModels();
337+
const models = await provider.fetchModels(config);
339338
if (models.length > 0) {
340339
const changed = this.mergeModelsIntoSnapshot(provider, models, 'api');
341340
// TODO: remove / update builtin models based on API response
@@ -351,7 +350,12 @@ export class ModelCatalog {
351350
} catch (error) {
352351
const err = error instanceof Error ? error : new Error(String(error));
353352
this.telemetry?.onFetchError?.(providerId, err);
354-
this.setStatus(providerId, 'ready', err.message);
353+
this.setStatus(
354+
providerId,
355+
'ready',
356+
err.message + (err.cause === undefined ? '' : `: ${JSON.stringify(err.cause)}`)
357+
);
358+
console.error('refresh error', err);
355359
}
356360
})().finally(() => {
357361
this.pendingRefreshes.delete(providerId);
@@ -361,7 +365,12 @@ export class ModelCatalog {
361365
}
362366

363367
async refreshAll(): Promise<void> {
364-
const providers = await getProvidersWithCredentials(this.providerStorage);
368+
const providers = [
369+
...(await getProvidersWithCredentials(this.providerStorage)),
370+
...this.providerRegistry
371+
.getProvidersNotRequiringCredentials()
372+
.map((provider) => provider.metadata.id),
373+
];
365374
const providersWithCreds = providers.filter((pid) => this.providerRegistry.hasProvider(pid));
366375
for (const pid of providersWithCreds) {
367376
await this.refresh(pid);

src/lib/hooks/useModelCatalog.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ export function useModelCatalog({
3535

3636
const actions = useMemo(
3737
() => ({
38-
refresh: (providerId: ProviderId, opts?: { force?: boolean }): Promise<void> =>
39-
catalog.refresh(providerId, opts),
38+
refresh: (providerId: ProviderId): Promise<void> => catalog.refresh(providerId),
4039
refreshAll: () => void catalog.refreshAll(),
4140
addUserModel: (providerId: ProviderId, modelId: ModelId) =>
4241
catalog.addUserModel(providerId, modelId),

src/lib/hooks/useModelsWithConfiguredProvider.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,15 @@ export function useModelsWithConfiguredProvider(
128128
]);
129129

130130
// Track providers with credentials
131-
const providers = providersWithCredentials.filter((pid) =>
131+
const providersRequiringCredentials = providersWithCredentials.filter((pid) =>
132132
providerRegistry.hasProvider(pid)
133133
);
134+
const providersNotRequiringCredentials = providerRegistry
135+
.getProvidersNotRequiringCredentials()
136+
.map((provider) => provider.metadata.id);
137+
const providers = [
138+
...new Set([...providersRequiringCredentials, ...providersNotRequiringCredentials]),
139+
];
134140
setProvidersWithCreds(providers);
135141

136142
// Recently used list, but only for models that currently exist in snapshot

src/lib/icons/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22
import { createElement } from 'react';
33
import type { IconComponent } from '../types';
44

5+
export const LmStudioIcon: IconComponent = (props) =>
6+
createElement(
7+
'svg',
8+
{
9+
viewBox: '0 0 24 24',
10+
fill: 'currentColor',
11+
...props,
12+
},
13+
createElement('path', {
14+
d: 'M2.84 2a1.273 1.273 0 100 2.547h14.107a1.273 1.273 0 100-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H22.04a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h14.106a1.274 1.274 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H15.38a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h14.106a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h9.698a1.273 1.273 0 100-2.547h-9.698z',
15+
fillOpacity: '0.3',
16+
}),
17+
createElement('path', {
18+
d: 'M2.84 2a1.273 1.273 0 100 2.547h10.287a1.274 1.274 0 000-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H18.22a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H11.56a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h5.78a1.273 1.273 0 100-2.547h-5.78z',
19+
})
20+
);
21+
522
export const OpenRouterIcon: IconComponent = (props) =>
623
createElement(
724
'svg',
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
type OpenAIModule = typeof import('@ai-sdk/openai-compatible');
2+
import type { OpenAICompatibleProviderSettings } from '@ai-sdk/openai-compatible';
3+
import type { LanguageModelV2 } from '@ai-sdk/provider';
4+
import type { ModelConfig, ProviderMetadata, ProviderInstanceParams } from '../types';
5+
import { AIProvider, createModelId, createProviderId, isObject } from '../types';
6+
import { LmStudioIcon } from '../icons';
7+
import { baseUrlField, makeConfiguration, type ConfigAPI } from './configuration';
8+
9+
type LmStudioModelType = 'llm' | 'embeddings';
10+
11+
interface LmStudioModel {
12+
id: string;
13+
type: LmStudioModelType;
14+
max_context_length?: number;
15+
capabilities?: string[];
16+
}
17+
18+
interface LmStudioModelListResponse {
19+
data: LmStudioModel[];
20+
}
21+
22+
function isLmStudioModel(value: unknown): value is LmStudioModel {
23+
if (!isObject(value)) {
24+
return false;
25+
}
26+
27+
const candidate = value as {
28+
id?: unknown;
29+
type?: unknown;
30+
max_context_length?: unknown;
31+
capabilities?: unknown;
32+
};
33+
34+
if (typeof candidate.id !== 'string') {
35+
return false;
36+
}
37+
38+
if (candidate.type !== 'llm' && candidate.type !== 'embeddings') {
39+
return false;
40+
}
41+
42+
if (
43+
candidate.max_context_length !== undefined &&
44+
typeof candidate.max_context_length !== 'number'
45+
) {
46+
return false;
47+
}
48+
49+
if (
50+
candidate.capabilities !== undefined &&
51+
(!Array.isArray(candidate.capabilities) ||
52+
candidate.capabilities.some((capability) => typeof capability !== 'string'))
53+
) {
54+
return false;
55+
}
56+
57+
return true;
58+
}
59+
60+
function isLmStudioModelListResponse(value: unknown): value is LmStudioModelListResponse {
61+
if (!isObject(value)) {
62+
return false;
63+
}
64+
65+
const candidate = value as { data?: unknown };
66+
67+
if (!Array.isArray(candidate.data)) {
68+
return false;
69+
}
70+
71+
return candidate.data.every((item) => isLmStudioModel(item));
72+
}
73+
74+
export class LmStudioProvider extends AIProvider {
75+
override readonly metadata: ProviderMetadata = {
76+
id: createProviderId('lmstudio'),
77+
name: 'LMStudio',
78+
description: 'Use GPT-4o, GPT-4, or other OpenAI models',
79+
icon: LmStudioIcon,
80+
documentationUrl: 'https://lmstudio.ai/docs/app/api/endpoints/openai',
81+
fetchModelListPath: '/api/v0/models',
82+
};
83+
84+
override readonly models: ModelConfig[] = [];
85+
86+
override readonly configuration: ConfigAPI<OpenAICompatibleProviderSettings> =
87+
makeConfiguration<OpenAICompatibleProviderSettings>()({
88+
fields: [baseUrlField('http://localhost:1234/v1')],
89+
});
90+
91+
override async fetchModels(
92+
options: ProviderInstanceParams['options'] | undefined
93+
): Promise<ModelConfig[]> {
94+
if (this.metadata.fetchModelListPath === undefined) {
95+
return [];
96+
}
97+
98+
this.configuration.assertValidConfigAndRemoveEmptyKeys(options);
99+
100+
const baseUrl =
101+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, sonarjs/different-types-comparison
102+
options !== undefined && 'baseURL' in options && options.baseURL.trim().length > 0
103+
? options.baseURL
104+
: 'http://localhost:1234/v1';
105+
106+
let requestUrl: URL;
107+
try {
108+
const base = new URL(baseUrl);
109+
requestUrl = new URL(this.metadata.fetchModelListPath, base.origin);
110+
} catch (error) {
111+
const reason = error instanceof Error ? error.message : String(error);
112+
throw new Error(`Invalid LM Studio base URL: ${reason}`);
113+
}
114+
115+
const response = await fetch(requestUrl);
116+
if (!response.ok) {
117+
throw new Error(
118+
`Failed to fetch LM Studio models: ${String(response.status)} ${response.statusText}`
119+
);
120+
}
121+
122+
const payload = (await response.json()) as unknown;
123+
if (!isLmStudioModelListResponse(payload)) {
124+
throw new TypeError('Unexpected LM Studio model list response payload');
125+
}
126+
127+
return payload.data
128+
.filter((model) => model.type !== 'embeddings')
129+
.map((model) => ({
130+
id: createModelId(model.id),
131+
displayName: model.id,
132+
contextLength: model.max_context_length,
133+
supportsTools: model.capabilities?.includes('tool_use') ?? false,
134+
discoveredAt: Date.now(),
135+
}));
136+
}
137+
138+
async createInstance(params: ProviderInstanceParams): Promise<LanguageModelV2> {
139+
// Dynamic import to avoid bundling if not needed
140+
let openai: OpenAIModule;
141+
142+
try {
143+
// This will be a peer dependency
144+
openai = await import('@ai-sdk/openai-compatible');
145+
} catch {
146+
throw new Error(
147+
'LmStudio provider requires "@ai-sdk/openai-compatible" to be installed. ' +
148+
'Please install it with: npm install @ai-sdk/openai-compatible'
149+
);
150+
}
151+
152+
this.configuration.assertValidConfigAndRemoveEmptyKeys(params.options);
153+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
154+
params.options.baseURL ??= 'http://localhost:1234/v1';
155+
const client = openai.createOpenAICompatible(params.options);
156+
return client(params.model);
157+
}
158+
}

src/lib/providers/ProviderRegistry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,10 @@ export class ProviderRegistry implements IProviderRegistry {
9191
unregister(providerId: ProviderId): boolean {
9292
return this.providers.delete(providerId);
9393
}
94+
95+
getProvidersNotRequiringCredentials(): AIProvider[] {
96+
return [...this.providers.values()].filter(
97+
(provider) => provider.configuration.validateConfig({}).ok
98+
);
99+
}
94100
}

src/lib/providers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BedrockProvider } from './BedrockProvider';
66
import { CohereProvider } from './CohereProvider';
77
import { DeepseekProvider } from './DeepseekProvider';
88
import { GoogleProvider } from './GoogleProvider';
9+
import { LmStudioProvider } from './LmStudioProvider';
910
import { MistralProvider } from './MistralProvider';
1011
import { MoonshotProvider } from './MoonshotProvider';
1112
import { OpenAIProvider } from './OpenAIProvider';
@@ -34,6 +35,7 @@ export const allProviders = {
3435
[createProviderId('bedrock')]: BedrockProvider,
3536
[createProviderId('cohere')]: CohereProvider,
3637
[createProviderId('deepseek')]: DeepseekProvider,
38+
[createProviderId('lmstudio')]: LmStudioProvider,
3739
[createProviderId('mistral')]: MistralProvider,
3840
[createProviderId('moonshot')]: MoonshotProvider,
3941
[createProviderId('openrouter')]: OpenRouterProvider,

0 commit comments

Comments
 (0)