You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Sub-issue of #382 (part 2 of 3). Capture the remaining-credits signal the PRO API returns on each call and emit an advisory note in tool responses once the balance drops below a configurable threshold — uniformly across all tools that use the HTTP transport, in both the native MCP and REST access modes.
The web3/RPC transport used by read_contract is out of scope (tracked as part 3, #395).
Relationship to other issues
The metadata/data request paths were already unified in #397 (closes #396), so make_metadata_request, make_blockscout_request, and make_blockscout_post_request all run through the single _make_blockscout_http_request core. This means the credit capture below is implemented in one place and automatically covers all three.
This issue and part 1 (#393) both edit _make_blockscout_http_request — coordinate to avoid merge conflicts, but there is no hard ordering dependency between them.
Background (from PRO API research + live config)
The PRO API sets the x-credits-remaining response header on every metered response (and also on 402/429).
The unit is credits, the same unit as endpoint_pricing in /api/json/config. Live values confirm per-call costs range 20–120 credits (distinct: 20, 25, 30, 40, 50, 100, 120). The metadata endpoint is priced at 120.
The header value is a serialized Decimal and can be negative (paid plans served while overdrawn).
Proposed Changes
Config:
Add pro_api_low_credits_threshold: int = 5000 (env BLOCKSCOUT_PRO_API_LOW_CREDITS_THRESHOLD; 0 = disabled).
Rationale (document in the field comment / .env.example): 5000 credits ≈ 250 cheapest (20-credit) calls or ~41 most expensive (120-credit) calls.
Capture mechanism (tools/common.py):
Add a mutable "box" class CreditSink and a module-level ContextVar _credit_sink (default None).
In _make_blockscout_http_request, on success, defensively capture x-credits-remaining into the sink: getattr(response, "headers", {}) + try/except (TypeError, ValueError) around the numeric parse (so MagicMock/headerless mocks don't break). Store the minimum value observed within the invocation (conservative — warns earlier). Since all PRO API helpers (incl. make_metadata_request, after Unify the PRO API request path for the metadata and data helpers #397) delegate to this core, this single capture point covers GET, POST, and metadata.
Per-invocation isolation (tools/decorators.py):
At the start of log_tool_invocation's wrapper (before await func(...)), do _credit_sink.set(CreditSink()). Because the box is created before any child tasks are spawned, asyncio.gather and anyio task-group children (e.g. inside make_request_with_periodic_progress) get a copied context that still references the same box — so a write in a child task is visible to build_tool_response in the parent. A fresh box per invocation guarantees isolation between calls.
Emit the note (build_tool_response):
Read _credit_sink; when threshold > 0 and remaining < threshold, append an advisory note. Negative balances also trigger it (more urgent — overdrawn paid account still being served). Absent sink / disabled threshold → stay silent.
Principle (for SPEC.md)
The low-credits note fires for all keys when remaining < threshold (including negative/overdrawn paid accounts). It complements the part-1 402 hard-stop: the note is the early warning (and the primary signal for paid keys, which never 402); the 402 is the clean stop (only free/admin-managed keys reach it).
The note is advisory/best-effort, based on the minimum remaining observed within the invocation.
Tests (tests/tools/test_credit_tracking.py, new)
Capture of x-credits-remaining into CreditSink.
Cross-task visibility — the key test: a value written inside make_request_with_periodic_progress (anyio task group) / asyncio.gather is visible in build_tool_response.
Threshold note present (< 5000) / absent (>=, disabled threshold, or no sink).
Box isolation between decorator invocations.
Works in both MCP and REST modes.
tests/test_config.py: default 5000 and env override.
Description
Sub-issue of #382 (part 2 of 3). Capture the remaining-credits signal the PRO API returns on each call and emit an advisory note in tool responses once the balance drops below a configurable threshold — uniformly across all tools that use the HTTP transport, in both the native MCP and REST access modes.
The web3/RPC transport used by
read_contractis out of scope (tracked as part 3, #395).Relationship to other issues
The metadata/data request paths were already unified in #397 (closes #396), so
make_metadata_request,make_blockscout_request, andmake_blockscout_post_requestall run through the single_make_blockscout_http_requestcore. This means the credit capture below is implemented in one place and automatically covers all three.This issue and part 1 (#393) both edit
_make_blockscout_http_request— coordinate to avoid merge conflicts, but there is no hard ordering dependency between them.Background (from PRO API research + live config)
x-credits-remainingresponse header on every metered response (and also on402/429).endpoint_pricingin/api/json/config. Live values confirm per-call costs range 20–120 credits (distinct: 20, 25, 30, 40, 50, 100, 120). The metadata endpoint is priced at 120.Decimaland can be negative (paid plans served while overdrawn).Proposed Changes
Config:
pro_api_low_credits_threshold: int = 5000(envBLOCKSCOUT_PRO_API_LOW_CREDITS_THRESHOLD;0= disabled)..env.example): 5000 credits ≈ 250 cheapest (20-credit) calls or ~41 most expensive (120-credit) calls.Capture mechanism (
tools/common.py):CreditSinkand a module-levelContextVar _credit_sink(defaultNone)._make_blockscout_http_request, on success, defensively capturex-credits-remaininginto the sink:getattr(response, "headers", {})+try/except (TypeError, ValueError)around the numeric parse (soMagicMock/headerless mocks don't break). Store the minimum value observed within the invocation (conservative — warns earlier). Since all PRO API helpers (incl.make_metadata_request, after Unify the PRO API request path for the metadata and data helpers #397) delegate to this core, this single capture point covers GET, POST, and metadata.Per-invocation isolation (
tools/decorators.py):log_tool_invocation'swrapper(beforeawait func(...)), do_credit_sink.set(CreditSink()). Because the box is created before any child tasks are spawned,asyncio.gatherandanyiotask-group children (e.g. insidemake_request_with_periodic_progress) get a copied context that still references the same box — so a write in a child task is visible tobuild_tool_responsein the parent. A fresh box per invocation guarantees isolation between calls.Emit the note (
build_tool_response):_credit_sink; when threshold > 0 andremaining < threshold, append an advisory note. Negative balances also trigger it (more urgent — overdrawn paid account still being served). Absent sink / disabled threshold → stay silent.Principle (for SPEC.md)
remaining < threshold(including negative/overdrawn paid accounts). It complements the part-1402hard-stop: the note is the early warning (and the primary signal for paid keys, which never402); the402is the clean stop (only free/admin-managed keys reach it).Tests (
tests/tools/test_credit_tracking.py, new)x-credits-remainingintoCreditSink.make_request_with_periodic_progress(anyio task group) /asyncio.gatheris visible inbuild_tool_response.< 5000) / absent (>=, disabled threshold, or no sink).tests/test_config.py: default5000and env override.Documentation
SPEC.md: credit-remaining capture + low-credits note +BLOCKSCOUT_PRO_API_LOW_CREDITS_THRESHOLD; the principle above.README.md: PRO API Key section — mention the low-credits note and the threshold env var..env.example:BLOCKSCOUT_PRO_API_LOW_CREDITS_THRESHOLD=5000with the rationale comment.API.md: mention the low-credits note in thenotesfield description.AGENTS.md: newtests/tools/test_credit_tracking.py; note thatdecorators.pynow initializes a per-invocation credit sink.Out of scope
402/CreditsExhaustedErrorhandling (part 1, Handle Blockscout PRO API credit-exhaustion as a distinct error #393).read_contractweb3/RPC transport (part 3, Surface low PRO API credit balance for read_contract #395).