Add pricing to a tool (avoid the NotFound trap)
Pricing is the step people forget. A tool registers fine, resolves fine, then every
call fails because the pricing lookup found no row. This page covers the whole model:
how pricing is declared, the license/mode/cost knobs, the usage_field that makes a
reserve quantity-aware, and why free must be explicit. The mechanism is live in the
fleet today. Deployed
If you’re building a tool from scratch, read Add a tool worker first — pricing is step 3 there. This page is the detail behind it.
Where pricing lives
Section titled “Where pricing lives”You declare pricing as class attributes on your tool — the same place you set model
and operation. On every boot, after RegisterTool, WorkerFleet upserts the declared
config into the registry via idempotent ON CONFLICT … DO UPDATE RPCs. Your code is the
source of truth: edit a value, restart the worker, it re-applies. There is no separate
seeding script.
The WorkerFleet._apply_tool_config sequence
(fleet.py)
is:
UpsertToolConfig— the config row (tool_type,config_scope, andusage_field).UpsertToolLicensePricing— one per license you accept.UpsertToolRateLimits— only if you declared aRateLimit.UpsertToolCredentials— only if a credentials field is non-empty.
The whole config step is best-effort: a failure is logged, not fatal, so one
misconfigured tool doesn’t strand the rest of the worker — but that tool then fails loudly
at call time. Pricing is keyed by the tool’s model + operation (the stable Soundverse
id used for both pricing and credential lookup), not by the class name.
The LicensePricing shape
Section titled “The LicensePricing shape”The dataclass is tiny — see
config.py:
@dataclass(frozen=True)class LicensePricing: license: tool_pb2.License.ValueType cost_mode: tool_pb2.CostMode.ValueType = tool_pb2.COST_MODE_STATIC cost_base: int = 0 # reserve-time hold / flat cost cost_increment: int = 0 # per-unit cost (DYNAMIC only) cost_base_multiplier: float = 1.0 cost_increment_multiplier: float = 1.0Declare pricing as a tuple with one entry per license the tool serves. Most tools
price all five identically with a comprehension:
from soundverse.tools import LicensePricingfrom soundverse_proto.generation.tool.v1 import tool_pb2
class MyTool(BaseTool[MyInput, MyOutput]): model = "my-model" operation = "generate" cost_unit = "seconds" # a label for reporting; not a billing input usage_field = "seconds" # see below — makes the reserve quantity-aware pricing = tuple( LicensePricing( license=lic, cost_mode=tool_pb2.COST_MODE_DYNAMIC, cost_base=10, # reserve floor cost_increment=1, # per reported unit ) for lic in ( tool_pb2.LICENSE_ROYALTY_FREE, tool_pb2.LICENSE_STANDARD, tool_pb2.LICENSE_DISTRIBUTION, tool_pb2.LICENSE_SYNC, tool_pb2.LICENSE_MASTER, ) )The five licenses
Section titled “The five licenses”Each call carries a requested license; the resolved pricing row is looked up per license, so a license you don’t declare fails as unpriced when someone requests it.
| Enum | Value | Meaning |
|---|---|---|
LICENSE_UNSPECIFIED |
0 | Never declare pricing for this — it is the zero value. |
LICENSE_ROYALTY_FREE |
1 | The default license for free/agent tools. |
LICENSE_STANDARD |
2 | Standard consumer license. |
LICENSE_DISTRIBUTION |
3 | Distribution rights. |
LICENSE_SYNC |
4 | Sync (film/ad) rights. |
LICENSE_MASTER |
5 | Master / full ownership — the paywalled tier. |
cost_mode: STATIC vs DYNAMIC
Section titled “cost_mode: STATIC vs DYNAMIC”The two knobs are cost_base and cost_increment, and cost_mode decides whether the
increment is used at all.
COST_MODE_STATIC(the dataclass default) — a flat charge ofcost_baseper call.cost_incrementis ignored. Reserve and settle are identical.COST_MODE_DYNAMIC—cost_baseis a floor, andcost_incrementis added per unit of reported usage. The worker callsctx.report_usage(units); the settlement chargescost_base + cost_increment × units.
The reserve (up-front hold) and settle (final charge) math is identical byte-for-byte
across the Go pricing path
(core-mcp/internal/pricing/pricing.go)
and the Python gateway path
(core-gateway-consumer/app/enforcement/pricing.py):
estimate(planned) = ceil( cost_base·cost_base_multiplier + [DYNAMIC and planned>0] cost_increment·cost_increment_multiplier·planned )
actual(usage) = ceil( cost_base·cost_base_multiplier + [DYNAMIC] cost_increment·cost_increment_multiplier·usage )The two multipliers exist because cost_increment is an int64: to express a fractional
per-unit rate (e.g. a per-second SFX price of 28.8 tokens), declare
cost_increment=288, cost_increment_multiplier=0.1. See
sfx_tool.py
for a worked example.
usage_field: make the reserve quantity-aware
Section titled “usage_field: make the reserve quantity-aware”This is the trap that lets users generate for free. Compare the two formulas above: the
settlement (actual) always adds the increment term in DYNAMIC mode, but the reserve
(estimate) only adds it when planned > 0. planned comes from reading the tool’s
declared usage_field out of the call payload.
So for a COST_MODE_DYNAMIC tool with cost_base=0 and no usage_field:
- reserve estimate =
ceil(0)= 0, Reserveshort-circuits onamount <= 0→ no balance check,- the worker generates unconditionally,
- settle charges
cost_increment × unitsafter the asset already shipped — which silently fails for a broke user.
Result: unlimited free generations. The fix is to declare usage_field = the name of
the input field that holds the planned count, so the reserve holds
cost_increment × planned up front:
class SongOrchestration(BaseTool[Any, SongGenOut]): operation = "generate_song" cost_unit = "songs" usage_field = "versions" # the input field that holds the planned count (1–2) pricing = tuple( LicensePricing( license=lic, cost_mode=tool_pb2.COST_MODE_DYNAMIC, cost_increment=_PER_SONG_CREDITS, # cost_base stays 0 ) for lic in ( ... ) # all five licenses )With usage_field="versions", a request for 2 versions holds 2 × _PER_SONG_CREDITS
before QueueTask, so a zero-balance user is rejected with INSUFFICIENT_FUNDS before
generation. If fewer versions come back, settlement reconciles down to the actual count.
When usage_field is empty, planned is 0 and you get the legacy base-only hold — which
is exactly right for a STATIC tool, and exactly wrong for a DYNAMIC one with a zero base.
Verified in
song_gen.py.
FREE must be explicit
Section titled “FREE must be explicit”“No pricing” is not free — it’s the NotFound trap. A free tool declares a real
LicensePricing with cost_base=0 (the dataclass default) in COST_MODE_STATIC (also the
default) for every license it serves:
pricing = tuple( LicensePricing(license=lic, cost_base=0) # STATIC, zero — free but present for lic in ( tool_pb2.LICENSE_ROYALTY_FREE, tool_pb2.LICENSE_STANDARD, tool_pb2.LICENSE_DISTRIBUTION, tool_pb2.LICENSE_SYNC, tool_pb2.LICENSE_MASTER, ))This is how album-art
(prompt_to_image.py)
and the Agent One worker
(agent_tool.py)
price today. Note that STATIC + cost_base=0 is safe (a flat zero, nothing to hold),
whereas DYNAMIC + cost_base=0 is the trap above.
Real shapes in the fleet
Section titled “Real shapes in the fleet”Flat zero across all licenses — the album-art / agent shape. Present, so no NotFound; free,
because both cost knobs are zero and STATIC ignores the increment.
pricing = tuple(LicensePricing(license=lic, cost_base=0) for lic in (...))Priced per reported unit, with a usage_field so the reserve is quantity-aware — the
song_gen shape.
usage_field = "versions"pricing = tuple( LicensePricing(license=lic, cost_mode=tool_pb2.COST_MODE_DYNAMIC, cost_increment=_PER_SONG_CREDITS) for lic in (...))A per-call floor (cost_base) plus a per-unit increment settled from ctx.report_usage —
the stitch shape
(stitch_tool.py).
The non-zero floor means it never hits the free-generation trap even without a usage_field.
cost_unit = "complexity"pricing = tuple( LicensePricing(license=lic, cost_mode=tool_pb2.COST_MODE_DYNAMIC, cost_base=_RESERVE_FLOOR_CREDITS, cost_increment=_CREDITS_PER_COMPLEXITY) for lic in (...))A fractional per-second rate expressed via the increment multiplier (int64 increment can’t hold a fraction) — the SFX shape.
usage_field = "duration_seconds"pricing = tuple( LicensePricing(license=lic, cost_mode=tool_pb2.COST_MODE_DYNAMIC, cost_increment=_SFX_COST_INCREMENT, cost_increment_multiplier=_SFX_COST_MULTIPLIER) for lic in (...))Checklist
Section titled “Checklist”-
Declare
pricingas a tuple with oneLicensePricingper license you accept. Serving a license you didn’t price fails that call as unpriced. -
Pick
cost_mode. Flat charge →COST_MODE_STATICwithcost_base. Per-unit →COST_MODE_DYNAMICwithcost_increment. -
If DYNAMIC, guard the reserve. Set
usage_fieldto the input field holding the planned count, or set a non-zerocost_basefloor. Never ship DYNAMIC +cost_base=0+ nousage_field. -
For a free tool, be explicit —
LicensePricing(license=lic, cost_base=0)(STATIC) per license. Not declaring pricing is theNotFoundtrap, not “free”. -
Restart the worker. The fleet re-upserts the declared config on boot; there is no separate seed step.
-
Deploy DYNAMIC changes in the safe order. Publish the proto, add the
generation.tool_configs.usage_fieldcolumn, roll out core-database, then soundverse-py, then the workers (re-registration writesusage_field), then core-mcp + the gateway (the reserve sites) last so an emptyusage_fieldfails to a safe base-only hold. Then flush theresolved_price:*Redis keys once —UpsertToolConfiginvalidates the tool-hierarchy cache, not the 60-second resolved-price cache.
Related
Section titled “Related”- Add a tool worker — pricing is step 3 of the full recipe
- Tool pricing model — the three-level scope cascade and how resolution works
- The token ledger & money path — estimate → reserve → settle
- Billing reconciliation — how surplus holds are settled back
- core-tool-sansaarm — the
song_genper-version pricing in context