Skip to content

feat(server): SessionStore.update() + per-turn cost tracking#117

Merged
rdwj merged 4 commits into
mainfrom
feat/session-store-update-116
Apr 28, 2026
Merged

feat(server): SessionStore.update() + per-turn cost tracking#117
rdwj merged 4 commits into
mainfrom
feat/session-store-update-116

Conversation

@rdwj
Copy link
Copy Markdown
Contributor

@rdwj rdwj commented Apr 28, 2026

Summary

First slice of #104 (Cost Tracking) — adds SessionStore.update() / get_cost_data() and wires the OpenAI server layer to record per-turn token usage on the session. Closes #116.

The architecture doc states cost data piggybacks on SessionStore.update(). This PR lands the storage plumbing + server wiring; pricing, budgets, and aggregation endpoints are deferred to follow-up issues.

What landed

  • SessionStore.update(session_id, *, cost_data: dict | None = None) -> bool — partial update on the ABC. Shallow-merge per top-level key, write-wins. Implemented on Null / Sqlite / Postgres / Http (Http maps to PATCH /v1/sessions/{id}).
  • SessionStore.get_cost_data(session_id) -> dict — symmetric reader so the server-side accumulator can read existing totals before writing cumulative ones back. Implemented on Null / Sqlite / Postgres; Http raises NotImplementedError until the platform exposes a GET endpoint (tracked as feat: GET /v1/sessions/{id}/cost_data for cumulative cost-tracking via HttpSessionStore fipsagents-platform#4).
  • Schema migration on existing databases — SQLite via PRAGMA table_info + ALTER TABLE ADD COLUMN; Postgres via ADD COLUMN IF NOT EXISTS. No operator action required.
  • SqliteSessionStore.save() switches from INSERT OR REPLACE to ON CONFLICT DO UPDATE SET messages, updated_at so cost_data survives saves of new messages.
  • OpenAIChatServer — both sync and streaming paths capture StreamMetrics.prompt_tokens / completion_tokens from the terminal StreamComplete event, accumulate cumulatively via get_cost_data + update, and persist after each turn. Failures are caught and logged so cost-tracking never breaks the chat response.
  • 93 passing tests across the three relevant test files; full suite 759 passing.

Companion PR

Out of scope (filed as follow-ups)

  • BudgetEnforcer observer with soft/hard limits + BudgetExceededError
  • Pricing model config in agent.yaml (per-token, per-request, custom for self-hosted vLLM)
  • GET /v1/sessions/{id}/usage REST aggregation endpoint
  • tenant_id / session_id label dimensions on agent_tokens_total Prometheus metric
  • OTEL GenAI semantic conventions
  • feat: GET /v1/sessions/{id}/cost_data for cumulative cost-tracking via HttpSessionStore fipsagents-platform#4GET /v1/sessions/{id}/cost_data so HTTP-backed deployments get cumulative semantics (today HTTP falls back to per-turn-delta writes)

Test plan

  • pytest tests/test_sessions.py — 25/25 (8 new for update() + get_cost_data() + migration)
  • pytest tests/test_http_stores.py — 28/28 (4 new for update() PATCH wire shape, 1 new for get_cost_data NotImplementedError)
  • pytest tests/test_http_stores_e2e.py — 17/17 (3 new for full agent → ASGI → SQLite round-trip via in-process platform)
  • pytest tests/test_server_openai.py — full suite (7 new for cumulative accumulation, no-session no-op, persist-failure isolation, NullSessionStore default)
  • Full unit suite: 759 passing
  • gitleaks: clean

Refs #104, #116.

Assisted-by: Claude Code (Opus 4.7)

rdwj added 4 commits April 27, 2026 19:48
Add a partial-update method to the SessionStore ABC and implement on
NullSessionStore, SqliteSessionStore, and PostgresSessionStore. First
slice of #116 (and umbrella #104, Cost Tracking) -- the architecture
doc states cost data piggybacks on SessionStore.update(), so this lays
the storage plumbing before BaseAgent and Http wiring.

Signature is keyword-only and additive (cost_data: dict | None = None
today; future fields can be added without breaking callers). Returns
True iff the session existed.

cost_data is opaque JSON owned by BaseAgent; the store does a shallow
merge so each per-turn write only carries the delta. SQLite stores it
as TEXT and merges in Python; Postgres uses native JSONB || merge in
a single statement. SqliteSessionStore.save() switches from
INSERT OR REPLACE to ON CONFLICT DO UPDATE so cost_data survives saves
of new messages -- the existing Postgres save() already had the right
shape.

Schema migration: SqliteSessionStore checks PRAGMA table_info and runs
ALTER TABLE ADD COLUMN when the column is missing; Postgres uses
ADD COLUMN IF NOT EXISTS. Existing databases pick up the column on
first connect with no operator action.

HttpSessionStore.update() raises NotImplementedError until the platform
PATCH /v1/sessions/{id} endpoint lands (companion fipsagents-platform#2,
agent-side wiring tracked separately as the next slice of #116).

Refs: #116, #104

Assisted-by: Claude Code (Opus 4.7)
Replace the NotImplementedError stub on HttpSessionStore.update() with
the real platform call. Maps to PATCH /v1/sessions/{session_id} with
{"cost_data": {...}} as the partial-update body; 200 → True, 404 →
False without raising. cost_data=None short-circuits to exists() to
mirror the SQLite/Postgres update() behavior.

Companion to fipsagents-platform#2; the platform-side PATCH route
ships separately on that repo. Tests cover the wire shape against
httpx.MockTransport (method/path/body/auth/error mapping) and the
full agent → ASGI → SQLite round-trip via the in-process platform
fixture, asserting write-wins shallow-merge semantics.

The e2e round-trip reads cost_data straight out of the platform's
SQLite — GET /v1/sessions/{id} doesn't surface cost_data and
extending it is out of scope for this slice (belongs with the
platform-side PATCH endpoint's evolution).

Refs: #116, #104, fips-agents/fipsagents-platform#2

Assisted-by: Claude Code (Opus 4.7)
…e.update

Wire the OpenAI chat-completions handler to extract token usage from
each turn's StreamComplete event and accumulate it onto the session's
cost_data via SessionStore.update(). Cost tracking is fully server-layer
-- BaseAgent and astep_stream are unchanged.

Adds a symmetric SessionStore.get_cost_data() reader so the accumulator
can read the existing total before writing the cumulative one back.
Implemented for Null/Sqlite/Postgres; HttpSessionStore raises
NotImplementedError until the platform exposes a GET endpoint, in which
case the accumulator falls back to a per-turn-delta write rather than
crashing. update() failures are caught and logged so cost-tracking
issues never break the chat response.

Refs #116, #104.

Assisted-by: Claude Code (Opus 4.7)
Records the SessionStore.update() / get_cost_data() additions and the
server-layer per-turn cost accumulator that lands the first slice of
#104. HTTP-backed deployments are flagged as last-write-wins until the
platform exposes a GET endpoint (fipsagents-platform#4).

Closes #116.

Assisted-by: Claude Code (Opus 4.7)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: SessionStore.update() for cost data — Track C v1 of #104

1 participant