Skip to content

Commit 7b6b41e

Browse files
fix(ollama): refresh stream timeout per chunk so slow remote hosts are not aborted mid-stream
1 parent 1a04b8e commit 7b6b41e

2 files changed

Lines changed: 48 additions & 1 deletion

File tree

extensions/ollama/src/stream.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,47 @@ describe("createOllamaStreamFn thinking events", () => {
451451
expect(yieldedBeforeDone).toBe(true);
452452
});
453453

454+
it("refreshes the guarded-fetch idle timeout for each streamed chunk", async () => {
455+
const chunks = [
456+
{
457+
model: "qwen3.5",
458+
created_at: "2026-01-01T00:00:00Z",
459+
message: { role: "assistant" as const, content: "Hello" },
460+
done: false,
461+
},
462+
{
463+
model: "qwen3.5",
464+
created_at: "2026-01-01T00:00:01Z",
465+
message: { role: "assistant" as const, content: " world" },
466+
done: false,
467+
},
468+
makeOllamaResponse({ content: "" }),
469+
];
470+
const body = makeNdjsonBody(chunks);
471+
const refreshTimeout = vi.fn();
472+
fetchWithSsrFGuardMock.mockResolvedValue({
473+
response: new Response(body, { status: 200 }),
474+
release: vi.fn(async () => undefined),
475+
refreshTimeout,
476+
});
477+
478+
const streamFn = createOllamaStreamFn("http://localhost:11434");
479+
const stream = streamFn(
480+
{ api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never,
481+
{ messages: [{ role: "user", content: "test" }] } as never,
482+
{},
483+
);
484+
485+
const events: Array<{ type: string }> = [];
486+
for await (const event of stream as AsyncIterable<{ type: string }>) {
487+
events.push(event);
488+
}
489+
490+
// One refresh per consumed NDJSON chunk keeps the deadline a no-progress
491+
// timeout instead of a wall-clock cap on slow remote Ollama hosts.
492+
expect(refreshTimeout).toHaveBeenCalledTimes(chunks.length);
493+
});
494+
454495
it("reports caller aborts during dense native stream processing as aborted", async () => {
455496
const chunks = [
456497
...Array.from({ length: 65 }, (_value, index) => ({

extensions/ollama/src/stream.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1195,7 +1195,7 @@ function createRawOllamaStreamFn(
11951195
headers.Authorization = `Bearer ${options.apiKey}`;
11961196
}
11971197

1198-
const { response, release } = await fetchWithSsrFGuard({
1198+
const { response, release, refreshTimeout } = await fetchWithSsrFGuard({
11991199
url: chatUrl,
12001200
init: {
12011201
method: "POST",
@@ -1353,6 +1353,12 @@ function createRawOllamaStreamFn(
13531353
};
13541354

13551355
for await (const chunk of parseNdjsonStream(reader)) {
1356+
// Reset the guarded-fetch idle timeout on each chunk so the request
1357+
// deadline behaves as a no-progress timeout, not a wall-clock cap.
1358+
// Without this, a slow remote Ollama host (CPU-only inference) is
1359+
// aborted mid-stream once timeoutMs elapses, which the server sees as
1360+
// a client disconnect and the session stalls at model_call:started.
1361+
refreshTimeout?.();
13561362
throwIfOllamaStreamAborted(options?.signal);
13571363
const thinkingDelta = chunk.message?.thinking ?? chunk.message?.reasoning;
13581364
if (thinkingDelta && shouldEmitThinking) {

0 commit comments

Comments
 (0)