Skip to content

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.

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()).

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

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:

FetchResolvedPricing — scope scoring (ORDER BY … DESC LIMIT 1)
CASE tc.scope
WHEN 'user' THEN 40
WHEN 'project' THEN 30
WHEN 'workspace' THEN 20
WHEN 'platform' THEN 10
END

So 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.

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

the two cost functions
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_increment is ignored entirely, so estimate == actual == ceil(cost_base × base_multiplier). Album art and the agent tool are static (the agent is priced free at cost_base=0).
  • COST_MODE_DYNAMICcost_base (a floor / minimum hold) plus cost_increment per unit of usage. The reserve-time estimate uses the planned quantity n; the settle-time actual uses the worker-reported usage. Song generation is dynamic: cost_base=0, cost_increment per 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.

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:

  1. usage_field is empty (not declared) → 0estimate falls 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.

  2. usage_field is declared and present as a JSON number ≥ 1 → that value.

  3. usage_field is declared but missing / non-numeric / < 11 (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:

a DYNAMIC, quantity-aware tool (illustrative numbers)
from soundverse.tools import LicensePricing
from soundverse_proto.generation.tool.v1 import tool_pb2
usage_field = "versions" # reserve holds cost_increment × versions up front
pricing = 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,
)
)

Pricing feeds directly into the money path (covered end-to-end in The token ledger & the money path):

  1. Estimate at reserve. The pipeline calls FetchResolvedPricing, computes estimate with the planned usage, and reserves it. Reserve is an immediate deduct, not a hold — the balance drops the moment a task is queued.

  2. Settle at completion. After the worker reports usage via ctx.report_usage(units), actual is 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.

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:

  1. UpsertToolConfig — creates/updates the platform-scope tool_configs row and writes usage_field.

  2. UpsertToolLicensePricing — one call per declared LicensePricing (upsert on (config_id, license)).

  3. 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.