diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ebb564..0ca380b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to the `fipsagents` package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [0.14.2] - 2026-04-28 + +### Added + +- **`HttpSessionStore.get_cost_data` reads from the platform.** Replaces the `NotImplementedError` placeholder with a real `GET /v1/sessions/{id}/cost_data`, closing the cumulative-cost gap noted in 0.14.0's release notes. HTTP-backed deployments now get the same cumulative shallow-merge semantics that SQLite/Postgres provide natively — the per-turn accumulator on `OpenAIChatServer` reads existing totals before computing the merge, so multi-turn sessions converge on cumulative numbers instead of last-write-wins. 5 new tests across the unit and e2e suites; total `fipsagents` suite 765 → 770. + +### Notes + +- Requires `fipsagents-platform>=0.2.1`. Older platforms 404 cleanly on the read path and the agent degrades to the previous last-write-wins behavior — same operational shape as 0.14.0/0.14.1, no breakage. + ## [0.14.1] - 2026-04-27 ### Fixed diff --git a/packages/fipsagents/pyproject.toml b/packages/fipsagents/pyproject.toml index a53ceff..24ec51d 100644 --- a/packages/fipsagents/pyproject.toml +++ b/packages/fipsagents/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fipsagents" -version = "0.14.1" +version = "0.14.2" description = "Production-ready AI agent framework for FIPS/OpenShift environments" readme = "README.md" license = {file = "LICENSE"} diff --git a/packages/fipsagents/src/fipsagents/server/http.py b/packages/fipsagents/src/fipsagents/server/http.py index 6a2debb..592f256 100644 --- a/packages/fipsagents/src/fipsagents/server/http.py +++ b/packages/fipsagents/src/fipsagents/server/http.py @@ -252,17 +252,19 @@ async def update( return status != 404 async def get_cost_data(self, session_id: str) -> dict: - # The platform service has no GET /v1/sessions/{id}/cost_data - # endpoint yet. Until it does, callers must treat the HTTP - # backend as write-only for cost accumulator state. The server's - # per-turn accumulator catches NotImplementedError and treats - # the existing total as empty (so the next write is the turn's - # delta rather than a true cumulative). A follow-up issue tracks - # exposing the read endpoint on the platform. - raise NotImplementedError( - "HttpSessionStore.get_cost_data: the platform service does " - "not expose a GET endpoint for cost_data yet." + # Mirrors the SQLite/Postgres contract: empty dict when the + # session is missing or has no cost_data yet. Requires + # fipsagents-platform >= 0.2.1 (which exposes the GET endpoint); + # against older platforms the route 404s on every call and we + # degrade gracefully to last-write-wins semantics. + status, data = await self._client.request( + "GET", + f"/v1/sessions/{session_id}/cost_data", + not_found_returns_none=True, ) + if status == 404 or data is None: + return {} + return data.get("cost_data") or {} async def delete(self, session_id: str) -> bool: status, _ = await self._client.request( diff --git a/packages/fipsagents/tests/test_http_stores.py b/packages/fipsagents/tests/test_http_stores.py index de2010e..33c46db 100644 --- a/packages/fipsagents/tests/test_http_stores.py +++ b/packages/fipsagents/tests/test_http_stores.py @@ -221,17 +221,41 @@ async def test_session_update_none_cost_data_delegates_to_exists() -> None: @pytest.mark.asyncio -async def test_session_get_cost_data_raises_not_implemented() -> None: - """The HTTP backend has no GET cost_data endpoint yet -- raise so the - server-side accumulator can fall back to a delta-only write.""" - rec = _Recorder([]) +async def test_session_get_cost_data_returns_dict() -> None: + """GET /v1/sessions/{id}/cost_data parses the cumulative dict.""" + rec = _Recorder([_ok(200, { + "session_id": "sess_abc", + "cost_data": {"input_tokens": 100, "output_tokens": 50}, + })]) + store = HttpSessionStore( + "http://platform.test", transport=httpx.MockTransport(rec), + ) + result = await store.get_cost_data("sess_abc") + assert result == {"input_tokens": 100, "output_tokens": 50} + assert rec.requests[0].method == "GET" + assert rec.requests[0].url.path == "/v1/sessions/sess_abc/cost_data" + await store.close() + + +@pytest.mark.asyncio +async def test_session_get_cost_data_404_returns_empty() -> None: + """Missing sessions degrade to {} per the ABC contract.""" + rec = _Recorder([_ok(404, {"detail": "not found"})]) + store = HttpSessionStore( + "http://platform.test", transport=httpx.MockTransport(rec), + ) + assert await store.get_cost_data("missing") == {} + await store.close() + + +@pytest.mark.asyncio +async def test_session_get_cost_data_empty_dict_round_trips() -> None: + """Existing session with no writes returns the platform's empty dict.""" + rec = _Recorder([_ok(200, {"session_id": "sess_empty", "cost_data": {}})]) store = HttpSessionStore( "http://platform.test", transport=httpx.MockTransport(rec), ) - with pytest.raises(NotImplementedError): - await store.get_cost_data("sess_anything") - # No HTTP request should have been issued. - assert rec.requests == [] + assert await store.get_cost_data("sess_empty") == {} await store.close() diff --git a/packages/fipsagents/tests/test_http_stores_e2e.py b/packages/fipsagents/tests/test_http_stores_e2e.py index 2524e00..f79af93 100644 --- a/packages/fipsagents/tests/test_http_stores_e2e.py +++ b/packages/fipsagents/tests/test_http_stores_e2e.py @@ -174,6 +174,31 @@ async def test_session_update_none_cost_data_returns_existence( await store.close() +@pytest.mark.asyncio +async def test_session_get_cost_data_round_trip(platform_transport) -> None: + """Two PATCHes then GET — the returned dict is the cumulative merge.""" + store = HttpSessionStore( + "http://platform.test", transport=platform_transport, + ) + sid = await store.create("sess_e2e_get_cost") + await store.update(sid, cost_data={"input_tokens": 10, "output_tokens": 5}) + await store.update(sid, cost_data={"input_tokens": 30, "requests": 2}) + + result = await store.get_cost_data(sid) + assert result == {"input_tokens": 30, "output_tokens": 5, "requests": 2} + await store.close() + + +@pytest.mark.asyncio +async def test_session_get_cost_data_missing_returns_empty(platform_transport) -> None: + """Platform 404 → ABC-compliant empty dict on the agent side.""" + store = HttpSessionStore( + "http://platform.test", transport=platform_transport, + ) + assert await store.get_cost_data("sess_e2e_never_existed") == {} + await store.close() + + # --------------------------------------------------------------------------- # Traces # ---------------------------------------------------------------------------