From 163a8d2f9b3d2e17b9a5bcc068a138859d80fcf6 Mon Sep 17 00:00:00 2001 From: Hossihub Date: Tue, 25 Aug 2026 14:00:52 +0200 Subject: [PATCH 1/2] feat: add OpenAI-compatible /v1/models discovery fallback When LM Studio's native GET /api/v1/models endpoint is unreachable or returns an unsupported response shape, fall back to the OpenAI-compatible GET /v1/models endpoint so models are still discovered. - Add OpenAICompatibleModelsResponseSchema and related types in src/types - Add discoverOpenAIModels() utility that parses { data: [{id}] } responses - Refactor enhance-config.ts with discoverCatalog() wrapper that tries native first, then falls back to /v1/models on failure - Remove early return when no provider exists + auto-detect fails so the fallback path can still execute - Pass pre-fetched response from autoDetectLMStudio() through to avoid redundant fetch calls when native succeeds during detection - Fix test assertion: "/api/v1/models".endsWith("/v1/models") is true, add negation for /api/v1/models suffix in fallback tests - Update docs/v1-contract.md and README.md to document the new fallback --- README.md | 14 +++-- docs/v1-contract.md | 13 +++++ src/plugin/enhance-config.ts | 103 +++++++++++++++++++++++++++-------- src/types/index.ts | 14 +++++ src/utils/lmstudio-api.ts | 44 +++++++++++++++ test/plugin.test.ts | 101 ++++++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 9dec203..9a798d3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/v1-contract.md b/docs/v1-contract.md index 6aa9bd9..a455ec4 100644 --- a/docs/v1-contract.md +++ b/docs/v1-contract.md @@ -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 @@ -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`. @@ -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 diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index a6a0fa0..ca37bc5 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -1,15 +1,19 @@ import type { LMStudioModel, + LMStudioModelsResponse, ModelConfig, OpenCodeConfig, PluginLogger, ProviderConfig, } from "../types/index.ts" +import type { DiscoverModelsOptions } from "../utils/lmstudio-api.ts" import { DEFAULT_LM_STUDIO_URL, LM_STUDIO_MODELS_PATH, + OPENAI_MODELS_PATH, autoDetectLMStudio, discoverModels, + discoverOpenAIModels, getLMStudioApiKey, isGenerativeModel, normalizeLMStudioURL, @@ -19,6 +23,7 @@ import { export interface EnhanceConfigResult { readonly discovered: number readonly discoveryPath: string + readonly models: Readonly> readonly skippedEmbeddings: number readonly skippedUnsupported: number readonly serverURL: string @@ -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> @@ -81,6 +88,68 @@ export function toModelConfig(model: LMStudioModel & { type: "llm" }): ModelConf } } +/** Build an OpenCode model from an OpenAI-compatible `/v1/models` record id. */ +export function toModelConfigFromID(id: string, context = DEFAULT_OPENAI_CONTEXT): ModelConfig { + return { + id, + name: 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 { + 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 ids = await discoverOpenAIModels(serverURL, options) + return { + discovered: ids.length, + discoveryPath: OPENAI_MODELS_PATH, + models: Object.fromEntries(ids.map((id) => [id, toModelConfigFromID(id)])), + skippedEmbeddings: 0, + skippedUnsupported: 0, + serverURL, + toolUse: { default: [], native: [], unknown: [...ids] }, + } + } catch { + throw nativeError + } + } +} + function mergeProvider( existing: ProviderConfig | undefined, explicitModels: Record, @@ -117,21 +186,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) @@ -158,19 +216,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) return result } catch (error) { const configured = Boolean(existing) diff --git a/src/types/index.ts b/src/types/index.ts index c41d9bb..fd06576 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -40,8 +40,22 @@ 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"), +}) + +/** 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 export type LMStudioModelsResponse = z.infer +export type OpenAICompatibleModel = z.infer +export type OpenAICompatibleModelsResponse = z.infer export type OpenCodeConfig = Parameters>[0] export type ProviderConfig = NonNullable[string] diff --git a/src/utils/lmstudio-api.ts b/src/utils/lmstudio-api.ts index 8ca8104..df1797a 100644 --- a/src/utils/lmstudio-api.ts +++ b/src/utils/lmstudio-api.ts @@ -1,5 +1,6 @@ import { LMStudioModelsResponseSchema, + OpenAICompatibleModelsResponseSchema, type LMStudioModel, type LMStudioModelsResponse, } from "../types/index.ts" @@ -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 @@ -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, @@ -132,6 +138,44 @@ export async function discoverModels( return result.data } +/** Discover model IDs from an OpenAI-compatible `GET /v1/models` endpoint. */ +export async function discoverOpenAIModels( + serverURL: string, + options: DiscoverModelsOptions = {}, +): Promise { + 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) => model.id))] +} + export function isGenerativeModel(model: LMStudioModel): model is LMStudioModel & { type: "llm" } { return model.type === "llm" } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 4aa1446..df064fd 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -144,6 +144,107 @@ describe("LM Studio native API v1", () => { }) }) +function openAIModelsResponse(ids: readonly string[], status = 200) { + return new Response(JSON.stringify({ + object: "list", + data: ids.map((id) => ({ id, object: "model", owned_by: "organization_owner" })), + }), { status, headers: { "Content-Type": "application/json" } }) +} + +describe("OpenAI-compatible fallback", () => { + it("falls back to /v1/models when native endpoint is unavailable", async () => { + const fetcher = vi.fn(async (url: string) => { + if (url.endsWith("/api/v1/models")) throw new Error("Not an LM Studio endpoint") + if (url.endsWith("/v1/models")) return openAIModelsResponse(["model/a", "model/b"]) + return new Response("", { status: 404 }) + }) + vi.stubGlobal("fetch", fetcher) + + const value = config() + const log = logger() + const result = await enhanceConfig(value, log) + + expect(result?.discovered).toBe(2) + expect(result?.discoveryPath).toBe("/v1/models") + expect(result?.skippedEmbeddings).toBe(0) + expect(result?.skippedUnsupported).toBe(0) + expect(log).toHaveBeenCalledWith( + "info", + "Discovered LM Studio models", + expect.objectContaining({ discoveryPath: "/v1/models" }), + ) + expect(value.provider?.lmstudio?.models).toEqual({ + "model/a": expect.objectContaining({ name: "model/a", tool_call: true, attachment: false }), + "model/b": expect.objectContaining({ name: "model/b", tool_call: true, attachment: false }), + }) + expect(value.provider?.lmstudio?.whitelist).toEqual(["model/a", "model/b"]) + }) + + it("falls back to /v1/models with auth when configured server uses authentication", async () => { + const fetcher = vi.fn(async (url: string) => { + if (url.endsWith("/api/v1/models")) throw new Error("Authentication failed") + if (url.endsWith("/v1/models")) return openAIModelsResponse(["auth-model"]) + return new Response("", { status: 404 }) + }) + vi.stubGlobal("fetch", fetcher) + + const value = config({ + provider: { + lmstudio: { + options: { baseURL: "http://127.0.0.1:1234/v1", apiKey: "test-token" }, + }, + }, + }) + + await enhanceConfig(value, logger()) + + expect(fetcher).toHaveBeenCalledWith( + "http://127.0.0.1:1234/v1/models", + expect.objectContaining({ headers: { Authorization: "Bearer test-token" } }), + ) + expect(value.provider?.lmstudio?.models?.["auth-model"]).toBeDefined() + }) + + it("stops at native discovery when it succeeds, ignoring /v1/models", async () => { + const fetcher = vi.fn(async (url: string) => { + if (url.endsWith("/api/v1/models")) { + return modelsResponse([ + model({ + key: "nvidia/nemotron-3", + display_name: "Nemotron 3", + capabilities: { vision: false, trained_for_tool_use: false }, + }), + ]) + } + if (url.endsWith("/v1/models")) return openAIModelsResponse(["model/should-not-be-used"]) + return new Response("", { status: 404 }) + }) + vi.stubGlobal("fetch", fetcher) + + const value = config() + await enhanceConfig(value, logger()) + + const calls = fetcher.mock.calls.map((c) => c[0]) + expect(calls.some((c) => String(c).endsWith("/v1/models") && !String(c).endsWith("/api/v1/models"))).toBe(false) + expect(value.provider?.lmstudio?.models?.["nvidia/nemotron-3"]).toBeDefined() + expect(value.provider?.lmstudio?.models?.["model/should-not-be-used"]).toBeUndefined() + expect(value.provider?.lmstudio?.whitelist).toEqual(["nvidia/nemotron-3"]) + }) + + it("leaves config unchanged when both native and OpenAI endpoints fail", async () => { + const fetcher = vi.fn(async () => { + throw new Error("offline") + }) + vi.stubGlobal("fetch", fetcher) + const value = config() + const log = logger() + + await expect(enhanceConfig(value, log)).resolves.toBeUndefined() + expect(value).toEqual({}) + expect(log).toHaveBeenCalledWith("debug", "LM Studio model discovery unavailable", expect.any(Object)) + }) +}) + describe("model mapping", () => { it("uses the model maximum when unloaded and the conservative active minimum when loaded", () => { const unloaded = model({ max_context_length: 131_072 }) From 410270a619047efd71cc6ce51d4e71df4af0a212 Mon Sep 17 00:00:00 2001 From: Hossihub Date: Wed, 26 Aug 2026 22:15:39 +0200 Subject: [PATCH 2/2] feat: extract context length from OpenAI-compatible model info - Add OpenAIModelInfo interface with contextLength and maxTokens - discoverOpenAIModels returns full model records instead of just IDs - toModelConfigFromID uses the record's context length instead of a fixed default - Add max_tokens and context_length to OpenAICompatibleModelSchema - Relax LMStudioCapabilitiesSchema reasoning fields to z.string for proxy compatibility --- src/plugin/enhance-config.ts | 17 ++++++------ src/types/index.ts | 6 +++-- src/utils/lmstudio-api.ts | 17 +++++++++--- tests/live.test.ts | 50 ++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 tests/live.test.ts diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index ca37bc5..b2ca9a3 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -6,7 +6,7 @@ import type { PluginLogger, ProviderConfig, } from "../types/index.ts" -import type { DiscoverModelsOptions } from "../utils/lmstudio-api.ts" +import type { DiscoverModelsOptions, OpenAIModelInfo } from "../utils/lmstudio-api.ts" import { DEFAULT_LM_STUDIO_URL, LM_STUDIO_MODELS_PATH, @@ -89,10 +89,11 @@ export function toModelConfig(model: LMStudioModel & { type: "llm" }): ModelConf } /** Build an OpenCode model from an OpenAI-compatible `/v1/models` record id. */ -export function toModelConfigFromID(id: string, context = DEFAULT_OPENAI_CONTEXT): ModelConfig { +export function toModelConfigFromID(info: OpenAIModelInfo): ModelConfig { + const context = info.contextLength ?? info.maxTokens ?? DEFAULT_OPENAI_CONTEXT return { - id, - name: id, + id: info.id, + name: info.id, attachment: false, tool_call: true, modalities: { @@ -134,15 +135,15 @@ async function discoverCatalog( } } catch (nativeError) { try { - const ids = await discoverOpenAIModels(serverURL, options) + const models = await discoverOpenAIModels(serverURL, options) return { - discovered: ids.length, + discovered: models.length, discoveryPath: OPENAI_MODELS_PATH, - models: Object.fromEntries(ids.map((id) => [id, toModelConfigFromID(id)])), + models: Object.fromEntries(models.map((info) => [info.id, toModelConfigFromID(info)])), skippedEmbeddings: 0, skippedUnsupported: 0, serverURL, - toolUse: { default: [], native: [], unknown: [...ids] }, + toolUse: { default: [], native: [], unknown: models.map((m) => m.id) }, } } catch { throw nativeError diff --git a/src/types/index.ts b/src/types/index.ts index fd06576..35be377 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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(), }) @@ -44,6 +44,8 @@ export const LMStudioModelsResponseSchema = z.looseObject({ 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. */ diff --git a/src/utils/lmstudio-api.ts b/src/utils/lmstudio-api.ts index df1797a..1af8d02 100644 --- a/src/utils/lmstudio-api.ts +++ b/src/utils/lmstudio-api.ts @@ -138,11 +138,18 @@ export async function discoverModels( return result.data } -/** Discover model IDs from an OpenAI-compatible `GET /v1/models` endpoint. */ +/** 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 { +): Promise { const fetcher = options.fetch ?? globalThis.fetch const timeoutMs = options.timeoutMs ?? 5_000 @@ -173,7 +180,11 @@ export async function discoverOpenAIModels( throw new LMStudioAPIError("OpenAI-compatible models API returned an unsupported response", result.error) } - return [...new Set(result.data.data.map((model) => model.id))] + 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" } { diff --git a/tests/live.test.ts b/tests/live.test.ts new file mode 100644 index 0000000..7ebb463 --- /dev/null +++ b/tests/live.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { discoverModels, discoverOpenAIModels } from '../src/utils/lmstudio-api.ts' +import { toModelConfig, effectiveContextLength, toModelConfigFromID } from '../src/plugin/enhance-config.ts' + +const SERVER = 'http://192.168.50.241:1234' + +describe('live: native endpoint', () => { + it('discovers LLMs with context_length metadata', async () => { + const response = await discoverModels(SERVER) + const llms = response.models.filter((m): m is typeof m & { type: 'llm' } => m.type === 'llm') + expect(llms.length).toBeGreaterThan(0) + + for (const model of llms) { + const ctx = effectiveContextLength(model) + const cfg = toModelConfig(model) + + // context should be derived from loaded_instances or max_context_length + expect(ctx).toBeGreaterThan(0) + + // config should have valid limits + expect(cfg.limit?.context).toBe(ctx) + expect(cfg.limit?.output).toBeGreaterThan(0) + expect(cfg.limit?.output).toBeLessThanOrEqual(cfg.limit?.context ?? 0) + + // tool_call should be true for models with trained_for_tool_use + if (model.capabilities?.trained_for_tool_use) { + expect(cfg.tool_call).toBe(true) + } + + console.log(` ${model.display_name}: context=${ctx}, output=${cfg.limit?.output}, tool_call=${cfg.tool_call}`) + } + }, 15000) +}) + +describe('live: OpenAI-compatible fallback', () => { + it('discovers models without metadata fields', async () => { + const models = await discoverOpenAIModels(SERVER) + expect(models.length).toBeGreaterThan(0) + + for (const m of models) { + expect(m.id).toBeTruthy() + // OpenAI endpoint doesn't return max_tokens/context_length + // So these should be undefined and fall back to DEFAULT_OPENAI_CONTEXT + const cfg = toModelConfigFromID(m) + // Should fall back to 32768 when no metadata available + expect(cfg.limit?.context).toBe(32768) + console.log(` ${m.id}: context=${cfg.limit?.context}, maxTokens=${m.maxTokens}, contextLength=${m.contextLength}`) + } + }, 15000) +})