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
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ At startup, the plugin:
1. connects to the configured LM Studio server, or the documented default at
`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;
5. uses the active loaded context when present and the model maximum when the
3. falls back to `GET /v1/models` (OpenAI-compatible) when the native endpoint
is unreachable or returns an unsupported schema;
4. adds `llm` records to OpenCode and excludes embedding records;
5. maps the model key, display name, vision support, and effective context;
6. 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
7. preserves explicit user model overrides and whitelists.
7. uses the provider's server and Bearer-token boundary for discovery; and
8. preserves explicit user model overrides and whitelists.

The complete endpoint, field-mapping, output-reserve, tool, reasoning, and
compatibility decisions are recorded in
Expand All @@ -33,7 +35,7 @@ compatibility decisions are recorded in

- OpenCode 1.17.7 or newer
- LM Studio 0.4.0 or newer with the local server enabled
- LM Studio native `GET /api/v1/models`
- LM Studio native `GET /api/v1/models` (or an OpenAI-compatible `GET /v1/models` fallback)
- Node.js `22.22.2`, `24.15.0`, or a supported version from `26` onward; or
Bun `1.3.5` or newer

Expand Down
13 changes: 13 additions & 0 deletions docs/v1-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ LM Studio's previous `/api/v0` shape.
| Purpose | Endpoint | Contract |
| --- | --- | --- |
| Model discovery | `GET /api/v1/models` | LM Studio native REST v1 |
| Model fallback | `GET /v1/models` | OpenAI-compatible list response |
| Chat inference | `/v1/chat/completions` | LM Studio OpenAI-compatible API |

Discovery requires a JSON object containing a `models` array. An HTTP 2xx
Expand All @@ -30,6 +31,13 @@ because LM Studio 0.4.16 can return HTTP 200 with an error body for an unknown
endpoint. Authentication failures, timeouts, invalid JSON, and invalid schemas
do not trigger a request to another endpoint.

When the native `GET /api/v1/models` endpoint is unreachable or returns an
unsupported response shape, the plugin falls back to the OpenAI-compatible
`GET /v1/models` endpoint. The fallback expects `{ data: [{ id, object }] }` and
maps each record's `id` as both the model key and display name with conservative
defaults (32 k context, 8 k output). This enables discovery for servers that
expose an OpenAI-compatible models list but not LM Studio's native schema.

Automatic discovery checks only LM Studio's documented default address,
`http://127.0.0.1:1234`. Custom ports and remote servers are explicit through
`provider.lmstudio.options.baseURL`.
Expand All @@ -47,6 +55,11 @@ 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 |

OpenAI-compatible fallback (`GET /v1/models`) uses a simplified mapping: each
record's `id` becomes both the model key and display name. Vision, tool use, and
context limits are set to conservative defaults since the OpenAI list response
does not include LM Studio capability metadata.

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
104 changes: 80 additions & 24 deletions src/plugin/enhance-config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import type {
LMStudioModel,
LMStudioModelsResponse,
ModelConfig,
OpenCodeConfig,
PluginLogger,
ProviderConfig,
} from "../types/index.ts"
import type { DiscoverModelsOptions, OpenAIModelInfo } from "../utils/lmstudio-api.ts"
import {
DEFAULT_LM_STUDIO_URL,
LM_STUDIO_MODELS_PATH,
OPENAI_MODELS_PATH,
autoDetectLMStudio,
discoverModels,
discoverOpenAIModels,
getLMStudioApiKey,
isGenerativeModel,
normalizeLMStudioURL,
Expand All @@ -19,6 +23,7 @@ import {
export interface EnhanceConfigResult {
readonly discovered: number
readonly discoveryPath: string
readonly models: Readonly<Record<string, ModelConfig>>
readonly skippedEmbeddings: number
readonly skippedUnsupported: number
readonly serverURL: string
Expand All @@ -30,6 +35,8 @@ export interface EnhanceConfigResult {
}

const MAX_OUTPUT_RESERVE = 8_192
/** Conservative context assumed for OpenAI-compatible IDs that report none. */
const DEFAULT_OPENAI_CONTEXT = 32_768
export type ToolUseMode = "default" | "native" | "unknown"
interface GeneratedState {
readonly models: Readonly<Record<string, ModelConfig>>
Expand Down Expand Up @@ -81,6 +88,69 @@ export function toModelConfig(model: LMStudioModel & { type: "llm" }): ModelConf
}
}

/** Build an OpenCode model from an OpenAI-compatible `/v1/models` record id. */
export function toModelConfigFromID(info: OpenAIModelInfo): ModelConfig {
const context = info.contextLength ?? info.maxTokens ?? DEFAULT_OPENAI_CONTEXT
return {
id: info.id,
name: info.id,
attachment: false,
tool_call: true,
modalities: {
input: ["text"],
output: ["text"],
},
limit: {
context,
output: Math.min(MAX_OUTPUT_RESERVE, Math.max(1, Math.floor(context / 4))),
},
}
}

/** Discover models using native LM Studio v1 first, falling back to OpenAI-compatible /v1/models. */
async function discoverCatalog(
serverURL: string,
options: DiscoverModelsOptions,
preFetched?: LMStudioModelsResponse,
): Promise<EnhanceConfigResult> {
try {
const response = preFetched ?? await discoverModels(serverURL, options)
const generative = response.models.filter(isGenerativeModel)
return {
discovered: generative.length,
discoveryPath: LM_STUDIO_MODELS_PATH,
models: Object.fromEntries(
generative.map((model) => [model.key, toModelConfig(model)]),
),
skippedEmbeddings: response.models.filter((model) => model.type === "embedding").length,
skippedUnsupported: response.models.filter(
(model) => !isGenerativeModel(model) && model.type !== "embedding",
).length,
serverURL,
toolUse: {
default: generative.filter((m) => toolUseMode(m) === "default").map((m) => m.key),
native: generative.filter((m) => toolUseMode(m) === "native").map((m) => m.key),
unknown: generative.filter((m) => toolUseMode(m) === "unknown").map((m) => m.key),
},
}
} catch (nativeError) {
try {
const models = await discoverOpenAIModels(serverURL, options)
return {
discovered: models.length,
discoveryPath: OPENAI_MODELS_PATH,
models: Object.fromEntries(models.map((info) => [info.id, toModelConfigFromID(info)])),
skippedEmbeddings: 0,
skippedUnsupported: 0,
serverURL,
toolUse: { default: [], native: [], unknown: models.map((m) => m.id) },
}
} catch {
throw nativeError
}
}
}

function mergeProvider(
existing: ProviderConfig | undefined,
explicitModels: Record<string, ModelConfig>,
Expand Down Expand Up @@ -117,21 +187,10 @@ export async function enhanceConfig(config: OpenCodeConfig, log: PluginLogger):
try {
const explicitApiKey = getString(existing?.options?.apiKey)
const detected = existing ? undefined : await autoDetectLMStudio()
if (!existing && !detected) {
await log("debug", "LM Studio model discovery unavailable", {
discoveryPath: LM_STUDIO_MODELS_PATH,
serverURL: DEFAULT_LM_STUDIO_URL,
})
return undefined
}

const serverURL = normalizeLMStudioURL(configuredBaseURL ?? detected?.serverURL ?? DEFAULT_LM_STUDIO_URL)
const apiKey = existing ? getLMStudioApiKey(explicitApiKey, serverURL) : detected?.apiKey
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)]),
)
const discovery = await discoverCatalog(serverURL, { apiKey }, detected?.response)
const discoveredModels = discovery.models
const previousGenerated = generatedStates.get(config)
const generatedWhitelist = previousGenerated?.whitelist !== undefined
&& previousGenerated.whitelist.length === (existing?.whitelist?.length ?? 0)
Expand All @@ -158,19 +217,16 @@ export async function enhanceConfig(config: OpenCodeConfig, log: PluginLogger):
...(shouldGenerateWhitelist ? { whitelist: Object.keys(discoveredModels) } : {}),
})

const result = {
discovered: generative.length,
discoveryPath: LM_STUDIO_MODELS_PATH,
skippedEmbeddings: response.models.filter((model) => model.type === "embedding").length,
skippedUnsupported: response.models.filter((model) => !isGenerativeModel(model) && model.type !== "embedding").length,
const result: EnhanceConfigResult = {
discovered: discovery.discovered,
discoveryPath: discovery.discoveryPath,
models: discoveredModels,
skippedEmbeddings: discovery.skippedEmbeddings,
skippedUnsupported: discovery.skippedUnsupported,
serverURL,
toolUse: {
default: generative.filter((model) => toolUseMode(model) === "default").map((model) => model.key),
native: generative.filter((model) => toolUseMode(model) === "native").map((model) => model.key),
unknown: generative.filter((model) => toolUseMode(model) === "unknown").map((model) => model.key),
},
toolUse: discovery.toolUse,
}
await log("info", "Discovered LM Studio models", result)
await log("info", "Discovered LM Studio models", result as unknown as Record<string, unknown>)
return result
} catch (error) {
const configured = Boolean(existing)
Expand Down
20 changes: 18 additions & 2 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export const LMStudioCapabilitiesSchema = z.looseObject({
vision: z.boolean(),
trained_for_tool_use: z.boolean(),
reasoning: z.looseObject({
allowed_options: z.array(z.enum(["off", "on", "low", "medium", "high"])),
default: z.enum(["off", "on", "low", "medium", "high"]),
allowed_options: z.array(z.string()),
default: z.string(),
}).optional(),
})

Expand All @@ -40,8 +40,24 @@ export const LMStudioModelsResponseSchema = z.looseObject({
models: z.array(LMStudioModelSchema),
})

/** A model record reported by the OpenAI-compatible `GET /v1/models` endpoint. */
export const OpenAICompatibleModelSchema = z.looseObject({
id: z.string().min(1),
object: z.literal("model"),
max_tokens: z.number().int().positive().nullable().optional(),
context_length: z.number().int().positive().nullable().optional(),
})

/** OpenAI's documented `GET /v1/models` list response. */
export const OpenAICompatibleModelsResponseSchema = z.looseObject({
object: z.literal("list"),
data: z.array(OpenAICompatibleModelSchema),
})

export type LMStudioModel = z.infer<typeof LMStudioModelSchema>
export type LMStudioModelsResponse = z.infer<typeof LMStudioModelsResponseSchema>
export type OpenAICompatibleModel = z.infer<typeof OpenAICompatibleModelSchema>
export type OpenAICompatibleModelsResponse = z.infer<typeof OpenAICompatibleModelsResponseSchema>

export type OpenCodeConfig = Parameters<NonNullable<Hooks["config"]>>[0]
export type ProviderConfig = NonNullable<OpenCodeConfig["provider"]>[string]
Expand Down
55 changes: 55 additions & 0 deletions src/utils/lmstudio-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
LMStudioModelsResponseSchema,
OpenAICompatibleModelsResponseSchema,
type LMStudioModel,
type LMStudioModelsResponse,
} from "../types/index.ts"
Expand All @@ -8,6 +9,7 @@ import { isIP } from "node:net"
export const DEFAULT_LM_STUDIO_URL = "http://127.0.0.1:1234"
export const LM_STUDIO_MODELS_PATH = "/api/v1/models"
export const OPENAI_COMPATIBLE_PATH = "/v1"
export const OPENAI_MODELS_PATH = "/v1/models"
export const AUTO_DETECT_URLS = [DEFAULT_LM_STUDIO_URL] as const
const API_KEY_ENV_VARS = ["LM_API_TOKEN", "LMSTUDIO_API_KEY"] as const

Expand Down Expand Up @@ -94,6 +96,10 @@ export function toModelsURL(serverURL: string): string {
return `${normalizeLMStudioURL(serverURL)}${LM_STUDIO_MODELS_PATH}`
}

export function toOpenAIModelsURL(serverURL: string): string {
return `${normalizeLMStudioURL(serverURL)}${OPENAI_MODELS_PATH}`
}

/** Discover and validate typed model metadata from LM Studio's REST API. */
export async function discoverModels(
serverURL: string,
Expand Down Expand Up @@ -132,6 +138,55 @@ export async function discoverModels(
return result.data
}

/** A model record from an OpenAI-compatible `GET /v1/models` endpoint with resolved metadata. */
export interface OpenAIModelInfo {
readonly id: string
readonly maxTokens: number | undefined
readonly contextLength: number | undefined
}

/** Discover model records from an OpenAI-compatible `GET /v1/models` endpoint. */
export async function discoverOpenAIModels(
serverURL: string,
options: DiscoverModelsOptions = {},
): Promise<readonly OpenAIModelInfo[]> {
const fetcher = options.fetch ?? globalThis.fetch
const timeoutMs = options.timeoutMs ?? 5_000

let response: Response
try {
response = await fetcher(toOpenAIModelsURL(serverURL), {
method: "GET",
headers: options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : undefined,
signal: AbortSignal.timeout(timeoutMs),
})
} catch (cause) {
throw new LMStudioAPIError("Could not reach the OpenAI-compatible models API", cause)
}

if (!response.ok) {
throw new LMStudioAPIError(`OpenAI-compatible models API returned HTTP ${response.status}`)
}

let payload: unknown
try {
payload = await response.json()
} catch (cause) {
throw new LMStudioAPIError("OpenAI-compatible models API returned invalid JSON", cause)
}

const result = OpenAICompatibleModelsResponseSchema.safeParse(payload)
if (!result.success) {
throw new LMStudioAPIError("OpenAI-compatible models API returned an unsupported response", result.error)
}

return [...new Set(result.data.data.map((model) => ({
id: model.id,
maxTokens: model.max_tokens ?? undefined,
contextLength: model.context_length ?? undefined,
})))]
}

export function isGenerativeModel(model: LMStudioModel): model is LMStudioModel & { type: "llm" } {
return model.type === "llm"
}
Expand Down
Loading