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:
huangjianxiong 2026-07-09 18:38:54 +08:00 committed by GitHub
parent ff031149f3
commit 29d2a1ed42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 7 deletions

View file

@ -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}',

View file

@ -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)}`);
}
}
}