mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(ollama): use truncateUtf16Safe for malformed NDJSON log truncation (#102593)
* fix(ollama): use truncateUtf16Safe for malformed NDJSON log warning The parseNdjsonStream function uses naive .slice(0, 120) on malformed NDJSON data in log warnings which can split surrogate pairs. Replace with truncateUtf16Safe(). * test(ollama): cover UTF-16-safe NDJSON warnings * test(ollama): satisfy lint in NDJSON coverage --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
ff031149f3
commit
29d2a1ed42
2 changed files with 50 additions and 7 deletions
|
|
@ -1,14 +1,23 @@
|
|||
// Ollama tests cover stream runtime plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
const { fetchWithSsrFGuardMock, ollamaStreamWarnMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
ollamaStreamWarnMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>();
|
||||
return {
|
||||
...actual,
|
||||
createSubsystemLogger: () => ({ warn: ollamaStreamWarnMock }),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
OLLAMA_INCOMPLETE_STREAM_ERROR,
|
||||
buildOllamaChatRequest,
|
||||
|
|
@ -73,6 +82,7 @@ function expectIteratorEvent(
|
|||
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
ollamaStreamWarnMock.mockReset();
|
||||
});
|
||||
|
||||
describe("buildOllamaChatRequest", () => {
|
||||
|
|
@ -1433,9 +1443,12 @@ describe("buildAssistantMessage", () => {
|
|||
});
|
||||
|
||||
// Helper: build a ReadableStreamDefaultReader from NDJSON lines
|
||||
function mockNdjsonReader(lines: string[]): ReadableStreamDefaultReader<Uint8Array> {
|
||||
function mockNdjsonReader(
|
||||
lines: string[],
|
||||
options: { trailingNewline?: boolean } = {},
|
||||
): ReadableStreamDefaultReader<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const payload = lines.join("\n") + "\n";
|
||||
const payload = lines.join("\n") + (options.trailingNewline === false ? "" : "\n");
|
||||
let consumed = false;
|
||||
return {
|
||||
read: async () => {
|
||||
|
|
@ -1465,7 +1478,37 @@ async function expectDoneEventContent(lines: string[], expectedContent: unknown)
|
|||
});
|
||||
}
|
||||
|
||||
async function expectNoParsedChunks(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
const chunks = [];
|
||||
for await (const chunk of parseNdjsonStream(reader)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
expect(chunks).toEqual([]);
|
||||
}
|
||||
|
||||
describe("parseNdjsonStream", () => {
|
||||
it("does not log a dangling surrogate for a malformed complete line", async () => {
|
||||
const prefix = "x".repeat(119);
|
||||
const reader = mockNdjsonReader([`${prefix}😀tail`]);
|
||||
|
||||
await expectNoParsedChunks(reader);
|
||||
|
||||
expect(ollamaStreamWarnMock).toHaveBeenCalledExactlyOnceWith(
|
||||
`Skipping malformed NDJSON line: ${prefix}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not log a dangling surrogate for malformed trailing data", async () => {
|
||||
const prefix = "x".repeat(119);
|
||||
const reader = mockNdjsonReader([`${prefix}😀tail`], { trailingNewline: false });
|
||||
|
||||
await expectNoParsedChunks(reader);
|
||||
|
||||
expect(ollamaStreamWarnMock).toHaveBeenCalledExactlyOnceWith(
|
||||
`Skipping malformed trailing data: ${prefix}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses text-only streaming chunks", async () => {
|
||||
const reader = mockNdjsonReader([
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"Hello"},"done":false}',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
normalizeLowercaseStringOrEmpty,
|
||||
readStringValue,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
|
||||
import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js";
|
||||
import { normalizeOllamaWireModelId } from "./model-id.js";
|
||||
|
|
@ -52,8 +53,7 @@ import {
|
|||
const log = createSubsystemLogger("ollama-stream");
|
||||
|
||||
export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL;
|
||||
export const OLLAMA_INCOMPLETE_STREAM_ERROR =
|
||||
"Ollama API stream ended without a final response";
|
||||
export const OLLAMA_INCOMPLETE_STREAM_ERROR = "Ollama API stream ended without a final response";
|
||||
|
||||
const OLLAMA_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12;
|
||||
const OLLAMA_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64;
|
||||
|
|
@ -1067,7 +1067,7 @@ export async function* parseNdjsonStream(
|
|||
try {
|
||||
yield parseJsonPreservingUnsafeIntegers(trimmed) as OllamaChatResponse;
|
||||
} catch {
|
||||
log.warn(`Skipping malformed NDJSON line: ${trimmed.slice(0, 120)}`);
|
||||
log.warn(`Skipping malformed NDJSON line: ${truncateUtf16Safe(trimmed, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1076,7 +1076,7 @@ export async function* parseNdjsonStream(
|
|||
try {
|
||||
yield parseJsonPreservingUnsafeIntegers(buffer.trim()) as OllamaChatResponse;
|
||||
} catch {
|
||||
log.warn(`Skipping malformed trailing data: ${buffer.trim().slice(0, 120)}`);
|
||||
log.warn(`Skipping malformed trailing data: ${truncateUtf16Safe(buffer.trim(), 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue