Skip to content

Commit bd28127

Browse files
committed
support lmstudio
1 parent 0858ebb commit bd28127

8 files changed

Lines changed: 112 additions & 3 deletions

File tree

src/agent/llm-model.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,26 @@ describe("createLlmModel", () => {
2727
assert.ok(model);
2828
assert.equal(typeof model, "object");
2929
});
30+
31+
it("returns a model for lmstudio provider", () => {
32+
const model = createLlmModel({
33+
...base,
34+
llm: { provider: "lmstudio", model: "qwen/qwen3-4b-2507" },
35+
} as PqaConfig);
36+
assert.ok(model);
37+
assert.equal(typeof model, "object");
38+
});
39+
40+
it("uses custom baseURL for lmstudio provider", () => {
41+
const model = createLlmModel({
42+
...base,
43+
llm: {
44+
provider: "lmstudio",
45+
model: "qwen/qwen3-4b-2507",
46+
baseURL: "http://127.0.0.1:8080/v1",
47+
},
48+
} as PqaConfig);
49+
assert.ok(model);
50+
assert.equal(typeof model, "object");
51+
});
3052
});

src/agent/llm-model.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { createAnthropic } from "@ai-sdk/anthropic";
22
import { createFireworks } from "@ai-sdk/fireworks";
33
import { createGoogleGenerativeAI } from "@ai-sdk/google";
4+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
45
import { createOpenAI } from "@ai-sdk/openai";
56
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
67
import { ollama } from "ollama-ai-provider-v2";
78
import type { LanguageModel } from "ai";
8-
import { PQA_LLM_API_KEY } from "../config/load.js";
9+
import {
10+
DEFAULT_LMSTUDIO_BASE_URL,
11+
PQA_LLM_API_KEY,
12+
} from "../config/load.js";
913
import type { PqaConfig } from "../types/config.js";
1014

1115
function llmProviderSettings():
@@ -15,6 +19,15 @@ function llmProviderSettings():
1519
return apiKey ? { apiKey } : {};
1620
}
1721

22+
function createLmstudioProvider(config: PqaConfig) {
23+
const settings = {
24+
name: "lmstudio",
25+
baseURL: config.llm.baseURL ?? DEFAULT_LMSTUDIO_BASE_URL,
26+
...llmProviderSettings(),
27+
};
28+
return createOpenAICompatible(settings);
29+
}
30+
1831
/** Resolve the Vercel AI SDK language model for the configured LLM provider. */
1932
export function createLlmModel(config: PqaConfig): LanguageModel {
2033
const { provider, model } = config.llm;
@@ -31,6 +44,8 @@ export function createLlmModel(config: PqaConfig): LanguageModel {
3144
return createFireworks(settings)(model);
3245
case "ollama":
3346
return ollama(model);
47+
case "lmstudio":
48+
return createLmstudioProvider(config)(model);
3449
case "google":
3550
return createGoogleGenerativeAI(settings)(model);
3651
case "openrouter":

src/agent/provider-options.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ describe("buildProviderOptions", () => {
181181
);
182182
});
183183

184+
it("maps thinking budget to lmstudio reasoning effort", () => {
185+
assert.deepEqual(
186+
buildProviderOptions(
187+
config("lmstudio", { enabled: true, budgetTokens: 3_000 }),
188+
),
189+
{ lmstudio: { reasoningEffort: "low" } },
190+
);
191+
});
192+
184193
it("enables openrouter reasoning with budget", () => {
185194
assert.deepEqual(
186195
buildProviderOptions(

src/agent/provider-options.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ function anthropicEffort(
5252

5353
/**
5454
* Provider-specific options for extended thinking / reasoning.
55-
* Honors `config.llm.thinking.enabled` across Anthropic, OpenAI, Fireworks, Google, OpenRouter, and Ollama.
55+
* Honors `config.llm.thinking.enabled` across Anthropic, OpenAI, LM Studio, Fireworks, Google, OpenRouter, and Ollama.
5656
*/
5757
export function buildProviderOptions(
5858
config: PqaConfig,
@@ -84,6 +84,12 @@ export function buildProviderOptions(
8484
reasoningEffort: resolveOpenAIReasoningEffort(config),
8585
},
8686
};
87+
case "lmstudio":
88+
return {
89+
lmstudio: {
90+
reasoningEffort: resolveOpenAIReasoningEffort(config),
91+
},
92+
};
8793
case "fireworks":
8894
return {
8995
fireworks: {

src/config/load.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
missingLlmApiKey,
1212
missingLlmConfig,
1313
PQA_LLM_API_KEY,
14+
PQA_LLM_BASE_URL,
1415
resolveAgentGuardConfig,
1516
resolveAgentParallel,
1617
resolveBrowserHeaded,
@@ -186,6 +187,38 @@ describe("loadConfig", () => {
186187
}
187188
});
188189

190+
it("applies PQA_LLM_BASE_URL when llm.baseURL is unset", async () => {
191+
const cwd = mkdtempSync(path.join(tmpdir(), "pqa-config-"));
192+
const prevBaseUrl = process.env[PQA_LLM_BASE_URL];
193+
process.env[PQA_LLM_BASE_URL] = "http://127.0.0.1:8080/v1";
194+
try {
195+
const config = await loadConfig(undefined, cwd);
196+
assert.equal(config.llm.baseURL, "http://127.0.0.1:8080/v1");
197+
} finally {
198+
if (prevBaseUrl === undefined) delete process.env[PQA_LLM_BASE_URL];
199+
else process.env[PQA_LLM_BASE_URL] = prevBaseUrl;
200+
}
201+
});
202+
203+
it("prefers llm.baseURL from pqa.config.json over env var", async () => {
204+
const cwd = mkdtempSync(path.join(tmpdir(), "pqa-config-"));
205+
writeFileSync(
206+
path.join(cwd, "pqa.config.json"),
207+
JSON.stringify({
208+
llm: { baseURL: "http://custom.local:1234/v1" },
209+
}),
210+
);
211+
const prevBaseUrl = process.env[PQA_LLM_BASE_URL];
212+
process.env[PQA_LLM_BASE_URL] = "http://127.0.0.1:8080/v1";
213+
try {
214+
const config = await loadConfig(undefined, cwd);
215+
assert.equal(config.llm.baseURL, "http://custom.local:1234/v1");
216+
} finally {
217+
if (prevBaseUrl === undefined) delete process.env[PQA_LLM_BASE_URL];
218+
else process.env[PQA_LLM_BASE_URL] = prevBaseUrl;
219+
}
220+
});
221+
189222
it("deep-merges browser.lightpanda overrides", async () => {
190223
const cwd = mkdtempSync(path.join(tmpdir(), "pqa-config-"));
191224
writeFileSync(
@@ -222,6 +255,14 @@ describe("missingLlmApiKey", () => {
222255
};
223256
assert.equal(missingLlmApiKey(config), undefined);
224257
});
258+
259+
it("does not require an API key for lmstudio", () => {
260+
const config = {
261+
...minimalConfig("chrome"),
262+
llm: { provider: "lmstudio" as const, model: "qwen/qwen3-4b-2507" },
263+
};
264+
assert.equal(missingLlmApiKey(config), undefined);
265+
});
225266
});
226267

227268
describe("resolveSensitiveEnvVars", () => {

src/config/load.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,14 @@ type LlmProvider = NonNullable<PqaConfig["llm"]["provider"]>;
193193
function applyLlmEnvOverrides(config: PqaConfig): PqaConfig {
194194
const provider = process.env.PQA_LLM_PROVIDER as LlmProvider | undefined;
195195
const model = process.env.PQA_LLM_MODEL;
196+
const baseURL = process.env[PQA_LLM_BASE_URL];
196197
return {
197198
...config,
198199
llm: {
199200
...config.llm,
200201
...(provider && !config.llm.provider ? { provider } : {}),
201202
...(model && !config.llm.model ? { model } : {}),
203+
...(baseURL && !config.llm.baseURL ? { baseURL } : {}),
202204
},
203205
};
204206
}
@@ -334,6 +336,8 @@ export function resolveHealingConfig(config: PqaConfig): Required<
334336
}
335337

336338
export const PQA_LLM_API_KEY = "PQA_LLM_API_KEY";
339+
export const PQA_LLM_BASE_URL = "PQA_LLM_BASE_URL";
340+
export const DEFAULT_LMSTUDIO_BASE_URL = "http://localhost:1234/v1";
337341

338342
const LLM_PROVIDERS_REQUIRING_API_KEY = new Set<
339343
NonNullable<PqaConfig["llm"]["provider"]>

src/redact/env-secrets.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,13 @@ describe("resolveSensitiveEnvVars", () => {
146146
});
147147
assert.deepEqual(names, ["PQA_TEST_PASSWORD"]);
148148
});
149+
150+
it("omits LLM API key env var for lmstudio", () => {
151+
const names = resolveSensitiveEnvVars({
152+
...baseConfig,
153+
llm: { provider: "lmstudio", model: "qwen/qwen3-4b-2507" },
154+
envVars: ["PQA_TEST_PASSWORD"],
155+
});
156+
assert.deepEqual(names, ["PQA_TEST_PASSWORD"]);
157+
});
149158
});

src/types/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,16 @@ export interface PqaConfig {
100100
| "openai"
101101
| "fireworks"
102102
| "ollama"
103+
| "lmstudio"
103104
| "google"
104105
| "openrouter";
105106
model?: string;
107+
/** LM Studio OpenAI-compatible server URL. Default: http://localhost:1234/v1 */
108+
baseURL?: string;
106109
/**
107110
* Extended thinking / reasoning (opt-in).
108111
* `budgetTokens`: Anthropic, Fireworks & OpenRouter reasoning budget (ignored for Google).
109-
* `reasoningEffort`: OpenAI reasoning effort; Anthropic effort; Google thinking level (mapped); OpenRouter reasoning effort.
112+
* `reasoningEffort`: OpenAI reasoning effort; Anthropic effort; Google thinking level (mapped); OpenRouter reasoning effort; LM Studio reasoning effort.
110113
* Ollama: `think` only (other fields ignored).
111114
*/
112115
thinking?: {

0 commit comments

Comments
 (0)