Skip to content

Surface low PRO API credit balance via advisory note #394

@akolotov

Description

@akolotov

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_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_requestcoordinate 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.

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=5000 with the rationale comment.
  • API.md: mention the low-credits note in the notes field description.
  • AGENTS.md: new tests/tools/test_credit_tracking.py; note that decorators.py now initializes a per-invocation credit sink.

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions