From 2688f7e3ca582f0024a8a922872ecdb9a1391293 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 29 Mar 2026 12:50:49 -0700 Subject: [PATCH] fix(ollama-client): remove timeout from pull(), guard formatBytes NaN, simplify stream body, use fetchJson for delete() --- packages/ollama-client/src/client.test.ts | 10 +++---- packages/ollama-client/src/client.ts | 19 +++++--------- packages/ollama-client/src/format.test.ts | 4 +++ packages/ollama-client/src/format.ts | 2 +- packages/ollama-client/src/stream.ts | 32 ++++++++++------------- 5 files changed, 29 insertions(+), 38 deletions(-) diff --git a/packages/ollama-client/src/client.test.ts b/packages/ollama-client/src/client.test.ts index f7530ac5..accd16c9 100644 --- a/packages/ollama-client/src/client.test.ts +++ b/packages/ollama-client/src/client.test.ts @@ -124,7 +124,7 @@ describe('OllamaClient', () => { describe('delete', () => { it('sends DELETE request', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + globalThis.fetch = mockFetch({}); await client.delete('llama3'); expect(globalThis.fetch).toHaveBeenCalledWith( @@ -137,12 +137,8 @@ describe('OllamaClient', () => { }); it('throws on failure', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 404, - text: () => Promise.resolve('model not found'), - }); - await expect(client.delete('nope')).rejects.toThrow('Ollama delete failed (404)'); + globalThis.fetch = mockFetch('model not found', { ok: false, status: 404 }); + await expect(client.delete('nope')).rejects.toThrow('Ollama /api/delete failed (404)'); }); }); diff --git a/packages/ollama-client/src/client.ts b/packages/ollama-client/src/client.ts index 8f76d3bb..d3549484 100644 --- a/packages/ollama-client/src/client.ts +++ b/packages/ollama-client/src/client.ts @@ -6,6 +6,7 @@ import type { OllamaPullProgress, OllamaVersionResponse, } from './types.js'; +import { parseNdjsonStream } from './ndjson.js'; /** * Ollama API client for model management operations. @@ -75,11 +76,13 @@ export class OllamaClient { stream: boolean = false ): Promise<{ status: string } | AsyncGenerator> { if (!stream) { + // Model pulls can download GBs — use 10 minute timeout instead of the default + const pullTimeoutMs = Math.max(this.timeoutMs, 600_000); const res = await fetch(`${this.baseUrl}/api/pull`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: model, stream: false }), - signal: AbortSignal.timeout(this.timeoutMs), + signal: AbortSignal.timeout(pullTimeoutMs), }); if (!res.ok) { const text = await res.text().catch(() => ''); @@ -88,9 +91,8 @@ export class OllamaClient { return (await res.json()) as { status: string }; } - // Streaming pull — return async generator - const baseUrl = this.baseUrl; - const res = await fetch(`${baseUrl}/api/pull`, { + // Streaming pull — return async generator (no timeout, consumer controls lifetime) + const res = await fetch(`${this.baseUrl}/api/pull`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: model, stream: true }), @@ -101,7 +103,6 @@ export class OllamaClient { } if (!res.body) throw new Error('No response body from Ollama pull'); - const { parseNdjsonStream } = await import('./ndjson.js'); return parseNdjsonStream(res.body); } @@ -130,16 +131,10 @@ export class OllamaClient { /** Delete a model (DELETE /api/delete). */ async delete(model: string): Promise { - const res = await fetch(`${this.baseUrl}/api/delete`, { + await this.fetchJson('/api/delete', { method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: model }), - signal: AbortSignal.timeout(this.timeoutMs), }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama delete failed (${res.status}): ${text.slice(0, 200)}`); - } } /** Get Ollama server version (GET /api/version). */ diff --git a/packages/ollama-client/src/format.test.ts b/packages/ollama-client/src/format.test.ts index 0ea32f3f..30111472 100644 --- a/packages/ollama-client/src/format.test.ts +++ b/packages/ollama-client/src/format.test.ts @@ -21,6 +21,10 @@ describe('formatBytes', () => { it('formats gigabytes', () => { expect(formatBytes(4294967296)).toBe('4 GB'); }); + + it('returns 0 B for negative input', () => { + expect(formatBytes(-100)).toBe('0 B'); + }); }); describe('estimateTokens', () => { diff --git a/packages/ollama-client/src/format.ts b/packages/ollama-client/src/format.ts index ef336634..c252ed06 100644 --- a/packages/ollama-client/src/format.ts +++ b/packages/ollama-client/src/format.ts @@ -5,7 +5,7 @@ * @example formatBytes(0) // '0 B' */ export function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; + if (bytes <= 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); diff --git a/packages/ollama-client/src/stream.ts b/packages/ollama-client/src/stream.ts index dcf34b06..5851d4a7 100644 --- a/packages/ollama-client/src/stream.ts +++ b/packages/ollama-client/src/stream.ts @@ -20,17 +20,15 @@ export async function* streamChat( ): AsyncGenerator { const { model, messages, signal, ...rest } = options; + const body: Record = { model, messages, stream: true }; + if (rest.options) body.options = rest.options; + if (rest.format) body.format = rest.format; + if (rest.keep_alive) body.keep_alive = rest.keep_alive; + const res = await fetch(`${baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - messages, - stream: true, - ...('options' in rest && rest.options ? { options: rest.options } : {}), - ...('format' in rest && rest.format ? { format: rest.format } : {}), - ...('keep_alive' in rest && rest.keep_alive ? { keep_alive: rest.keep_alive } : {}), - }), + body: JSON.stringify(body), signal, }); @@ -60,19 +58,17 @@ export async function* streamGenerate( ): AsyncGenerator { const { model, prompt, signal, ...rest } = options; + const body: Record = { model, prompt, stream: true }; + if (rest.system) body.system = rest.system; + if (rest.options) body.options = rest.options; + if (rest.format) body.format = rest.format; + if (rest.keep_alive) body.keep_alive = rest.keep_alive; + if (rest.context) body.context = rest.context; + const res = await fetch(`${baseUrl}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - prompt, - stream: true, - ...('system' in rest && rest.system ? { system: rest.system } : {}), - ...('options' in rest && rest.options ? { options: rest.options } : {}), - ...('format' in rest && rest.format ? { format: rest.format } : {}), - ...('keep_alive' in rest && rest.keep_alive ? { keep_alive: rest.keep_alive } : {}), - ...('context' in rest && rest.context ? { context: rest.context } : {}), - }), + body: JSON.stringify(body), signal, });