diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 081999e189a5..c16559068c3b 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1534,6 +1534,28 @@ function getGuardedFetchCall(fetchMock: typeof fetchWithSsrFGuardMock): GuardedF return (fetchMock.mock.calls.at(0)?.[0] as GuardedFetchCall | undefined) ?? { url: "" }; } +function cancelTrackedResponse( + text: string, + init: ResponseInit, +): { + response: Response; + wasCanceled: () => boolean; +} { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(stream, init), + wasCanceled: () => canceled, + }; +} + async function createOllamaTestStream(params: { baseUrl: string; defaultHeaders?: Record; @@ -2684,12 +2706,14 @@ describe("createOllamaStreamFn", () => { ); }); - it("surfaces non-2xx HTTP response as status-prefixed error", async () => { + it("surfaces bounded non-2xx HTTP response text as a status-prefixed error", async () => { + const tracked = cancelTrackedResponse(`${"Service Unavailable ".repeat(1024)}tail`, { + status: 503, + statusText: "Service Unavailable", + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response("Service Unavailable", { - status: 503, - statusText: "Service Unavailable", - }), + response: tracked.response, release: vi.fn(async () => undefined), }); try { @@ -2705,6 +2729,10 @@ describe("createOllamaStreamFn", () => { // The error message must start with the HTTP status code so that // extractLeadingHttpStatus can parse it for failover/retry logic. expect(errorEvent.error.errorMessage).toMatch(/^503\b/); + expect(errorEvent.error.errorMessage).toContain("Service Unavailable"); + expect(errorEvent.error.errorMessage).not.toContain("tail"); + expect(tracked.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); } finally { fetchWithSsrFGuardMock.mockReset(); } diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index d271921b7117..2143ddca7c48 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -18,6 +18,7 @@ import type { ProviderWrapStreamFnContext, } from "openclaw/plugin-sdk/plugin-entry"; import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth"; +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import { DEFAULT_CONTEXT_TOKENS, normalizeProviderId, @@ -54,6 +55,7 @@ export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL; const OLLAMA_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12; const OLLAMA_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64; +const OLLAMA_STREAM_ERROR_BODY_LIMIT_BYTES = 8 * 1024; const GARBLED_VISIBLE_TEXT_MODEL_RE = /\b(?:glm|kimi)\b/i; const GARBLED_VISIBLE_TEXT_MIN_CHARS = 80; const GARBLED_VISIBLE_TEXT_SYMBOL_RE = /[$#%&="'_~`^|\\/*+\-[\]{}()<>:;,.!?]/gu; @@ -1211,7 +1213,10 @@ function createRawOllamaStreamFn( try { if (!response.ok) { - const errorText = await response.text().catch(() => "unknown error"); + const errorText = await readResponseTextLimited( + response, + OLLAMA_STREAM_ERROR_BODY_LIMIT_BYTES, + ).catch(() => "unknown error"); throw new Error(`${response.status} ${errorText}`); } if (!response.body) {