From 753ccf615cb83d0b83597c392cf31da10ea94efd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 01:30:22 +0100 Subject: [PATCH] fix: preserve LM Studio quant model refs (#71486) --- CHANGELOG.md | 3 ++ src/agents/model-ref-profile.ts | 4 +-- src/agents/model-selection.test.ts | 24 ++++++++++++++++ src/auto-reply/model.test.ts | 14 +++++++++ src/plugins/providers.test.ts | 46 ++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e2af493e3..fda98454798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,9 @@ Docs: https://docs.openclaw.ai - Gateway/Fly.io: seed Control UI allowed origins from the actual runtime bind and port so CLI-driven non-loopback starts do not crash before config exists. Fixes #71823. +- Models/LM Studio: preserve `@iq*` quant suffixes in model refs and provider + matching so `/model lmstudio/...@iq3_xxs` keeps the exact LM Studio variant. + Fixes #71474. (#71486) Thanks @Bartok9, @XinwuC, and @Sanjays2402. - Feishu: accept Schema 2.0 card action callbacks that report `context.open_chat_id` instead of legacy `context.chat_id`, so button callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068. diff --git a/src/agents/model-ref-profile.ts b/src/agents/model-ref-profile.ts index 3114ed74e57..865752ac2a8 100644 --- a/src/agents/model-ref-profile.ts +++ b/src/agents/model-ref-profile.ts @@ -29,8 +29,8 @@ export function splitTrailingAuthProfile(raw: string): { // of the model id. These often use '@' (ex: gemma-4-31b-it@q8_0) which would // otherwise be misinterpreted as an auth profile delimiter. // - // Covers standard GGUF quant tags (q4_0, q8_0, q4_k_xl, …) and importance- - // quantization variants (iq3_xxs, iq4_xs, …) used by llama.cpp / LM Studio. + // Covers standard GGUF quant tags (q4_0, q8_0, q4_k_xl, ...) and importance- + // quantization variants (iq3_xxs, iq4_xs, ...) used by llama.cpp / LM Studio. // // If an auth profile is needed, it can still be specified as a second suffix: // lmstudio/foo@q8_0@work lmstudio/foo@iq3_xxs@work diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index b7de04c4f7b..a6c80997599 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -894,6 +894,30 @@ describe("model-selection", () => { }); }); + it("preserves LM Studio @iq* quant suffixes", () => { + const resolved = resolveModelRefFromString({ + raw: "lmstudio/qwen3.6-27b@iq3_xxs", + defaultProvider: "anthropic", + }); + + expect(resolved?.ref).toEqual({ + provider: "lmstudio", + model: "qwen3.6-27b@iq3_xxs", + }); + }); + + it("splits trailing profile suffix after LM Studio @iq* quant suffixes", () => { + const resolved = resolveModelRefFromString({ + raw: "lmstudio/qwen3.6-27b@iq3_xxs@work", + defaultProvider: "anthropic", + }); + + expect(resolved?.ref).toEqual({ + provider: "lmstudio", + model: "qwen3.6-27b@iq3_xxs", + }); + }); + it("strips profile suffix before alias resolution", () => { const index = { byAlias: new Map([ diff --git a/src/auto-reply/model.test.ts b/src/auto-reply/model.test.ts index 5352f892280..c43e46bdc0e 100644 --- a/src/auto-reply/model.test.ts +++ b/src/auto-reply/model.test.ts @@ -72,6 +72,20 @@ describe("extractModelDirective", () => { expect(result.rawProfile).toBe("cf:default"); }); + it("keeps LM Studio @iq* quant suffixes inside model ids", () => { + const result = extractModelDirective("/model lmstudio/qwen3.6-27b@iq3_xxs"); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("lmstudio/qwen3.6-27b@iq3_xxs"); + expect(result.rawProfile).toBeUndefined(); + }); + + it("allows profile overrides after LM Studio @iq* quant suffixes", () => { + const result = extractModelDirective("/model lmstudio/qwen3.6-27b@iq3_xxs@work"); + expect(result.hasDirective).toBe(true); + expect(result.rawModel).toBe("lmstudio/qwen3.6-27b@iq3_xxs"); + expect(result.rawProfile).toBe("work"); + }); + it("returns no directive for plain text", () => { const result = extractModelDirective("hello world"); expect(result.hasDirective).toBe(false); diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 4f3200e8822..ab93185da1d 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -1321,6 +1321,52 @@ describe("resolvePluginProviders", () => { expectModelOwningPluginIds("gpt-5.4", ["workspace-openai"]); }); + it("preserves LM Studio @iq* quant suffixes when resolving model-owned provider plugins", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "lmstudio", + providerIds: ["lmstudio"], + modelSupport: { + modelPatterns: ["^qwen3\\.6-27b@iq3_xxs$"], + }, + }), + ]); + const provider: ProviderPlugin = { + id: "lmstudio", + label: "LM Studio", + auth: [], + }; + const registry = createEmptyPluginRegistry(); + registry.providers.push({ pluginId: "lmstudio", provider, source: "bundled" }); + resolveRuntimePluginRegistryMock.mockReturnValue(registry); + + expectModelOwningPluginIds("qwen3.6-27b@iq3_xxs", ["lmstudio"]); + expectModelOwningPluginIds("qwen3.6-27b", undefined); + + const providers = resolvePluginProviders({ + config: {}, + modelRefs: ["qwen3.6-27b@iq3_xxs"], + bundledProviderAllowlistCompat: true, + }); + + expectResolvedProviders(providers, [ + { id: "lmstudio", label: "LM Studio", auth: [], pluginId: "lmstudio" }, + ]); + expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["lmstudio"], + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: ["lmstudio"], + entries: { + lmstudio: { enabled: true }, + }, + }), + }), + }), + ); + }); + it("auto-loads a model-owned provider plugin from shorthand model refs", () => { setManifestPlugins([ createManifestProviderPlugin({