Skip to content

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.

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:

  1. UpsertToolConfig — the config row (tool_type, config_scope, and usage_field).
  2. UpsertToolLicensePricingone per license you accept.
  3. UpsertToolRateLimits — only if you declared a RateLimit.
  4. 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 dataclass is tiny — see config.py:

soundverse/tools/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.0

Declare pricing as a tuple with one entry per license the tool serves. Most tools price all five identically with a comprehension:

app/tools/my_tool.py (class attributes)
from soundverse.tools import LicensePricing
from 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,
)
)

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.

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 of cost_base per call. cost_increment is ignored. Reserve and settle are identical.
  • COST_MODE_DYNAMICcost_base is a floor, and cost_increment is added per unit of reported usage. The worker calls ctx.report_usage(units); the settlement charges cost_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):

the reserve/settle formula
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,
  • Reserve short-circuits on amount <= 0no balance check,
  • the worker generates unconditionally,
  • settle charges cost_increment × units after 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:

song generation — DYNAMIC, priced per version
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.

“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:

a genuinely-free tool
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.

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 (...))
  1. Declare pricing as a tuple with one LicensePricing per license you accept. Serving a license you didn’t price fails that call as unpriced.

  2. Pick cost_mode. Flat charge → COST_MODE_STATIC with cost_base. Per-unit → COST_MODE_DYNAMIC with cost_increment.

  3. If DYNAMIC, guard the reserve. Set usage_field to the input field holding the planned count, or set a non-zero cost_base floor. Never ship DYNAMIC + cost_base=0 + no usage_field.

  4. For a free tool, be explicitLicensePricing(license=lic, cost_base=0) (STATIC) per license. Not declaring pricing is the NotFound trap, not “free”.

  5. Restart the worker. The fleet re-upserts the declared config on boot; there is no separate seed step.

  6. Deploy DYNAMIC changes in the safe order. Publish the proto, add the generation.tool_configs.usage_field column, roll out core-database, then soundverse-py, then the workers (re-registration writes usage_field), then core-mcp + the gateway (the reserve sites) last so an empty usage_field fails to a safe base-only hold. Then flush the resolved_price:* Redis keys once — UpsertToolConfig invalidates the tool-hierarchy cache, not the 60-second resolved-price cache.