mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 07:50:44 +00:00
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
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 { 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;
|
|
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")[];
|
|
};
|
|
|
|
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";
|
|
}
|
|
|
|
/**
|
|
* 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 allowedOptionsRaw = reasoning.allowed_options;
|
|
const allowedOptions = Array.isArray(allowedOptionsRaw)
|
|
? allowedOptionsRaw
|
|
.map((option) => normalizeReasoningOption(option))
|
|
.filter((option): option is string => option !== null)
|
|
: [];
|
|
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 length = instance?.config?.context_length;
|
|
if (length === undefined || !Number.isFinite(length) || length <= 0) {
|
|
continue;
|
|
}
|
|
const normalized = Math.floor(length);
|
|
contextWindow = contextWindow === null ? normalized : Math.max(contextWindow, normalized);
|
|
}
|
|
return contextWindow;
|
|
}
|
|
|
|
/**
|
|
* 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 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);
|
|
return normalizedBaseUrl === provider.baseUrl
|
|
? provider
|
|
: { ...provider, baseUrl: normalizedBaseUrl };
|
|
}
|
|
|
|
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 =
|
|
typeof record.contextWindow === "number" && record.contextWindow > 0
|
|
? record.contextWindow
|
|
: undefined;
|
|
const contextTokens =
|
|
typeof record.contextTokens === "number" && record.contextTokens > 0
|
|
? record.contextTokens
|
|
: undefined;
|
|
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;
|
|
return {
|
|
id,
|
|
name,
|
|
contextWindow,
|
|
contextTokens,
|
|
reasoning,
|
|
input: input && input.length > 0 ? input : undefined,
|
|
};
|
|
}
|
|
|
|
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: ModelDefinitionConfig["input"];
|
|
cost: ModelDefinitionConfig["cost"];
|
|
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 =
|
|
entry.max_context_length !== undefined &&
|
|
Number.isFinite(entry.max_context_length) &&
|
|
entry.max_context_length > 0
|
|
? Math.floor(entry.max_context_length)
|
|
: null;
|
|
const contextWindow = advertisedContextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW;
|
|
// Keep native/advertised context window metadata in catalog, but use a practical
|
|
// default target for model loading unless callers explicitly override it.
|
|
const contextTokens = Math.min(contextWindow, LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH);
|
|
const rawDisplayName = entry.display_name?.trim();
|
|
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,
|
|
contextWindow,
|
|
contextTokens,
|
|
maxTokens: Math.max(1, Math.min(contextWindow, 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,
|
|
contextWindow: base.contextWindow,
|
|
contextTokens: base.contextTokens,
|
|
maxTokens: base.maxTokens,
|
|
};
|
|
})
|
|
.filter((entry): entry is ModelDefinitionConfig => entry !== null);
|
|
}
|