Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion docs/v1-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,23 @@ 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 |
| `capabilities.trained_for_tool_use` | `tool_call`, structured diagnostics | Keep tools enabled; report native or default handling |
| `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
Expand Down
95 changes: 92 additions & 3 deletions src/plugin/enhance-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})

Expand Down
Loading