Skip to content

Commit 8876908

Browse files
committed
fix(ollama): reuse shared bounded JSON reader for model discovery
Replace the local readOllamaDiscoveryJson helper with the shared readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which already enforces the 16 MiB cap, cancels the stream on overflow, and wraps malformed JSON with the caller label. The /api/tags and /api/show discovery reads now go through it directly while keeping the existing fail-soft handlers ({ reachable: false, models: [] } and {}). Add a focused regression test: when a discovery stream exceeds the JSON byte cap, fetchOllamaModels returns { reachable: false, models: [] }, queryOllamaModelShowInfo returns {}, and the bounded reader cancels the body mid-flight so less than the full advertised stream is read.
1 parent 92d4b74 commit 8876908

2 files changed

Lines changed: 58 additions & 22 deletions

File tree

extensions/ollama/src/provider-models.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
buildOllamaProvider,
66
buildOllamaModelDefinition,
77
enrichOllamaModelsWithContext,
8+
fetchOllamaModels,
89
parseOllamaNumCtxParameter,
10+
queryOllamaModelShowInfo,
911
resetOllamaModelShowInfoCacheForTest,
1012
resolveOllamaApiBase,
1113
type OllamaTagModel,
@@ -380,4 +382,57 @@ describe("ollama provider models", () => {
380382
expect(parseOllamaNumCtxParameter('stop "<|eot_id|>"')).toBeUndefined();
381383
expect(parseOllamaNumCtxParameter({ num_ctx: 8192 })).toBeUndefined();
382384
});
385+
386+
it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => {
387+
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels
388+
// the stream mid-flight; if the cap were removed the reader would buffer the whole payload.
389+
const ONE_MIB = 1024 * 1024;
390+
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
391+
const chunk = new Uint8Array(ONE_MIB);
392+
393+
let bytesPulled = 0;
394+
let canceled = false;
395+
const makeOversizedJsonResponse = (): Response => {
396+
bytesPulled = 0;
397+
canceled = false;
398+
let pulled = 0;
399+
const body = new ReadableStream<Uint8Array>({
400+
pull(controller) {
401+
if (pulled >= TOTAL_CHUNKS) {
402+
controller.close();
403+
return;
404+
}
405+
pulled += 1;
406+
bytesPulled += chunk.length;
407+
controller.enqueue(chunk);
408+
},
409+
cancel() {
410+
canceled = true;
411+
},
412+
});
413+
return new Response(body, {
414+
status: 200,
415+
headers: { "Content-Type": "application/json" },
416+
});
417+
};
418+
419+
vi.stubGlobal(
420+
"fetch",
421+
vi.fn(async () => makeOversizedJsonResponse()),
422+
);
423+
const tags = await fetchOllamaModels("http://127.0.0.1:11434");
424+
expect(tags).toEqual({ reachable: false, models: [] });
425+
expect(canceled).toBe(true);
426+
// Only the bounded prefix is pulled, never the full advertised 32 MiB stream.
427+
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
428+
429+
vi.stubGlobal(
430+
"fetch",
431+
vi.fn(async () => makeOversizedJsonResponse()),
432+
);
433+
const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest");
434+
expect(showInfo).toEqual({});
435+
expect(canceled).toBe(true);
436+
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
437+
});
383438
});

extensions/ollama/src/provider-models.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { createHash } from "node:crypto";
33
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
44
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
5-
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
5+
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
66
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
77
import {
88
OLLAMA_DEFAULT_BASE_URL,
@@ -39,25 +39,6 @@ const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
3939
const MAX_OLLAMA_SHOW_CACHE_ENTRIES = 256;
4040
const ollamaModelShowInfoCache = new Map<string, Promise<OllamaModelShowInfo>>();
4141
const OLLAMA_ALWAYS_BLOCKED_HOSTNAMES = new Set(["metadata.google.internal"]);
42-
// Ollama base URLs are user-supplied and can point at remote/cloud endpoints, so the discovery
43-
// responses (`/api/tags`, `/api/show`) are untrusted. Cap the success body before parsing so a
44-
// hostile or buggy server cannot stream an unbounded JSON payload into memory during discovery.
45-
const OLLAMA_DISCOVERY_JSON_MAX_BYTES = 16 * 1024 * 1024;
46-
47-
/**
48-
* Reads an Ollama discovery JSON body under a byte cap, cancelling the stream on overflow.
49-
*
50-
* The body is read through the shared bounded reader instead of `await response.json()` so an
51-
* endpoint that streams an unbounded (or never-ending) body cannot force the discovery path to
52-
* buffer the whole payload before parsing. Overflow throws a bounded error that the existing
53-
* fail-soft discovery handlers swallow.
54-
*/
55-
async function readOllamaDiscoveryJson<T>(response: Response, label: string): Promise<T> {
56-
const bytes = await readResponseWithLimit(response, OLLAMA_DISCOVERY_JSON_MAX_BYTES, {
57-
onOverflow: ({ maxBytes }) => new Error(`${label}: JSON response exceeds ${maxBytes} bytes`),
58-
});
59-
return JSON.parse(new TextDecoder().decode(bytes)) as T;
60-
}
6142

6243
export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string) {
6344
const trimmed = baseUrl.trim();
@@ -166,7 +147,7 @@ export async function queryOllamaModelShowInfo(
166147
if (!response.ok) {
167148
return {};
168149
}
169-
const data = await readOllamaDiscoveryJson<{
150+
const data = await readProviderJsonResponse<{
170151
model_info?: Record<string, unknown>;
171152
capabilities?: unknown;
172153
parameters?: unknown;
@@ -334,7 +315,7 @@ export async function fetchOllamaModels(
334315
if (!response.ok) {
335316
return { reachable: true, models: [] };
336317
}
337-
const data = await readOllamaDiscoveryJson<OllamaTagsResponse>(
318+
const data = await readProviderJsonResponse<OllamaTagsResponse>(
338319
response,
339320
"ollama-provider-models.tags",
340321
);

0 commit comments

Comments
 (0)