Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
570
extensions/lmstudio/src/models.ts
Normal file
570
extensions/lmstudio/src/models.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
// 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;
|
||||
|
||||
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 normalized = Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, mapped]) => [key.trim(), typeof mapped === "string" ? mapped.trim() : ""])
|
||||
.filter(([key, mapped]) => key.length > 0 && mapped.length > 0),
|
||||
);
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
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"]> = {};
|
||||
if (typeof record.supportsUsageInStreaming === "boolean") {
|
||||
compat.supportsUsageInStreaming = record.supportsUsageInStreaming;
|
||||
}
|
||||
if (typeof record.supportsReasoningEffort === "boolean") {
|
||||
compat.supportsReasoningEffort = record.supportsReasoningEffort;
|
||||
}
|
||||
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;
|
||||
// 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,
|
||||
compat: resolveLmstudioReasoningCompat(entry),
|
||||
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,
|
||||
...(base.compat ? { compat: base.compat } : {}),
|
||||
contextWindow: base.contextWindow,
|
||||
contextTokens: base.contextTokens,
|
||||
maxTokens: base.maxTokens,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is ModelDefinitionConfig => entry !== null);
|
||||
}
|
||||
Reference in New Issue
Block a user