Tool pricing model
A tool is a registry row; its price lives in separate rows scoped by config and by
license. That split is the single most important thing to understand here, because a tool
with no pricing row is the number-one cause of a “works on my machine, NotFound in
chat” failure. The worker itself never prices anything — it declares pricing as class
attributes and reports raw usage; the gateway (Python) and core-mcp
(Go) do the actual money math. This page explains the data shape, how a price is resolved,
and the static/dynamic/quantity-aware cost modes.
The recipe for setting pricing on a tool lives in Add pricing to a tool; this page is the “why” behind it.
The three-level cascade
Section titled “The three-level cascade”Pricing is a cascade of three tables in the generation schema, plus two sibling tables
for credentials and rate limits. The authoritative DDL is the proto’s
schema.sql
(see the data schema catalog); the RPCs that read and
write it live in
tool.proto.
flowchart TD T["generation.tools<br/>registry row: id · model · operation · cost_unit"] C["generation.tool_configs<br/>scope anchor: platform / workspace / project / user<br/>tool_type · enabled · usage_field"] L["generation.tool_config_licenses<br/>the money: license · cost_mode · cost_base · cost_increment"] R["generation.tool_config_rate_limits<br/>per hour / per day"] K["generation.tool_config_credentials<br/>secret_ref · endpoint_url · model_override"] T --> C C --> L C --> R C --> K classDef data fill:#8b5cf6,color:#fff,stroke:#6d28d9 class T,C,L,R,K data
| Level | Table | What it holds |
|---|---|---|
| 1 — registry | generation.tools |
One row per tool, keyed on (environment, model, operation). Carries the input/output JSON schemas and a descriptive cost_unit (e.g. requests, second). No money here. |
| 2 — config anchor | generation.tool_configs |
A config scoped at platform, workspace, project, or user (CHECK constraints force the right id column set/null per scope). Carries tool_type, enabled, and the usage_field. |
| 3 — the money | generation.tool_config_licenses |
One row per license. Carries cost_mode, cost_base, cost_increment, and the two multipliers. This is the row a missing-pricing tool lacks. |
The two siblings hang off the same level-2 config: tool_config_rate_limits
(limit_per_hour, limit_per_day; 0 = unlimited) and tool_config_credentials
(the provider secret_ref, resolved at call time via ctx.resolve_credentials()).
The five licenses
Section titled “The five licenses”Every priced call names a license, and pricing is resolved per license — the same tool
can cost differently for a royalty-free preview vs. a distribution-grade master. The enum
(from tool.proto)
is exactly five values:
LICENSE_ROYALTY_FREE · LICENSE_STANDARD · LICENSE_DISTRIBUTION · LICENSE_SYNC · LICENSE_MASTER
Scope resolution: most-specific wins
Section titled “Scope resolution: most-specific wins”FetchResolvedPricing resolves a price for a (tool_id, tool_type, license) triple by
joining tool_configs → tool_config_licenses, filtering to enabled configs whose scope
matches the caller’s workspace_id / project_id / user_id, and taking the most
specific one:
CASE tc.scope WHEN 'user' THEN 40 WHEN 'project' THEN 30 WHEN 'workspace' THEN 20 WHEN 'platform' THEN 10ENDSo a user-scoped override beats a project override beats a workspace override beats
the platform default. The platform-scope row is what a worker declares in code (see
below); ops can layer narrower overrides directly without touching the tool. tool_type
(default CONSUMER, or ENTERPRISE) is part of the key, so consumer and enterprise
surfaces can be priced independently.
Static vs dynamic cost
Section titled “Static vs dynamic cost”cost_mode decides how cost_base and cost_increment combine. The math is a byte-identical
port across the two billing engines —
core-mcp/internal/pricing/pricing.go
(Go, agent path) and
core-gateway-consumer/app/enforcement/pricing.py
(Python, consumer path):
estimate = ceil( cost_base·cost_base_multiplier + [DYNAMIC and n>0] cost_increment·cost_increment_multiplier·n )
actual = ceil( cost_base·cost_base_multiplier + [DYNAMIC] cost_increment·cost_increment_multiplier·usage )COST_MODE_STATIC— a flat charge.cost_incrementis ignored entirely, soestimate == actual == ceil(cost_base × base_multiplier). Album art and the agent tool are static (the agent is priced free atcost_base=0).COST_MODE_DYNAMIC—cost_base(a floor / minimum hold) pluscost_incrementper unit of usage. The reserve-timeestimateuses the planned quantityn; the settle-timeactualuses the worker-reported usage. Song generation is dynamic:cost_base=0,cost_incrementper song version.
A multiplier of 0 is treated as 1.0 (the nonZero guard) so an unset multiplier never
zeroes a price. cost_base/cost_increment are BIGINT token counts, not currency.
Quantity-aware usage (usage_field)
Section titled “Quantity-aware usage (usage_field)”A DYNAMIC tool whose real cost scales with a quantity must declare which input field
carries that quantity, via usage_field on generation.tool_configs. This is what makes
the reserve-time hold quantity-aware — and it fixed a real infinite-free-generation bug.
PlannedUsage(usage_field, payloadJSON) reads the number out of the call payload:
-
usage_fieldis empty (not declared) →0→estimatefalls back to a base-only hold. This is the backward-compatible default: static tools and dynamic tools whose per-call count isn’t known up front are unaffected. -
usage_fieldis declared and present as a JSON number≥ 1→ that value. -
usage_fieldis declared but missing / non-numeric /< 1→1(charge for at least one unit).
Song generation declares usage_field = "versions" (the input field, 1–3), so a request
for three versions reserves cost_increment × 3 up front and settle refunds the surplus if
fewer come back. See
song_gen.py:
from soundverse.tools import LicensePricingfrom soundverse_proto.generation.tool.v1 import tool_pb2
usage_field = "versions" # reserve holds cost_increment × versions up frontpricing = tuple( LicensePricing( license=lic, cost_mode=tool_pb2.COST_MODE_DYNAMIC, cost_increment=200, # illustrative: credits per song version # cost_base defaults to 0 → the floor is a single version's increment ) for lic in ( tool_pb2.LICENSE_ROYALTY_FREE, tool_pb2.LICENSE_STANDARD, tool_pb2.LICENSE_DISTRIBUTION, tool_pb2.LICENSE_SYNC, tool_pb2.LICENSE_MASTER, ))Estimate → reserve → settle
Section titled “Estimate → reserve → settle”Pricing feeds directly into the money path (covered end-to-end in The token ledger & the money path):
-
Estimate at reserve. The pipeline calls
FetchResolvedPricing, computesestimatewith the planned usage, and reserves it. Reserve is an immediate deduct, not a hold — the balance drops the moment a task is queued. -
Settle at completion. After the worker reports usage via
ctx.report_usage(units),actualis computed and reconciled against the reservation — surplus refunded, shortfall chased. The reconciler is the at-least-once backstop for tasks that never settled in-request.
The agent path bills a second time inside core-mcp for each sub-tool it calls — same pricing tables, same math — which is why the numbers above are shared Go/Python code. See The agent + MCP path.
How pricing gets into the DB
Section titled “How pricing gets into the DB”There is no out-of-band seeding step. A tool declares its platform-scope config as
class attributes; on every boot the WorkerFleet calls RegisterTool (level 1) and then
idempotently upserts the config and pricing (levels 2–3) from
fleet.py:
-
UpsertToolConfig— creates/updates theplatform-scopetool_configsrow and writesusage_field. -
UpsertToolLicensePricing— one call per declaredLicensePricing(upsert on(config_id, license)). -
UpsertToolRateLimits/UpsertToolCredentials— only if declared.
Every RPC is an idempotent ON CONFLICT … DO UPDATE, so editing a declared value and
restarting the worker re-applies it — the tool’s declaration is the source of truth for
its platform-scope config. A tool that declares nothing skips this entirely (legacy
registry-only behaviour). Config upserts are best-effort: a failure is logged, not fatal,
so an unpriced tool stays registered and simply fails loudly at call time.