From 8ab616c3dd0ef0617e46066e6006a6297402f233 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Saha <225407166+AP24110011695@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:20:04 +0530 Subject: [PATCH] client: skip blank lines in streaming NDJSON responses A blank line anywhere in the streamed body (from proxies, load balancers, or compatible servers that re-chunk or pad responses) crashed the parser with a raw json.JSONDecodeError instead of yielding responses: iter_lines/aiter_lines yield empty strings for blank lines and json.loads('') raises. Skip empty lines in both the sync and async streaming paths, so generate(..., stream=True) and chat(..., stream=True) tolerate non-ollama response formatting. Regression tests cover both paths with a blank line between events. --- ollama/_client.py | 8 ++++++++ tests/test_client.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/ollama/_client.py b/ollama/_client.py index 3654e3c0..5d5d5fbd 100644 --- a/ollama/_client.py +++ b/ollama/_client.py @@ -193,6 +193,10 @@ def inner(): raise ResponseError(e.response.text, e.response.status_code) from None for line in r.iter_lines(): + if not line: + # Proxies and compatible servers can pad or re-chunk the + # NDJSON stream with blank lines. + continue part = json.loads(line) if err := part.get('error'): raise ResponseError(err) @@ -813,6 +817,10 @@ async def inner(): raise ResponseError(e.response.text, e.response.status_code) from None async for line in r.aiter_lines(): + if not line: + # Proxies and compatible servers can pad or re-chunk the + # NDJSON stream with blank lines. + continue part = json.loads(line) if err := part.get('error'): raise ResponseError(err) diff --git a/tests/test_client.py b/tests/test_client.py index c212a11d..731c11ca 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1527,3 +1527,48 @@ def test_client_chat_with_think_level(httpserver: HTTPServer): assert response['model'] == 'qwen3.8:27b' assert response['message']['content'] == 'Hi there.' assert response['message']['thinking'] == 'Thinking deeply...' + + +def test_client_generate_stream_skips_blank_lines(httpserver: HTTPServer): + "Blank lines in an NDJSON stream (proxies, keep-alive padding) must not crash the parser." + + httpserver.expect_ordered_request( + '/api/generate', + method='POST', + json={ + 'model': 'dummy', + 'prompt': 'Why is the sky blue?', + 'stream': True, + }, + ).respond_with_data( + json.dumps({'model': 'dummy', 'response': 'Because '}) + '\n\n' + json.dumps({'model': 'dummy', 'response': 'it is.', 'done': True}) + '\n', + content_type='application/x-ndjson', + ) + + client = Client(httpserver.url_for('/')) + response = client.generate('dummy', 'Why is the sky blue?', stream=True) + + assert [part.response for part in response] == ['Because ', 'it is.'] + + +async def test_async_client_chat_stream_skips_blank_lines(httpserver: HTTPServer): + "Blank lines in an NDJSON stream must not crash the async parser either." + + httpserver.expect_ordered_request( + '/api/chat', + method='POST', + json={ + 'model': 'dummy', + 'messages': [{'role': 'user', 'content': 'Why is the sky blue?'}], + 'tools': [], + 'stream': True, + }, + ).respond_with_data( + json.dumps({'model': 'dummy', 'message': {'role': 'assistant', 'content': 'Because '}}) + '\n\n' + json.dumps({'model': 'dummy', 'message': {'role': 'assistant', 'content': 'it is.'}, 'done': True}) + '\n', + content_type='application/x-ndjson', + ) + + client = AsyncClient(httpserver.url_for('/')) + response = await client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}], stream=True) + + assert [part.message.content async for part in response] == ['Because ', 'it is.']