From 2ac7fc4e443ff4c6272ac6455c671ae219111850 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sun, 16 Aug 2026 00:18:39 -0300 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#68:=20Request=20stream?= =?UTF-8?q?=5Foptions.include=5Fusage=20in=20SSE=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pycodeloop/providers/generic.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pycodeloop/providers/generic.py b/pycodeloop/providers/generic.py index 754e5cd..6e5da7b 100644 --- a/pycodeloop/providers/generic.py +++ b/pycodeloop/providers/generic.py @@ -380,7 +380,11 @@ def _stream( known_tools: set[str], config: _ConnectionSnapshot, ) -> ProviderResponse: - body = {**body, "stream": True} + body = { + **body, + "stream": True, + "stream_options": {"include_usage": True}, + } text = "" pending: dict[int, dict] = {} stop_reason: str | None = None From 126e9b8c8a72488729ba59f551fa3900e7707552 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sun, 16 Aug 2026 00:18:42 -0300 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#68:=20Cover=20st?= =?UTF-8?q?reaming=20usage=20regression=20via=20include=5Fusage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/providers/test_generic.py | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/providers/test_generic.py b/tests/providers/test_generic.py index 903846b..05ffbf0 100644 --- a/tests/providers/test_generic.py +++ b/tests/providers/test_generic.py @@ -338,6 +338,48 @@ def test_streaming_flags_a_connection_dropped_mid_response(self): self.assertEqual(result.text, "cut off mid") self.assertEqual(result.stop_reason, "connection_lost") + def test_streaming_requests_usage_and_captures_it_from_final_chunk(self): + """Regression: streaming previously sent `stream: True` with no + `stream_options.include_usage`, so OpenAI-compatible servers that + only report usage when asked (e.g. Ollama's /v1/chat/completions) + never sent a usage chunk and every streamed response reported + 0/0 tokens.""" + path = self._write_config( + {"url": "http://fake/v1/chat/completions", "model": "my-model"} + ) + provider = GenericProvider.from_json(path) + + chunks = [ + {"choices": [{"delta": {"content": "hi"}}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}]}, + { + "choices": [], + "usage": {"prompt_tokens": 12, "completion_tokens": 3}, + }, + ] + sse_body = ( + "".join(f"data: {json.dumps(c)}\n" for c in chunks) + + "data: [DONE]\n" + ).encode() + + captured_requests = [] + + def fake_urlopen(request, timeout=None): + captured_requests.append(json.loads(request.data)) + return _FakeResponse(sse_body) + + with mock.patch( + "pycodeloop.providers.generic.urllib.request.urlopen", + side_effect=fake_urlopen, + ): + result = provider.complete("sys", [], [], on_delta=lambda _: None) + + self.assertEqual( + captured_requests[0]["stream_options"], {"include_usage": True} + ) + self.assertEqual(result.usage.input_tokens, 12) + self.assertEqual(result.usage.output_tokens, 3) + def test_streaming_cuts_a_looping_response_short(self): path = self._write_config( {"url": "http://fake/v1/chat/completions", "model": "my-model"} From 537d40846019f33c59cbfa155ad0cf8ac03b5018 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sun, 16 Aug 2026 00:32:07 -0300 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#68:=20Merge=20stream=5F?= =?UTF-8?q?options=20instead=20of=20overwriting,=20add=20opt-out=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pycodeloop/providers/generic.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/pycodeloop/providers/generic.py b/pycodeloop/providers/generic.py index 6e5da7b..08163e4 100644 --- a/pycodeloop/providers/generic.py +++ b/pycodeloop/providers/generic.py @@ -121,6 +121,7 @@ class _ConnectionSnapshot: request_builder: RequestBuilder response_parser: ResponseParser supports_openai_sse: bool + include_usage_in_stream: bool class GenericProvider(Provider): @@ -196,6 +197,7 @@ def __init__( repetition_repeats: int = _REPETITION_REPEATS, context_window: int | None = None, supports_openai_sse: bool = True, + include_usage_in_stream: bool = True, **kwargs, ) -> None: super().__init__(model=model, api_key=api_key, **kwargs) @@ -211,6 +213,7 @@ def __init__( self.repetition_repeats = repetition_repeats self.context_window = context_window self._supports_openai_sse = supports_openai_sse + self._include_usage_in_stream = include_usage_in_stream self._config_path: Path | None = None self._lock = threading.Lock() @@ -258,6 +261,9 @@ def _build_from_json(cls, path: str | Path) -> GenericProvider: timeout=data.get("timeout", 60.0), context_window=data.get("context_window"), supports_openai_sse=response_shape != "anthropic", + include_usage_in_stream=data.get( + "include_usage_in_stream", True + ), ) def reload(self) -> None: @@ -281,6 +287,7 @@ def reload(self) -> None: self.timeout = fresh.timeout self.context_window = fresh.context_window self._supports_openai_sse = fresh._supports_openai_sse + self._include_usage_in_stream = fresh._include_usage_in_stream @staticmethod def _default_request( @@ -308,6 +315,7 @@ def _snapshot_locked(self) -> _ConnectionSnapshot: request_builder=self.request_builder, response_parser=self.response_parser, supports_openai_sse=self._supports_openai_sse, + include_usage_in_stream=self._include_usage_in_stream, ) def _headers(self, config: _ConnectionSnapshot) -> dict[str, str]: @@ -380,11 +388,13 @@ def _stream( known_tools: set[str], config: _ConnectionSnapshot, ) -> ProviderResponse: - body = { - **body, - "stream": True, - "stream_options": {"include_usage": True}, - } + body = {**body, "stream": True} + if config.include_usage_in_stream: + existing_stream_options = body.get("stream_options") or {} + body["stream_options"] = { + "include_usage": True, + **existing_stream_options, + } text = "" pending: dict[int, dict] = {} stop_reason: str | None = None From c1cb97f42ecb5aadad8e2561e769a5a1120e6727 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sun, 16 Aug 2026 00:32:09 -0300 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#68:=20Cover=20st?= =?UTF-8?q?ream=5Foptions=20merge=20and=20include=5Fusage=5Fin=5Fstream=20?= =?UTF-8?q?opt-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/providers/test_generic.py | 75 +++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/providers/test_generic.py b/tests/providers/test_generic.py index 05ffbf0..c3d139a 100644 --- a/tests/providers/test_generic.py +++ b/tests/providers/test_generic.py @@ -380,6 +380,81 @@ def fake_urlopen(request, timeout=None): self.assertEqual(result.usage.input_tokens, 12) self.assertEqual(result.usage.output_tokens, 3) + def test_streaming_merges_include_usage_into_callers_stream_options( + self, + ): + """A caller opting out via params.stream_options.include_usage + (e.g. a provider that rejects the field) must not be silently + overwritten, and sibling flags must survive the merge.""" + path = self._write_config( + { + "url": "http://fake/v1/chat/completions", + "model": "my-model", + "request": { + "params": { + "stream_options": { + "include_usage": False, + "include_intermediary_tokens": True, + } + } + }, + } + ) + provider = GenericProvider.from_json(path) + + sse_body = ( + 'data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}\n' + "data: [DONE]\n" + ).encode() + + captured_requests = [] + + def fake_urlopen(request, timeout=None): + captured_requests.append(json.loads(request.data)) + return _FakeResponse(sse_body) + + with mock.patch( + "pycodeloop.providers.generic.urllib.request.urlopen", + side_effect=fake_urlopen, + ): + provider.complete("sys", [], [], on_delta=lambda _: None) + + self.assertEqual( + captured_requests[0]["stream_options"], + {"include_usage": False, "include_intermediary_tokens": True}, + ) + + def test_include_usage_in_stream_false_omits_stream_options(self): + """Strict OpenAI-compatible endpoints that 400 on unknown fields + can opt out entirely via config.""" + path = self._write_config( + { + "url": "http://fake/v1/chat/completions", + "model": "my-model", + "include_usage_in_stream": False, + } + ) + provider = GenericProvider.from_json(path) + + sse_body = ( + 'data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}\n' + "data: [DONE]\n" + ).encode() + + captured_requests = [] + + def fake_urlopen(request, timeout=None): + captured_requests.append(json.loads(request.data)) + return _FakeResponse(sse_body) + + with mock.patch( + "pycodeloop.providers.generic.urllib.request.urlopen", + side_effect=fake_urlopen, + ): + provider.complete("sys", [], [], on_delta=lambda _: None) + + self.assertNotIn("stream_options", captured_requests[0]) + def test_streaming_cuts_a_looping_response_short(self): path = self._write_config( {"url": "http://fake/v1/chat/completions", "model": "my-model"} From eb197e872625a1ed087b257868b037c0aa80780f Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sun, 16 Aug 2026 00:35:23 -0300 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20PEP8:=20Apply=20ruff=20forma?= =?UTF-8?q?t/lint=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pycodeloop/providers/generic.py | 4 +--- tests/providers/test_generic.py | 12 ++++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/pycodeloop/providers/generic.py b/pycodeloop/providers/generic.py index 08163e4..7bcafe9 100644 --- a/pycodeloop/providers/generic.py +++ b/pycodeloop/providers/generic.py @@ -261,9 +261,7 @@ def _build_from_json(cls, path: str | Path) -> GenericProvider: timeout=data.get("timeout", 60.0), context_window=data.get("context_window"), supports_openai_sse=response_shape != "anthropic", - include_usage_in_stream=data.get( - "include_usage_in_stream", True - ), + include_usage_in_stream=data.get("include_usage_in_stream", True), ) def reload(self) -> None: diff --git a/tests/providers/test_generic.py b/tests/providers/test_generic.py index c3d139a..2037e2d 100644 --- a/tests/providers/test_generic.py +++ b/tests/providers/test_generic.py @@ -403,9 +403,9 @@ def test_streaming_merges_include_usage_into_callers_stream_options( provider = GenericProvider.from_json(path) sse_body = ( - 'data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}\n' - "data: [DONE]\n" - ).encode() + b'data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}\n' + b"data: [DONE]\n" + ) captured_requests = [] @@ -437,9 +437,9 @@ def test_include_usage_in_stream_false_omits_stream_options(self): provider = GenericProvider.from_json(path) sse_body = ( - 'data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}\n' - "data: [DONE]\n" - ).encode() + b'data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}\n' + b"data: [DONE]\n" + ) captured_requests = []