diff --git a/CHANGELOG.md b/CHANGELOG.md index ef7734e..01ceec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- Metadata badge on the model display name: a + `params · quant · size · format` suffix (e.g. + `Devstral Small (24B · Q4_K_M · 14.3 GB · MLX)`) drawn from the native + record's `params_string`, `quantization.name`, `size_bytes`, and `format`, so + otherwise-identical builds are distinguishable in the OpenCode model picker. + Each segment is dropped when LM Studio does not report it; a record with no + metadata keeps the bare `display_name`. The model `key` (ID) is unchanged. +- Configurable badge verbosity via `provider.lmstudio.options.badge`: + `off` | `format` | `compact` | `full` (default `full`, so existing behaviour + is unchanged). `compact` omits the parameter count, which never disambiguates + builds of the same model. An unrecognised value falls back to `full`. +- `params_string` and `size_bytes` added to the native `GET /api/v1/models` + schema (both optional for backward compatibility). + ## [1.0.0-rc.2] - 2026-06-21 ### Added diff --git a/README.md b/README.md index 9dec203..02eda9a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ At startup, the plugin: `http://127.0.0.1:1234`; 2. validates `GET /api/v1/models` against the native LM Studio response shape; 3. adds `llm` records to OpenCode and excludes embedding records; -4. maps the model key, display name, vision support, and effective context; +4. maps the model key, a metadata-badged display name (params, quant, size, + format), vision support, and effective context; 5. uses the active loaded context when present and the model maximum when the model is available for on-demand loading; 6. uses the provider's server and Bearer-token boundary for discovery; and @@ -123,6 +124,50 @@ The plugin preserves the LM Studio `key` as the model ID and uses `display_name` as the OpenCode display name. There are no model-family or model-name heuristics. +### Metadata badge + +The plugin appends a compact badge to the display name so otherwise-identical +builds are distinguishable in the OpenCode model picker: + +``` +Devstral Small (24B · Q4_K_M · 14.3 GB · MLX) +``` + +The badge is drawn from the native record's `params_string`, +`quantization.name`, `size_bytes`, and `format`. Each segment is dropped when LM +Studio does not report it, so the badge degrades cleanly on older servers, and a +record with no metadata keeps the bare `display_name`. Size uses LM Studio's +decimal (base 1000) convention. Only the display name changes; the model `key` +(ID) is untouched. + +#### Verbosity — `options.badge` + +Set `provider.lmstudio.options.badge` to dial the badge up or down. Default is +`full`, so the badge is on unless you turn it down: + +| Tier | Example | +|-----------|-----------------------------------------------| +| `off` | `Devstral Small` | +| `format` | `Devstral Small (MLX)` | +| `compact` | `Devstral Small (Q4_K_M · 14.3 GB · MLX)` | +| `full` | `Devstral Small (24B · Q4_K_M · 14.3 GB · MLX)` | + +```json +{ + "provider": { + "lmstudio": { + "options": { "badge": "compact" } + } + } +} +``` + +`compact` omits the parameter count because it is identical across the quant and +format builds of one model, so it never disambiguates them — the tier keeps only +the segments that do. An unrecognised value falls back to `full` rather than +failing startup. The `badge` key is read by the plugin and does not affect the +underlying OpenAI-compatible request. + ### Context limits For an unloaded model, `max_context_length` becomes OpenCode's context limit so diff --git a/docs/v1-contract.md b/docs/v1-contract.md index 6aa9bd9..ab99651 100644 --- a/docs/v1-contract.md +++ b/docs/v1-contract.md @@ -39,7 +39,8 @@ Automatic discovery checks only LM Studio's documented default address, | LM Studio native v1 field | OpenCode field | Policy | | --- | --- | --- | | `key` | model map key and `id` | Preserve exactly | -| `display_name` | `name` | Use the server's display name | +| `display_name` | `name` | Server display name plus a metadata badge (see below) | +| `params_string`, `quantization.name`, `size_bytes`, `format` | `name` badge | Append `(params · quant · size · format)`; verbosity via `options.badge`, default `full` | | `type: "llm"` | chat model | Include | | `type: "embedding"` | none | Exclude from the chat provider | | `capabilities.vision` | `attachment`, input modalities | Add image input only when true | @@ -47,6 +48,14 @@ Automatic discovery checks only LM Studio's documented default address, | `max_context_length` | `limit.context` | Use when no instance is loaded | | loaded `config.context_length` | `limit.context` | Use one active value, or the minimum for multiple instances | +The `name` is the server's `display_name` with a metadata badge appended so +otherwise-identical builds are distinguishable. `options.badge` selects the +verbosity: `off` (bare name), `format` (`MLX`/`GGUF`), `compact` +(`quant · size · format`), or `full` (default; adds `params`). Unrecognised +values fall back to `full`. The `key`/`id` is never modified. The `badge` key is +plugin-only; `@ai-sdk/openai-compatible` ignores it, and it is retained in the +forwarded options so the tier survives repeated config-hook runs. + The active context is also capped by `max_context_length`. A model key can refer to more than one loaded instance, while OpenCode has one limit per model key; the minimum active allocation is therefore the only safe value that does diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index a6a0fa0..aa6b222 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -41,6 +41,30 @@ function getString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined } +/** Verbosity of the model-name metadata badge, set via `options.badge`. */ +export const BADGE_TIERS = ["off", "format", "compact", "full"] as const +export type BadgeTier = (typeof BADGE_TIERS)[number] +export const DEFAULT_BADGE_TIER: BadgeTier = "full" + +/** + * Separator between badge segments. A single named constant (U+00B7 MIDDLE DOT) + * so it is swappable in one place if a terminal renders it poorly; deliberately + * not a config option, to keep the provider-options surface minimal. + */ +const BADGE_SEPARATOR = " · " + +/** + * Resolve the user's `options.badge` value to a known tier, failing open to the + * default so a typo never breaks OpenCode startup (matching the plugin's + * discovery-error-degrades ethos). Only the string enum is accepted; anything + * else (object, number, null, undefined) falls back to the default. + */ +export function resolveBadgeTier(raw: unknown): BadgeTier { + return typeof raw === "string" && (BADGE_TIERS as readonly string[]).includes(raw) + ? (raw as BadgeTier) + : DEFAULT_BADGE_TIER +} + export function effectiveContextLength(model: LMStudioModel): number { const loaded = model.loaded_instances.map((instance) => instance.config.context_length) return loaded.length === 0 @@ -54,14 +78,72 @@ export function toolUseMode(model: LMStudioModel): ToolUseMode { return trained === true ? "native" : trained === false ? "default" : "unknown" } -export function toModelConfig(model: LMStudioModel & { type: "llm" }): ModelConfig { +/** Human-facing tag for LM Studio's runtime format, or undefined when unknown. */ +export function formatLabel(format: LMStudioModel["format"]): string | undefined { + return format === "mlx" ? "MLX" : format === "gguf" ? "GGUF" : undefined +} + +/** + * Compact on-disk size using LM Studio's decimal (base-1000) convention. Unit + * gates sit at 999.5 (not 1000) so a value that the next-smaller unit would + * round up to a 4-digit "1000" promotes to the larger unit instead — e.g. + * 999_999_999 reads "1.0 GB", not "1000 MB". + */ +export function formatSize(bytes: number | null | undefined): string | undefined { + if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return undefined + if (bytes >= 999.5e6) return `${(bytes / 1e9).toFixed(1)} GB` + if (bytes >= 999.5e3) return `${Math.round(bytes / 1e6)} MB` + return `${Math.round(bytes / 1e3)} KB` +} + +/** + * Build the metadata badge appended to a model's display name. The tier selects + * an order-preserving subset of `params · quant · size · format`: + * + * off → no badge + * format → `GGUF` / `MLX` + * compact → `Q4_K_M · 16.5 GB · GGUF` (drops params) + * full → `27B · Q4_K_M · 16.5 GB · GGUF` (default) + * + * `compact` drops params because params is identical across the quant/format + * siblings of one model, so it never disambiguates them — it only informs. + * Within a tier, each segment is still dropped when LM Studio does not report + * it, so the badge degrades cleanly on older servers. Returns undefined when + * the tier is `off` or nothing is known. + */ +export function modelBadge(model: LMStudioModel, tier: BadgeTier = DEFAULT_BADGE_TIER): string | undefined { + if (tier === "off") return undefined + const params = getString(model.params_string) // "27B" + const quant = getString(model.quantization?.name) // "Q4_K_M" + const size = formatSize(model.size_bytes) // "16.5 GB" + const format = formatLabel(model.format) // "MLX" | "GGUF" + const segments = + tier === "format" ? [format] + : tier === "compact" ? [quant, size, format] + : [params, quant, size, format] + const shown = segments.filter((segment): segment is string => segment !== undefined) + return shown.length > 0 ? shown.join(BADGE_SEPARATOR) : undefined +} + +/** + * Append the metadata badge to the display name so the OpenCode model picker + * distinguishes otherwise-identical builds. An MLX quant and its GGUF sibling, + * or two quant levels of one model, usually share a `display_name`; the badge + * makes size, quant, and runtime visible in the list and tooltip. + */ +export function decorateModelName(displayName: string, model: LMStudioModel, tier: BadgeTier = DEFAULT_BADGE_TIER): string { + const badge = modelBadge(model, tier) + return badge ? `${displayName} (${badge})` : displayName +} + +export function toModelConfig(model: LMStudioModel & { type: "llm" }, badge: BadgeTier = DEFAULT_BADGE_TIER): ModelConfig { const vision = model.capabilities?.vision === true const input: Array<"text" | "image"> = vision ? ["text", "image"] : ["text"] const context = effectiveContextLength(model) return { id: model.key, - name: model.display_name, + name: decorateModelName(model.display_name, model, badge), attachment: vision, // LM Studio supports tools for every LLM. The training flag distinguishes // native handling from its lower-reliability default tool format; it does @@ -93,6 +175,12 @@ function mergeProvider( ...existing, name: existing?.name ?? "LM Studio", npm: existing?.npm ?? "@ai-sdk/openai-compatible", + // `options.badge` is a plugin-only key read in enhanceConfig. It is left in + // the forwarded options on purpose: `@ai-sdk/openai-compatible` reads only + // named fields and silently ignores unknown keys (never sent on the wire), + // and `options` is the one config record that persists across repeated + // config-hook runs, so stripping badge here would drop the user's tier on + // the next load (the same reason apiKey below is written back into options). options: { ...existing?.options, baseURL: toOpenAICompatibleURL(serverURL), @@ -127,10 +215,11 @@ export async function enhanceConfig(config: OpenCodeConfig, log: PluginLogger): const serverURL = normalizeLMStudioURL(configuredBaseURL ?? detected?.serverURL ?? DEFAULT_LM_STUDIO_URL) const apiKey = existing ? getLMStudioApiKey(explicitApiKey, serverURL) : detected?.apiKey + const badgeTier = resolveBadgeTier(existing?.options?.badge) const response = detected?.response ?? await discoverModels(serverURL, { apiKey }) const generative = response.models.filter(isGenerativeModel) const discoveredModels = Object.fromEntries( - generative.map((model) => [model.key, toModelConfig(model)]), + generative.map((model) => [model.key, toModelConfig(model, badgeTier)]), ) const previousGenerated = generatedStates.get(config) const generatedWhitelist = previousGenerated?.whitelist !== undefined diff --git a/src/types/index.ts b/src/types/index.ts index c41d9bb..8002ffb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -33,6 +33,11 @@ export const LMStudioModelSchema = z.looseObject({ loaded_instances: z.array(LMStudioLoadedInstanceSchema), max_context_length: z.number().int().positive(), format: z.enum(["gguf", "mlx"]).nullable(), + // Human-readable parameter count ("7B", "27B") and on-disk size in bytes. + // Present on LM Studio 0.4.0+ native records; optional so older servers and + // captured fixtures without them still validate. + params_string: z.string().nullable().optional(), + size_bytes: z.number().nonnegative().nullable().optional(), capabilities: LMStudioCapabilitiesSchema.optional(), }) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 4aa1446..9263b36 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -2,8 +2,15 @@ import type { PluginInput } from "@opencode-ai/plugin" import { afterEach, describe, expect, it, vi } from "vitest" import { LMStudioPlugin } from "../src/index.ts" import { + BADGE_TIERS, + DEFAULT_BADGE_TIER, + decorateModelName, effectiveContextLength, enhanceConfig, + formatLabel, + formatSize, + modelBadge, + resolveBadgeTier, toolUseMode, toModelConfig, } from "../src/plugin/enhance-config.ts" @@ -181,7 +188,7 @@ describe("model mapping", () => { expect(mapped).toMatchObject({ id: "zai-org/glm-4.5v", - name: "GLM 4.5V", + name: "GLM 4.5V (Q4_K_M · GGUF)", attachment: true, tool_call: true, modalities: { input: ["text", "image"], output: ["text"] }, @@ -191,6 +198,103 @@ describe("model mapping", () => { expect(toolUseMode(model({ capabilities: undefined }))).toBe("unknown") expect(mapped).not.toHaveProperty("reasoning") }) + + it("badges the display name with params, quant, size, and format", () => { + const full = toModelConfig(model({ + display_name: "Devstral Small", + params_string: "24B", + quantization: { name: "Q4_K_M", bits_per_weight: 4 }, + size_bytes: 14_300_000_000, + format: "mlx", + }) as LMStudioModel & { type: "llm" }) + expect(full.name).toBe("Devstral Small (24B · Q4_K_M · 14.3 GB · MLX)") + + // Each segment drops out cleanly when LM Studio does not report it. + const bare = toModelConfig(model({ + display_name: "Devstral Small", + params_string: null, + quantization: null, + size_bytes: null, + format: null, + }) as LMStudioModel & { type: "llm" }) + expect(bare.name).toBe("Devstral Small") + + const partial = model({ + display_name: "Devstral Small", + params_string: "7B", + quantization: null, + size_bytes: undefined, + format: "gguf", + }) + expect(modelBadge(partial)).toBe("7B · GGUF") + expect(decorateModelName("Devstral Small", partial)).toBe("Devstral Small (7B · GGUF)") + }) + + it("formats size with LM Studio's decimal convention and format labels", () => { + expect(formatSize(14_300_000_000)).toBe("14.3 GB") + expect(formatSize(634_550_000)).toBe("635 MB") + expect(formatSize(84_110_000)).toBe("84 MB") + // KB tier (real models are never this small, but the branch exists). + expect(formatSize(512_000)).toBe("512 KB") + expect(formatSize(1_000)).toBe("1 KB") + // Unit-boundary promotion: a value that would round to "1000 " + // promotes to the larger unit instead of showing four digits. + expect(formatSize(999_999_999)).toBe("1.0 GB") + expect(formatSize(999_500_000)).toBe("1.0 GB") + expect(formatSize(999_499_999)).toBe("999 MB") + expect(formatSize(999_999)).toBe("1 MB") + expect(formatSize(999_499)).toBe("999 KB") + expect(formatSize(0)).toBeUndefined() + expect(formatSize(null)).toBeUndefined() + expect(formatSize(undefined)).toBeUndefined() + expect(formatLabel("mlx")).toBe("MLX") + expect(formatLabel("gguf")).toBe("GGUF") + expect(formatLabel(null)).toBeUndefined() + }) + + it("selects badge segments per tier as order-preserving subsets", () => { + const full = model({ + display_name: "Devstral Small", + params_string: "24B", + quantization: { name: "Q4_K_M", bits_per_weight: 4 }, + size_bytes: 14_300_000_000, + format: "mlx", + }) + + expect(modelBadge(full, "off")).toBeUndefined() + expect(modelBadge(full, "format")).toBe("MLX") + expect(modelBadge(full, "compact")).toBe("Q4_K_M · 14.3 GB · MLX") + expect(modelBadge(full, "full")).toBe("24B · Q4_K_M · 14.3 GB · MLX") + // Default tier is full. + expect(modelBadge(full)).toBe(modelBadge(full, "full")) + expect(DEFAULT_BADGE_TIER).toBe("full") + expect(BADGE_TIERS).toEqual(["off", "format", "compact", "full"]) + + // Segments still drop within a tier when a field is missing. + const noQuant = model({ display_name: "X", params_string: "24B", quantization: null, size_bytes: 14_300_000_000, format: "mlx" }) + expect(modelBadge(noQuant, "compact")).toBe("14.3 GB · MLX") + // format tier with unknown format yields no badge. + expect(modelBadge(model({ format: null }), "format")).toBeUndefined() + + // toModelConfig / decorateModelName honour the tier. + expect(toModelConfig(full as LMStudioModel & { type: "llm" }, "off").name).toBe("Devstral Small") + expect(toModelConfig(full as LMStudioModel & { type: "llm" }, "format").name).toBe("Devstral Small (MLX)") + expect(decorateModelName("Devstral Small", full, "compact")).toBe("Devstral Small (Q4_K_M · 14.3 GB · MLX)") + }) + + it("resolves the badge option, failing open to the default tier", () => { + expect(resolveBadgeTier("off")).toBe("off") + expect(resolveBadgeTier("format")).toBe("format") + expect(resolveBadgeTier("compact")).toBe("compact") + expect(resolveBadgeTier("full")).toBe("full") + // Invalid / wrong-typed values degrade to the default rather than throwing. + expect(resolveBadgeTier("bogus")).toBe(DEFAULT_BADGE_TIER) + expect(resolveBadgeTier(undefined)).toBe(DEFAULT_BADGE_TIER) + expect(resolveBadgeTier(null)).toBe(DEFAULT_BADGE_TIER) + expect(resolveBadgeTier(42)).toBe(DEFAULT_BADGE_TIER) + expect(resolveBadgeTier({ tier: "compact" })).toBe(DEFAULT_BADGE_TIER) + expect(resolveBadgeTier("")).toBe(DEFAULT_BADGE_TIER) + }) }) describe("config enhancement", () => { @@ -233,13 +337,13 @@ describe("config enhancement", () => { expect(value.provider?.lmstudio?.options?.baseURL).toBe("http://127.0.0.1:1234/v1") expect(value.provider?.lmstudio?.models).toEqual({ "nvidia/nemotron-3-nano-omni": expect.objectContaining({ - name: "Nemotron 3 Nano Omni", + name: "Nemotron 3 Nano Omni (Q4_K_M · GGUF)", attachment: false, tool_call: true, modalities: { input: ["text"], output: ["text"] }, }), "zai-org/glm-4.5v": expect.objectContaining({ - name: "GLM 4.5V", + name: "GLM 4.5V (Q4_K_M · GGUF)", attachment: true, tool_call: true, modalities: { input: ["text", "image"], output: ["text"] }, @@ -312,6 +416,42 @@ describe("config enhancement", () => { ) }) + it("honours options.badge tiers and preserves the option idempotently", async () => { + const badged = model({ + key: "publisher/model", + display_name: "Devstral Small", + params_string: "24B", + size_bytes: 14_300_000_000, + quantization: { name: "Q4_K_M", bits_per_weight: 4 }, + format: "mlx", + }) + + // "off" → bare display name. + const off = config({ provider: { lmstudio: { options: { baseURL: "http://127.0.0.1:1234/v1", badge: "off" } } } }) + vi.stubGlobal("fetch", vi.fn(async () => modelsResponse([badged]))) + await enhanceConfig(off, logger()) + expect(off.provider?.lmstudio?.models?.["publisher/model"]?.name).toBe("Devstral Small") + + // "compact" drops params; runs twice to prove the option is not stripped + // (options is the persistent store, so a strip would revert to full on + // the second config load). + const compact = config({ provider: { lmstudio: { options: { baseURL: "http://127.0.0.1:1234/v1", badge: "compact" } } } }) + vi.stubGlobal("fetch", vi.fn(async () => modelsResponse([badged]))) + await enhanceConfig(compact, logger()) + await enhanceConfig(compact, logger()) + expect(compact.provider?.lmstudio?.options?.badge).toBe("compact") + expect(compact.provider?.lmstudio?.options?.baseURL).toBe("http://127.0.0.1:1234/v1") + expect(compact.provider?.lmstudio?.models?.["publisher/model"]?.name) + .toBe("Devstral Small (Q4_K_M · 14.3 GB · MLX)") + + // Invalid value degrades to the default (full) instead of breaking startup. + const bogus = config({ provider: { lmstudio: { options: { baseURL: "http://127.0.0.1:1234/v1", badge: "nope" } } } }) + vi.stubGlobal("fetch", vi.fn(async () => modelsResponse([badged]))) + await enhanceConfig(bogus, logger()) + expect(bogus.provider?.lmstudio?.models?.["publisher/model"]?.name) + .toBe("Devstral Small (24B · Q4_K_M · 14.3 GB · MLX)") + }) + it("preserves an explicitly empty whitelist", async () => { vi.stubGlobal("fetch", vi.fn(async () => modelsResponse([model()]))) const value = config({