




















@@ -1,23 +1,18 @@
11import type { StreamFn } from "@earendil-works/pi-agent-core";
2-import { createAssistantMessageEventStream, streamSimple } from "@earendil-works/pi-ai";
2+import { streamSimple } from "@earendil-works/pi-ai";
33import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
44import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
5+import { createPlainTextToolCallPromotionWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
56import { ssrfPolicyFromHttpBaseUrlAllowedHostname } from "openclaw/plugin-sdk/ssrf-runtime";
67import { LMSTUDIO_PROVIDER_ID } from "./defaults.js";
78import { ensureLmstudioModelLoaded } from "./models.fetch.js";
89import { resolveLmstudioInferenceBase } from "./models.js";
9-import {
10-createLmstudioSyntheticToolCallId,
11-parseLmstudioPlainTextToolCalls,
12-} from "./plain-text-tool-calls.js";
1310import { resolveLmstudioProviderHeaders, resolveLmstudioRuntimeApiKey } from "./runtime.js";
14111512const log = createSubsystemLogger("extensions/lmstudio/stream");
16131714type StreamOptions = Parameters<StreamFn>[2];
1815type StreamModel = Parameters<StreamFn>[0];
19-type StreamContext = Parameters<StreamFn>[1];
20-2116const preloadInFlight = new Map<string, Promise<void>>();
22172318/**
@@ -137,218 +132,6 @@ function withLmstudioUsageCompat(model: StreamModel): StreamModel {
137132};
138133}
139134140-function resolveContextToolNames(context: StreamContext): Set<string> {
141-const tools = (context as { tools?: unknown }).tools;
142-if (!Array.isArray(tools)) {
143-return new Set();
144-}
145-const names = tools
146-.map((tool) => {
147-const record = toRecord(tool);
148-return typeof record?.name === "string" && record.name.trim() ? record.name : undefined;
149-})
150-.filter((name): name is string => Boolean(name));
151-return new Set(names);
152-}
153-154-function couldStillBePlainTextToolCall(text: string): boolean {
155-if (text.length > 256_000) {
156-return false;
157-}
158-const trimmed = text.trimStart();
159-return (
160-trimmed.length === 0 ||
161-trimmed.startsWith("[") ||
162-trimmed.startsWith("<|channel|>") ||
163-trimmed.startsWith("commentary") ||
164-trimmed.startsWith("analysis") ||
165-trimmed.startsWith("final")
166-);
167-}
168-169-function createLmstudioToolCallBlock(parsed: {
170-arguments: Record<string, unknown>;
171-name: string;
172-}): Record<string, unknown> {
173-return {
174-type: "toolCall",
175-id: createLmstudioSyntheticToolCallId(),
176-name: parsed.name,
177-arguments: parsed.arguments,
178-partialArgs: JSON.stringify(parsed.arguments),
179-};
180-}
181-182-function promoteLmstudioPlainTextToolCalls(
183-message: unknown,
184-toolNames: Set<string>,
185-): Record<string, unknown> | undefined {
186-const messageRecord = toRecord(message);
187-if (!messageRecord) {
188-return undefined;
189-}
190-if (!Array.isArray(messageRecord.content)) {
191-if (typeof messageRecord.content !== "string" || !messageRecord.content.trim()) {
192-return undefined;
193-}
194-const parsed = parseLmstudioPlainTextToolCalls(messageRecord.content, toolNames);
195-if (!parsed) {
196-return undefined;
197-}
198-return {
199- ...messageRecord,
200-content: parsed.map(createLmstudioToolCallBlock),
201-stopReason: "toolUse",
202-};
203-}
204-if (
205-messageRecord.content.some((block) => toRecord(block)?.type === "toolCall") ||
206-messageRecord.content.length === 0
207-) {
208-return undefined;
209-}
210-211-let promoted = false;
212-const nextContent: Array<Record<string, unknown>> = [];
213-for (const block of messageRecord.content) {
214-const blockRecord = toRecord(block);
215-if (!blockRecord) {
216-return undefined;
217-}
218-if (blockRecord.type !== "text") {
219-nextContent.push(blockRecord);
220-continue;
221-}
222-const text = typeof blockRecord.text === "string" ? blockRecord.text : "";
223-if (!text.trim()) {
224-continue;
225-}
226-const parsed = parseLmstudioPlainTextToolCalls(text, toolNames);
227-if (!parsed) {
228-return undefined;
229-}
230-nextContent.push(...parsed.map(createLmstudioToolCallBlock));
231-promoted = true;
232-}
233-234-if (!promoted) {
235-return undefined;
236-}
237-return {
238- ...messageRecord,
239-content: nextContent,
240-stopReason: "toolUse",
241-};
242-}
243-244-function emitPromotedToolCallEvents(
245-stream: { push(event: unknown): void },
246-message: Record<string, unknown>,
247-): void {
248-const content = Array.isArray(message.content) ? message.content : [];
249-content.forEach((block, contentIndex) => {
250-const record = toRecord(block);
251-if (record?.type !== "toolCall") {
252-return;
253-}
254-stream.push({ type: "toolcall_start", contentIndex, partial: message });
255-stream.push({
256-type: "toolcall_delta",
257- contentIndex,
258-delta: typeof record.partialArgs === "string" ? record.partialArgs : "{}",
259-partial: message,
260-});
261-});
262-}
263-264-function wrapLmstudioPlainTextToolCalls(
265-source: ReturnType<StreamFn>,
266-context: StreamContext,
267-): ReturnType<StreamFn> {
268-const toolNames = resolveContextToolNames(context);
269-if (toolNames.size === 0) {
270-return source;
271-}
272-const output = createAssistantMessageEventStream();
273-const stream = output as unknown as { push(event: unknown): void; end(): void };
274-275-void (async () => {
276-const bufferedTextEvents: unknown[] = [];
277-let bufferedText = "";
278-let ended = false;
279-const endStream = () => {
280-if (!ended) {
281-ended = true;
282-stream.end();
283-}
284-};
285-const flushBufferedTextEvents = () => {
286-for (const event of bufferedTextEvents.splice(0)) {
287-stream.push(event);
288-}
289-bufferedText = "";
290-};
291-292-try {
293-for await (const event of source as AsyncIterable<unknown>) {
294-const record = toRecord(event);
295-const type = typeof record?.type === "string" ? record.type : "";
296-297-if (type === "text_start" || type === "text_delta" || type === "text_end") {
298-bufferedTextEvents.push(event);
299-if (typeof record?.delta === "string") {
300-bufferedText += record.delta;
301-} else if (typeof record?.content === "string" && !bufferedText) {
302-bufferedText = record.content;
303-}
304-if (!couldStillBePlainTextToolCall(bufferedText)) {
305-flushBufferedTextEvents();
306-}
307-continue;
308-}
309-310-if (type === "done") {
311-const promotedMessage = promoteLmstudioPlainTextToolCalls(record?.message, toolNames);
312-if (promotedMessage) {
313-bufferedTextEvents.splice(0);
314-bufferedText = "";
315-emitPromotedToolCallEvents(stream, promotedMessage);
316-stream.push({ ...record, reason: "toolUse", message: promotedMessage });
317-} else {
318-flushBufferedTextEvents();
319-stream.push(event);
320-}
321-endStream();
322-return;
323-}
324-325-flushBufferedTextEvents();
326-stream.push(event);
327-if (type === "error") {
328-endStream();
329-return;
330-}
331-}
332-flushBufferedTextEvents();
333-} catch (error) {
334-stream.push({
335-type: "error",
336-reason: "error",
337-error: {
338-role: "assistant",
339-content: [],
340-stopReason: "error",
341-errorMessage: error instanceof Error ? error.message : String(error),
342-},
343-});
344-} finally {
345-endStream();
346-}
347-})();
348-349-return output as ReturnType<StreamFn>;
350-}
351-352135function createPreloadKey(params: {
353136baseUrl: string;
354137modelKey: string;
@@ -396,6 +179,7 @@ async function ensureLmstudioModelLoadedBestEffort(params: {
396179397180export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext): StreamFn {
398181const underlying = ctx.streamFn ?? streamSimple;
182+const streamWithPlainTextToolCalls = createPlainTextToolCallPromotionWrapper(underlying);
399183return (model, context, options) => {
400184if (model.provider !== LMSTUDIO_PROVIDER_ID) {
401185return underlying(model, context, options);
@@ -406,11 +190,7 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext):
406190}
407191const providerConfig = ctx.config?.models?.providers?.[LMSTUDIO_PROVIDER_ID];
408192if (!shouldPreloadLmstudioModels(providerConfig)) {
409-const stream = underlying(withLmstudioUsageCompat(model), context, options);
410-return (async () => {
411-const resolvedStream = stream instanceof Promise ? await stream : stream;
412-return wrapLmstudioPlainTextToolCalls(resolvedStream, context);
413-})();
193+return streamWithPlainTextToolCalls(withLmstudioUsageCompat(model), context, options);
414194}
415195const providerBaseUrl = providerConfig?.baseUrl;
416196const resolvedBaseUrl = resolveLmstudioInferenceBase(
@@ -485,9 +265,9 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext):
485265// LM Studio uses OpenAI-compatible streaming usage payloads when requested via
486266// `stream_options.include_usage`. Force this compat flag at call time so usage
487267// reporting remains enabled even when catalog entries omitted compat metadata.
488-const stream = underlying(withLmstudioUsageCompat(model), context, options);
268+const stream = streamWithPlainTextToolCalls(withLmstudioUsageCompat(model), context, options);
489269const resolvedStream = stream instanceof Promise ? await stream : stream;
490-return wrapLmstudioPlainTextToolCalls(resolvedStream, context);
270+return resolvedStream;
491271})();
492272};
493273}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。