Skip to content

LCORE-3910: count the summarization calls in quota, token counts and metrics - #2797

Closed
max-svistunov wants to merge 2 commits into
lightspeed-core:mainfrom
max-svistunov:lcore-3910-summarization-token-accounting
Closed

max-svistunov wants to merge 2 commits into
lightspeed-core:mainfrom
max-svistunov:lcore-3910-summarization-token-accounting

Conversation

@max-svistunov

@max-svistunov max-svistunov commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Description

LCORE-3910. Compaction summarizes older turns with an LLM call of its own, and folds the summaries with another. The provider bills both. Their usage was discarded, so the calls consumed no quota, were missing from the input_tokens / output_tokens the client is told, and were missing from the token and call metrics. A user could go over the quota without it showing, by more the longer the conversation was. The user guide said the opposite.

Stacked on #2796 (LCORE-3908). This branch starts from the branch of #2796, because both change the same lines of the streaming and A2A call sites. Until #2796 is merged the diff here shows its commit too; the commit to review is the last one, LCORE-3910: count the summarization calls in quota, token counts and metrics. Merging #2796 first leaves only that commit here.

What was decided on the ticket.

Endpoint Quota Counts the client sees Metrics
/v1/query charged input_tokens / output_tokens include the summarization calls under /v1/query
/v1/streaming_query charged the same, in the end event under /v1/streaming_query
/v1/responses charged usage as the provider reported it, the answer alone; available_quotas shows the quota left after both under /v1/responses
/a2a none, the endpoint has no quota none under /a2a

No new response fields. /v1/responses keeps the usage object untouched to stay a drop-in for clients of the OpenAI Responses API.

How it works.

  • summarize_chunk() and recursively_resummarize() take a count_call callback and report each LLM call to it, with the model and the usage the provider reported. They do so as soon as the response arrived and before they look at it, so a call that returned no text is reported as well.
  • apply_compaction() takes the path of the endpoint and a charge callback. For each call it records ls_llm_token_sent_total, ls_llm_token_received_total and ls_llm_calls_total under that endpoint, calls charge, and adds the usage up. The sum is returned as CompactionResult.summarization_usage.
  • The endpoints with a quota pass consume_summarization_tokens(), bound to the user, as charge. A call is therefore charged when it returned, and not together with the turn. The summary is written before the model is asked for the answer and is kept whatever becomes of the turn. So the call is charged also when the turn is blocked by a shield, fails or is interrupted, and when compaction itself fails after the call (the marker cannot be written, the fold fails). The next request does not pay for the summary again.
  • /v1/query and /v1/streaming_query add the summarization usage to the usage of the turn in what they report to the client and set on the request span. TokenCounter gained __add__ for the sum; it returns a new counter.
  • ConversationSummary and the cache are not changed.

Known limits.

  • A call that fails, or is cancelled before its response arrived, has no usage to count, whatever the provider bills for it.
  • A call the provider reports no usage for is counted as a call and charges nothing.
  • A request that a shield blocked still runs compaction first, as before. With this change the summarization it triggers is charged to the user. Skipping compaction for a request that is already blocked would be a change of its own.

Seen while testing, not changed here (both are on main without this PR, see the "before" run below): /v1/streaming_query counts two LLM calls per request in ls_llm_calls_total, and non-streaming /v1/responses counts the tokens of the answer twice in the token metrics.

File Change
src/utils/compaction.py the two calls report their usage to count_call
src/utils/compaction_usage.py new: records each call in the metrics, charges it, adds the usage up
src/utils/conversation_compaction.py passes the counter to the two calls, returns summarization_usage
src/utils/query.py new: consume_summarization_tokens()
src/utils/token_counter.py TokenCounter.__add__
src/app/endpoints/query.py, streaming_query.py, responses.py, src/utils/agents/streaming.py pass the charge; add the calls to the reported usage
src/app/endpoints/a2a.py, src/constants.py the endpoint path for the metrics
tests/integration/endpoints/test_compaction_token_usage.py new: four endpoints against a real quota limiter
tests/unit/utils/test_summarization_usage.py, tests/unit/utils/test_query.py new unit tests
docs/user_doc/conversation_compaction.md, docs/design/..., docs/devel_doc/query_endpoint.md, docs/devel_doc/ARCHITECTURE.md documentation

Type of change

  • Refactor
  • New feature
  • Bug fix
  • CVE fix
  • Optimization
  • Documentation Update
  • Configuration Update
  • Bump-up service version
  • Bump-up dependent library [pyproject.toml + uv.lock]
  • Bump-up dependent library [requirements.*.txt for Konflux]
  • Bump-up library or tool used for development (does not change the final image)
  • CI configuration change
  • Konflux configuration change
  • Unit tests improvement
  • Integration tests improvement
  • End to end tests improvement
  • Benchmarks improvement

Tools used to create PR

Identify any AI code assistants used in this PR (for transparency and review context)

  • Assisted-by: Claude Opus 4.8
  • Generated by: Claude Opus 4.8

Related Tickets & Documents

  • Related Issue # LCORE-3910
  • Closes # LCORE-3910

Checklist before requesting a review

  • I have performed a self-review of my code.
  • PR has passed all pre-merge test jobs.
  • If it is a core feature, I have added thorough tests.

Testing

1. Manual verification

Start the service in library mode with compaction enabled (context_windows: openai/gpt-4o-mini: 2000, threshold_ratio: 0.1, token_floor: 100, buffer_turns: 1) and a user quota limiter on SQLite (initial_quota: 1000000). Send the five queries of the e2e compaction scenario through each endpoint. After every request read the quota from the limiter's database and compare it with what the client was told. Expected: on a request that summarizes, the quota goes down by the answer plus the summarization; /v1/query and /v1/streaming_query report both, /v1/responses reports the answer.

Before, on the branch this one starts from (requests 3 and 5 summarize; nothing but the answer is counted):

/v1/query
  request 2: context_status=full       reported in=   77 out=   1 | quota consumed=78 (reported+0)
  request 3: context_status=summarized reported in=  406 out=  10 | quota consumed=416 (reported+0)
  request 5: context_status=summarized reported in=  423 out=   8 | quota consumed=431 (reported+0)
  total: reported to the client=1389 quota consumed=1389 metrics sent+received=1389 llm calls=5
/v1/responses
  request 3: context_status=-          reported in=  409 out=  10 | quota consumed=419 (reported+0)
  request 5: context_status=-          reported in=  306 out=   8 | quota consumed=314 (reported+0)
  total: reported to the client=1278 quota consumed=1278 metrics sent+received=2556 llm calls=10

After, on this branch:

/v1/query
  request 1: context_status=full       reported in=   48 out=   1 | quota consumed=49 (reported+0) | client told {'UserQuotaLimiter': 999951} | limiter holds 999951
  request 2: context_status=full       reported in=   77 out=   1 | quota consumed=78 (reported+0) | client told {'UserQuotaLimiter': 999873} | limiter holds 999873
  request 3: context_status=summarized reported in=  546 out= 105 | quota consumed=651 (reported+0) | client told {'UserQuotaLimiter': 999222} | limiter holds 999222
  request 4: context_status=summarized reported in=  406 out=   1 | quota consumed=407 (reported+0) | client told {'UserQuotaLimiter': 998815} | limiter holds 998815
  request 5: context_status=summarized reported in=  791 out= 239 | quota consumed=1030 (reported+0) | client told {'UserQuotaLimiter': 997785} | limiter holds 997785
  total: reported to the client=2215 quota consumed=2215 metrics sent+received=2215 llm calls=7
/v1/streaming_query
  request 1: context_status=full       reported in=   48 out=   1 | quota consumed=49 (reported+0) | client told {'UserQuotaLimiter': 997736} | limiter holds 997736
  request 2: context_status=full       reported in=   77 out=   1 | quota consumed=78 (reported+0) | client told {'UserQuotaLimiter': 997658} | limiter holds 997658
  request 3: context_status=summarized reported in=  600 out= 159 | quota consumed=759 (reported+0) | client told {'UserQuotaLimiter': 996899} | limiter holds 996899
  request 4: context_status=summarized reported in=  460 out=   1 | quota consumed=461 (reported+0) | client told {'UserQuotaLimiter': 996438} | limiter holds 996438
  request 5: context_status=summarized reported in=  797 out= 191 | quota consumed=988 (reported+0) | client told {'UserQuotaLimiter': 995450} | limiter holds 995450
  total: reported to the client=2335 quota consumed=2335 metrics sent+received=2335 llm calls=12
/v1/responses
  request 1: context_status=-          reported in=   48 out=   1 | quota consumed=49 (reported+0) | client told {'UserQuotaLimiter': 995401} | limiter holds 995401
  request 2: context_status=-          reported in=   77 out=   1 | quota consumed=78 (reported+0) | client told {'UserQuotaLimiter': 995323} | limiter holds 995323
  request 3: context_status=-          reported in=  436 out=  10 | quota consumed=727 (reported+281) | client told {'UserQuotaLimiter': 994596} | limiter holds 994596
  request 4: context_status=-          reported in=  444 out=   1 | quota consumed=445 (reported+0) | client told {'UserQuotaLimiter': 994151} | limiter holds 994151
  request 5: context_status=-          reported in=  439 out=   8 | quota consumed=1036 (reported+589) | client told {'UserQuotaLimiter': 993115} | limiter holds 993115
  total: reported to the client=1465 quota consumed=2335 metrics sent+received=3800 llm calls=12
/v1/responses (stream)
  request 1: context_status=-          reported in=   48 out=   1 | quota consumed=49 (reported+0) | client told {'UserQuotaLimiter': 993066} | limiter holds 993066
  request 2: context_status=-          reported in=   77 out=   1 | quota consumed=78 (reported+0) | client told {'UserQuotaLimiter': 992988} | limiter holds 992988
  request 3: context_status=-          reported in=  422 out=  10 | quota consumed=699 (reported+267) | client told {'UserQuotaLimiter': 992289} | limiter holds 992289
  request 4: context_status=-          reported in=  430 out=   1 | quota consumed=431 (reported+0) | client told {'UserQuotaLimiter': 991858} | limiter holds 991858
  request 5: context_status=-          reported in=  407 out=   8 | quota consumed=986 (reported+571) | client told {'UserQuotaLimiter': 990872} | limiter holds 990872
  total: reported to the client=1405 quota consumed=2243 metrics sent+received=2243 llm calls=7
client was told what the limiter holds on every request: True

The service log of request 3 on /v1/query shows the two charges, the summarization first:

INFO: Summarizing 2 conversation items (2 messages) for model openai/gpt-4o-mini.
INFO: Consuming 148 input and 95 output tokens for subject 00000000-0000-0000-0000-000
INFO: Consuming 398 input and 10 output tokens for subject 00000000-0000-0000-0000-000

148 + 398 = 546 and 95 + 10 = 105, which is what the client was told. The two runs are separate model calls, so the numbers before and after are not comparable token for token. The token usage history holds 7722 input and 1406 output tokens at the end, 9128 together, and the limiter holds 1000000 - 9128 = 990872.

Two things in the output are on main as well and are not changed here: /v1/streaming_query counts two LLM calls per request (12 = 2 x 5 + 2), and /v1/responses without a stream counts the tokens of the answer twice (3800 = 2 x 1465 + 870).

2. Tests of the change

uv run pytest tests/integration/endpoints/test_compaction_token_usage.py tests/unit/utils/test_summarization_usage.py tests/unit/utils/test_query.py --no-cov -q

Expected: all pass. Without the endpoint changes (compaction layer kept) 14 of the 19 integration tests fail; the five that pass are the controls that nothing extra is charged.

3. Full suites

uv run pytest tests/unit --no-cov -q          # 3689 passed, 1 skipped
uv run pytest tests/integration --no-cov -q   # 389 passed, 1 failed

The one failure is tests/integration/container_lifecycle/test_container_lifecycle.py::TestContainerLifecycle::test_container_lifecycle, which needs a container runtime and fails on main the same way.

Linters: black, ruff, pylint (10.00/10), pyright (0 errors), pydocstyle pass. mypy on src reports the one error that is on main (src/models/config.py, CustomProfile).

Summary by CodeRabbit

  • New Features
    • Conversation compaction now includes summarization and folding token usage in quota charges and metrics where applicable. Failed or cancelled calls aren’t charged; completed summarization calls remain chargeable even if a later response is blocked, fails, or is interrupted.
  • Bug Fixes
    • Improved conversation history handling to prevent duplicate turn storage, including when a shield-blocked stream is interrupted. Compacted turns are stored once across supported endpoints.

OGX appends a turn to the conversation only when it is handed the
conversation parameter and runs the inference. In compacted mode that
parameter is dropped, so lightspeed-stack appends the turn itself. Every
endpoint made that write on its own, and nothing enforced it: two
unrelated cleanups removed the calls, and for six weeks conversations
stopped growing after a compaction without anything failing
(LCORE-3883).

The write now has one owner, PendingTurn in utils/pending_turn.py. An
endpoint creates it from the parameters the request is sent with and
the original input of the compaction result, and reports how the turn
ended: store_completed, store_blocked, store_interrupted or drop. The
owner decides whether the turn is ours, which input is stored, and it
stores a turn once: the first report settles the turn, every later one
does nothing.

It covers all nine places that stored a turn, not only the compacted
ones, because they are the same duty: a turn OGX does not store. That
is a conversation in compacted mode, a request a shield blocked, a
stream the client interrupted, a continuation from
previous_response_id. store_compacted_turn is removed; no endpoint
appends a turn except through the owner. The shield capabilities still
store the turn they rejected themselves, from inside the agent run, and
only when the model was handed the conversation, so never in compacted
mode.

A compacted request that loses its turn through a change in the code
fails:
- creating the owner for compacted parameters without the original
  input raises ValueError, which is how a caller that lost the
  hand-over looks;
- ensure_settled raises TurnNotStoredError when a compacted request
  reaches the end of its handler and nobody tried to store its turn or
  dropped it on purpose. /v1/query runs in a scope that makes the check
  when it is left; the streaming paths, /v1/responses and A2A make it
  after their write, in the function that calls the write, not in the
  one that performs it. The error is not a RuntimeError, because the
  endpoints report that one as an inference failure.
The check does not cover a stream the client stops reading, a
/v1/responses stream without a final response, and a failed write that
is logged. They store nothing, as before.

Every condition of the old call sites is kept: where the write
happens, what is stored for a failed or incomplete turn, what a failed
write does to the request (it fails /v1/query and /v1/responses, it is
logged on /v1/streaming_query, A2A and the interrupt path), and the
order relative to quota consumption. /v1/query still stores nothing
for a request blocked on a compacted conversation; that is now an
explicit drop, and LCORE-3788 settles what all endpoints should do.
The guard that lets only one of stream end, cancellation handler and
interrupt callback finish a turn is unchanged.

One behaviour changes, on purpose. A blocked request on a conversation
that is not compacted has its refusal turn stored before the stream
starts. An interrupt while the refusal was streamed stored the turn a
second time, with the interruption notice for an answer. It is stored
once now; the interrupt still records the turn in the database. The
path cannot be reached today, because run_shield_moderation always
passes.

Tests:
- integration, tests/integration/endpoints/test_turn_persistence.py:
  for /v1/query, /v1/streaming_query, /v1/responses (blocking and
  streaming) and A2A, every way a turn can end (completed, blocked,
  interrupted, failed, cut short by the model, abandoned by the client,
  continued from a previous response, store off), compacted and not;
  what a failed write does to the request on each endpoint; and what
  happens when the step that stores the turn is taken out of an
  endpoint. Each test runs the real handler and compares the
  conversation item by item afterwards, so a missing write, a second
  write and a write of the wrong input all fail it. 44 of the 50 tests
  pass on main unchanged. The six that fail there are the double write
  above and the five that take the write out of an endpoint, which
  main does not notice.
- unit: the owner, the hand-over from the compaction seam
  (omit_conversation and the original input are set together), and a
  scan of src that fails when a module other than the known writers
  mentions one of the functions that append to a conversation.
- existing unit tests follow the new parameter. Those that asserted on
  the arguments of a patched helper in the agent and interrupt paths
  now assert on what is stored. Mocked request parameters got the
  fields the owner reads. The A2A helpers of the compaction tests moved
  to _compaction_helpers.py, where both test modules import them.

The tests were checked by mutation: with the write removed from any
of the four endpoints, a check removed, the write doubled, the settled
state ignored, the interrupt guard ignored, the explicit rewrite stored
in place of the input, a turn OGX stores stored by us as well, or the
store flag ignored, tests fail.

Docs: the design document describes the owner and has a table of what
is stored per endpoint and per way a turn can end. The endpoint guide
names the exception for an interrupted stream.
…metrics

Compaction summarizes older turns with an LLM call of its own, and folds the
summaries with another. The provider bills both. Their usage was discarded:
summarize_chunk() and recursively_resummarize() read the text of the response
and nothing else. So the calls consumed no quota, were missing from the
input_tokens / output_tokens the client is told, and were missing from the
token and call metrics. A user could go over the quota without it showing, by
more the longer the conversation was. The user guide said the opposite.

What is decided (by the owner of the ticket):
- the summarization tokens are charged to the user's quota;
- they are part of the existing input_tokens / output_tokens of /v1/query and
  of the end event of /v1/streaming_query, no new fields;
- /v1/responses passes the usage object of the response through unchanged, to
  stay a drop-in for clients of the OpenAI Responses API; the quota is charged
  and available_quotas shows it;
- /a2a has no quota, so the calls show in the metrics only.

How it works:
- summarize_chunk() and recursively_resummarize() take a count_call callback
  and report each LLM call to it, with the model and the usage the provider
  reported. They do so as soon as the response arrived and before they look
  at it, so a call that returned no text is reported as well.
- apply_compaction() takes the path of the endpoint and a charge callback,
  and counts the calls with SummarizationCalls (new module
  utils/compaction_usage.py). For each call it records
  ls_llm_token_sent_total, ls_llm_token_received_total and
  ls_llm_calls_total under that endpoint, calls charge, and adds the usage
  up. The sum is returned as CompactionResult.summarization_usage. Without
  an endpoint path no metrics are recorded, without charge nobody is charged.
  The counter has a module of its own because conversation_compaction.py is
  close to the 1000 lines pylint allows once the open pull requests on it are
  merged.
- The endpoints with a quota pass consume_summarization_tokens(), bound to
  the user, as charge. A call is therefore charged when it returned, and not
  together with the turn. The summary is written before the model is asked
  for the answer and is kept whatever becomes of the turn, so the call is
  charged also when the turn is blocked by a shield, fails or is interrupted,
  and when compaction itself fails after the call (the marker cannot be
  written, the fold fails). The next request is served from the stored
  summary and does not pay again.
- /v1/query and /v1/streaming_query add the summarization usage to the usage
  of the turn in what they report to the client and set on the request span.
  The turn summary itself is left as it is. TokenCounter gained __add__ for
  the sum; it returns a new counter.
- /a2a passes its endpoint path (new constant ENDPOINT_PATH_A2A) and no
  charge.

Known limits:
- A call that fails, or is cancelled before its response arrived, has no
  usage to count, whatever the provider bills for it.
- A call the provider reports no usage for is counted as a call and charges
  nothing.

Tests:
- tests/integration/endpoints/test_compaction_token_usage.py runs the four
  endpoints against a real quota limiter on SQLite and the real summarization
  path, answered by the mocked OGX client. It reads what the limiter holds
  after the request, what the client is told, and the Prometheus registry. It
  covers a turn that summarizes, one that also folds (against a real SQLite
  conversation cache), a turn served from a stored summary, a short
  conversation, and a turn that is blocked, fails or is interrupted after the
  summarization. 14 of the 19 tests fail without the endpoint changes; the
  other five are the controls that nothing extra is charged.
- tests/unit/utils/test_summarization_usage.py covers what the two calls
  report, and what a request records, charges and is told, including a call
  that returned no text, a marker that cannot be written, a fold that fails
  and a fold that cannot be stored.
- tests/unit/utils/test_query.py covers consume_summarization_tokens().

Documentation: the user guide gets a section on token usage and quota and the
FAQ answers are made exact per endpoint; the design document describes who
pays for the calls; the statements about failed and interrupted requests in
ARCHITECTURE.md and query_endpoint.md now name the exception; the open
question in the guardrails design document is updated.
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: lightspeed-core/lightspeed-stack/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 9c05881c-aa27-4ae5-8428-7540b54a33c4

📥 Commits

Reviewing files that changed from the base of the PR and between f9811bd and 5411cdf.

📒 Files selected for processing (35)
  • docs/design/conversation-compaction/conversation-compaction.md
  • docs/design/prompt-guardrails/prompt-guardrails.md
  • docs/devel_doc/ARCHITECTURE.md
  • docs/devel_doc/query_endpoint.md
  • docs/user_doc/conversation_compaction.md
  • src/app/endpoints/a2a.py
  • src/app/endpoints/query.py
  • src/app/endpoints/responses.py
  • src/app/endpoints/streaming_query.py
  • src/constants.py
  • src/utils/agents/query.py
  • src/utils/agents/streaming.py
  • src/utils/compaction.py
  • src/utils/compaction_usage.py
  • src/utils/conversation_compaction.py
  • src/utils/pending_turn.py
  • src/utils/query.py
  • src/utils/stream_interrupts.py
  • src/utils/token_counter.py
  • tests/integration/endpoints/_compaction_helpers.py
  • tests/integration/endpoints/test_compaction_a2a.py
  • tests/integration/endpoints/test_compaction_token_usage.py
  • tests/integration/endpoints/test_turn_persistence.py
  • tests/unit/app/endpoints/test_a2a.py
  • tests/unit/app/endpoints/test_query.py
  • tests/unit/app/endpoints/test_query_otel.py
  • tests/unit/app/endpoints/test_responses.py
  • tests/unit/app/endpoints/test_streaming_query.py
  • tests/unit/utils/agents/test_query.py
  • tests/unit/utils/agents/test_streaming.py
  • tests/unit/utils/test_conversation_compaction.py
  • tests/unit/utils/test_pending_turn.py
  • tests/unit/utils/test_query.py
  • tests/unit/utils/test_stream_interrupts.py
  • tests/unit/utils/test_summarization_usage.py
💤 Files with no reviewable changes (1)
  • tests/unit/utils/test_conversation_compaction.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📜 Recent review details
⏰ Context from checks skipped due to timeout. (22)
  • GitHub Check: E2E: library / ci / other
  • GitHub Check: E2E: server / ci / default
  • GitHub Check: E2E: library / ci / skills
  • GitHub Check: E2E: library / ci / shields
  • GitHub Check: E2E: server / ci / skills
  • GitHub Check: E2E: server / ci / mcp
  • GitHub Check: E2E: library / ci / default
  • GitHub Check: E2E: library / ci / rbac
  • GitHub Check: E2E: server / ci / shields
  • GitHub Check: E2E: server / ci / rbac
  • GitHub Check: E2E: library / ci / authorized
  • GitHub Check: E2E: library / ci / mcp
  • GitHub Check: E2E: server / ci / other
  • GitHub Check: E2E: server / ci / authorized
  • GitHub Check: E2E: server / ci / tls
  • GitHub Check: Red Hat Konflux / lightspeed-stack-0-8-e2e-tests / lightspeed-stack-0-8
  • GitHub Check: Red Hat Konflux / rag-content-0-8-e2e-tests / lightspeed-stack-0-8
  • GitHub Check: build-pr
  • GitHub Check: integration_tests (3.12)
  • GitHub Check: Red Hat Konflux / lightspeed-core-0-8-enterprise-contract / lightspeed-stack-0-8
  • GitHub Check: integration_tests (3.13)
  • GitHub Check: Konflux kflux-prd-rh02 / lightspeed-stack-0-8-on-pull-request
🧰 Additional context used
📓 Path-based instructions (2)
Source excerpt: **Required**: Use pytest for all unit and integration tests Source excerpt: **Forbidden**: Do not use unittest - pytest is the standard for this project

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • tests/unit/app/endpoints/test_streaming_query.py
  • tests/unit/utils/test_query.py
  • tests/unit/app/endpoints/test_a2a.py
  • tests/unit/app/endpoints/test_query_otel.py
  • tests/unit/app/endpoints/test_responses.py
  • tests/unit/app/endpoints/test_query.py
  • tests/unit/utils/agents/test_query.py
  • tests/unit/utils/test_pending_turn.py
  • tests/unit/utils/test_stream_interrupts.py
  • tests/integration/endpoints/test_compaction_token_usage.py
  • tests/unit/utils/agents/test_streaming.py
  • tests/integration/endpoints/test_compaction_a2a.py
  • tests/integration/endpoints/test_turn_persistence.py
  • tests/unit/utils/test_summarization_usage.py
  • tests/integration/endpoints/_compaction_helpers.py
Source excerpt: Check `constants.py` for shared constants before defining new ones Source excerpt: Central `constants.py` for shared constants with descriptive comments

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • src/constants.py
🧠 Learnings (1)
📚 Learning: 2026-06-24T13:45:37.249Z
Learnt from: Jdubrick
Repo: lightspeed-core/lightspeed-stack PR: 1971
File: src/utils/markdown_repair.py:31-36
Timestamp: 2026-06-24T13:45:37.249Z
Learning: In the lightspeed-stack repository, docstrings must use the section header name "Parameters:" (not "Args:") for function arguments, even if the project references Google Python docstring conventions. Ensure docstrings follow the project’s established "Parameters:" header format for any documented function parameters.

Applied to files:

  • src/utils/query.py
  • src/utils/token_counter.py
  • src/utils/compaction_usage.py
  • src/app/endpoints/a2a.py
  • src/utils/compaction.py
  • src/app/endpoints/responses.py
  • src/utils/conversation_compaction.py
🪛 ast-grep (0.45.3)
tests/integration/endpoints/_compaction_helpers.py

[warning] 274-274: Do not make http calls without encryption
Context: "http://test"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)


[info] 305-305: use jsonify instead of json.dumps for JSON output
Context: json.dumps(body_dict)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🪛 LanguageTool
docs/user_doc/conversation_compaction.md

[locale-violation] ~170-~170: In American English, ‘afterward’ is the preferred variant. ‘Afterwards’ is more commonly used in British English and other dialects.
Context: ...e request is blocked by a shield, fails afterwards, or is interrupted by the client. The s...

(AFTERWARDS_US)

docs/design/conversation-compaction/conversation-compaction.md

[style] ~388-~388: ‘with success’ might be wordy. Consider a shorter alternative.
Context: ...| not stored | | | | run did not finish with success | OGX | not stored | | | `/v1/streaming...

(EN_WORDINESS_PREMIUM_WITH_SUCCESS)


[style] ~389-~389: ‘with success’ might be wordy. Consider a shorter alternative.
Context: ...leted, also when the run did not finish with success | OGX | stored when the stream ends | l...

(EN_WORDINESS_PREMIUM_WITH_SUCCESS)

🔇 Additional comments (34)
src/utils/compaction_usage.py (1)

40-63: Charge failure after metrics are recorded leaves no retry path; confirm ordering is intended.

count adds usage and records metrics before it calls charge. When charge raises HTTPException, the request fails, but the metrics already count the call. The summary marker is not written yet at that point, because summarize_chunk raises before _persist_new_summary_chunk runs. The next request therefore summarizes and charges again, while the metrics already hold the first call. This behavior is acceptable because the provider billed both calls. No change is required.

src/utils/compaction.py (1)

211-226: LGTM!

src/utils/conversation_compaction.py (1)

673-673: LGTM!

src/utils/query.py (1)

313-337: LGTM!

src/utils/token_counter.py (1)

26-40: LGTM!

tests/unit/utils/test_summarization_usage.py (1)

1-561: LGTM!

tests/unit/utils/test_query.py (1)

619-675: LGTM!

tests/integration/endpoints/test_compaction_token_usage.py (1)

1-853: LGTM!

docs/design/conversation-compaction/conversation-compaction.md (1)

452-495: LGTM!

docs/design/prompt-guardrails/prompt-guardrails.md (1)

445-446: LGTM!

docs/devel_doc/ARCHITECTURE.md (1)

277-279: LGTM!

docs/devel_doc/query_endpoint.md (1)

364-366: LGTM!

docs/user_doc/conversation_compaction.md (1)

147-177: LGTM!

src/app/endpoints/query.py (1)

321-323: LGTM!

src/app/endpoints/responses.py (1)

728-732: LGTM!

src/app/endpoints/streaming_query.py (1)

454-472: LGTM!

src/constants.py (1)

347-347: LGTM!

tests/integration/endpoints/_compaction_helpers.py (1)

268-353: LGTM!

tests/integration/endpoints/test_compaction_a2a.py (1)

22-30: LGTM!

src/utils/agents/streaming.py (1)

362-364: LGTM!

src/utils/pending_turn.py (1)

1-301: LGTM!

src/utils/agents/query.py (1)

48-48: LGTM!

Also applies to: 239-239, 251-254, 264-267, 284-290, 350-352

src/utils/stream_interrupts.py (1)

9-9: LGTM!

Also applies to: 20-20, 251-251, 268-279, 326-326, 345-345, 366-366

tests/unit/utils/test_pending_turn.py (1)

1-416: LGTM!

tests/unit/utils/agents/test_query.py (1)

39-39: LGTM!

Also applies to: 366-378, 391-403, 462-469, 482-533, 742-749, 774-774

tests/unit/utils/agents/test_streaming.py (1)

71-71: LGTM!

Also applies to: 507-519, 539-556, 575-583, 1680-1710, 1730-1730, 1760-1862, 1869-1900

tests/unit/utils/test_stream_interrupts.py (1)

4-57: LGTM!

Also applies to: 80-143, 164-164

src/app/endpoints/a2a.py (1)

36-36: LGTM!

Also applies to: 60-66, 75-75, 206-239, 251-257, 492-493, 585-586

tests/unit/app/endpoints/test_a2a.py (1)

793-796: LGTM!

Also applies to: 880-883, 961-964, 1292-1304, 1394-1406, 1510-1522, 1734-1746

tests/unit/app/endpoints/test_query.py (1)

138-141: LGTM!

Also applies to: 228-231, 321-324, 400-403, 485-488, 556-559, 634-637

tests/unit/app/endpoints/test_query_otel.py (1)

93-96: LGTM!

tests/unit/app/endpoints/test_responses.py (1)

61-62: LGTM!

Also applies to: 651-651, 664-667, 791-791, 977-977, 1552-1552, 2835-2843, 2863-2870, 2888-2896, 2907-2907

tests/unit/app/endpoints/test_streaming_query.py (1)

151-154: LGTM!

Also applies to: 242-245, 344-347, 444-447, 538-541, 645-648

tests/integration/endpoints/test_turn_persistence.py (1)

1-1534: LGTM!


Walkthrough

Compaction now reports and charges summarization and fold usage. Endpoint handlers add or preserve that usage according to endpoint behavior. PendingTurn manages conversation storage and prevents duplicate settlement across completed, blocked, and interrupted requests.

Changes

Compaction accounting

Layer / File(s) Summary
Collect compaction-call usage
src/utils/compaction.py, src/utils/compaction_usage.py, src/utils/conversation_compaction.py, src/utils/token_counter.py, src/utils/query.py, tests/unit/utils/test_summarization_usage.py, tests/unit/utils/test_query.py, tests/integration/endpoints/test_compaction_token_usage.py
Compaction records provider-reported usage after summarization and fold responses return. It aggregates usage, records configured metrics, and calls configured quota callbacks. Tests cover successful and failed processing paths.
Wire accounting into endpoints
src/app/endpoints/query.py, src/app/endpoints/streaming_query.py, src/app/endpoints/responses.py, src/app/endpoints/a2a.py, src/constants.py, tests/integration/endpoints/_compaction_helpers.py, tests/integration/endpoints/test_compaction_a2a.py, tests/integration/endpoints/test_compaction_token_usage.py, docs/design/conversation-compaction/conversation-compaction.md, docs/design/prompt-guardrails/prompt-guardrails.md, docs/devel_doc/ARCHITECTURE.md, docs/devel_doc/query_endpoint.md, docs/user_doc/conversation_compaction.md
Query and streaming-query include summarization usage in reported totals and metrics. Responses charges compaction calls but preserves provider-reported response usage. A2A records endpoint metrics without quota charging. Documentation describes these endpoint differences.

Turn persistence

Layer / File(s) Summary
Define turn ownership and settlement
src/utils/pending_turn.py, src/utils/agents/query.py, src/utils/agents/streaming.py, src/utils/stream_interrupts.py, src/utils/conversation_compaction.py, tests/unit/utils/test_pending_turn.py, tests/unit/utils/agents/test_query.py, tests/unit/utils/agents/test_streaming.py, tests/unit/utils/test_stream_interrupts.py, tests/unit/utils/test_conversation_compaction.py
PendingTurn selects the storage owner and provides completion, blocked, interrupted, and drop outcomes. Repeated settlement does not write again. Tests cover ownership, storage-disabled requests, failed writes, and unsettled turns.
Apply turn settlement across endpoints
src/app/endpoints/*, src/constants.py, tests/integration/endpoints/test_turn_persistence.py, tests/unit/app/endpoints/*, docs/design/conversation-compaction/conversation-compaction.md, docs/devel_doc/ARCHITECTURE.md, docs/devel_doc/query_endpoint.md
Endpoint handlers pass pending turns through completion, moderation, interruption, and continuation paths. Tests check stored conversation items and endpoint outcomes when writes fail or remain unsettled. Documentation describes turn-storage ownership and outcomes.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Endpoint
  participant apply_compaction
  participant summarize_chunk
  participant SummarizationCalls
  participant consume_summarization_tokens
  Endpoint->>apply_compaction: request compaction with endpoint and charge callback
  apply_compaction->>summarize_chunk: summarize or fold items
  summarize_chunk->>SummarizationCalls: report provider usage after response
  SummarizationCalls->>consume_summarization_tokens: charge when callback is configured
  SummarizationCalls-->>apply_compaction: accumulated usage
  apply_compaction-->>Endpoint: CompactionResult with summarization_usage
Loading

Suggested reviewers: tisnik, asimurka

Merge Risk: ⚪ Minimal · up to 5411c

Conversation-summarization calls are now counted in quotas, token totals, and metrics. Conversation turns are stored once per request across endpoints. No concrete defects were identified, so the change appears ready to merge.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 5411c

Summarization calls are now charged to the authenticated user, improving accountability. A request checks quota before those calls, however, and no second check is visible before it starts the answer after summarization has consumed the remaining balance. The practical limit depends on how the quota backend handles deductions.

Retained concerns

  • Medium · security · inferred: New per-call compaction charges can exhaust a caller’s balance after the request’s availability check, while the answer path has no visible second check. Whether deduction itself prevents further spend is unresolved.
Security review details

Security Blast Radius

  • inferred — The quota-ordering question is reachable through authenticated, conversation-bearing requests on three quota-enabled endpoints. Its direct exposure is the caller’s quota and potentially a configured shared quota; no new cross-tenant identity or privilege path was established.

Security Findings and Attack Paths

  • inferred — An authenticated caller can reach compaction after an initial quota check. If its newly charged summarization calls exhaust the balance, the inspected paths can still start answer generation; backend deduction and enforcement semantics determine whether this yields additional permitted spend. No billable, attacker-induced missing-usage or failed-provider-call path was established.

Trust Boundaries and Controls

  • observed — The query endpoint derives the charging identity from its authentication tuple, validates an existing conversation against that user or an authorized cross-conversation action, and passes the authenticated identity into compaction charging.

Resilience and Maintainability Implications

  • observed — Streaming interruption and completion share the pending-turn settlement gate. An append failure is logged on the interruption path, while the settled outcome prevents a later persistence path from repeating the uncertain write.

Hardening Proposals

  • proposed — Confirm whether quota deduction rejects exhausted balances and, if it does not, reassess availability after compaction or reserve capacity for the answer. Establish provider billing and usage semantics for failed and missing-usage calls before treating reported usage as complete cost coverage.
🚥 Pre-merge checks | ✅ 7
✅ Passed checks (7 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: including summarization calls in quota usage, token counts, and metrics.
Docstring Coverage ✅ Passed Docstring coverage is 95.67% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 300 functions across 29 files. (5 skipped: …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Performance And Algorithmic Complexity ✅ Passed No meaningful performance regression is introduced. The new accounting performs constant-time counter addition and at most one metrics call plus one quota call per bounded compaction LLM call. `Pendin…
Security And Secret Handling ✅ Passed PASS. The pull request adds no secrets or tokens, raw SQL, shell execution, path handling, or new API routes. Existing query, streaming, Responses, and A2A endpoints retain authentication and authoriz…
✨ Finishing Touches 💡 1
🧪 Generate unit tests (beta)
  • Create a new PR
✨ Simplify code
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

1 participant