Skip to content

feat(tools): meter and bill gateway-run tool calls - #504

Merged
njbrake merged 7 commits into
mainfrom
feat/meter-gateway-run-tools
Aug 5, 2026
Merged

feat(tools): meter and bill gateway-run tool calls#504
njbrake merged 7 commits into
mainfrom
feat/meter-gateway-run-tools

Conversation

@njbrake

@njbrake njbrake commented Aug 5, 2026

Copy link
Copy Markdown
Member

Description

Gateway-run tool calls were never billed. otari_web_search, otari_code_execution, and MCP tools ran with no pricing, no reservation, and no record, so the same search costs money through POST /v1/search and was free inside /v1/chat/completions: a key with a $1 budget could search without limit. docs/tools.md already claimed Otari handled billing for both paths.

Each call is now a billing meter on the request's own usage row, priced per call under otari:<tool> through the existing flat_request_cost convention, folded into cost, and reconciled against the budget. No migration: the counts ride billing_meters and pricing_breakdown, which already carry non-token charge lines. Tools get a price-per-call row on the Tools page, Activity marks the rows that ran them, and Usage gains a per-tool spend table.

Breaking change: with require_pricing on (the default), a configured tool with no price is refused with a 402 at admission, matching how an unpriced model behaves. A startup warning names the exact pricing call, so it surfaces before the first rejection. Worth a release note.

Also fixes the streaming tool loop's wire framing on all three formats. It forwarded the gateway's own tool calls to clients that can never receive their results, and let a second message_start arrive mid-response while reusing content-block indices, which SDK stream accumulators reject.

On top of #492: charges settle on the row that served, absorbed rows stay tally-free, and an exhausted plan still owes for the searches it ran.

PR Type

  • New Feature
  • Bug Fix
  • Refactor
  • Documentation
  • Infrastructure / CI

Relevant issues

None open. Related: #181 (OTel spans for gateway-run tools), which this does not change.

Checklist

  • I understand the code I am submitting.
  • I have added or updated tests that cover my change (tests/unit, tests/integration).
  • I ran the Definition of Done checks locally (make lint, make typecheck, make test).
  • Documentation was updated where necessary.
  • If the API contract changed, I regenerated the OpenAPI spec (uv run python scripts/generate_openapi.py).

Checks

make lint, make typecheck, make openapi-check, make postman-check, 1249 unit, 970 integration, and 437 dashboard tests, with the committed bundle rebuilt. CI is green on all of it.

The load-bearing test is tests/integration/test_tool_billing_settlement.py, which asserts users.spend rather than only the usage row. That is what catches billing a charge onto a row while refunding the reservation, since refund_reservation releases a hold without recording spend.

AI Usage

  • No AI was used.
  • AI was used for drafting/refactoring.
  • This is fully AI-generated.

AI Model/Tool used:

Claude Opus 5 via Claude Code.

Any additional AI details you'd like to share:

Written by Claude through back-and-forth with @njbrake, who made the design calls: bill the work rather than only surface it, fail closed on an unpriced tool instead of serving it free, ship the streaming framing fix here rather than separately, and emit the native item on Responses with inbound stripping rather than dropping it.

Reviewed by three independent agents (design, engineering, developer experience) with no knowledge of how it was built. That pass found six defects the implementation had missed, including refunding instead of reconciling on every failure path that ran tool calls, and a caller-named MCP tool being able to collide with the token meters that the billed-token SQL reads.

  • I am an AI Agent filling out this form (check box if true)

Summary

  • Added per-call metering and billing for gateway-run web search, code execution, and MCP tools.
  • Added pricing configuration, startup warnings, HTTP 402 enforcement, and budget reconciliation.
  • Added tool usage filters and spending breakdowns to Activity, Usage, APIs, and dashboard pages.
  • Fixed streaming behavior so internal tool calls stay hidden and visible content blocks remain valid.
  • Added routing, billing, streaming, and absorbed-row test coverage.
  • Updated documentation and generated dashboard assets.

njbrake and others added 2 commits August 5, 2026 12:45
Gateway-run tool calls (otari_web_search, otari_code_execution, MCP) were
executed with no pricing, no reservation, and no record: the identical search
cost money through POST /v1/search and was free inside /v1/chat/completions, so
a key with a $1 budget could search without limit.

Each call is now a billing meter on the request's own usage row, priced through
the existing flat_request_cost convention, folded into cost, and reconciled
against the budget. No migration: the counts ride billing_meters and
pricing_breakdown, which already carry non-token charge lines.

Also fixes the streaming tool loop's wire framing on all three formats. The
loop forwarded its own tool calls to the client (which can never receive their
results) and let a second message_start reach it mid-response, reusing content
block indices, which every SDK stream accumulator rejects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Integrating tool metering with routing policies surfaced one real gap. A request
whose plan is exhausted writes its error row through log_exhausted_plan, which
carried no tool tally, and the caller's single release site then refunded the
reservation. So a tool loop that had already run searches lost them twice over:
absent from the row, and absent from users.spend.

This is not a corner case. Once a tool loop produces its first assistant message
the plan locks to that provider, so a failure inside the loop cannot fail over
and this error row is the only row the request gets.

The exhausted-plan row now carries the tool ledger and records what it charged on
the request context, so the existing single release site reconciles that amount
instead of refunding. Absorbed rows stay deliberately tally-free: they settle no
reservation, and the per-tool breakdown counts requests rather than rows so a
failed-over request is one request, not two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 12:52 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@njbrake, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a44206a9-7ecb-42be-ac98-b4e47569e903

📥 Commits

Reviewing files that changed from the base of the PR and between 169da0b and 8ffd9e3.

📒 Files selected for processing (1)
  • tests/integration/test_usage_summary.py

Walkthrough

Changes

Gateway tool billing and analytics

Layer / File(s) Summary
Tool metering and settlement
src/gateway/services/*, src/gateway/api/routes/_pipeline.py, tests/integration/*
Gateway-run tools now record calls and failures, apply per-call pricing, enforce require_pricing, and reconcile charges across success, failure, streaming, and failover paths.
Streaming tool-loop visibility
src/gateway/services/mcp_loop*, src/gateway/services/_tool_loop.py, tests/unit/test_mcp_loop*
Streaming paths hide gateway-owned calls, preserve provider-owned calls, renumber visible events, and emit native Responses web-search events.
Usage filtering and aggregation
src/gateway/api/routes/usage.py, src/gateway/services/usage_admin_service.py, web/src/api/types.ts
Usage APIs support tool filters, tool dimensions, per-tool summaries, nested meters, and separate token and per-call pricing lines.
Dashboard tool pricing and analytics
web/src/pages/*, web/src/api/hooks.ts, web/src/pages/*test.tsx
Dashboard pages support tool filters, pricing editors, Activity details, usage summaries, and tool-specific drill-downs.
Documentation and generated assets
README.md, docs/*, scripts/seed_usage_smoke.py, src/gateway/static/dashboard/*
Documentation, smoke data, Postman requests, and generated dashboard assets describe and package gateway-tool billing and analytics.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: tbille

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the required feat: prefix, uses imperative wording, stays under 70 characters, and clearly describes the main change.
Description check ✅ Passed The description covers the change, rationale, issue context, checklist, tests, documentation, API impact, and AI usage as required by the template.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/meter-gateway-run-tools
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/meter-gateway-run-tools

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.

@coderabbitai
coderabbitai Bot requested a review from tbille August 5, 2026 12:53
make typecheck covers tests and scripts, not just src, and the new tests read
fields off union-typed SDK stream events (an Anthropic content_block, a Responses
output item) plus any_llm's re-exported tool-call class, which is a distinct type
from the openai one the message field is annotated with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 12:58 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (3)
src/gateway/services/pricing_init_service.py (1)

96-102: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Run the independent pricing checks concurrently.

The sandbox and web-search lookups do not depend on each other. Use asyncio.gather for these checks, then derive unpriced from the paired results.

As per coding guidelines, “Run independent asynchronous operations with asyncio.gather.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/pricing_init_service.py` around lines 96 - 102, Update
the pricing checks surrounding find_model_pricing in the initialization flow to
run independently configured-tool lookups concurrently with asyncio.gather,
preserving the existing provider and use_defaults=False arguments. Pair each
result with its corresponding tool, then derive unpriced from results whose
pricing lookup returned None.

Source: Coding guidelines

src/gateway/api/routes/_pipeline.py (1)

1770-1800: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Broaden the guard so _apply_tool_charges matches its "Never raises" contract.

The docstring states this helper never raises, and settlement depends on that. The try block catches only SQLAlchemyError. price_tool_calls also calls find_model_pricing, which can reach the default-pricing fallback and normalize_effective_at; a ValueError, TypeError, or KeyError from that path would escape log_usage and turn an accounting problem into a failed response. That is exactly the outcome the docstring rules out.

Catching Exception here keeps the counts on the row and keeps the promise. SQLAlchemyError stays covered because it is a subclass.

♻️ Proposed change
     try:
         tool_cost, lines, unpriced = await price_tool_calls(db, billable, as_of=usage_log.timestamp)
-    except SQLAlchemyError:
+    except Exception:  # noqa: BLE001 - settlement must not fail on a pricing problem
         logger.exception("Failed to price gateway tool calls; counts recorded without cost")
         commit_meters()
         return

If you prefer to keep the narrow catch, consider softening the docstring instead so a future reader does not rely on a guarantee the code does not give.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/_pipeline.py` around lines 1770 - 1800, Broaden the
exception handler around price_tool_calls in _apply_tool_charges from
SQLAlchemyError to Exception so pricing failures, including ValueError,
TypeError, and KeyError, cannot escape the helper. Preserve the existing
exception logging and commit_meters fallback so usage counts remain recorded and
the Never raises contract is maintained.
scripts/seed_usage_smoke.py (1)

87-87: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Use explicit billing sentinels instead of truthiness.

tool_cost is initialized to 0.0, and this expression uses both if not tool_cost and token_cost or 0.0. These checks conflate absent values with legitimate zero values. Initialize tool_cost as None when no priced tool exists and use is None.

As per coding guidelines, billing fields must distinguish absent values from legitimate zero values with is None.

Suggested billing sentinel change
-    tool_cost = 0.0
+    tool_cost: float | None = None

-            cost=token_cost if not tool_cost else round((token_cost or 0.0) + tool_cost, 6),
+            cost=(
+                token_cost
+                if tool_cost is None
+                else round((token_cost if token_cost is not None else 0.0) + tool_cost, 6)
+            ),

Also applies to: 132-136

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/seed_usage_smoke.py` at line 87, Update the billing logic around
tool_cost and token_cost to use None as the absent-value sentinel instead of 0.0
or truthiness checks. Initialize tool_cost to None when no priced tool exists,
replace not tool_cost checks with is None, and replace token_cost or 0.0 with an
explicit None check so legitimate zero costs remain unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/seed_usage_smoke.py`:
- Around line 57-64: After the conditional ModelPricing initialization, load the
effective persisted rate for the web_search model key and use that stored value
when calculating both tool_lines and tool_cost, instead of directly using
TOOL_UNIT_RATES["web_search"]. Ensure the lookup occurs after any pending
initialization is flushed or committed so existing database pricing is honored.

In `@src/gateway/api/routes/usage.py`:
- Around line 967-1002: The _tool_breakdown function must accept the active
status filter and pass it to _request_count_expr so absorbed rows count requests
consistently with calls, errors, and cost. Update its caller to provide the
status value, and add coverage for dimensions=tool with status=absorbed.

In `@src/gateway/services/pricing_service.py`:
- Around line 372-382: Batch the pricing lookups in the settlement flow before
iterating over billable_calls: fetch effective pricing for all eligible tool
keys with one query using an IN-style filter, then build a tool-to-pricing
lookup map. Update the existing loop to read from that map, preserving unpriced
handling and charge-line calculations for each tool.

In `@src/gateway/static/dashboard/assets/ActivityPage-CBy3JnT8.js`:
- Line 1: Update the ActivityPage query inputs around the memoized E object and
D=we(E,S,qt) so E includes the selected tool as tool: M || void 0. Add M to that
memo’s dependency array, then rebuild the generated ActivityPage bundle from the
source ActivityPage component.

In `@web/src/pages/ActivityPage.tsx`:
- Around line 1096-1107: Update the contextSummary request to include the “tool”
breakdown dimension instead of NO_BREAKDOWNS, and add toolFilter to
contextFilters so the context summary and timeline honor the selected tool.
Preserve the existing Tool selector rendering based on
contextSummary.data?.by_tool and toolFilter.

In `@web/src/pages/ToolsGuardrailsPage.tsx`:
- Around line 477-480: Update the ToolsGuardrailsPage pricing flow so a failed
usePricing() load is treated as unavailable data, not just a finished load.
Surface pricing.error in the page state/UI and change the ToolPriceRow disabled
condition to include the unavailable/errored pricing state, not only
pricing.isLoading. Use the existing pricing, currentRates, and ToolPriceRow
symbols to gate write-enabled inputs until valid pricing data is present.

In `@web/src/pages/UsagePage.test.tsx`:
- Line 56: Add colocated Vitest coverage in UsagePage.test.tsx for the by_tool
fixture: verify the “Gateway-run tools” card renders tool-call, failure, and
spend metrics, then assert selecting a tool navigates with the tool=web_search
query parameter.

---

Nitpick comments:
In `@scripts/seed_usage_smoke.py`:
- Line 87: Update the billing logic around tool_cost and token_cost to use None
as the absent-value sentinel instead of 0.0 or truthiness checks. Initialize
tool_cost to None when no priced tool exists, replace not tool_cost checks with
is None, and replace token_cost or 0.0 with an explicit None check so legitimate
zero costs remain unchanged.

In `@src/gateway/api/routes/_pipeline.py`:
- Around line 1770-1800: Broaden the exception handler around price_tool_calls
in _apply_tool_charges from SQLAlchemyError to Exception so pricing failures,
including ValueError, TypeError, and KeyError, cannot escape the helper.
Preserve the existing exception logging and commit_meters fallback so usage
counts remain recorded and the Never raises contract is maintained.

In `@src/gateway/services/pricing_init_service.py`:
- Around line 96-102: Update the pricing checks surrounding find_model_pricing
in the initialization flow to run independently configured-tool lookups
concurrently with asyncio.gather, preserving the existing provider and
use_defaults=False arguments. Pair each result with its corresponding tool, then
derive unpriced from results whose pricing lookup returned None.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 528384d6-607e-42ae-a2a2-499101f79b5d

📥 Commits

Reviewing files that changed from the base of the PR and between e4ed919 and f3ff98d.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (57)
  • README.md
  • docs/dashboard.md
  • docs/public/otari.postman_collection.json
  • docs/routing.md
  • docs/tools.md
  • scripts/seed_usage_smoke.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/_tools.py
  • src/gateway/api/routes/responses.py
  • src/gateway/api/routes/usage.py
  • src/gateway/main.py
  • src/gateway/services/_tool_loop.py
  • src/gateway/services/mcp_client.py
  • src/gateway/services/mcp_loop.py
  • src/gateway/services/mcp_loop_messages.py
  • src/gateway/services/mcp_loop_responses.py
  • src/gateway/services/pricing_init_service.py
  • src/gateway/services/pricing_service.py
  • src/gateway/services/sandbox_backend.py
  • src/gateway/services/tool_usage.py
  • src/gateway/services/usage_admin_service.py
  • src/gateway/services/web_search_backend.py
  • src/gateway/static/dashboard/assets/ActivityPage-CBy3JnT8.js
  • src/gateway/static/dashboard/assets/ActivityPage-DoWiqnsE.js
  • src/gateway/static/dashboard/assets/BudgetsPage-B5-6Ex0R.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-CFWFtJrb.js
  • src/gateway/static/dashboard/assets/DocsPage-BEmI5Wnd.js
  • src/gateway/static/dashboard/assets/KeysPage-CgYrLT-S.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-BfTfTYXk.js
  • src/gateway/static/dashboard/assets/ModelsPage-ASSDqxHK.js
  • src/gateway/static/dashboard/assets/ModelsPage-DMGjenfl.js
  • src/gateway/static/dashboard/assets/OverviewPage-CJfJuf3u.js
  • src/gateway/static/dashboard/assets/ProvidersPage-DU2bXkbD.js
  • src/gateway/static/dashboard/assets/RoutingPage-D7qcgK6F.js
  • src/gateway/static/dashboard/assets/SettingsPage-BLJb0Asq.js
  • src/gateway/static/dashboard/assets/TablePagination-DRAWJGML.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-BCliGg09.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-BgHcrK3G.js
  • src/gateway/static/dashboard/assets/UsagePage-B8hBZljQ.js
  • src/gateway/static/dashboard/assets/UsagePage-qe0sVX_i.js
  • src/gateway/static/dashboard/assets/UsersPage-KByto6ON.js
  • src/gateway/static/dashboard/assets/index-Bf4pieZ0.css
  • src/gateway/static/dashboard/assets/index-D3sjy7jc.js
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_hybrid_mode_chat.py
  • tests/integration/test_routing_policies.py
  • tests/integration/test_tool_billing_settlement.py
  • tests/unit/test_mcp_loop_messages.py
  • tests/unit/test_mcp_loop_responses.py
  • web/src/api/types.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/ModelsPage.tsx
  • web/src/pages/OverviewPage.test.tsx
  • web/src/pages/ToolsGuardrailsPage.tsx
  • web/src/pages/UsagePage.test.tsx
  • web/src/pages/UsagePage.tsx
💤 Files with no reviewable changes (4)
  • src/gateway/static/dashboard/assets/UsagePage-B8hBZljQ.js
  • src/gateway/static/dashboard/assets/ActivityPage-DoWiqnsE.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-BgHcrK3G.js
  • src/gateway/static/dashboard/assets/ModelsPage-DMGjenfl.js

Comment thread scripts/seed_usage_smoke.py
Comment thread src/gateway/api/routes/usage.py
Comment thread src/gateway/services/pricing_service.py Outdated
Comment thread src/gateway/static/dashboard/assets/ActivityPage-CBy3JnT8.js Outdated
Comment thread web/src/pages/ActivityPage.tsx
Comment thread web/src/pages/ToolsGuardrailsPage.tsx
Comment thread web/src/pages/UsagePage.test.tsx
…pricing

Streaming, all three formats. A mixed batch (the caller's tools plus a gateway
tool in one message) filtered the gateway's fragments out but left the survivors
on their upstream indices, so a client received tool_calls[0].index == 1 and the
official OpenAI accumulator raised IndexError where main accumulated it fine. The
survivors are now renumbered into a gapless sequence, matching what the Messages
and Responses strategies already did for content blocks. The deferred terminal
chunk kept the unrewritten event, which leaked the hidden call back for any
provider that packs tool_calls and finish_reason together; it now defers the
rewritten one, and the Responses terminal event no longer carries the owned
function_call in response.output. Those calls were also hidden without being run,
so the model's search silently vanished: a mixed batch now executes them for their
side effects, which is the contract the non-streaming loop already applied.

Tool pricing. A priced tool appeared in /v1/models as a selectable model quoting a
per-request rate as a per-million-token price, and calling it 502'd; the reserved
otari: provider is now filtered out of the catalogue, as it already was in the
dashboard. Pricing only a tool no longer silences the "no model pricing
configured" startup alarm. The tool gate now skips budget-exempt keys and writes a
rejection row, matching the model gate it claims to mirror. Client disconnect
bills the tool work already done rather than refunding it. Pricing lookups run as
one query instead of one per tool.

Docs claimed Otari meters Anthropic's native web search; nothing reads the
provider's search count, so the claim is removed rather than the feature implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 13:42 — with GitHub Actions Inactive
The Activity page's Tool filter was inert. `usageParams` enumerates every filter
by hand and `tool` was never added, so the list, the row count, and the timeline
all went out unfiltered while the chip and the URL said otherwise: the table
showed rows that did not match, most of them with no tool usage at all.

Found by clicking it against a populated gateway. The predicate had been verified
in isolation against both dialects, which is why nothing caught that the request
never carried it, and the existing tests asserted the chip and the URL state
rather than the outgoing query.

The regression test asserts the query string of every request the page makes. The
model typeahead now scopes by tool as well, like it already did for endpoint,
provider, and session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 14:09 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
src/gateway/api/routes/usage.py (1)

990-1006: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Batch the tool aggregates into one query.

Lines 990-1006 execute one SELECT per entry in GATEWAY_TOOL_NAMES. A dimensions=tool summary therefore adds one database round trip per tool. Build conditional aggregates for all known tools in one statement, then construct UsageToolRow values from that result.

As per coding guidelines, “Avoid N+1 queries in async SQLAlchemy gateway code: do not execute queries or deletes inside row loops; batch with IN, bulk operations, or eager loading, and verify nested endpoints do not fan out.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/usage.py` around lines 990 - 1006, The per-tool
summary loop in the usage route is doing an N+1 query pattern by running one
SELECT for each entry in GATEWAY_TOOL_NAMES. Update the aggregation flow around
the existing _tool_calls_expr, _tool_cost_expr, and UsageLog.billing_meters
usage to fetch all tool metrics in a single statement with conditional
aggregates, then map that one result into the per-tool UsageToolRow values. Keep
the existing request-count and cost semantics intact while removing the per-tool
db.execute inside the loop.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/gateway/services/mcp_loop_messages.py`:
- Line 181: Update the exception comment in mcp_loop_messages.py at the except
Exception as exc branch to remove the em dash from the prose, and rephrase it
using standard punctuation while keeping the same meaning; only adjust the
inline comment text near the non-stream loop handling and leave the exception
logic unchanged.
- Around line 181-183: Update the exception handling around the streaming tool
execution to log only spec["name"] and an opaque exception class, removing the
raw exc value from logger.warning. Keep the user-facing text assignment generic
so text does not expose raw exception details.

In `@src/gateway/services/mcp_loop.py`:
- Around line 345-354: Update the foreign-tool-call filtering branch around
_with_tool_calls so SDK copy failure cannot forward the original unfiltered
event. Use the supported reconstructed chunk path when rebuilding the visible
event, and ensure removal failure hides or safely replaces the chunk rather than
allowing gateway-owned fragments to reach finalize_exit.

In `@web/shot-tmp.mjs`:
- Around line 4-23: Wrap the browser workflow after chromium.launch in a
try/finally block and move browser.close into finally, covering navigation,
authentication, page inspection, and screenshot capture failures. Keep the
existing workflow unchanged within the try block and ensure cleanup runs for
every exit path.
- Around line 2-3: Update the top-level OUT and BASE constants in shot-tmp.mjs
so they are read from environment variables instead of hardcoded workspace and
host values, and fall back to the documented local defaults when unset. Ensure
the script creates OUT before any screenshot write path is used, and keep the
change scoped to the existing OUT/BASE setup so the rest of the script continues
to use those symbols unchanged.
- Around line 9-21: Update the smoke-check flow in shot-tmp.mjs so it actually
validates the Activity page instead of only logging diagnostics: capture and
verify the response from the POST to /v1/auth/session, avoid setting the
localStorage session flag unless authentication succeeds, and replace the fixed
timeout plus console-only checks with an assertion that waits for a stable UI
signal on the Activity page. Use the existing page.evaluate, page.goto,
page.reload, and locator-based checks to confirm the expected table headers and
Gateway tool pills are present, and throw when those indicators are missing.

---

Nitpick comments:
In `@src/gateway/api/routes/usage.py`:
- Around line 990-1006: The per-tool summary loop in the usage route is doing an
N+1 query pattern by running one SELECT for each entry in GATEWAY_TOOL_NAMES.
Update the aggregation flow around the existing _tool_calls_expr,
_tool_cost_expr, and UsageLog.billing_meters usage to fetch all tool metrics in
a single statement with conditional aggregates, then map that one result into
the per-tool UsageToolRow values. Keep the existing request-count and cost
semantics intact while removing the per-tool db.execute inside the loop.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d96a688-be4a-4631-bc32-97d68964505f

📥 Commits

Reviewing files that changed from the base of the PR and between 73a75f5 and 91caac8.

📒 Files selected for processing (37)
  • docs/tools.md
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/models.py
  • src/gateway/api/routes/usage.py
  • src/gateway/services/_tool_loop.py
  • src/gateway/services/mcp_loop.py
  • src/gateway/services/mcp_loop_messages.py
  • src/gateway/services/mcp_loop_responses.py
  • src/gateway/services/pricing_init_service.py
  • src/gateway/services/pricing_service.py
  • src/gateway/services/tool_usage.py
  • src/gateway/static/dashboard/assets/ActivityPage-BNPDINq4.js
  • src/gateway/static/dashboard/assets/BudgetsPage-U9mSOH8I.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-BaM1X73w.js
  • src/gateway/static/dashboard/assets/DocsPage-Cmcl6wuO.js
  • src/gateway/static/dashboard/assets/KeysPage-Bydt6eMb.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-Dm9RmnpY.js
  • src/gateway/static/dashboard/assets/ModelsPage-ClVppCTp.js
  • src/gateway/static/dashboard/assets/OverviewPage-CuazTUm1.js
  • src/gateway/static/dashboard/assets/ProvidersPage-BVPOO2TZ.js
  • src/gateway/static/dashboard/assets/RoutingPage-9w3Zx5mI.js
  • src/gateway/static/dashboard/assets/SettingsPage-CsCLYy4Z.js
  • src/gateway/static/dashboard/assets/TablePagination-BLyGHwYY.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-DPjR2v0v.js
  • src/gateway/static/dashboard/assets/UsagePage-vanhi_2x.js
  • src/gateway/static/dashboard/assets/UsersPage-UyJ_ZHMT.js
  • src/gateway/static/dashboard/assets/index-DooGBETD.js
  • src/gateway/static/dashboard/index.html
  • tests/unit/test_mcp_loop.py
  • tests/unit/test_mcp_loop_messages.py
  • tests/unit/test_mcp_loop_responses.py
  • web/shot-tmp.mjs
  • web/src/api/hooks.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/ToolsGuardrailsPage.tsx
  • web/src/pages/UsagePage.test.tsx
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/gateway/services/pricing_init_service.py
  • src/gateway/services/mcp_loop_responses.py
  • src/gateway/static/dashboard/index.html
  • src/gateway/services/tool_usage.py
  • src/gateway/services/_tool_loop.py
  • web/src/pages/ToolsGuardrailsPage.tsx
  • src/gateway/services/pricing_service.py
  • docs/tools.md
  • web/src/pages/ActivityPage.tsx
  • src/gateway/api/routes/_pipeline.py

Comment thread src/gateway/services/mcp_loop_messages.py Outdated
Comment thread src/gateway/services/mcp_loop_messages.py Outdated
Comment thread src/gateway/services/mcp_loop.py Outdated
Comment thread web/shot-tmp.mjs Outdated
Comment thread web/shot-tmp.mjs Outdated
Comment thread web/shot-tmp.mjs Outdated
Streaming chat: _with_tool_calls fell back to returning the upstream chunk when
the SDK copy failed, which since the hiding landed means leaking a gateway tool
call the client cannot answer and that the loop is about to execute itself. It now
signals failure, the chunk is dropped, and a terminal whose rewrite failed is sent
with no tool_calls rather than with the gateway's.

Seeder: read the persisted otari:<tool> rate back instead of trusting its own
constant, so an instance already priced through POST /v1/pricing does not get
seeded rows whose unit_rate and cost contradict the pricing table. That mismatch
was real, not hypothetical; the two agreed only by coincidence.

Adds the dimensions=tool&status=absorbed coverage that was asked for. It passes
because absorbed rows carry no tool meters by construction, so the aggregate
matches nothing and the entry is dropped; the test pins that invariant rather than
asserting the request count.

Also drops a screenshot script that should never have been committed, and the em
dashes from two comments this change moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 14:25 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/integration/test_usage_summary.py`:
- Around line 970-989: Update test_tool_breakdown_is_empty_for_absorbed_rows to
create an absorbed usage row with the existing _make_log helper before issuing
the summary request, passing status="absorbed" and appropriate billing_meters.
Keep the request and assert that the resulting by_tool list is empty, ensuring
the test validates an actual absorbed row rather than an empty dataset.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ff40531-4f80-44b7-b646-7a29070cbb24

📥 Commits

Reviewing files that changed from the base of the PR and between 91caac8 and 169da0b.

📒 Files selected for processing (5)
  • scripts/seed_usage_smoke.py
  • src/gateway/services/mcp_loop.py
  • src/gateway/services/mcp_loop_messages.py
  • src/gateway/services/mcp_loop_responses.py
  • tests/integration/test_usage_summary.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/gateway/services/mcp_loop.py
  • src/gateway/services/mcp_loop_responses.py
  • scripts/seed_usage_smoke.py
  • src/gateway/services/mcp_loop_messages.py

Comment thread tests/integration/test_usage_summary.py Outdated
The test asserted an empty by_tool against an empty table, so it passed
vacuously. It now inserts the production shape (an absorbed attempt plus the row
that served, with only the latter carrying the tool ledger) and asserts the work
is reported once, off the row that served.

The docstring no longer claims to cover _request_count_expr in that aggregate.
Reverting that expression leaves the test green, because the query already
restricts to rows carrying tool meters and an absorbed row never has them, so it
is excluded before the count is taken. The invariant that defends is asserted
where it is observable, in the routing failover test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 14:37 — with GitHub Actions Inactive
@njbrake
njbrake merged commit d71a9ce into main Aug 5, 2026
11 checks passed
@njbrake
njbrake deleted the feat/meter-gateway-run-tools branch August 5, 2026 14:44
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