Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
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.
  • Loading branch information
Alix-007 committed Jun 23, 2026
commit 8876908d1c11b724f061d26b8c80c6ba61ebf9bb
55 changes: 55 additions & 0 deletions extensions/ollama/src/provider-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
buildOllamaProvider,
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
parseOllamaNumCtxParameter,
queryOllamaModelShowInfo,
resetOllamaModelShowInfoCacheForTest,
resolveOllamaApiBase,
type OllamaTagModel,
Expand Down Expand Up @@ -380,4 +382,57 @@ describe("ollama provider models", () => {
expect(parseOllamaNumCtxParameter('stop "<|eot_id|>"')).toBeUndefined();
expect(parseOllamaNumCtxParameter({ num_ctx: 8192 })).toBeUndefined();
});

it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => {
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels
// the stream mid-flight; if the cap were removed the reader would buffer the whole payload.
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
const chunk = new Uint8Array(ONE_MIB);

let bytesPulled = 0;
let canceled = false;
const makeOversizedJsonResponse = (): Response => {
bytesPulled = 0;
canceled = false;
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulled >= TOTAL_CHUNKS) {
controller.close();
return;
}
pulled += 1;
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/json" },
});
};

vi.stubGlobal(
"fetch",
vi.fn(async () => makeOversizedJsonResponse()),
);
const tags = await fetchOllamaModels("http://127.0.0.1:11434");
expect(tags).toEqual({ reachable: false, models: [] });
expect(canceled).toBe(true);
// Only the bounded prefix is pulled, never the full advertised 32 MiB stream.
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);

vi.stubGlobal(
"fetch",
vi.fn(async () => makeOversizedJsonResponse()),
);
const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest");
expect(showInfo).toEqual({});
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
});
25 changes: 3 additions & 22 deletions extensions/ollama/src/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { createHash } from "node:crypto";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
OLLAMA_DEFAULT_BASE_URL,
Expand Down Expand Up @@ -39,25 +39,6 @@ const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
const MAX_OLLAMA_SHOW_CACHE_ENTRIES = 256;
const ollamaModelShowInfoCache = new Map<string, Promise<OllamaModelShowInfo>>();
const OLLAMA_ALWAYS_BLOCKED_HOSTNAMES = new Set(["metadata.google.internal"]);
// Ollama base URLs are user-supplied and can point at remote/cloud endpoints, so the discovery
// responses (`/api/tags`, `/api/show`) are untrusted. Cap the success body before parsing so a
// hostile or buggy server cannot stream an unbounded JSON payload into memory during discovery.
const OLLAMA_DISCOVERY_JSON_MAX_BYTES = 16 * 1024 * 1024;

/**
* Reads an Ollama discovery JSON body under a byte cap, cancelling the stream on overflow.
*
* The body is read through the shared bounded reader instead of `await response.json()` so an
* endpoint that streams an unbounded (or never-ending) body cannot force the discovery path to
* buffer the whole payload before parsing. Overflow throws a bounded error that the existing
* fail-soft discovery handlers swallow.
*/
async function readOllamaDiscoveryJson<T>(response: Response, label: string): Promise<T> {
const bytes = await readResponseWithLimit(response, OLLAMA_DISCOVERY_JSON_MAX_BYTES, {
onOverflow: ({ maxBytes }) => new Error(`${label}: JSON response exceeds ${maxBytes} bytes`),
});
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}

export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string) {
const trimmed = baseUrl.trim();
Expand Down Expand Up @@ -166,7 +147,7 @@ export async function queryOllamaModelShowInfo(
if (!response.ok) {
return {};
}
const data = await readOllamaDiscoveryJson<{
const data = await readProviderJsonResponse<{
model_info?: Record<string, unknown>;
capabilities?: unknown;
parameters?: unknown;
Expand Down Expand Up @@ -334,7 +315,7 @@ export async function fetchOllamaModels(
if (!response.ok) {
return { reachable: true, models: [] };
}
const data = await readOllamaDiscoveryJson<OllamaTagsResponse>(
const data = await readProviderJsonResponse<OllamaTagsResponse>(
response,
"ollama-provider-models.tags",
);
Expand Down
Loading