diff --git a/packages/ai-config/src/__tests__/defaults.test.ts b/packages/ai-config/src/__tests__/defaults.test.ts index c565c78..5bc1e91 100644 --- a/packages/ai-config/src/__tests__/defaults.test.ts +++ b/packages/ai-config/src/__tests__/defaults.test.ts @@ -26,6 +26,8 @@ describe("provider connection defaults", () => { }); it("LMSTUDIO_DEFAULTS has expected endpoint", () => { + // OpenAI-compatible convention: the endpoint includes /v1; clients use + // it as-is (only a bare default host is normalized for back-compat). expect(LMSTUDIO_DEFAULTS.endpoint).toBe("http://localhost:1234/v1"); }); diff --git a/packages/ai-config/src/defaults.ts b/packages/ai-config/src/defaults.ts index f7dbd46..207ec5c 100644 --- a/packages/ai-config/src/defaults.ts +++ b/packages/ai-config/src/defaults.ts @@ -28,7 +28,11 @@ export const OLLAMA_DEFAULTS = { endpoint: "http://localhost:11434", } as const satisfies ResolvedConnection; -/** LM Studio default endpoint. */ +/** + * LM Studio default endpoint. Includes the `/v1` version segment, following + * the OpenAI-compatible base-URL convention (the client uses the endpoint + * as-is; only a bare default host is normalized for backward compatibility). + */ export const LMSTUDIO_DEFAULTS = { endpoint: "http://localhost:1234/v1", } as const satisfies ResolvedConnection; diff --git a/packages/ai-provider-bridge/src/__tests__/base-url.test.ts b/packages/ai-provider-bridge/src/__tests__/base-url.test.ts index b866fc4..6c543bc 100644 --- a/packages/ai-provider-bridge/src/__tests__/base-url.test.ts +++ b/packages/ai-provider-bridge/src/__tests__/base-url.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest"; import { normalizeBaseUrlForProvider } from "../base-url"; interface KnownProviderCase { - providerId: "anthropic" | "openai" | "gemini"; + providerId: "anthropic" | "openai" | "gemini" | "lmstudio"; host: string; versioned: string; } @@ -24,6 +24,7 @@ const KNOWN_PROVIDERS: KnownProviderCase[] = [ host: "https://generativelanguage.googleapis.com", versioned: "https://generativelanguage.googleapis.com/v1beta", }, + { providerId: "lmstudio", host: "http://localhost:1234", versioned: "http://localhost:1234/v1" }, ]; describe.each(KNOWN_PROVIDERS)( diff --git a/packages/ai-provider-bridge/src/__tests__/local-provider-manager.test.ts b/packages/ai-provider-bridge/src/__tests__/local-provider-manager.test.ts index 1cfb8a5..f6711e1 100644 --- a/packages/ai-provider-bridge/src/__tests__/local-provider-manager.test.ts +++ b/packages/ai-provider-bridge/src/__tests__/local-provider-manager.test.ts @@ -95,10 +95,33 @@ describe("LocalProviderManager", () => { it("returns cached value after initialize", async () => { vi.mocked(options.readSettings).mockResolvedValue({ - providers: { lmstudio: { endpoint: "http://localhost:1234" } }, + providers: { lmstudio: { endpoint: "http://localhost:1234/v1" } }, }); await manager.initialize(); - expect(manager.getEndpoint("lmstudio")).toBe("http://localhost:1234"); + expect(manager.getEndpoint("lmstudio")).toBe("http://localhost:1234/v1"); + }); + + it("corrects a stored bare default host to its versioned form (lmstudio)", async () => { + vi.mocked(options.readSettings).mockResolvedValue({ + providers: { + ollama: { endpoint: "http://localhost:11434" }, + lmstudio: { endpoint: "http://localhost:1234" }, + }, + }); + await manager.initialize(); + // Previously stored bare-root LM Studio endpoints keep working now + // that clients trust endpoints as given (see base-url.ts). + expect(manager.getEndpoint("lmstudio")).toBe("http://localhost:1234/v1"); + // Ollama has no version segment (native API) — returned as stored. + expect(manager.getEndpoint("ollama")).toBe("http://localhost:11434"); + }); + + it("returns a custom lmstudio endpoint as stored, without correction", async () => { + vi.mocked(options.readSettings).mockResolvedValue({ + providers: { lmstudio: { endpoint: "http://gpu-box:1234" } }, + }); + await manager.initialize(); + expect(manager.getEndpoint("lmstudio")).toBe("http://gpu-box:1234"); }); it("treats non-string endpoint values as unset", async () => { @@ -299,7 +322,7 @@ describe("LocalProviderManager", () => { vi.mocked(options.readSettings).mockResolvedValue({ providers: { ollama: { endpoint: "http://localhost:11434" }, - lmstudio: { endpoint: "http://localhost:1234" }, + lmstudio: { endpoint: "http://localhost:1234/v1" }, }, }); await manager.initialize(); @@ -310,7 +333,7 @@ describe("LocalProviderManager", () => { vi.mocked(options.readSettings).mockResolvedValue({ providers: { ollama: { endpoint: "http://localhost:99999" }, - lmstudio: { endpoint: "http://localhost:1234" }, + lmstudio: { endpoint: "http://localhost:1234/v1" }, }, }); @@ -322,7 +345,7 @@ describe("LocalProviderManager", () => { expect(callback).toHaveBeenCalledWith(["ollama"]); expect(manager.getEndpoint("ollama")).toBe("http://localhost:99999"); - expect(manager.getEndpoint("lmstudio")).toBe("http://localhost:1234"); + expect(manager.getEndpoint("lmstudio")).toBe("http://localhost:1234/v1"); }); }); diff --git a/packages/ai-provider-bridge/src/__tests__/phase4-protocol-routing.test.ts b/packages/ai-provider-bridge/src/__tests__/phase4-protocol-routing.test.ts index 01efb1d..d197225 100644 --- a/packages/ai-provider-bridge/src/__tests__/phase4-protocol-routing.test.ts +++ b/packages/ai-provider-bridge/src/__tests__/phase4-protocol-routing.test.ts @@ -151,7 +151,7 @@ describe("Posit AI protocol mapping", () => { describe("LMStudioClient protocol guard", () => { it("rejects openai-responses protocol", async () => { - const client = new LMStudioClient("http://localhost:1234"); + const client = new LMStudioClient("http://localhost:1234/v1"); const params: ModelClientChatParams = { model: "some-model", @@ -166,7 +166,7 @@ describe("LMStudioClient protocol guard", () => { }); it("rejects anthropic-messages protocol", async () => { - const client = new LMStudioClient("http://localhost:1234"); + const client = new LMStudioClient("http://localhost:1234/v1"); const params: ModelClientChatParams = { model: "some-model", @@ -181,7 +181,7 @@ describe("LMStudioClient protocol guard", () => { }); it("accepts openai-chat protocol (or legacy openai)", async () => { - const client = new LMStudioClient("http://localhost:1234"); + const client = new LMStudioClient("http://localhost:1234/v1"); const params: ModelClientChatParams = { model: "some-model", diff --git a/packages/ai-provider-bridge/src/__tests__/provider-test.test.ts b/packages/ai-provider-bridge/src/__tests__/provider-test.test.ts new file mode 100644 index 0000000..79d5bbd --- /dev/null +++ b/packages/ai-provider-bridge/src/__tests__/provider-test.test.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { testLMStudioProvider, testOllamaProvider } from "../providers/provider-test"; + +function mockFetchCapturingUrl(): { urls: string[] } { + const captured = { urls: [] as string[] }; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL | Request) => { + captured.urls.push(String(url)); + return new Response(JSON.stringify({ data: [], models: [] }), { status: 200 }); + }), + ); + return captured; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("testLMStudioProvider URL construction", () => { + it("uses a /v1 endpoint as-is (no double /v1)", async () => { + const captured = mockFetchCapturingUrl(); + await testLMStudioProvider("http://localhost:1234/v1"); + expect(captured.urls).toEqual(["http://localhost:1234/v1/models"]); + }); + + it("normalizes the bare default host for backward compatibility", async () => { + const captured = mockFetchCapturingUrl(); + await testLMStudioProvider("http://localhost:1234"); + expect(captured.urls).toEqual(["http://localhost:1234/v1/models"]); + }); + + it("leaves a custom versioned endpoint untouched", async () => { + const captured = mockFetchCapturingUrl(); + await testLMStudioProvider("http://gpu-box:1234/v1"); + expect(captured.urls).toEqual(["http://gpu-box:1234/v1/models"]); + }); +}); + +describe("testOllamaProvider URL construction", () => { + it("appends the native API path to the bare root", async () => { + const captured = mockFetchCapturingUrl(); + await testOllamaProvider("http://localhost:11434"); + expect(captured.urls).toEqual(["http://localhost:11434/api/tags"]); + }); +}); diff --git a/packages/ai-provider-bridge/src/base-url.ts b/packages/ai-provider-bridge/src/base-url.ts index dd2f453..44ddf52 100644 --- a/packages/ai-provider-bridge/src/base-url.ts +++ b/packages/ai-provider-bridge/src/base-url.ts @@ -37,11 +37,22 @@ export const GEMINI_HOST = "https://generativelanguage.googleapis.com"; /** Version segment `@ai-sdk/google` expects appended to the host. */ export const GEMINI_API_VERSION = "v1beta"; +/** + * LM Studio default local server host. Configured endpoints include the `/v1` + * segment (OpenAI-compatible convention); the bare default host is corrected + * at the config read seam (`LocalProviderManager.getEndpoint`) for backward + * compatibility with previously stored endpoints. + */ +export const LMSTUDIO_HOST = "http://localhost:1234"; +/** Version segment LM Studio's OpenAI-compatible API expects appended to the host. */ +export const LMSTUDIO_API_VERSION = "v1"; + /** Providers whose public API requires a version segment the SDK won't add. */ const KNOWN_HOSTS: Partial> = { anthropic: { host: ANTHROPIC_HOST, version: ANTHROPIC_API_VERSION }, openai: { host: OPENAI_HOST, version: OPENAI_API_VERSION }, gemini: { host: GEMINI_HOST, version: GEMINI_API_VERSION }, + lmstudio: { host: LMSTUDIO_HOST, version: LMSTUDIO_API_VERSION }, }; /** diff --git a/packages/ai-provider-bridge/src/local-providers.ts b/packages/ai-provider-bridge/src/local-providers.ts index ef0bfe7..333dd8f 100644 --- a/packages/ai-provider-bridge/src/local-providers.ts +++ b/packages/ai-provider-bridge/src/local-providers.ts @@ -17,6 +17,7 @@ * in sync with external edits. */ +import { normalizeBaseUrlForProvider } from "./base-url"; import type { Disposable } from "./CredentialProvider"; export type { Disposable }; @@ -32,6 +33,19 @@ export function isLocalProviderId(providerId: string): providerId is LocalProvid return LOCAL_PROVIDER_ID_SET.has(providerId); } +/** + * Canonical default endpoints for local providers. + * + * Ollama is the bare server root — its client uses the native API and appends + * `/api/...` paths itself. LM Studio follows the OpenAI-compatible convention: + * the endpoint includes the `/v1` version segment (a bare default host is + * normalized for backward compatibility). + */ +export const LOCAL_PROVIDER_DEFAULT_ENDPOINTS: Record = { + ollama: "http://localhost:11434", + lmstudio: "http://localhost:1234/v1", +}; + // ============================================================================ // Dependency Injection Interface // ============================================================================ @@ -100,9 +114,15 @@ export class LocalProviderManager { /** * Read the endpoint for a local provider from the in-memory cache. * Returns undefined if no endpoint is set. + * + * This is the config read seam for stored endpoints: a bare known default + * host is corrected to its versioned form (e.g. `http://localhost:1234` → + * `http://localhost:1234/v1` for LM Studio) so previously stored values + * keep working now that clients trust endpoints as given (see base-url.ts). */ getEndpoint(providerId: LocalProviderId): string | undefined { - return this.endpointCache.get(providerId); + const stored = this.endpointCache.get(providerId); + return stored === undefined ? undefined : normalizeBaseUrlForProvider(providerId, stored); } /** diff --git a/packages/ai-provider-bridge/src/model-clients/LMStudioClient.ts b/packages/ai-provider-bridge/src/model-clients/LMStudioClient.ts index 5643376..c4a72e9 100644 --- a/packages/ai-provider-bridge/src/model-clients/LMStudioClient.ts +++ b/packages/ai-provider-bridge/src/model-clients/LMStudioClient.ts @@ -5,8 +5,12 @@ /** * LM Studio API Client * - * LM Studio provides OpenAI-compatible API at /v1/chat/completions, - * so we delegate to OpenAIClient with configured endpoint URL. + * LM Studio provides an OpenAI-compatible API, so we delegate to OpenAIClient + * with the configured endpoint URL. Like other OpenAI-compatible providers, + * the configured endpoint already includes the version segment (e.g. + * `http://localhost:1234/v1`) and is trusted as given — bare-host correction + * happens at the config read seam (see base-url.ts and + * `LocalProviderManager.getEndpoint`), not here. */ import type { LMStreamPart } from "../types"; @@ -14,17 +18,17 @@ import { normalizeProtocol } from "../types"; import type { ModelClient, ModelClientChatParams } from "./ModelClient"; import { OpenAIClient } from "./OpenAIClient"; +// Host/version constants live in base-url.ts (which must stay free of this +// module's Node-only imports); re-exported here for the provider modules. +export { LMSTUDIO_API_VERSION, LMSTUDIO_HOST } from "../base-url"; + export class LMStudioClient implements ModelClient { private readonly openaiClient: OpenAIClient; constructor(endpoint: string) { - // LM Studio OpenAI-compatible endpoint: {endpoint}/v1 - // Example: http://localhost:1234/v1 - const baseURL = endpoint.endsWith("/") ? `${endpoint}v1` : `${endpoint}/v1`; - // LM Studio doesn't require API key - pass dummy string (LM Studio ignores auth headers) // Use 'completions' API mode since LM Studio doesn't support the Responses API - this.openaiClient = new OpenAIClient("lmstudio", baseURL, "completions"); + this.openaiClient = new OpenAIClient("lmstudio", endpoint, "completions"); } async chat(params: ModelClientChatParams): Promise> { diff --git a/packages/ai-provider-bridge/src/providers/lmstudio-provider.ts b/packages/ai-provider-bridge/src/providers/lmstudio-provider.ts index b40757c..97f006e 100644 --- a/packages/ai-provider-bridge/src/providers/lmstudio-provider.ts +++ b/packages/ai-provider-bridge/src/providers/lmstudio-provider.ts @@ -2,10 +2,14 @@ * Copyright (C) 2025 Posit Software, PBC. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { LMStudioClient } from "../model-clients/LMStudioClient"; +import { + LMSTUDIO_API_VERSION, + LMSTUDIO_HOST, + LMStudioClient, +} from "../model-clients/LMStudioClient"; import type { Logger, ModelInfo } from "../types"; import type { LocalCredentials } from "../types"; -import { joinPath } from "../utils"; +import { joinPath, normalizeProviderBaseUrl } from "../utils"; import { createCachedModelFetcher } from "./cached-model-fetcher"; import type { ProviderRegistry } from "./ProviderRegistry"; @@ -69,7 +73,12 @@ export function registerLMStudioProvider(registry: ProviderRegistry, logger: Log createCachedModelFetcher({ providerId: "lmstudio", resolveUrl: (credentials) => { - return joinPath(credentials.endpoint, "v1/models"); + // Endpoint already includes /v1 (bare default host corrected at the + // config read seam); normalizeProviderBaseUrl only trims here. + return joinPath( + normalizeProviderBaseUrl(credentials.endpoint, LMSTUDIO_HOST, LMSTUDIO_API_VERSION), + "models", + ); }, hasCredentials: (credentials) => Boolean(credentials.endpoint), createHeaders: () => ({ diff --git a/packages/ai-provider-bridge/src/providers/provider-test.ts b/packages/ai-provider-bridge/src/providers/provider-test.ts index c2375db..113cba9 100644 --- a/packages/ai-provider-bridge/src/providers/provider-test.ts +++ b/packages/ai-provider-bridge/src/providers/provider-test.ts @@ -9,6 +9,9 @@ * Both standalone and Positron consume these functions. */ +import { normalizeBaseUrlForProvider } from "../base-url"; +import { joinPath } from "../utils"; + export async function testOllamaProvider( endpoint: string, ): Promise<{ success: true; modelCount: number } | { success: false; error: string }> { @@ -38,7 +41,9 @@ export async function testOllamaProvider( export async function testLMStudioProvider( endpoint: string, ): Promise<{ success: true; modelCount: number } | { success: false; error: string }> { - const apiUrl = endpoint.endsWith("/") ? `${endpoint}v1/models` : `${endpoint}/v1/models`; + // The endpoint is raw user input (pre-save), so apply the same bare-host + // correction the config read seam applies to stored values. + const apiUrl = joinPath(normalizeBaseUrlForProvider("lmstudio", endpoint), "models"); try { const response = await fetch(apiUrl, {