Files
openclaw/extensions/lmstudio/src/runtime.test.ts
Frank Yang 431db078f2 [codex] Fix LM Studio header-auth follow-ups (#65806)
* fix: harden lmstudio header auth handling

* fix: suppress lmstudio shell env auth
2026-04-13 17:45:06 +08:00

311 lines
9.0 KiB
TypeScript

import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { CUSTOM_LOCAL_AUTH_MARKER } from "openclaw/plugin-sdk/provider-auth";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER } from "./defaults.js";
import {
buildLmstudioAuthHeaders,
resolveLmstudioConfiguredApiKey,
resolveLmstudioProviderHeaders,
resolveLmstudioRuntimeApiKey,
} from "./runtime.js";
const resolveApiKeyForProviderMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth-runtime")>();
return {
...actual,
resolveApiKeyForProvider: (...args: unknown[]) => resolveApiKeyForProviderMock(...args),
};
});
function buildLmstudioConfig(overrides?: {
apiKey?: unknown;
headers?: unknown;
auth?: "api-key";
}): OpenClawConfig {
return {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1",
api: "openai-completions",
...(overrides?.auth ? { auth: overrides.auth } : {}),
...(overrides?.apiKey !== undefined ? { apiKey: overrides.apiKey } : {}),
...(overrides?.headers !== undefined ? { headers: overrides.headers } : {}),
models: [],
},
},
},
} as OpenClawConfig;
}
describe("lmstudio-runtime", () => {
beforeEach(() => {
resolveApiKeyForProviderMock.mockReset();
});
it("throws when runtime auth resolves to blank and no configured key exists", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({
apiKey: " ",
source: "profile:lmstudio:default",
mode: "api-key",
});
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig({ auth: "api-key" }),
}),
).rejects.toThrow(/LM Studio API key is required/i);
});
it("falls back to configured env marker key when profile resolution fails", async () => {
resolveApiKeyForProviderMock.mockRejectedValueOnce(
new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'),
);
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig({
auth: "api-key",
apiKey: "${LM_API_TOKEN}",
}),
env: {
LM_API_TOKEN: "template-lmstudio-key",
},
}),
).resolves.toBe("template-lmstudio-key");
});
it("accepts synthesized lmstudio-local for non-explicit auth mode", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
source: "models.providers.lmstudio (synthetic local key)",
mode: "api-key",
});
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig(),
}),
).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER);
});
it("accepts synthesized lmstudio-local for explicit api-key mode", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
source: "models.providers.lmstudio (synthetic local key)",
mode: "api-key",
});
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig({ auth: "api-key" }),
}),
).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER);
});
it("accepts shared synthetic local marker for keyless runtime auth", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({
apiKey: CUSTOM_LOCAL_AUTH_MARKER,
source: "models.providers.lmstudio (synthetic local key)",
mode: "api-key",
});
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig(),
}),
).resolves.toBe(CUSTOM_LOCAL_AUTH_MARKER);
});
it("allows header-only runtime auth when Authorization is configured", async () => {
resolveApiKeyForProviderMock.mockRejectedValueOnce(
new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'),
);
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig({
headers: {
Authorization: "Bearer proxy-token",
},
}),
}),
).resolves.toBeUndefined();
});
it("suppresses profile runtime auth when Authorization is configured", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({
apiKey: "stale-profile-key",
source: "profile:lmstudio:default",
mode: "api-key",
});
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig({
headers: {
Authorization: "Bearer proxy-token",
},
}),
}),
).resolves.toBeUndefined();
});
it("suppresses env runtime auth when Authorization is configured", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({
apiKey: "stale-env-key",
source: "env:LM_API_TOKEN",
mode: "api-key",
});
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig({
headers: {
Authorization: "Bearer proxy-token",
},
}),
}),
).resolves.toBeUndefined();
});
it("suppresses shell env runtime auth when Authorization is configured", async () => {
resolveApiKeyForProviderMock.mockResolvedValueOnce({
apiKey: "stale-shell-env-key",
source: "shell env: LM_API_TOKEN",
mode: "api-key",
});
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig({
headers: {
Authorization: "Bearer proxy-token",
},
}),
}),
).resolves.toBeUndefined();
});
it("throws when explicit api-key mode cannot resolve any key", async () => {
resolveApiKeyForProviderMock.mockRejectedValue(
new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'),
);
await expect(
resolveLmstudioRuntimeApiKey({
config: buildLmstudioConfig({ auth: "api-key" }),
}),
).rejects.toThrow(/LM Studio API key is required/i);
await expect(
resolveLmstudioConfiguredApiKey({
config: buildLmstudioConfig({ auth: "api-key" }),
}),
).resolves.toBeUndefined();
});
it("resolves SecretRef api key and headers", async () => {
const headerRef = {
"X-Proxy-Auth": {
source: "env" as const,
provider: "default" as const,
id: "LMSTUDIO_PROXY_TOKEN",
},
};
await expect(
resolveLmstudioConfiguredApiKey({
config: buildLmstudioConfig({
apiKey: {
source: "env",
provider: "default",
id: "LM_API_TOKEN",
},
}),
env: {
LM_API_TOKEN: "secretref-lmstudio-key",
},
}),
).resolves.toBe("secretref-lmstudio-key");
await expect(
resolveLmstudioProviderHeaders({
config: buildLmstudioConfig({ headers: headerRef }),
env: {
LMSTUDIO_PROXY_TOKEN: "proxy-token",
},
headers: headerRef,
}),
).resolves.toEqual({
"X-Proxy-Auth": "proxy-token",
});
});
it("resolves env-template api keys from config", async () => {
await expect(
resolveLmstudioConfiguredApiKey({
config: buildLmstudioConfig({
apiKey: "${LM_API_TOKEN}",
}),
env: {
LM_API_TOKEN: "template-lmstudio-key",
},
}),
).resolves.toBe("template-lmstudio-key");
});
it("throws a path-specific error when a SecretRef header cannot be resolved", async () => {
const headerRef = {
"X-Proxy-Auth": {
source: "env" as const,
provider: "default" as const,
id: "LMSTUDIO_PROXY_TOKEN",
},
};
await expect(
resolveLmstudioProviderHeaders({
config: buildLmstudioConfig({ headers: headerRef }),
env: {},
headers: headerRef,
}),
).rejects.toThrow(/models\.providers\.lmstudio\.headers\.X-Proxy-Auth/i);
});
it("builds auth headers with key precedence and json support", () => {
expect(buildLmstudioAuthHeaders({})).toBeUndefined();
expect(buildLmstudioAuthHeaders({ apiKey: " sk-test " })).toEqual({
Authorization: "Bearer sk-test",
});
expect(buildLmstudioAuthHeaders({ apiKey: " " })).toBeUndefined();
expect(
buildLmstudioAuthHeaders({ apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER }),
).toBeUndefined();
expect(
buildLmstudioAuthHeaders({
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
headers: {
Authorization: "Bearer proxy-token",
},
}),
).toEqual({
Authorization: "Bearer proxy-token",
});
expect(
buildLmstudioAuthHeaders({
apiKey: "sk-new",
json: true,
headers: {
authorization: "Bearer sk-old",
"X-Proxy": "proxy-token",
},
}),
).toEqual({
"Content-Type": "application/json",
"X-Proxy": "proxy-token",
Authorization: "Bearer sk-new",
});
});
});