Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -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
# "* <PR title> by @<author> in #<PR>", 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:
- "*"
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions ferrolabsai/completions/async_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import httpx

from ..exceptions import FerroStreamError
from ..types import ChatCompletion, ChatCompletionChunk


Expand Down Expand Up @@ -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)
9 changes: 6 additions & 3 deletions ferrolabsai/completions/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from collections.abc import Iterator
from typing import Any, Literal, overload

from ..exceptions import FerroStreamError
from ..types import ChatCompletion, ChatCompletionChunk


Expand Down Expand Up @@ -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)
87 changes: 87 additions & 0 deletions tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
FerroNotFoundError,
FerroRateLimitError,
FerroServerError,
FerroStreamError,
)

BASE_URL = "http://localhost:8080"
Expand Down Expand Up @@ -497,6 +498,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(
Expand All @@ -522,6 +540,41 @@ 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

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
# ------------------------------------------------------------------
Expand Down Expand Up @@ -636,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
Expand Down
Loading