Files
openclaw/extensions/lmstudio/src/embedding-provider.ts
Peter Steinberger edecdbd05e refactor(config): config-surface reduction tranche 3 — product consolidations (review request) (#111527)
* refactor(config): consolidate media model lists

* refactor(config): unify memory configuration

* refactor(config): consolidate TTS ownership

* refactor(config): move typing policy to agents

* refactor(config): retire product-level config surfaces

* refactor(config): share scoped tool policy type

* chore(config): refresh generated baselines

* fix(config): honor agent typing overrides

* fix(config): migrate sibling config consumers

* refactor(infra): keep base64url decoder private

* fix(config): strip invalid legacy TTS values

* chore(config): refresh rebased baseline hash

* fix(doctor): route legacy messages.tts.realtime voice to talk during tts move

* refactor(config): polish final layout names

* refactor(config): freeze retired tuning defaults

* feat(config): add fast mode default symmetry

* refactor(config): key agent entries by id

* docs(config): update final layout reference

* test(config): cover final layout migrations

* chore(config): refresh final layout baselines

* fix(config): align final layout runtime readers

* fix(config): align remaining readers

* fix(config): stabilize final layout migrations

* fix(config): finalize config projection proof

* fix(config): address final layout review

* docs(release): preserve historical config names

* fix(config): complete keyed agent migration

* fix(config): close final migration gaps

* fix(config): finish full-branch review

* fix(config): complete runtime secret detection

* fix(config): close final review findings

* fix(config): finish canonical docs and heartbeat migration

* fix(config): integrate latest main after rebase

* refactor(env): isolate test-only controls

* refactor(env): isolate build and development controls

* refactor(env): collapse process identity indirection

* refactor(env): remove duplicate config and temp aliases

* docs(env): define the operator-facing allowlist

* ci(env): ratchet production variable count

* fix(env): remove stale provider helper import

* fix(env): make ratchet sorting explicit

* test(env): keep test seam in dead-code audit

* test(env): cover ratchet growth and boundary; document surface budgets

* docs(config): document tier-eval consolidations

* docs(config): clarify speech preference ownership

* test(memory): align retired tuning fixtures

* refactor(memory): freeze engine heuristics

* refactor(config): apply tier-eval tranche

* refactor(tts): move persona shaping to providers

* refactor(compaction): move prompt policy to providers

* test(config): align hookified prompt fixtures

* chore(deadcode): classify test-only exports

* chore(github): remove unused spawn helper

* chore(deadcode): classify queue diagnostics

* chore(deadcode): remove unused lane snapshot export

* chore(plugin-sdk): ratchet consolidated surface

* fix(config): integrate latest main after rebase
2026-07-21 20:28:43 -07:00

296 lines
11 KiB
TypeScript

// Lmstudio provider module implements model/runtime integration.
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import {
buildRemoteBaseUrlPolicy,
createRemoteEmbeddingProvider,
normalizeEmbeddingModelWithPrefixes,
type MemoryEmbeddingProvider,
type MemoryEmbeddingProviderCreateOptions,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { resolveMemorySecretInputString } from "openclaw/plugin-sdk/memory-core-host-secret";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { formatErrorMessage, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { asPositiveSafeInteger } from "openclaw/plugin-sdk/string-coerce-runtime";
import { LMSTUDIO_DEFAULT_EMBEDDING_MODEL, LMSTUDIO_PROVIDER_ID } from "./defaults.js";
import { ensureLmstudioModelLoaded } from "./models.fetch.js";
import {
normalizeLmstudioConfiguredCatalogEntries,
resolveLmstudioInferenceBase,
resolveLmstudioServerBase,
} from "./models.js";
import {
buildLmstudioAuthHeaders,
resolveLmstudioConfiguredApiKeyForProvider,
resolveLmstudioProviderHeaders,
resolveLmstudioRuntimeApiKey,
} from "./runtime.js";
const log = createSubsystemLogger("memory/embeddings");
type LmstudioEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
model: string;
};
type MemoryCoreAcquireLocalService = (
target: {
providerId: string;
baseUrl: string;
headers?: HeadersInit;
},
signal?: AbortSignal | null,
) => Promise<{ release: () => void } | undefined>;
type LocalServiceAwareEmbeddingOptions = MemoryEmbeddingProviderCreateOptions & {
acquireLocalService?: MemoryCoreAcquireLocalService;
};
export const DEFAULT_LMSTUDIO_EMBEDDING_MODEL = LMSTUDIO_DEFAULT_EMBEDDING_MODEL;
/** Normalizes LM Studio embedding model refs and accepts `lmstudio/` prefix. */
function normalizeLmstudioModel(model: string, providerId?: string): string {
return normalizeEmbeddingModelWithPrefixes({
model,
defaultModel: DEFAULT_LMSTUDIO_EMBEDDING_MODEL,
prefixes: [`${providerId?.trim() || LMSTUDIO_PROVIDER_ID}/`, `${LMSTUDIO_PROVIDER_ID}/`],
});
}
function hasAuthorizationHeader(headers: Record<string, string> | undefined): boolean {
if (!headers) {
return false;
}
return Object.entries(headers).some(
([headerName, value]) =>
headerName.trim().toLowerCase() === "authorization" && value.trim().length > 0,
);
}
/** Resolves API key (real or synthetic placeholder) from runtime/provider auth config. */
async function resolveLmstudioApiKey(
options: MemoryEmbeddingProviderCreateOptions,
providerId?: string,
): Promise<string | undefined> {
const selectedProviderId = providerId?.trim();
const selectedApiKey =
selectedProviderId && selectedProviderId !== LMSTUDIO_PROVIDER_ID
? options.config.models?.providers?.[selectedProviderId]?.apiKey
: undefined;
if (selectedProviderId && selectedProviderId !== LMSTUDIO_PROVIDER_ID) {
return selectedApiKey === undefined || selectedApiKey === null
? undefined
: await resolveLmstudioConfiguredApiKeyForProvider({
providerId: selectedProviderId,
config: options.config,
env: process.env,
});
}
try {
return await resolveLmstudioRuntimeApiKey({
config: options.config,
agentDir: options.agentDir,
});
} catch (error) {
// Embeddings can target local LM Studio instances that do not require auth.
if (/LM Studio API key is required/i.test(formatErrorMessage(error))) {
return undefined;
}
throw error;
}
}
function resolveEmbeddingPreloadContextLength(params: {
model: string;
models: unknown;
providerContextTokens: unknown;
providerContextWindow: unknown;
}): number | undefined {
const configuredModel = normalizeLmstudioConfiguredCatalogEntries(params.models).find(
(entry) => normalizeLmstudioModel(entry.id) === params.model,
);
if (configuredModel?.contextTokens !== undefined) {
return configuredModel.contextTokens;
}
// Provider contextTokens is the model default, so it caps an explicit model
// window only when that model did not declare its own effective token cap.
const providerContextTokens = asPositiveSafeInteger(params.providerContextTokens);
if (configuredModel?.contextWindow !== undefined && providerContextTokens !== undefined) {
return Math.min(configuredModel.contextWindow, providerContextTokens);
}
return (
providerContextTokens ??
configuredModel?.contextWindow ??
asPositiveSafeInteger(params.providerContextWindow)
);
}
function resolveConfiguredLmstudioProvider(options: MemoryEmbeddingProviderCreateOptions) {
const providers = options.config.models?.providers;
if (!providers) {
return undefined;
}
const providerId = options.provider?.trim() || LMSTUDIO_PROVIDER_ID;
const direct = providers[providerId];
if (direct) {
return { providerId, config: direct };
}
const normalized = normalizeProviderId(providerId);
for (const [candidateId, candidate] of Object.entries(providers)) {
if (normalizeProviderId(candidateId) === normalized) {
return { providerId: candidateId, config: candidate };
}
}
const fallback = providers[LMSTUDIO_PROVIDER_ID];
return fallback ? { providerId: LMSTUDIO_PROVIDER_ID, config: fallback } : undefined;
}
function resolveLmstudioLocalServiceBaseUrl(
configuredBaseUrl: string | undefined,
inferenceBaseUrl: string,
): string {
const configured = configuredBaseUrl?.trim();
if (!configured) {
return inferenceBaseUrl;
}
const configuredPath = configured.replace(/[?#].*$/u, "").replace(/\/+$/u, "");
const serverBaseUrl = resolveLmstudioServerBase(configured);
return /\/api\/v1$/iu.test(configuredPath) ? `${serverBaseUrl}/api/v1` : `${serverBaseUrl}/v1`;
}
/** Creates the LM Studio embedding provider client and preloads the target model before return. */
export async function createLmstudioEmbeddingProvider(
options: MemoryEmbeddingProviderCreateOptions,
): Promise<{ provider: MemoryEmbeddingProvider; client: LmstudioEmbeddingClient }> {
const resolvedProvider = resolveConfiguredLmstudioProvider(options);
const providerConfig = resolvedProvider?.config;
const providerBaseUrl = providerConfig?.baseUrl?.trim();
const isFallbackActivation = options.fallback === "lmstudio" && options.provider !== "lmstudio";
const remoteBaseUrl = options.remote?.baseUrl?.trim();
const remoteApiKey = !isFallbackActivation
? resolveMemorySecretInputString({
value: options.remote?.apiKey,
path: "memory.search.remote.apiKey",
})
: undefined;
// memorySearch.remote is shared across primary + fallback providers.
// Ignore it during fallback activation to avoid inheriting another provider's
// endpoint/headers/credentials when LM Studio activates as a fallback.
const baseUrlSource = !isFallbackActivation ? remoteBaseUrl : undefined;
const configuredBaseUrl =
baseUrlSource && baseUrlSource.length > 0
? baseUrlSource
: providerBaseUrl && providerBaseUrl.length > 0
? providerBaseUrl
: undefined;
const baseUrl = resolveLmstudioInferenceBase(configuredBaseUrl);
const model = normalizeLmstudioModel(options.model, resolvedProvider?.providerId);
const providerHeaders = await resolveLmstudioProviderHeaders({
config: options.config,
env: process.env,
headers: Object.assign(
{},
providerConfig?.headers,
!isFallbackActivation ? options.remote?.headers : {},
),
});
const apiKey = hasAuthorizationHeader(providerHeaders)
? undefined
: !isFallbackActivation
? remoteApiKey?.trim() || (await resolveLmstudioApiKey(options, resolvedProvider?.providerId))
: await resolveLmstudioApiKey(options, resolvedProvider?.providerId);
const headerOverrides = Object.assign({}, providerHeaders);
const headers =
buildLmstudioAuthHeaders({
apiKey,
json: true,
headers: headerOverrides,
}) ?? {};
const ssrfPolicy = buildRemoteBaseUrlPolicy(baseUrl);
const client: LmstudioEmbeddingClient = {
baseUrl,
model,
headers,
ssrfPolicy,
};
const requestedContextLength = resolveEmbeddingPreloadContextLength({
model,
models: providerConfig?.models,
providerContextTokens: providerConfig?.contextTokens,
providerContextWindow: providerConfig?.contextWindow,
});
const localServiceTarget =
providerConfig?.localService && !baseUrlSource
? {
providerId: resolvedProvider?.providerId ?? LMSTUDIO_PROVIDER_ID,
baseUrl: resolveLmstudioLocalServiceBaseUrl(providerBaseUrl, baseUrl),
headers,
}
: undefined;
const acquireLocalService = (options as LocalServiceAwareEmbeddingOptions).acquireLocalService;
const withLocalServiceLease = async <T>(
signal: AbortSignal | undefined,
action: () => Promise<T>,
): Promise<T> => {
const lease =
localServiceTarget && acquireLocalService
? await acquireLocalService(localServiceTarget, signal)
: undefined;
try {
return await action();
} finally {
lease?.release();
}
};
await withLocalServiceLease(undefined, async () => {
try {
await ensureLmstudioModelLoaded({
baseUrl,
apiKey,
headers: headerOverrides,
ssrfPolicy,
modelKey: model,
requestedContextLength,
timeoutMs: 120_000,
});
} catch (error) {
log.warn("lmstudio embeddings warmup failed; continuing without preload", {
baseUrl,
model,
error: formatErrorMessage(error),
});
}
});
const remoteProvider = createRemoteEmbeddingProvider({
id: LMSTUDIO_PROVIDER_ID,
client,
errorPrefix: "lmstudio embeddings failed",
});
const provider: MemoryEmbeddingProvider = {
...remoteProvider,
embedQuery: async (text, callOptions) =>
await withLocalServiceLease(callOptions?.signal, async () => {
return await remoteProvider.embedQuery(text, callOptions);
}),
embedBatch: async (texts, callOptions) =>
await withLocalServiceLease(callOptions?.signal, async () => {
return await remoteProvider.embedBatch(texts, callOptions);
}),
...(remoteProvider.embedBatchInputs
? {
embedBatchInputs: async (
inputs: Parameters<NonNullable<MemoryEmbeddingProvider["embedBatchInputs"]>>[0],
callOptions?: Parameters<NonNullable<MemoryEmbeddingProvider["embedBatchInputs"]>>[1],
) =>
await withLocalServiceLease(callOptions?.signal, async () => {
return await remoteProvider.embedBatchInputs!(inputs, callOptions);
}),
}
: {}),
};
return {
provider,
client,
};
}