Skip to content

Commit fffb8c9

Browse files
MonkeyLeeThxy91819
andauthored
fix(lmstudio): resolve env-template API keys (#80568)
Merged via squash. Prepared head SHA: 03224c8 Co-authored-by: MonkeyLeeT <6754057+MonkeyLeeT@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819
1 parent 023e33c commit fffb8c9

5 files changed

Lines changed: 122 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
5353
- Gateway/sessions: clear stored CLI provider resume bindings on non-subagent `/reset` so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.
5454
- Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.
5555
- Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.
56+
- LM Studio: resolve env-template API keys like `${LMSTUDIO_API_KEY}` through the standard SecretInput path instead of sending the raw template as the bearer token, and preserve header-auth and discovery-key precedence when the template is unset. Fixes #80495. (#80568) Thanks @MonkeyLeeT.
5657
- Discord/subagents: route the initial reply from thread-bound delegated sessions into the bound Discord thread instead of the parent channel. Fixes #83170. (#83172) Thanks @100menotu001.
5758
- Gateway/sessions: rotate failed agent sessions when their transcript file is missing instead of wedging per-channel lanes. Fixes #83488. (#83553) Thanks @LLagoon3.
5859
- Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.

extensions/lmstudio/src/runtime.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,24 @@ describe("lmstudio-runtime", () => {
140140
).resolves.toBeUndefined();
141141
});
142142

143+
it("allows header-only runtime auth when an api key env template is unset", async () => {
144+
resolveApiKeyForProviderMock.mockRejectedValueOnce(
145+
new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'),
146+
);
147+
148+
await expect(
149+
resolveLmstudioRuntimeApiKey({
150+
config: buildLmstudioConfig({
151+
apiKey: "${LMSTUDIO_API_KEY}",
152+
headers: {
153+
Authorization: "Bearer proxy-token",
154+
},
155+
}),
156+
env: {},
157+
}),
158+
).resolves.toBeUndefined();
159+
});
160+
143161
it("suppresses profile runtime auth when Authorization is configured", async () => {
144162
resolveApiKeyForProviderMock.mockResolvedValueOnce({
145163
apiKey: "stale-profile-key",
@@ -261,6 +279,30 @@ describe("lmstudio-runtime", () => {
261279
).resolves.toBe("template-lmstudio-key");
262280
});
263281

282+
it("resolves arbitrary env-template api keys from config", async () => {
283+
await expect(
284+
resolveLmstudioConfiguredApiKey({
285+
config: buildLmstudioConfig({
286+
apiKey: "${LMSTUDIO_API_KEY}",
287+
}),
288+
env: {
289+
LMSTUDIO_API_KEY: "custom-template-lmstudio-key",
290+
},
291+
}),
292+
).resolves.toBe("custom-template-lmstudio-key");
293+
});
294+
295+
it("throws a path-specific error when an env-template api key cannot be resolved", async () => {
296+
await expect(
297+
resolveLmstudioConfiguredApiKey({
298+
config: buildLmstudioConfig({
299+
apiKey: "${LMSTUDIO_API_KEY}",
300+
}),
301+
env: {},
302+
}),
303+
).rejects.toThrow(/models\.providers\.lmstudio\.apiKey/i);
304+
});
305+
264306
it("throws a path-specific error when a SecretRef header cannot be resolved", async () => {
265307
const headerRef = {
266308
"X-Proxy-Auth": {

extensions/lmstudio/src/runtime.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,40 @@ export async function resolveLmstudioConfiguredApiKey(params: {
7474
config?: OpenClawConfig;
7575
env?: NodeJS.ProcessEnv;
7676
path?: string;
77+
allowUnresolved?: boolean;
7778
}): Promise<string | undefined> {
7879
const providerConfig = params.config?.models?.providers?.[LMSTUDIO_PROVIDER_ID];
7980
const apiKeyInput = providerConfig?.apiKey;
8081
if (apiKeyInput === undefined || apiKeyInput === null) {
8182
return undefined;
8283
}
8384

85+
const path = params.path ?? "models.providers.lmstudio.apiKey";
86+
const env = params.env ?? process.env;
8487
const directApiKey = normalizeOptionalSecretInput(apiKeyInput);
8588
if (directApiKey !== undefined) {
86-
const trimmed = normalizeApiKeyConfig(directApiKey).trim();
89+
const resolved = params.config
90+
? await resolveConfiguredSecretInputString({
91+
config: params.config,
92+
env,
93+
value: directApiKey,
94+
path,
95+
unresolvedReasonStyle: "detailed",
96+
})
97+
: { value: directApiKey };
98+
if (resolved.unresolvedRefReason) {
99+
if (params.allowUnresolved) {
100+
return undefined;
101+
}
102+
throw new Error(`${path}: ${resolved.unresolvedRefReason}`);
103+
}
104+
const resolvedValue = normalizeOptionalSecretInput(resolved.value);
105+
const trimmed = resolvedValue ? normalizeApiKeyConfig(resolvedValue).trim() : "";
87106
if (!trimmed) {
88107
return undefined;
89108
}
90109
if (isKnownEnvApiKeyMarker(trimmed)) {
91-
const envValue = normalizeOptionalSecretInput((params.env ?? process.env)[trimmed]);
110+
const envValue = normalizeOptionalSecretInput(env[trimmed]);
92111
return envValue;
93112
}
94113
return isNonSecretApiKeyMarker(trimmed) ? undefined : trimmed;
@@ -97,15 +116,17 @@ export async function resolveLmstudioConfiguredApiKey(params: {
97116
if (!params.config) {
98117
return undefined;
99118
}
100-
const path = params.path ?? "models.providers.lmstudio.apiKey";
101119
const resolved = await resolveConfiguredSecretInputString({
102120
config: params.config,
103-
env: params.env ?? process.env,
121+
env,
104122
value: apiKeyInput,
105123
path,
106124
unresolvedReasonStyle: "detailed",
107125
});
108126
if (resolved.unresolvedRefReason) {
127+
if (params.allowUnresolved) {
128+
return undefined;
129+
}
109130
throw new Error(`${path}: ${resolved.unresolvedRefReason}`);
110131
}
111132
const resolvedValue = normalizeOptionalSecretInput(resolved.value);
@@ -205,6 +226,7 @@ export async function resolveLmstudioRuntimeApiKey(params: {
205226
configuredApiKeyPromise ??= resolveLmstudioConfiguredApiKey({
206227
config,
207228
env: params.env,
229+
allowUnresolved: hasAuthorizationHeader,
208230
});
209231
return await configuredApiKeyPromise;
210232
};

extensions/lmstudio/src/setup.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,20 @@ describe("lmstudio setup", () => {
11411141
},
11421142
},
11431143
},
1144+
{
1145+
name: "ignores unresolved apiKey template when Authorization header is configured",
1146+
providerPatch: {
1147+
apiKey: "${LMSTUDIO_API_KEY}",
1148+
headers: {
1149+
Authorization: "Bearer custom-token",
1150+
},
1151+
},
1152+
expectedProviderPatch: {
1153+
headers: {
1154+
Authorization: "Bearer custom-token",
1155+
},
1156+
},
1157+
},
11441158
{
11451159
name: "still injects lmstudio-local when only non-auth headers are configured",
11461160
providerPatch: {
@@ -1352,6 +1366,38 @@ describe("lmstudio setup", () => {
13521366
});
13531367
});
13541368

1369+
it("discoverLmstudioProvider ignores an unresolved apiKey template when discoveryApiKey is resolved", async () => {
1370+
discoverLmstudioModelsMock.mockResolvedValueOnce([
1371+
createModel("qwen3-8b-instruct", "Qwen3 8B"),
1372+
]);
1373+
1374+
await discoverLmstudioProvider(
1375+
buildDiscoveryContext({
1376+
discoveryApiKey: "resolved-discovery-key",
1377+
config: {
1378+
models: {
1379+
providers: {
1380+
lmstudio: {
1381+
baseUrl: "http://localhost:1234/v1",
1382+
api: "openai-completions",
1383+
apiKey: "${LMSTUDIO_API_KEY}",
1384+
models: [],
1385+
},
1386+
},
1387+
},
1388+
} as OpenClawConfig,
1389+
env: {},
1390+
}),
1391+
);
1392+
1393+
expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({
1394+
baseUrl: "http://localhost:1234/v1",
1395+
apiKey: "resolved-discovery-key",
1396+
headers: undefined,
1397+
quiet: false,
1398+
});
1399+
});
1400+
13551401
it("discoverLmstudioProvider suppresses stale discovery apiKey when Authorization header auth is configured", async () => {
13561402
discoverLmstudioModelsMock.mockResolvedValueOnce([
13571403
createModel("qwen3-8b-instruct", "Qwen3 8B"),

extensions/lmstudio/src/setup.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -760,32 +760,33 @@ export async function discoverLmstudioProvider(ctx: ProviderCatalogContext): Pro
760760
}
761761
const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
762762
const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID);
763-
let configuredDiscoveryApiKey: string | undefined;
763+
let resolvedHeaders: Record<string, string> | undefined;
764764
try {
765-
configuredDiscoveryApiKey = await resolveLmstudioConfiguredApiKey({
765+
resolvedHeaders = await resolveLmstudioProviderHeaders({
766766
config: ctx.config,
767767
env: ctx.env,
768+
headers: explicit?.headers,
768769
});
769770
} catch (error) {
770771
if (isLmstudioDiscoveryConfigResolutionError(error)) {
771772
return null;
772773
}
773774
throw error;
774775
}
775-
let resolvedHeaders: Record<string, string> | undefined;
776+
const hasAuthorizationHeader = hasLmstudioAuthorizationHeader(resolvedHeaders);
777+
let configuredDiscoveryApiKey: string | undefined;
776778
try {
777-
resolvedHeaders = await resolveLmstudioProviderHeaders({
779+
configuredDiscoveryApiKey = await resolveLmstudioConfiguredApiKey({
778780
config: ctx.config,
779781
env: ctx.env,
780-
headers: explicit?.headers,
782+
allowUnresolved: hasAuthorizationHeader || Boolean(discoveryApiKey),
781783
});
782784
} catch (error) {
783785
if (isLmstudioDiscoveryConfigResolutionError(error)) {
784786
return null;
785787
}
786788
throw error;
787789
}
788-
const hasAuthorizationHeader = hasLmstudioAuthorizationHeader(resolvedHeaders);
789790
const resolvedDiscoveryApiKey = hasAuthorizationHeader
790791
? undefined
791792
: (discoveryApiKey ?? configuredDiscoveryApiKey);

0 commit comments

Comments
 (0)