426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
719 lines
23 KiB
TypeScript
719 lines
23 KiB
TypeScript
/**
|
|
* LM Studio text generation for ElizaOS.
|
|
*
|
|
* ## Why this module looks like Ollama's, not OpenAI's
|
|
*
|
|
* LM Studio is an OpenAI-compatible local server, so the bytes-on-the-wire are very
|
|
* similar to `plugin-openai`. But the orchestration concerns are closer to
|
|
* `plugin-ollama`:
|
|
*
|
|
* - Models are picked up from what the user has loaded locally — there is no canonical
|
|
* "gpt-4o-class" identifier. Callers either override per-tier env vars
|
|
* (`LMSTUDIO_SMALL_MODEL`, `LMSTUDIO_LARGE_MODEL`) or we fall back to the first id
|
|
* returned by `GET /v1/models`.
|
|
* - There's no need for the OpenAI plugin's Cerebras / reasoning / image branches.
|
|
*
|
|
* So we mirror Ollama's structure (single `handleTextWithModelType`, structured-output
|
|
* via `Output.object`, optional streaming) but build on `@ai-sdk/openai-compatible`
|
|
* instead of `ollama-ai-provider-v2`.
|
|
*
|
|
* ## Errors
|
|
*
|
|
* `AI_SDK` errors carry `responseBody` / `statusCode` / `url`. `summarizeAiSdkError`
|
|
* surfaces these so operators get LM Studio's actual response body in logs instead
|
|
* of a generic "Internal Server Error" — local servers commonly fail with "no model
|
|
* loaded" or OOM, both of which are only visible in the body.
|
|
*/
|
|
|
|
import type {
|
|
GenerateTextParams,
|
|
GenerateTextResult,
|
|
IAgentRuntime,
|
|
ModelTypeName,
|
|
TextStreamResult,
|
|
TokenUsage,
|
|
ToolCall,
|
|
} from "@elizaos/core";
|
|
import {
|
|
buildCanonicalSystemPrompt,
|
|
dropDuplicateLeadingSystemMessage,
|
|
logger,
|
|
ModelType,
|
|
renderChatMessagesForPrompt,
|
|
resolveEffectiveSystemPrompt,
|
|
} from "@elizaos/core";
|
|
import {
|
|
generateText,
|
|
type JSONSchema7,
|
|
jsonSchema,
|
|
type LanguageModel,
|
|
type ModelMessage,
|
|
Output,
|
|
streamText,
|
|
type ToolChoice,
|
|
type ToolSet,
|
|
type UserContent,
|
|
} from "ai";
|
|
import type { LMStudioModelInfo } from "../types";
|
|
import { createLMStudioClient } from "../utils/client";
|
|
import { getBaseURL, getLargeModel, getSmallModel } from "../utils/config";
|
|
import { detectLMStudio } from "../utils/detect";
|
|
import { emitModelUsed, estimateUsage, normalizeTokenUsage } from "../utils/model-usage";
|
|
|
|
const TEXT_NANO_MODEL_TYPE = ModelType.TEXT_NANO as ModelTypeName;
|
|
const TEXT_MEDIUM_MODEL_TYPE = ModelType.TEXT_MEDIUM as ModelTypeName;
|
|
const TEXT_MEGA_MODEL_TYPE = ModelType.TEXT_MEGA as ModelTypeName;
|
|
const RESPONSE_HANDLER_MODEL_TYPE = ModelType.RESPONSE_HANDLER as ModelTypeName;
|
|
const ACTION_PLANNER_MODEL_TYPE = ModelType.ACTION_PLANNER as ModelTypeName;
|
|
|
|
type TextModelType =
|
|
| typeof TEXT_NANO_MODEL_TYPE
|
|
| typeof ModelType.TEXT_SMALL
|
|
| typeof TEXT_MEDIUM_MODEL_TYPE
|
|
| typeof ModelType.TEXT_LARGE
|
|
| typeof TEXT_MEGA_MODEL_TYPE
|
|
| typeof RESPONSE_HANDLER_MODEL_TYPE
|
|
| typeof ACTION_PLANNER_MODEL_TYPE;
|
|
|
|
/**
|
|
* `GenerateTextParams` widened with the fields ElizaOS core sends alongside the documented
|
|
* prompt parameters — messages, tools, toolChoice and an optional response schema. Typed
|
|
* loosely (matching plugin-ollama) because callers cross several runtime versions.
|
|
*/
|
|
type GenerateTextParamsWithNativeOptions = Omit<GenerateTextParams, "responseSchema"> & {
|
|
messages?: unknown[];
|
|
tools?: unknown;
|
|
toolChoice?: unknown;
|
|
responseSchema?: unknown;
|
|
};
|
|
|
|
type NativeTextOutput = NonNullable<Parameters<typeof generateText>[0]["output"]>;
|
|
type NativeTextModelResult = string & GenerateTextResult;
|
|
|
|
const _firstModelIdCache = new WeakMap<IAgentRuntime, Promise<string | null>>();
|
|
|
|
/**
|
|
* Resolves a model identifier for a given tier. Order of resolution:
|
|
*
|
|
* 1. `LMSTUDIO_<TIER>_MODEL` env (e.g. `LMSTUDIO_SMALL_MODEL`).
|
|
* 2. Generic `<TIER>_MODEL` env fallback.
|
|
* 3. First model returned by `GET /v1/models` — LM Studio always returns at least the
|
|
* currently-loaded model, so this gives a useful default without per-install config.
|
|
*
|
|
* The detection result is cached per runtime so we don't hit `/v1/models` on every call.
|
|
*/
|
|
async function resolveModelForType(
|
|
runtime: IAgentRuntime,
|
|
modelType: TextModelType
|
|
): Promise<string> {
|
|
// Tier-specific overrides win.
|
|
if (
|
|
modelType === ModelType.TEXT_LARGE ||
|
|
modelType === TEXT_MEGA_MODEL_TYPE ||
|
|
modelType === ACTION_PLANNER_MODEL_TYPE
|
|
) {
|
|
const large = getLargeModel(runtime);
|
|
if (large) return large;
|
|
} else {
|
|
const small = getSmallModel(runtime);
|
|
if (small) return small;
|
|
}
|
|
|
|
// Fall back to LM Studio's first reported model.
|
|
let pending = _firstModelIdCache.get(runtime);
|
|
if (!pending) {
|
|
pending = (async (): Promise<string | null> => {
|
|
const result = await detectLMStudio({
|
|
baseURL: getBaseURL(runtime),
|
|
...(runtime.fetch ? { fetcher: runtime.fetch } : {}),
|
|
});
|
|
if (!result.available || !result.models || result.models.length === 0) {
|
|
return null;
|
|
}
|
|
const first: LMStudioModelInfo = result.models[0] as LMStudioModelInfo;
|
|
return first.id;
|
|
})();
|
|
_firstModelIdCache.set(runtime, pending);
|
|
}
|
|
|
|
const resolved = await pending;
|
|
if (resolved) {
|
|
return resolved;
|
|
}
|
|
|
|
throw new Error(
|
|
"[LMStudio] No model configured and `GET /v1/models` returned no entries. Set LMSTUDIO_SMALL_MODEL / LMSTUDIO_LARGE_MODEL or load a model in LM Studio."
|
|
);
|
|
}
|
|
|
|
function summarizeAiSdkError(error: unknown, depth = 0): Record<string, unknown> {
|
|
if (depth > 4) {
|
|
return { note: "max depth summarizing nested error" };
|
|
}
|
|
if (error == null) {
|
|
return { raw: String(error) };
|
|
}
|
|
if (typeof error !== "object") {
|
|
return { message: String(error) };
|
|
}
|
|
const e = error as Record<string, unknown>;
|
|
const out: Record<string, unknown> = {};
|
|
if (typeof e.name === "string") out.errorName = e.name;
|
|
if (typeof e.message === "string") out.message = e.message;
|
|
if (typeof e.url === "string") out.requestUrl = e.url;
|
|
if (typeof e.statusCode === "number") out.httpStatus = e.statusCode;
|
|
if (typeof e.responseBody === "string") out.lmstudioResponseBody = e.responseBody;
|
|
if (e.cause != null && typeof e.cause === "object") {
|
|
out.cause = summarizeAiSdkError(e.cause, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function logTextFailure(
|
|
phase: "generateText" | "streamText.textStream",
|
|
modelType: TextModelType,
|
|
modelId: string,
|
|
endpoint: string,
|
|
error: unknown
|
|
): void {
|
|
logger.error(
|
|
{
|
|
src: "plugin:lmstudio:text",
|
|
phase,
|
|
modelType,
|
|
modelId,
|
|
lmstudioBaseURL: endpoint,
|
|
...summarizeAiSdkError(error),
|
|
},
|
|
`[LMStudio] ${phase} failed (${modelType}, model=${modelId}).`
|
|
);
|
|
}
|
|
|
|
function buildStructuredOutput(responseSchema: unknown): NativeTextOutput {
|
|
if (
|
|
responseSchema &&
|
|
typeof responseSchema === "object" &&
|
|
"responseFormat" in responseSchema &&
|
|
"parseCompleteOutput" in responseSchema
|
|
) {
|
|
return responseSchema as NativeTextOutput;
|
|
}
|
|
|
|
const schemaOptions =
|
|
responseSchema && typeof responseSchema === "object" && "schema" in responseSchema
|
|
? (responseSchema as { schema: unknown; name?: string; description?: string })
|
|
: { schema: responseSchema };
|
|
|
|
return Output.object({
|
|
schema: jsonSchema(schemaOptions.schema as JSONSchema7),
|
|
...(schemaOptions.name ? { name: schemaOptions.name } : {}),
|
|
...(schemaOptions.description ? { description: schemaOptions.description } : {}),
|
|
}) as NativeTextOutput;
|
|
}
|
|
|
|
function serializeStructuredResult(result: { text: string; output: unknown }): string {
|
|
if (result.output !== undefined && result.output !== null) {
|
|
return typeof result.output === "string" ? result.output : JSON.stringify(result.output);
|
|
}
|
|
const trimmed = result.text.trim();
|
|
if (trimmed) return trimmed;
|
|
throw new Error("[LMStudio] Structured generation returned no text or output.");
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function firstString(...values: unknown[]): string | undefined {
|
|
for (const value of values) {
|
|
if (typeof value === "string" && value.length > 0) {
|
|
return value;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseJsonIfPossible(value: unknown): unknown {
|
|
if (typeof value !== "string") {
|
|
return value;
|
|
}
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
// error-policy:J3 tool-argument values are JSON text OR an already-plain
|
|
// string literal; a non-JSON string is a valid argument, not a parse failure
|
|
// of required data. Returning it unchanged is the designed passthrough.
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function inferJsonSchemaType(schema: Record<string, unknown>, isRoot: boolean): string | undefined {
|
|
if ("items" in schema && !("properties" in schema)) {
|
|
return "array";
|
|
}
|
|
if (
|
|
"properties" in schema ||
|
|
"required" in schema ||
|
|
"additionalProperties" in schema ||
|
|
isRoot
|
|
) {
|
|
return "object";
|
|
}
|
|
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
const types = new Set(schema.enum.map((value) => typeof value));
|
|
if (types.size === 1) {
|
|
const [type] = [...types];
|
|
if (type === "string" || type === "number" || type === "boolean") {
|
|
return type;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function sanitizeJsonSchema(schema: unknown, isRoot = false): JSONSchema7 {
|
|
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
return { type: "object" };
|
|
}
|
|
|
|
const record = schema as Record<string, unknown>;
|
|
const sanitized: Record<string, unknown> = { ...record };
|
|
|
|
if (typeof sanitized.type !== "string") {
|
|
const inferredType = inferJsonSchemaType(sanitized, isRoot);
|
|
if (inferredType) {
|
|
sanitized.type = inferredType;
|
|
}
|
|
}
|
|
|
|
if (
|
|
sanitized.properties &&
|
|
typeof sanitized.properties === "object" &&
|
|
!Array.isArray(sanitized.properties)
|
|
) {
|
|
const properties: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(sanitized.properties as Record<string, unknown>)) {
|
|
properties[key] = sanitizeJsonSchema(value);
|
|
}
|
|
sanitized.properties = properties;
|
|
}
|
|
|
|
if (sanitized.items) {
|
|
sanitized.items = Array.isArray(sanitized.items)
|
|
? sanitized.items.map((item) => sanitizeJsonSchema(item))
|
|
: sanitizeJsonSchema(sanitized.items);
|
|
}
|
|
|
|
for (const unionKey of ["anyOf", "oneOf", "allOf"] as const) {
|
|
const value = sanitized[unionKey];
|
|
if (Array.isArray(value)) {
|
|
sanitized[unionKey] = value.map((item) => sanitizeJsonSchema(item));
|
|
}
|
|
}
|
|
|
|
return sanitized as JSONSchema7;
|
|
}
|
|
|
|
export function normalizeNativeTools(tools: unknown): ToolSet | undefined {
|
|
if (!tools) {
|
|
return undefined;
|
|
}
|
|
if (!Array.isArray(tools)) {
|
|
return tools as ToolSet;
|
|
}
|
|
|
|
const toolSet: Record<string, unknown> = {};
|
|
for (const rawTool of tools) {
|
|
const tool = asRecord(rawTool);
|
|
const functionTool = asRecord(tool.function);
|
|
const name = firstString(tool.name, functionTool.name);
|
|
|
|
if (!name) {
|
|
throw new Error("[LMStudio] Native tool definition is missing a name.");
|
|
}
|
|
|
|
const description = firstString(tool.description, functionTool.description);
|
|
const rawSchema =
|
|
tool.parameters ?? functionTool.parameters ?? ({ type: "object" } satisfies JSONSchema7);
|
|
const inputSchema = sanitizeJsonSchema(rawSchema, true);
|
|
|
|
toolSet[name] = {
|
|
...(description ? { description } : {}),
|
|
inputSchema: jsonSchema(inputSchema as JSONSchema7),
|
|
};
|
|
}
|
|
return Object.keys(toolSet).length > 0 ? (toolSet as ToolSet) : undefined;
|
|
}
|
|
|
|
export function normalizeToolChoice(toolChoice: unknown): ToolChoice<ToolSet> | undefined {
|
|
if (!toolChoice) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
typeof toolChoice === "string" &&
|
|
(toolChoice === "auto" || toolChoice === "none" || toolChoice === "required")
|
|
) {
|
|
return toolChoice;
|
|
}
|
|
const choice = asRecord(toolChoice);
|
|
if (choice.type === "tool") {
|
|
const toolName = firstString(choice.toolName, choice.name);
|
|
if (toolName) {
|
|
return { type: "tool", toolName };
|
|
}
|
|
}
|
|
if (choice.type === "function") {
|
|
const fn = asRecord(choice.function);
|
|
const toolName = firstString(fn.name);
|
|
if (toolName) {
|
|
return { type: "tool", toolName };
|
|
}
|
|
}
|
|
return toolChoice as ToolChoice<ToolSet>;
|
|
}
|
|
|
|
function stringifyMessageContent(content: unknown): string {
|
|
if (typeof content === "string") return content;
|
|
if (content == null) return "";
|
|
if (typeof content === "object") {
|
|
try {
|
|
return JSON.stringify(content);
|
|
} catch {
|
|
// error-policy:J7 message-content stringify for the wire request; a
|
|
// non-serializable (e.g. circular) object degrades to a marker so the
|
|
// request still forms. Not a data/inference-result path.
|
|
return "[unserializable content]";
|
|
}
|
|
}
|
|
return String(content);
|
|
}
|
|
|
|
function normalizeUserContent(content: unknown): UserContent {
|
|
if (Array.isArray(content)) {
|
|
return content as UserContent;
|
|
}
|
|
return stringifyMessageContent(content);
|
|
}
|
|
|
|
function normalizeNativeMessage(message: unknown): ModelMessage {
|
|
const raw = asRecord(message);
|
|
if (raw.role === "system") {
|
|
return {
|
|
role: "system",
|
|
content: stringifyMessageContent(raw.content),
|
|
} as ModelMessage;
|
|
}
|
|
if (raw.role === "assistant") {
|
|
return {
|
|
role: "assistant",
|
|
content: typeof raw.content === "string" || Array.isArray(raw.content) ? raw.content : "",
|
|
} as ModelMessage;
|
|
}
|
|
if (raw.role === "tool") {
|
|
return {
|
|
role: "tool",
|
|
content: Array.isArray(raw.content)
|
|
? raw.content
|
|
: [
|
|
{
|
|
type: "tool-result",
|
|
toolCallId: String(firstString(raw.toolCallId, raw.id) ?? "tool-call"),
|
|
toolName: String(firstString(raw.toolName, raw.name) ?? "tool"),
|
|
output: {
|
|
type: "text",
|
|
value: stringifyMessageContent(raw.content),
|
|
},
|
|
},
|
|
],
|
|
} as ModelMessage;
|
|
}
|
|
return {
|
|
role: "user",
|
|
content: normalizeUserContent(raw.content),
|
|
} as ModelMessage;
|
|
}
|
|
|
|
export function normalizeNativeMessages(messages: unknown): ModelMessage[] | undefined {
|
|
if (!Array.isArray(messages)) {
|
|
return undefined;
|
|
}
|
|
return messages.map((m) => normalizeNativeMessage(m));
|
|
}
|
|
|
|
function mapToolCalls(toolCalls: unknown[] | undefined): ToolCall[] {
|
|
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
|
|
return [];
|
|
}
|
|
const out: ToolCall[] = [];
|
|
for (const raw of toolCalls) {
|
|
const r = asRecord(raw);
|
|
const id = String(firstString(r.toolCallId, r.id) ?? "");
|
|
const name = String(firstString(r.toolName, r.name) ?? "").trim();
|
|
if (!name) continue;
|
|
|
|
const rawInput = r.input ?? r.arguments ?? r.args;
|
|
let args: Record<string, unknown> | string;
|
|
if (typeof rawInput === "string") {
|
|
const parsed = parseJsonIfPossible(rawInput);
|
|
args =
|
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
? (parsed as Record<string, unknown>)
|
|
: rawInput;
|
|
} else if (rawInput && typeof rawInput === "object" && !Array.isArray(rawInput)) {
|
|
args = rawInput as Record<string, unknown>;
|
|
} else {
|
|
args = {};
|
|
}
|
|
out.push({ id, name, arguments: args } as ToolCall);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildNativeResultCast(
|
|
result: Awaited<ReturnType<typeof generateText>>,
|
|
modelName: string,
|
|
usage: TokenUsage
|
|
): string {
|
|
const payload: GenerateTextResult = {
|
|
text: result.text,
|
|
toolCalls: mapToolCalls(result.toolCalls as unknown[] | undefined),
|
|
finishReason: String(result.finishReason),
|
|
usage,
|
|
providerMetadata: { modelName },
|
|
};
|
|
return payload as NativeTextModelResult;
|
|
}
|
|
|
|
type StreamTextParams = Parameters<typeof streamText>[0];
|
|
|
|
function buildStreamResult(args: {
|
|
runtime: IAgentRuntime;
|
|
modelType: TextModelType;
|
|
model: string;
|
|
endpoint: string;
|
|
streamParams: StreamTextParams;
|
|
promptForEstimate: string;
|
|
}): TextStreamResult {
|
|
const streamResult = streamText(args.streamParams);
|
|
// error-policy:J5 side-promise catches only dedupe the unhandled rejection; the
|
|
// authoritative failure is rethrown from the textStream generator's catch below.
|
|
const textPromise = Promise.resolve(streamResult.text).catch(() => "");
|
|
const finishReasonPromise = Promise.resolve(streamResult.finishReason).catch(
|
|
() => undefined
|
|
) as Promise<string | undefined>;
|
|
const usagePromise = Promise.resolve(streamResult.usage)
|
|
.then(async (usage) => {
|
|
const fullText = await textPromise;
|
|
const normalized =
|
|
normalizeTokenUsage(usage) ?? estimateUsage(args.promptForEstimate, fullText);
|
|
emitModelUsed(args.runtime, args.modelType, args.model, normalized);
|
|
return normalized;
|
|
})
|
|
// error-policy:J7 usage/telemetry estimation must not crash the stream; the
|
|
// generation itself still surfaces via the textStream generator.
|
|
.catch(() => undefined);
|
|
|
|
async function* textStreamWithUsage(): AsyncIterable<string> {
|
|
let completed = false;
|
|
try {
|
|
for await (const chunk of streamResult.textStream) {
|
|
yield chunk;
|
|
}
|
|
completed = true;
|
|
} catch (err) {
|
|
logTextFailure("streamText.textStream", args.modelType, args.model, args.endpoint, err);
|
|
throw err;
|
|
} finally {
|
|
if (completed) {
|
|
// error-policy:J7 only after a SUCCESSFUL stream; a usage-emit failure
|
|
// must not convert a completed generation into an error.
|
|
await usagePromise.catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
textStream: textStreamWithUsage(),
|
|
text: textPromise,
|
|
usage: usagePromise,
|
|
finishReason: finishReasonPromise,
|
|
};
|
|
}
|
|
|
|
async function handleTextWithModelType(
|
|
runtime: IAgentRuntime,
|
|
modelType: TextModelType,
|
|
params: GenerateTextParams
|
|
): Promise<string | TextStreamResult> {
|
|
const extended = params as GenerateTextParamsWithNativeOptions;
|
|
const responseSchema = extended.responseSchema;
|
|
const tools = normalizeNativeTools(extended.tools);
|
|
|
|
const {
|
|
prompt,
|
|
maxTokens = 8192,
|
|
temperature = 0.7,
|
|
frequencyPenalty = 0.7,
|
|
presencePenalty = 0.7,
|
|
} = params;
|
|
|
|
let modelIdForLog = "";
|
|
const baseURL = getBaseURL(runtime);
|
|
|
|
try {
|
|
const client = createLMStudioClient(runtime);
|
|
const model = await resolveModelForType(runtime, modelType);
|
|
modelIdForLog = model;
|
|
|
|
logger.log(`[LMStudio] Using ${modelType} model: ${model}`);
|
|
|
|
const system = resolveEffectiveSystemPrompt({
|
|
params,
|
|
fallback: buildCanonicalSystemPrompt({ character: runtime.character }),
|
|
});
|
|
|
|
let outputSpec: NativeTextOutput | undefined =
|
|
responseSchema !== undefined && responseSchema !== null
|
|
? buildStructuredOutput(responseSchema)
|
|
: undefined;
|
|
|
|
if (tools && outputSpec) {
|
|
logger.debug(
|
|
"[LMStudio] tools and responseSchema both present — omitting structured output for this call."
|
|
);
|
|
outputSpec = undefined;
|
|
}
|
|
|
|
const wireRaw = dropDuplicateLeadingSystemMessage(
|
|
extended.messages as Parameters<typeof dropDuplicateLeadingSystemMessage>[0],
|
|
system
|
|
);
|
|
const normalizedMessages = normalizeNativeMessages(wireRaw);
|
|
const hasChatMessages = Array.isArray(normalizedMessages) && normalizedMessages.length > 0;
|
|
const toolChoice = tools ? normalizeToolChoice(extended.toolChoice) : undefined;
|
|
const shouldReturnNative = Boolean(
|
|
hasChatMessages || tools || extended.toolChoice || outputSpec !== undefined
|
|
);
|
|
|
|
const renderedPrompt = hasChatMessages
|
|
? ""
|
|
: (renderChatMessagesForPrompt(params.messages, {
|
|
...(system ? { omitDuplicateSystem: system } : {}),
|
|
}) ??
|
|
prompt ??
|
|
"");
|
|
|
|
const promptOrMessages = hasChatMessages
|
|
? { messages: normalizedMessages }
|
|
: { prompt: renderedPrompt };
|
|
|
|
const resolvedStopSequences =
|
|
Array.isArray(params.stopSequences) && params.stopSequences.length > 0
|
|
? params.stopSequences
|
|
: undefined;
|
|
|
|
const promptForUsageEstimate = hasChatMessages
|
|
? JSON.stringify(normalizedMessages)
|
|
: renderedPrompt;
|
|
|
|
const baseArgs = {
|
|
model: client(model) as LanguageModel,
|
|
...promptOrMessages,
|
|
...(system ? { system } : {}),
|
|
temperature,
|
|
maxOutputTokens: maxTokens,
|
|
frequencyPenalty,
|
|
presencePenalty,
|
|
...(resolvedStopSequences ? { stopSequences: resolvedStopSequences } : {}),
|
|
...(tools ? { tools, ...(toolChoice ? { toolChoice } : {}) } : {}),
|
|
...(outputSpec ? { output: outputSpec } : {}),
|
|
};
|
|
|
|
// Streaming branches — we only forward via `streamText` when there is no structured
|
|
// output and no toolChoice without tools; structured + streaming combinations vary
|
|
// across LM Studio model engines, so we conservatively go through generateText there.
|
|
if (params.stream && !outputSpec && !(extended.toolChoice && !tools)) {
|
|
return buildStreamResult({
|
|
runtime,
|
|
modelType,
|
|
model,
|
|
endpoint: baseURL,
|
|
streamParams: baseArgs as StreamTextParams,
|
|
promptForEstimate: promptForUsageEstimate,
|
|
});
|
|
}
|
|
|
|
const result = await generateText(baseArgs);
|
|
const usage =
|
|
normalizeTokenUsage(result.usage) ?? estimateUsage(promptForUsageEstimate, result.text);
|
|
emitModelUsed(runtime, modelType, model, usage);
|
|
|
|
if (shouldReturnNative) {
|
|
if (outputSpec !== undefined) {
|
|
return serializeStructuredResult(result);
|
|
}
|
|
return buildNativeResultCast(result, model, usage);
|
|
}
|
|
return result.text;
|
|
} catch (error) {
|
|
// error-policy:J2 context-adding rethrow — log then rethrow the original error.
|
|
logTextFailure("generateText", modelType, modelIdForLog || "(unknown)", baseURL, error);
|
|
// Throw, never fabricate a reply. A hardcoded "Error generating text…" string
|
|
// would be persisted to memory and sent to the user as the agent's response —
|
|
// in the wrong language/voice — and would bypass core's grounded failure-reply
|
|
// path (buildFailureReplyPrompt). The canonical providers all throw here.
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function handleTextSmall(
|
|
runtime: IAgentRuntime,
|
|
params: GenerateTextParams
|
|
): Promise<string | TextStreamResult> {
|
|
return handleTextWithModelType(runtime, ModelType.TEXT_SMALL, params);
|
|
}
|
|
|
|
export async function handleTextNano(
|
|
runtime: IAgentRuntime,
|
|
params: GenerateTextParams
|
|
): Promise<string | TextStreamResult> {
|
|
return handleTextWithModelType(runtime, TEXT_NANO_MODEL_TYPE, params);
|
|
}
|
|
|
|
export async function handleTextMedium(
|
|
runtime: IAgentRuntime,
|
|
params: GenerateTextParams
|
|
): Promise<string | TextStreamResult> {
|
|
return handleTextWithModelType(runtime, TEXT_MEDIUM_MODEL_TYPE, params);
|
|
}
|
|
|
|
export async function handleTextLarge(
|
|
runtime: IAgentRuntime,
|
|
params: GenerateTextParams
|
|
): Promise<string | TextStreamResult> {
|
|
return handleTextWithModelType(runtime, ModelType.TEXT_LARGE, params);
|
|
}
|
|
|
|
export async function handleTextMega(
|
|
runtime: IAgentRuntime,
|
|
params: GenerateTextParams
|
|
): Promise<string | TextStreamResult> {
|
|
return handleTextWithModelType(runtime, TEXT_MEGA_MODEL_TYPE, params);
|
|
}
|
|
|
|
export async function handleResponseHandler(
|
|
runtime: IAgentRuntime,
|
|
params: GenerateTextParams
|
|
): Promise<string | TextStreamResult> {
|
|
return handleTextWithModelType(runtime, RESPONSE_HANDLER_MODEL_TYPE, params);
|
|
}
|
|
|
|
export async function handleActionPlanner(
|
|
runtime: IAgentRuntime,
|
|
params: GenerateTextParams
|
|
): Promise<string | TextStreamResult> {
|
|
return handleTextWithModelType(runtime, ACTION_PLANNER_MODEL_TYPE, params);
|
|
}
|