diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..a86bb13 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,50 @@ +# Configuration for GitHub's automatically generated release notes. +# +# When a release is drafted, every merged PR since the last tag is listed as +# "* by @ in #", so each contributor is credited with +# their GitHub handle automatically, and a "New Contributors" section tags +# first-time contributors. PRs are grouped into the categories below by label. +# +# Docs: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes +changelog: + exclude: + labels: + - ignore-for-release + authors: + - dependabot + - github-actions + categories: + - title: ๐Ÿšจ Breaking Changes + labels: + - breaking-change + - title: โœจ Features + labels: + - enhancement + - title: ๐Ÿ› Bug Fixes + labels: + - bug + - title: โšก Reliability & Performance + labels: + - reliability + - performance + - title: ๐Ÿงช Tests + labels: + - testing + - title: ๐Ÿท๏ธ Typing + labels: + - typing + - title: ๐ŸŒŠ Streaming & Observability + labels: + - streaming + - observability + - error-handling + - title: ๐Ÿ“ Documentation + labels: + - documentation + - title: ๐Ÿ”ง CI & Tooling + labels: + - ci + - chore + - title: ๐Ÿ” Other Changes + labels: + - "*" diff --git a/CHANGELOG.md b/CHANGELOG.md index fae7614..037d322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [Unreleased] + +### Fixed +- Streaming completions now raise `FerroStreamError` on malformed SSE chunks + instead of silently dropping them, for both the sync and async clients. This + is the first code path that actually raises the previously-unused + `FerroStreamError`. + +### Added +- Test coverage for `admin.dashboard()` and `admin.plugins.list()` (bare-array, + `{"data": [...]}`, and `{"plugins": [...]}` response shapes). +- Streaming malformed-chunk regression tests for the sync and async clients. + ## [0.2.0] โ€” 2026-05-14 ### Added diff --git a/ferrolabsai/completions/async_resource.py b/ferrolabsai/completions/async_resource.py index 47b3295..090e4a3 100644 --- a/ferrolabsai/completions/async_resource.py +++ b/ferrolabsai/completions/async_resource.py @@ -8,6 +8,7 @@ import httpx +from ..exceptions import FerroStreamError from ..types import ChatCompletion, ChatCompletionChunk @@ -85,6 +86,9 @@ async def _stream(self, path: str, body: dict[str, Any]) -> AsyncIterator[ChatCo if payload == "[DONE]": return try: - yield ChatCompletionChunk.from_dict(json.loads(payload)) - except json.JSONDecodeError: - continue + chunk_data = json.loads(payload) + except json.JSONDecodeError as e: + raise FerroStreamError( + f"Malformed SSE chunk in streaming response: {payload[:200]!r}" + ) from e + yield ChatCompletionChunk.from_dict(chunk_data) diff --git a/ferrolabsai/completions/resource.py b/ferrolabsai/completions/resource.py index 597c9ef..72f2a20 100644 --- a/ferrolabsai/completions/resource.py +++ b/ferrolabsai/completions/resource.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from typing import Any, Literal, overload +from ..exceptions import FerroStreamError from ..types import ChatCompletion, ChatCompletionChunk @@ -137,6 +138,8 @@ def _stream(self, path: str, body: dict[str, Any]) -> Iterator[ChatCompletionChu return try: chunk_data = json.loads(payload) - yield ChatCompletionChunk.from_dict(chunk_data) - except json.JSONDecodeError: - continue + except json.JSONDecodeError as e: + raise FerroStreamError( + f"Malformed SSE chunk in streaming response: {payload[:200]!r}" + ) from e + yield ChatCompletionChunk.from_dict(chunk_data) diff --git a/tests/test_sdk.py b/tests/test_sdk.py index dbbc9fe..e6ff7ad 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -20,6 +20,7 @@ FerroNotFoundError, FerroRateLimitError, FerroServerError, + FerroStreamError, ) BASE_URL = "http://localhost:8080" @@ -563,6 +564,16 @@ def test_list_plugins_accepts_data_wrapper(self, client, httpx_mock: HTTPXMock): assert client.admin.plugins.list() == plugins + def test_list_plugins_accepts_plugins_wrapper(self, client, httpx_mock: HTTPXMock): + plugins = [{"name": "logger", "enabled": True}] + httpx_mock.add_response( + method="GET", + url=f"{BASE_URL}/admin/plugins", + json={"plugins": plugins}, + ) + + assert client.admin.plugins.list() == plugins + # ------------------------------------------------------------------ # Error handling @@ -678,6 +689,40 @@ def test_sync_stream_500_raises_server_error(self, client, httpx_mock: HTTPXMock ) ) + def test_sync_stream_malformed_chunk_raises_stream_error(self, client, httpx_mock: HTTPXMock): + sse_data = "data: {not valid json}\n\n" + httpx_mock.add_response( + method="POST", + url=f"{BASE_URL}/v1/chat/completions", + content=sse_data.encode(), + ) + with pytest.raises(FerroStreamError, match="Malformed SSE chunk"): + list( + client.chat.completions.create( + model="gpt-4o", + messages=[{"role": "user", "content": "Hi"}], + stream=True, + ) + ) + + @pytest.mark.asyncio + async def test_async_stream_malformed_chunk_raises_stream_error( + self, async_client, httpx_mock: HTTPXMock + ): + sse_data = "data: {not valid json}\n\n" + httpx_mock.add_response( + method="POST", + url=f"{BASE_URL}/v1/chat/completions", + content=sse_data.encode(), + ) + with pytest.raises(FerroStreamError, match="Malformed SSE chunk"): + async for _ in await async_client.chat.completions.create( + model="gpt-4o", + messages=[{"role": "user", "content": "Hi"}], + stream=True, + ): + pass + # ------------------------------------------------------------------ # P0-2: Async completions has parity params