Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/ai-config/src/__tests__/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
6 changes: 5 additions & 1 deletion packages/ai-config/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/ai-provider-bridge/src/__tests__/base-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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)(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand All @@ -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" },
},
});

Expand All @@ -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");
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions packages/ai-provider-bridge/src/__tests__/provider-test.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
11 changes: 11 additions & 0 deletions packages/ai-provider-bridge/src/base-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<ProviderId, { host: string; version: string }>> = {
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 },
};

/**
Expand Down
22 changes: 21 additions & 1 deletion packages/ai-provider-bridge/src/local-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* in sync with external edits.
*/

import { normalizeBaseUrlForProvider } from "./base-url";
import type { Disposable } from "./CredentialProvider";

export type { Disposable };
Expand All @@ -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<LocalProviderId, string> = {
ollama: "http://localhost:11434",
lmstudio: "http://localhost:1234/v1",
};

// ============================================================================
// Dependency Injection Interface
// ============================================================================
Expand Down Expand Up @@ -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);
}

/**
Expand Down
18 changes: 11 additions & 7 deletions packages/ai-provider-bridge/src/model-clients/LMStudioClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,30 @@
/**
* 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";
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<AsyncIterable<LMStreamPart>> {
Expand Down
15 changes: 12 additions & 3 deletions packages/ai-provider-bridge/src/providers/lmstudio-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -69,7 +73,12 @@ export function registerLMStudioProvider(registry: ProviderRegistry, logger: Log
createCachedModelFetcher<LocalCredentials>({
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: () => ({
Expand Down
7 changes: 6 additions & 1 deletion packages/ai-provider-bridge/src/providers/provider-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> {
Expand Down Expand Up @@ -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, {
Expand Down