From e3965b23637a2accfa4af6c7dc261c4dfc8b5129 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Mon, 24 Aug 2026 18:28:07 -0300 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#79:=20Preserve=20partia?= =?UTF-8?q?l=20stream=20text=20on=20mid-response=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pycodeloop/providers/generic.py | 164 +++++++++++++++++--------------- 1 file changed, 86 insertions(+), 78 deletions(-) diff --git a/pycodeloop/providers/generic.py b/pycodeloop/providers/generic.py index d2b3531..3b09f0f 100644 --- a/pycodeloop/providers/generic.py +++ b/pycodeloop/providers/generic.py @@ -137,7 +137,7 @@ class GenericProvider(Provider): "model": "my-model", "api_key_env": "MY_API_KEY", "headers": {"X-Custom": "value"}, - "timeout": 60, + "timeout": 180, "context_window": 4096, "response_paths": { "text": "choices.0.message.content", @@ -191,7 +191,7 @@ def __init__( auth_prefix: str = "Bearer ", request_builder: RequestBuilder | None = None, response_parser: ResponseParser | None = None, - timeout: float = 60.0, + timeout: float = 180.0, repetition_min_period: int = _REPETITION_MIN_PERIOD, repetition_max_period: int = _REPETITION_MAX_PERIOD, repetition_repeats: int = _REPETITION_REPEATS, @@ -258,7 +258,7 @@ def _build_from_json(cls, path: str | Path) -> GenericProvider: auth_prefix=data.get("auth_prefix", "Bearer "), request_builder=request_builder, response_parser=response_parser, - timeout=data.get("timeout", 60.0), + timeout=data.get("timeout", 180.0), context_window=data.get("context_window"), supports_openai_sse=response_shape != "anthropic", include_usage_in_stream=data.get("include_usage_in_stream", True), @@ -406,82 +406,90 @@ def _stream( saw_terminal_marker = False usage = Usage() - with self._open(body, config) as response: - for raw_line in response: - if cancel_event is not None and cancel_event.is_set(): - stop_reason = "cancelled" - saw_terminal_marker = True - break - line = raw_line.decode().strip() - if not line or not line.startswith("data: "): - continue - payload = line[len("data: ") :] - if payload == "[DONE]": - saw_terminal_marker = True - break - try: - chunk = json.loads(payload) - except json.JSONDecodeError: - if stop_reason is None: - stop_reason = "malformed_stream" - break - - if chunk.get("usage"): - usage = Usage( - input_tokens=chunk["usage"].get("prompt_tokens", 0), - output_tokens=chunk["usage"].get( - "completion_tokens", 0 - ), - ) - - choices = chunk.get("choices") or [] - if not choices: - continue - choice = choices[0] - delta = choice.get("delta") or {} - - if delta.get("content"): - candidate = text + delta["content"] - if _is_repeating( - candidate, - self.repetition_min_period, - self.repetition_max_period, - self.repetition_repeats, - ): - stop_reason = "repetition" + try: + with self._open(body, config) as response: + for raw_line in response: + if cancel_event is not None and cancel_event.is_set(): + stop_reason = "cancelled" + saw_terminal_marker = True + break + line = raw_line.decode().strip() + if not line or not line.startswith("data: "): + continue + payload = line[len("data: ") :] + if payload == "[DONE]": + saw_terminal_marker = True break - text = candidate - on_delta(delta["content"]) - - for tc in delta.get("tool_calls") or []: - index = tc.get("index", 0) - acc = pending.setdefault( - index, - { - "id": None, - "name": None, - "arguments": "", - "extra": {}, - }, - ) - if tc.get("id"): - acc["id"] = tc["id"] - function = tc.get("function") or {} - if function.get("name"): - acc["name"] = function["name"] - if function.get("arguments"): - acc["arguments"] += function["arguments"] - acc["extra"].update( - { - k: v - for k, v in tc.items() - if k not in ("index", "id", "type", "function") - } - ) - - if choice.get("finish_reason"): - stop_reason = choice["finish_reason"] - saw_terminal_marker = True + try: + chunk = json.loads(payload) + except json.JSONDecodeError: + if stop_reason is None: + stop_reason = "malformed_stream" + break + + if chunk.get("usage"): + usage = Usage( + input_tokens=chunk["usage"].get( + "prompt_tokens", 0 + ), + output_tokens=chunk["usage"].get( + "completion_tokens", 0 + ), + ) + + choices = chunk.get("choices") or [] + if not choices: + continue + choice = choices[0] + delta = choice.get("delta") or {} + + if delta.get("content"): + candidate = text + delta["content"] + if _is_repeating( + candidate, + self.repetition_min_period, + self.repetition_max_period, + self.repetition_repeats, + ): + stop_reason = "repetition" + break + text = candidate + on_delta(delta["content"]) + + for tc in delta.get("tool_calls") or []: + index = tc.get("index", 0) + acc = pending.setdefault( + index, + { + "id": None, + "name": None, + "arguments": "", + "extra": {}, + }, + ) + if tc.get("id"): + acc["id"] = tc["id"] + function = tc.get("function") or {} + if function.get("name"): + acc["name"] = function["name"] + if function.get("arguments"): + acc["arguments"] += function["arguments"] + acc["extra"].update( + { + k: v + for k, v in tc.items() + if k not in ("index", "id", "type", "function") + } + ) + + if choice.get("finish_reason"): + stop_reason = choice["finish_reason"] + saw_terminal_marker = True + except (TimeoutError, ConnectionError, urllib.error.URLError): + if not text and not pending: + raise + stop_reason = "connection_lost" + saw_terminal_marker = False if stop_reason is None: stop_reason = "stop" if saw_terminal_marker else "connection_lost" From 9c680cf93c4411c87bd4e3b7afa7ebacb73ad28d Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Mon, 24 Aug 2026 18:28:07 -0300 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#79:=20Cover=20mi?= =?UTF-8?q?d-stream=20timeout=20preserving/reraising=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/providers/test_generic.py | 74 +++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/providers/test_generic.py b/tests/providers/test_generic.py index 4fbd9dc..877d7f9 100644 --- a/tests/providers/test_generic.py +++ b/tests/providers/test_generic.py @@ -21,6 +21,37 @@ def __exit__(self, *exc): return False +class _TimeoutAfterLinesResponse(io.BytesIO): + """Like `_FakeResponse`, but raises `TimeoutError` once iteration + passes `raise_after` lines — simulates the model going quiet + mid-stream (long reasoning) and the socket's read timeout firing + before any terminal marker or `finish_reason` arrives.""" + + def __init__(self, data: bytes, raise_after: int): + super().__init__(data) + self._raise_after = raise_after + self._yielded = 0 + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + return False + + def __iter__(self): + return self + + def __next__(self): + if self._yielded >= self._raise_after: + raise TimeoutError("timed out") + line = super().readline() + if not line: + raise StopIteration + self._yielded += 1 + return line + + class GenericProviderTestCase(unittest.TestCase): def setUp(self): tmpdir = tempfile.TemporaryDirectory() @@ -356,6 +387,49 @@ 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_returns_partial_text_on_mid_stream_timeout(self): + """Regression: a `TimeoutError` raised mid-read (model silent for + longer than the socket timeout while "thinking") used to + propagate out of `_stream()` uncaught, discarding whatever text + had already streamed in and losing the assistant's turn + entirely instead of returning it as a partial response.""" + path = self._write_config( + {"url": "http://fake/v1/chat/completions", "model": "my-model"} + ) + provider = GenericProvider.from_json(path) + + chunks = [ + {"choices": [{"delta": {"content": "partial "}}]}, + {"choices": [{"delta": {"content": "answer"}}]}, + ] + sse_body = "".join(f"data: {json.dumps(c)}\n" for c in chunks).encode() + + with mock.patch( + "pycodeloop.providers.generic.urllib.request.urlopen", + return_value=_TimeoutAfterLinesResponse(sse_body, raise_after=1), + ): + result = provider.complete("sys", [], [], on_delta=lambda _: None) + + self.assertEqual(result.text, "partial ") + self.assertEqual(result.stop_reason, "connection_lost") + + def test_streaming_reraises_timeout_when_nothing_was_streamed_yet(self): + """A timeout before any content or tool-call delta arrived means + nothing was generated to preserve — the exception should still + propagate so `Agent._complete()`'s existing retry logic kicks + in, instead of being swallowed into an empty response.""" + path = self._write_config( + {"url": "http://fake/v1/chat/completions", "model": "my-model"} + ) + provider = GenericProvider.from_json(path) + + with mock.patch( + "pycodeloop.providers.generic.urllib.request.urlopen", + return_value=_TimeoutAfterLinesResponse(b"", raise_after=0), + ): + with self.assertRaises(TimeoutError): + provider.complete("sys", [], [], on_delta=lambda _: None) + def test_streaming_stops_promptly_when_cancel_event_is_set(self): """Regression: cancel_event was accepted nowhere in the streaming read loop, so pressing Esc/Cancel mid-response did nothing until From 2390ae9ddb18d4201a6d9c10c2c229e2bdfdfd82 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Mon, 24 Aug 2026 18:28:17 -0300 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#79:=20Bump=20example=20?= =?UTF-8?q?provider=20timeout=20to=20match=20new=20180s=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/examples/provider.example.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/provider.example.json b/docs/examples/provider.example.json index bf326ee..64464d7 100644 --- a/docs/examples/provider.example.json +++ b/docs/examples/provider.example.json @@ -5,7 +5,7 @@ "headers": { "X-Org": "acme" }, - "timeout": 60, + "timeout": 180, "_comment": "response_paths is optional. Omit it entirely if the API already returns the OpenAI chat-completions shape (choices[0].message.content, usage.prompt_tokens, ...). Keep it only to remap a different shape, like the example below.", "response_paths": {