Files
openclaw/extensions/lmstudio/src/models.ts
2026-08-02 22:45:09 +08:00

658 lines
22 KiB
TypeScript

// Lmstudio plugin module implements models behavior.
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
SELF_HOSTED_DEFAULT_COST,
SELF_HOSTED_DEFAULT_MAX_TOKENS,
} from "openclaw/plugin-sdk/provider-setup";
import { asPositiveSafeInteger, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { LMSTUDIO_DEFAULT_BASE_URL, LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js";
export type LmstudioModelWire = {
type?: "llm" | "embedding";
key?: string;
display_name?: string;
max_context_length?: number;
format?: "gguf" | "mlx" | null;
variants?: unknown;
selected_variant?: unknown;
capabilities?: {
vision?: boolean;
trained_for_tool_use?: boolean;
reasoning?: LmstudioReasoningCapabilityWire;
};
loaded_instances?: Array<{
id?: string;
config?: {
context_length?: number;
} | null;
} | null>;
};
type LmstudioReasoningCapabilityWire = {
allowed_options?: unknown;
default?: unknown;
};
type LmstudioConfiguredCatalogEntry = {
id: string;
name?: string;
contextWindow?: number;
contextTokens?: number;
reasoning?: boolean;
input?: ("text" | "image" | "document")[];
compat?: ModelDefinitionConfig["compat"];
};
const LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS = [
"minimal",
"low",
"medium",
"high",
"xhigh",
] as const;
const LMSTUDIO_OPENAI_COMPAT_REASONING_EFFORTS = [
"none",
...LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS,
] as const;
const LMSTUDIO_CONFIGURED_BOOLEAN_COMPAT_FIELDS = [
"supportsStore",
"supportsPromptCacheKey",
"supportsDeveloperRole",
"supportsReasoningEffort",
"supportsTemperature",
"supportsUsageInStreaming",
"supportsTools",
"supportsStrictMode",
"supportsJsonSchemaResponseFormat",
"requiresStringContent",
"strictMessageKeys",
"requiresToolResultName",
"requiresAssistantAfterToolResult",
"requiresThinkingAsText",
"requiresReasoningContentOnAssistantMessages",
"requiresOpenAiAnthropicToolPayload",
] as const satisfies readonly (keyof NonNullable<ModelDefinitionConfig["compat"]>)[];
const LMSTUDIO_CONFIGURED_THINKING_FORMATS = [
"openai",
"openrouter",
"deepseek",
"together",
"qwen",
"qwen-chat-template",
"zai",
] as const satisfies readonly NonNullable<
NonNullable<ModelDefinitionConfig["compat"]>["thinkingFormat"]
>[];
function normalizeReasoningOption(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
function isReasoningEnabledOption(value: unknown): boolean {
const normalized = normalizeReasoningOption(value);
if (!normalized) {
return false;
}
return normalized !== "off";
}
function normalizeReasoningOptions(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return uniqueStrings(value.flatMap((option) => normalizeReasoningOption(option) ?? []));
}
function isLmstudioBinaryReasoningOptions(allowedOptions: readonly string[]): boolean {
return (
allowedOptions.some((option) => option === "on") &&
allowedOptions.every((option) => option === "on" || option === "off")
);
}
function resolveLmstudioTransportReasoningEfforts(allowedOptions: readonly string[]): string[] {
if (isLmstudioBinaryReasoningOptions(allowedOptions)) {
return allowedOptions.includes("off")
? [...LMSTUDIO_OPENAI_COMPAT_REASONING_EFFORTS]
: [...LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS];
}
return uniqueStrings(
allowedOptions
.map((option) => (option === "off" ? "none" : option))
.filter((option) => option !== "on"),
);
}
function resolveLmstudioEnabledTransportReasoningOption(
supportedReasoningEfforts: readonly string[],
): string | undefined {
return (
supportedReasoningEfforts.find((option) => option === "xhigh") ??
supportedReasoningEfforts.find((option) => option === "high") ??
supportedReasoningEfforts.find((option) => option !== "none")
);
}
function buildLmstudioReasoningEffortMap(
supportedReasoningEfforts: readonly string[],
): Record<string, string> | undefined {
const disabled = supportedReasoningEfforts.includes("none") ? "none" : undefined;
const max = resolveLmstudioEnabledTransportReasoningOption(supportedReasoningEfforts);
const map = {
...(disabled ? { off: disabled, none: disabled } : {}),
...(max ? { adaptive: max, max } : {}),
};
return Object.keys(map).length > 0 ? map : undefined;
}
function buildLmstudioReasoningCompat(
allowedOptions: readonly string[],
): ModelDefinitionConfig["compat"] | undefined {
const supportedReasoningEfforts = resolveLmstudioTransportReasoningEfforts(allowedOptions);
if (supportedReasoningEfforts.length === 0) {
return undefined;
}
if (!supportedReasoningEfforts.some((option) => option !== "none")) {
return undefined;
}
return {
supportsReasoningEffort: true,
supportedReasoningEfforts,
reasoningEffortMap: buildLmstudioReasoningEffortMap(supportedReasoningEfforts),
};
}
function normalizeLmstudioTransportReasoningCompat(
compat: NonNullable<ModelDefinitionConfig["compat"]>,
): NonNullable<ModelDefinitionConfig["compat"]> {
const supportedReasoningEfforts = compat.supportedReasoningEfforts;
const map = compat.reasoningEffortMap;
const hasBinarySupported =
Array.isArray(supportedReasoningEfforts) &&
supportedReasoningEfforts.some((option) => option === "on");
const hasBinaryMapValue =
map !== undefined && Object.values(map).some((value) => value === "on" || value === "off");
if (!hasBinarySupported && !hasBinaryMapValue) {
return compat;
}
const hasDisabled =
supportedReasoningEfforts?.includes("off") === true ||
supportedReasoningEfforts?.includes("none") === true ||
Object.values(map ?? {}).some((value) => value === "off" || value === "none");
const normalizedSupportedReasoningEfforts = hasDisabled
? [...LMSTUDIO_OPENAI_COMPAT_REASONING_EFFORTS]
: [...LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS];
return {
...compat,
supportedReasoningEfforts: normalizedSupportedReasoningEfforts,
reasoningEffortMap: buildLmstudioReasoningEffortMap(normalizedSupportedReasoningEfforts),
};
}
export function resolveLmstudioReasoningCompat(
entry: Pick<LmstudioModelWire, "capabilities">,
): ModelDefinitionConfig["compat"] | undefined {
const reasoning = entry.capabilities?.reasoning;
if (reasoning === undefined || reasoning === null) {
return undefined;
}
const allowedOptions = normalizeReasoningOptions(reasoning.allowed_options);
if (allowedOptions.length === 0) {
return undefined;
}
return buildLmstudioReasoningCompat(allowedOptions);
}
/**
* Resolves LM Studio reasoning support from capabilities payloads.
* Defaults to false when the server omits reasoning metadata.
*/
export function resolveLmstudioReasoningCapability(
entry: Pick<LmstudioModelWire, "capabilities">,
): boolean {
const reasoning = entry.capabilities?.reasoning;
if (reasoning === undefined || reasoning === null) {
return false;
}
const allowedOptions = normalizeReasoningOptions(reasoning.allowed_options);
if (allowedOptions.length > 0) {
return allowedOptions.some((option) => isReasoningEnabledOption(option));
}
return isReasoningEnabledOption(reasoning.default);
}
/**
* Reads loaded LM Studio instances and returns the largest valid context window.
* Returns null when no usable loaded context is present.
*/
export function resolveLoadedContextWindow(
entry: Pick<LmstudioModelWire, "loaded_instances">,
): number | null {
const loadedInstances = Array.isArray(entry.loaded_instances) ? entry.loaded_instances : [];
let contextWindow: number | null = null;
for (const instance of loadedInstances) {
// Discovery payload is external JSON, so tolerate malformed entries.
const normalized = asPositiveSafeInteger(instance?.config?.context_length);
if (normalized === undefined) {
continue;
}
contextWindow = contextWindow === null ? normalized : Math.max(contextWindow, normalized);
}
return contextWindow;
}
function normalizeLmstudioVariantIds(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return uniqueStrings(
value.flatMap((variant) =>
typeof variant === "string" && variant.trim().length > 0 ? variant.trim() : [],
),
);
}
/**
* Resolves LM Studio variant ids back to their loadable model key.
*
* LM Studio exposes quantized variants separately from the canonical `key`, but
* `/api/v1/models/load` expects the key. Exact key matches still win so unusual
* servers that expose a suffix as the real key are preserved.
*/
export function resolveLmstudioCanonicalModelKey(params: {
modelKey: string;
models: LmstudioModelWire[];
}): string {
const modelKey = params.modelKey.trim();
if (!modelKey) {
return modelKey;
}
const normalizedModelKey = modelKey.toLowerCase();
for (const entry of params.models) {
if (entry.key?.trim() === modelKey) {
return modelKey;
}
}
for (const entry of params.models) {
const key = entry.key?.trim();
if (!key) {
continue;
}
const selectedVariant =
typeof entry.selected_variant === "string" ? entry.selected_variant.trim() : "";
const variants = normalizeLmstudioVariantIds(entry.variants);
if (
selectedVariant.toLowerCase() === normalizedModelKey ||
variants.some((variant) => variant.toLowerCase() === normalizedModelKey)
) {
return key;
}
}
return modelKey;
}
/**
* Normalizes a server path by stripping trailing slash and inference suffixes.
*
* LM Studio users often copy their inference URL (e.g. "http://localhost:1234/v1") instead
* of the server root. This function strips a trailing "/v1" or "/api/v1" so the caller always
* receives a clean root base URL. The expected input is the server root without any API version
* path (e.g. "http://localhost:1234").
*/
function normalizeUrlPath(pathname: string): string {
const trimmed = pathname.replace(/\/+$/, "");
if (!trimmed) {
return "";
}
return trimmed.replace(/\/api\/v1$/i, "").replace(/\/v1$/i, "");
}
function hasExplicitHttpScheme(value: string): boolean {
return /^https?:\/\//i.test(value);
}
function isLikelyHostBaseUrl(value: string): boolean {
return (
/^(?:localhost|(?:\d{1,3}\.){3}\d{1,3}|[a-z0-9.-]+\.[a-z]{2,}|[^/\s?#]+:\d+)(?:[/?#].*)?$/i.test(
value,
) && !value.startsWith("/")
);
}
function normalizeConfiguredReasoningEffortMap(value: unknown): Record<string, string> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const entries: Array<[string, string]> = [];
for (const [key, mapped] of Object.entries(value)) {
const normalizedKey = key.trim();
const normalizedValue = typeof mapped === "string" ? mapped.trim() : "";
if (normalizedKey && normalizedValue) {
entries.push([normalizedKey, normalizedValue]);
}
}
const normalized = Object.fromEntries(entries);
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeConfiguredCompatStringList(value: unknown): string[] | undefined {
if (
!Array.isArray(value) ||
!value.every((entry): entry is string => typeof entry === "string" && entry.length > 0)
) {
return undefined;
}
return [...value];
}
function isLmstudioConfiguredThinkingFormat(
value: unknown,
): value is (typeof LMSTUDIO_CONFIGURED_THINKING_FORMATS)[number] {
return (
typeof value === "string" &&
LMSTUDIO_CONFIGURED_THINKING_FORMATS.some((format) => format === value)
);
}
function normalizeLmstudioConfiguredCompat(value: unknown): ModelDefinitionConfig["compat"] {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const supportedReasoningEfforts = normalizeReasoningOptions(record.supportedReasoningEfforts);
const reasoningEffortMap = normalizeConfiguredReasoningEffortMap(record.reasoningEffortMap);
const compat: NonNullable<ModelDefinitionConfig["compat"]> = {};
for (const key of LMSTUDIO_CONFIGURED_BOOLEAN_COMPAT_FIELDS) {
const configuredValue = record[key];
if (typeof configuredValue === "boolean") {
compat[key] = configuredValue;
}
}
const visibleReasoningDetailTypes = normalizeConfiguredCompatStringList(
record.visibleReasoningDetailTypes,
);
if (visibleReasoningDetailTypes) {
compat.visibleReasoningDetailTypes = visibleReasoningDetailTypes;
}
const unsupportedToolSchemaKeywords = normalizeConfiguredCompatStringList(
record.unsupportedToolSchemaKeywords,
);
if (unsupportedToolSchemaKeywords) {
compat.unsupportedToolSchemaKeywords = unsupportedToolSchemaKeywords;
}
if (record.maxTokensField === "max_completion_tokens" || record.maxTokensField === "max_tokens") {
compat.maxTokensField = record.maxTokensField;
}
if (isLmstudioConfiguredThinkingFormat(record.thinkingFormat)) {
compat.thinkingFormat = record.thinkingFormat;
}
if (typeof record.toolSchemaProfile === "string") {
compat.toolSchemaProfile = record.toolSchemaProfile;
}
if (typeof record.toolCallArgumentsEncoding === "string") {
compat.toolCallArgumentsEncoding = record.toolCallArgumentsEncoding;
}
if (supportedReasoningEfforts.length > 0) {
compat.supportedReasoningEfforts = supportedReasoningEfforts;
}
if (reasoningEffortMap) {
compat.reasoningEffortMap = reasoningEffortMap;
}
return Object.keys(compat).length > 0
? normalizeLmstudioTransportReasoningCompat(compat)
: undefined;
}
function toFetchableLmstudioBaseUrl(value: string): string {
if (hasExplicitHttpScheme(value) || !isLikelyHostBaseUrl(value)) {
return value;
}
return `http://${value}`;
}
/** Resolves LM Studio server base URL (without /v1 or /api/v1). */
export function resolveLmstudioServerBase(configuredBaseUrl?: string): string {
// Use configured value when present; otherwise target local LM Studio default.
const configured = configuredBaseUrl?.trim();
const resolved = configured && configured.length > 0 ? configured : LMSTUDIO_DEFAULT_BASE_URL;
const fetchableBaseUrl = toFetchableLmstudioBaseUrl(resolved);
try {
const parsed = new URL(fetchableBaseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new TypeError(`Unsupported LM Studio protocol: ${parsed.protocol}`);
}
const pathname = normalizeUrlPath(parsed.pathname);
parsed.pathname = pathname.length > 0 ? pathname : "/";
parsed.search = "";
parsed.hash = "";
return parsed.toString().replace(/\/$/, "");
} catch {
const trimmed = resolved.replace(/\/+$/, "");
const normalized = normalizeUrlPath(trimmed);
return normalized.length > 0 ? normalized : LMSTUDIO_DEFAULT_BASE_URL;
}
}
/** Resolves LM Studio inference base URL and always appends /v1. */
export function resolveLmstudioInferenceBase(configuredBaseUrl?: string): string {
const serverBase = resolveLmstudioServerBase(configuredBaseUrl);
return `${serverBase}/v1`;
}
/** Canonicalizes persisted LM Studio provider config to the inference base URL form. */
export function normalizeLmstudioProviderConfig(
provider: ModelProviderConfig,
): ModelProviderConfig {
const configuredBaseUrl = typeof provider.baseUrl === "string" ? provider.baseUrl.trim() : "";
if (!configuredBaseUrl) {
return provider;
}
const normalizedBaseUrl = resolveLmstudioInferenceBase(configuredBaseUrl);
const request =
provider.request && typeof provider.request === "object" && !Array.isArray(provider.request)
? provider.request
: undefined;
const requestWithPrivateNetworkDefault =
typeof request?.allowPrivateNetwork === "boolean"
? request
: {
...request,
allowPrivateNetwork: true,
};
if (
normalizedBaseUrl === provider.baseUrl &&
requestWithPrivateNetworkDefault === provider.request
) {
return provider;
}
return {
...provider,
baseUrl: normalizedBaseUrl,
request: requestWithPrivateNetworkDefault,
};
}
export function normalizeLmstudioConfiguredCatalogEntry(
entry: unknown,
): LmstudioConfiguredCatalogEntry | null {
if (!entry || typeof entry !== "object") {
return null;
}
const record = entry as Record<string, unknown>;
if (typeof record.id !== "string" || record.id.trim().length === 0) {
return null;
}
const id = record.id.trim();
const name = typeof record.name === "string" && record.name.trim().length > 0 ? record.name : id;
const contextWindow = asPositiveSafeInteger(record.contextWindow);
const contextTokens = asPositiveSafeInteger(record.contextTokens);
const reasoning = typeof record.reasoning === "boolean" ? record.reasoning : undefined;
const input = Array.isArray(record.input)
? record.input.filter(
(item): item is "text" | "image" | "document" =>
item === "text" || item === "image" || item === "document",
)
: undefined;
const compat = normalizeLmstudioConfiguredCompat(record.compat);
return {
id,
name,
contextWindow,
contextTokens,
reasoning,
input: input && input.length > 0 ? input : undefined,
compat,
};
}
export function normalizeLmstudioConfiguredCatalogEntries(
models: unknown,
): LmstudioConfiguredCatalogEntry[] {
if (!Array.isArray(models)) {
return [];
}
return models
.map((entry) => normalizeLmstudioConfiguredCatalogEntry(entry))
.filter((entry): entry is LmstudioConfiguredCatalogEntry => entry !== null);
}
export function buildLmstudioModelName(model: {
displayName: string;
format: "gguf" | "mlx" | null;
vision: boolean;
trainedForToolUse: boolean;
loaded: boolean;
}): string {
const tags: string[] = [];
if (model.format === "mlx") {
tags.push("MLX");
} else if (model.format === "gguf") {
tags.push("GGUF");
}
if (model.vision) {
tags.push("vision");
}
if (model.trainedForToolUse) {
tags.push("tool-use");
}
if (model.loaded) {
tags.push("loaded");
}
if (tags.length === 0) {
return model.displayName;
}
return `${model.displayName} (${tags.join(", ")})`;
}
/**
* Base model fields extracted from a single LM Studio wire entry.
* Shared by the setup layer (persists simple names to config) and the runtime
* discovery path (which enriches the name with format/state tags).
*/
export type LmstudioModelBase = {
id: string;
displayName: string;
format: "gguf" | "mlx" | null;
vision: boolean;
trainedForToolUse: boolean;
loaded: boolean;
reasoning: boolean;
input: Array<"text" | "image">;
cost: ModelDefinitionConfig["cost"];
compat?: ModelDefinitionConfig["compat"];
contextWindow: number;
contextTokens: number;
maxTokens: number;
};
/**
* Maps a single LM Studio wire entry to its base model fields.
* Returns null for non-LLM entries or entries with no usable key.
*
* Shared by both the setup layer (persists simple names to config) and the
* runtime discovery path (which enriches the name with format/state tags via
* buildLmstudioModelName).
*/
export function mapLmstudioWireEntry(entry: LmstudioModelWire): LmstudioModelBase | null {
if (entry.type !== "llm") {
return null;
}
const id = entry.key?.trim() ?? "";
if (!id) {
return null;
}
const loadedContextWindow = resolveLoadedContextWindow(entry);
const advertisedContextWindow = asPositiveSafeInteger(entry.max_context_length) ?? null;
const contextWindow = advertisedContextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW;
// ModelDefinitionConfig keeps the native maximum in contextWindow. Runtime
// budgeting and preload prefer contextTokens, so cap that to the loaded instance.
const effectiveContextWindow = loadedContextWindow ?? contextWindow;
const contextTokens = Math.min(effectiveContextWindow, LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH);
const rawDisplayName = entry.display_name?.trim();
const reasoningCompat = resolveLmstudioReasoningCompat(entry);
const trainedForToolUse = entry.capabilities?.trained_for_tool_use;
// Native tool training is a positive capability, not proof that LM Studio's
// OpenAI-compatible fallback cannot call tools.
const compat =
trainedForToolUse === true
? { ...reasoningCompat, supportsTools: trainedForToolUse }
: reasoningCompat;
return {
id,
displayName: rawDisplayName && rawDisplayName.length > 0 ? rawDisplayName : id,
format: entry.format ?? null,
vision: entry.capabilities?.vision === true,
trainedForToolUse: entry.capabilities?.trained_for_tool_use === true,
// Use the same validity check as resolveLoadedContextWindow so malformed entries
// like [null, {}] don't produce a false positive "loaded" tag.
loaded: loadedContextWindow !== null,
reasoning: resolveLmstudioReasoningCapability(entry),
input: entry.capabilities?.vision ? ["text", "image"] : ["text"],
cost: SELF_HOSTED_DEFAULT_COST,
compat,
contextWindow,
contextTokens,
maxTokens: Math.max(1, Math.min(effectiveContextWindow, SELF_HOSTED_DEFAULT_MAX_TOKENS)),
};
}
/**
* Maps LM Studio wire models to config entries using plain display names.
* Use this for config persistence where runtime format/state tags are not needed.
* For runtime discovery with enriched names, use discoverLmstudioModels from models.fetch.ts.
*/
export function mapLmstudioWireModelsToConfig(
models: LmstudioModelWire[],
): ModelDefinitionConfig[] {
return models
.map((entry): ModelDefinitionConfig | null => {
const base = mapLmstudioWireEntry(entry);
if (!base) {
return null;
}
return {
id: base.id,
name: base.displayName,
reasoning: base.reasoning,
input: base.input,
cost: base.cost,
...(base.compat ? { compat: base.compat } : {}),
contextWindow: base.contextWindow,
contextTokens: base.contextTokens,
maxTokens: base.maxTokens,
};
})
.filter((entry): entry is ModelDefinitionConfig => entry !== null);
}