From 4d05cccf4a253207de524fe0d12581970717eb58 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk Date: Mon, 19 Oct 2026 15:08:54 +0100 Subject: [PATCH] fix: preserve Unicode line separators in streamed JSON --- ollama/_client.py | 41 ++++++++++++++++++++++++++++++++-- tests/test_client.py | 52 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/ollama/_client.py b/ollama/_client.py index 3654e3c0..3df4d79f 100644 --- a/ollama/_client.py +++ b/ollama/_client.py @@ -192,7 +192,7 @@ def inner(): e.response.read() raise ResponseError(e.response.text, e.response.status_code) from None - for line in r.iter_lines(): + for line in _iter_ndjson_lines(r.iter_text()): part = json.loads(line) if err := part.get('error'): raise ResponseError(err) @@ -812,7 +812,7 @@ async def inner(): await e.response.aread() raise ResponseError(e.response.text, e.response.status_code) from None - async for line in r.aiter_lines(): + async for line in _aiter_ndjson_lines(r.aiter_text()): part = json.loads(line) if err := part.get('error'): raise ResponseError(err) @@ -1376,6 +1376,43 @@ def _copy_images(images: Optional[Sequence[Union[Image, Any]]]) -> Iterator[Imag yield image if isinstance(image, Image) else Image(value=image) +class _NDJSONLineSplitter: + # NDJSON records end at '\n' (CRLF accepted). httpx iter_lines() uses str.splitlines(), which also splits on + # U+0085, U+2028 and U+2029 that JSON allows unescaped inside strings. Partial text is kept as pieces and + # joined once per record, so a record split across many chunks is not re-copied for every chunk. + + def __init__(self) -> None: + self._pending: List[str] = [] + + def feed(self, text: str) -> List[str]: + *lines, tail = text.split('\n') + if lines: + lines[0] = ''.join([*self._pending, lines[0]]) + self._pending = [] + if tail: + self._pending.append(tail) + return [line[:-1] if line.endswith('\r') else line for line in lines] + + def flush(self) -> List[str]: + return self.feed('\n') if self._pending else [] + + +def _iter_ndjson_lines(texts: Iterator[str]) -> Iterator[str]: + splitter = _NDJSONLineSplitter() + for text in texts: + yield from splitter.feed(text) + yield from splitter.flush() + + +async def _aiter_ndjson_lines(texts: AsyncIterator[str]) -> AsyncIterator[str]: + splitter = _NDJSONLineSplitter() + async for text in texts: + for line in splitter.feed(text): + yield line + for line in splitter.flush(): + yield line + + def _copy_messages(messages: Optional[Sequence[Union[Mapping[str, Any], Message]]]) -> Iterator[Message]: for message in messages or []: yield Message.model_validate( diff --git a/tests/test_client.py b/tests/test_client.py index c212a11d..5727eef2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any +import httpx import pytest from httpx import Response as httpxResponse from pydantic import BaseModel @@ -1527,3 +1528,54 @@ 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...' + + +NDJSON_UNICODE_CONTENTS = ['a\u0085b', 'c\u2028d', 'e\u2029f', 'tail\U0001f642'] + + +def _ndjson_transport(data: bytes, content_type: str = 'application/x-ndjson', *, is_async: bool = False) -> httpx.MockTransport: + # one-byte chunks split multi-byte UTF-8 sequences and CRLF pairs across reads + chunks = [data[i : i + 1] for i in range(len(data))] + + async def achunks(): + for chunk in chunks: + yield chunk + + def handler(_: httpx.Request) -> httpxResponse: + return httpxResponse(200, headers={'Content-Type': content_type}, content=achunks() if is_async else iter(chunks)) + + return httpx.MockTransport(handler) + + +@pytest.mark.parametrize('newline', ['\n', '\r\n'], ids=['lf', 'crlf']) +def test_client_chat_stream_unicode_line_separators(newline: str): + data = ''.join(json.dumps({'model': 'dummy', 'message': {'role': 'assistant', 'content': content}}, ensure_ascii=False) + newline for content in NDJSON_UNICODE_CONTENTS).encode() + client = Client('http://127.0.0.1:11434', transport=_ndjson_transport(data), trust_env=False) + response = client.chat('dummy', messages=[{'role': 'user', 'content': 'Why is the sky blue?'}], stream=True) + assert [part['message']['content'] for part in response] == NDJSON_UNICODE_CONTENTS + + +async def test_async_client_generate_stream_unicode_line_separators(): + # final record intentionally has no trailing newline + data = '\n'.join(json.dumps({'model': 'dummy', 'response': content}, ensure_ascii=False) for content in NDJSON_UNICODE_CONTENTS).encode() + client = AsyncClient('http://127.0.0.1:11434', transport=_ndjson_transport(data, is_async=True), trust_env=False) + response = await client.generate('dummy', 'Why is the sky blue?', stream=True) + assert [part['response'] async for part in response] == NDJSON_UNICODE_CONTENTS + + +@pytest.mark.parametrize( + ('content_type', 'client_kwargs'), + [ + ('application/x-ndjson; charset=iso-8859-1', {}), + ('application/x-ndjson', {'default_encoding': 'iso-8859-1'}), + ], + ids=['content-type-charset', 'default-encoding'], +) +async def test_stream_keeps_httpx_response_encoding(content_type: str, client_kwargs: dict): + # backwards compatibility with existing httpx text decoding; NDJSON itself is UTF-8 + expected = ['caf\u00e9', 'na\u00efve'] + data = ''.join(json.dumps({'status': status}, ensure_ascii=False) + '\n' for status in expected).encode('iso-8859-1') + client = Client('http://127.0.0.1:11434', transport=_ndjson_transport(data, content_type), trust_env=False, **client_kwargs) + assert [part['status'] for part in client.pull('dummy', stream=True)] == expected + async_client = AsyncClient('http://127.0.0.1:11434', transport=_ndjson_transport(data, content_type, is_async=True), trust_env=False, **client_kwargs) + assert [part['status'] async for part in await async_client.pull('dummy', stream=True)] == expected