From 363195a4c2e15bc731614dee18694cc4c1a556d8 Mon Sep 17 00:00:00 2001 From: Mitul Shah Date: Thu, 28 May 2026 13:29:59 +0530 Subject: [PATCH 1/5] Development (#4) (#6) * feat: ship langchain adapter * fix: support langchain integration on python 3.9 From 4d677860afdb15c9794a79a702446a1a10f00cf5 Mon Sep 17 00:00:00 2001 From: Tine <119507611+tine1117@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:11:46 +0900 Subject: [PATCH 2/5] test: cover admin plugins list (#25) --- tests/test_sdk.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_sdk.py b/tests/test_sdk.py index e0dfe71..2a4a0b8 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -522,6 +522,31 @@ def test_logs_stats(self, client, httpx_mock: HTTPXMock): assert stats["total"] == 42 +class TestAdminPlugins: + def test_list_plugins_accepts_bare_array(self, client, httpx_mock: HTTPXMock): + plugins = [ + {"name": "cache", "enabled": True}, + {"name": "logger", "enabled": False}, + ] + httpx_mock.add_response( + method="GET", + url=f"{BASE_URL}/admin/plugins", + json=plugins, + ) + + assert client.admin.plugins.list() == plugins + + def test_list_plugins_accepts_data_wrapper(self, client, httpx_mock: HTTPXMock): + plugins = [{"name": "ratelimit", "enabled": True}] + httpx_mock.add_response( + method="GET", + url=f"{BASE_URL}/admin/plugins", + json={"data": plugins}, + ) + + assert client.admin.plugins.list() == plugins + + # ------------------------------------------------------------------ # Error handling # ------------------------------------------------------------------ From 62677ddf75dccbbb1c4416385f0a65958833509f Mon Sep 17 00:00:00 2001 From: AmitabhainArunachala <79852583+AmitabhainArunachala@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:12:27 +0800 Subject: [PATCH 3/5] test: cover admin dashboard (#23) --- tests/test_sdk.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 2a4a0b8..dbbc9fe 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -497,6 +497,23 @@ def test_rollback(self, client, httpx_mock: HTTPXMock): assert result["rolled_back_to"] == 1 +class TestAdminDashboard: + def test_get_dashboard(self, client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url=f"{BASE_URL}/admin/dashboard", + json={ + "providers": {"enabled": 3, "disabled": 1}, + "keys": {"active": 5, "revoked": 2}, + "requests": {"total": 128, "errors": 4}, + }, + ) + dashboard = client.admin.dashboard() + assert dashboard["providers"]["enabled"] == 3 + assert dashboard["keys"]["active"] == 5 + assert dashboard["requests"]["total"] == 128 + + class TestAdminLogs: def test_list_logs(self, client, httpx_mock: HTTPXMock): httpx_mock.add_response( From 05d5b7791d80873f4a8875867ac5b3dbec7c230e Mon Sep 17 00:00:00 2001 From: Mitul Shah Date: Fri, 12 Jun 2026 19:17:10 +0530 Subject: [PATCH 4/5] fix: raise FerroStreamError on malformed streaming chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the sync and async _stream() paths swallowed JSONDecodeError with a bare `continue`, silently dropping malformed SSE chunks and losing data with no error or log. They now raise FerroStreamError instead โ€” the first code path that actually raises this previously-unused exception. Also adds the {"plugins": [...]} wrapper coverage for admin.plugins.list() and streaming malformed-chunk regression tests for both clients. Closes #15 Closes #16 --- CHANGELOG.md | 13 +++++++ ferrolabsai/completions/async_resource.py | 10 +++-- ferrolabsai/completions/resource.py | 9 +++-- tests/test_sdk.py | 45 +++++++++++++++++++++++ 4 files changed, 71 insertions(+), 6 deletions(-) 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 From 85f33b9c44ab266382d1ca2cecb666b52c485f25 Mon Sep 17 00:00:00 2001 From: Mitul Shah Date: Fri, 12 Jun 2026 19:29:57 +0530 Subject: [PATCH 5/5] ci: add release-notes config to credit contributors on tagged releases Add .github/release.yml so GitHub's auto-generated release notes (the publish job already runs softprops/action-gh-release with generate_release_notes: true) group merged PRs by label and credit every contributor by @handle, plus a "New Contributors" section. Contributor credit lives in the release notes only, not in CHANGELOG.md. --- .github/release.yml | 50 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/release.yml 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: + - "*"