Skip to content

feat(signer): charge BYOC live payments at the per-capability price#3967

Closed
seanhanca wants to merge 3 commits into
feat/add-model-id-signer-kafkafrom
feat/byoc-per-cap-pricing
Closed

feat(signer): charge BYOC live payments at the per-capability price#3967
seanhanca wants to merge 3 commits into
feat/add-model-id-signer-kafkafrom
feat/byoc-per-cap-pricing

Conversation

@seanhanca

Copy link
Copy Markdown

Stacked on #3966 (review/merge that first)

This PR is stacked on #3966 (fix/byoc-usage-attribution, which adds RemotePaymentRequest.Capability/.ModelID for metering). Because #3966's head lives on a fork, this PR is opened against the shared base feat/add-model-id-signer-kafka, so the diff currently shows two commits: 023d25e9 (= #3966) and 597dbc62 (this change). Review only 597dbc62 here (or merge #3966 first and rebase — the first commit then collapses). Gateway side: livepeer/livepeer-python-gateway#33 (no further gateway change needed). Config side: livepeer/simple-infra per-cap USD tariff PR.

Summary

BYOC generations are charged a flat, model-independent fee today: the remote signer's GenerateLivePayment reads only oInfo.PriceInfo (the base price) and synthesizes lv2v pixels (1280*720*30 * billableSecs), so nano-banana, recraft-v4, ltx-*, etc. all cost the same — even though the orchestrator already advertises per-capability prices in oInfo.CapabilitiesPrices and already debits per-capability per-second on its side (byoc.JobPriceInfo * seconds). The advertised per-cap prices are dead on the charged path.

This PR makes the signer resolve the fee per capability and charge it on compute-seconds, aligning the gateway's paid amount with the orchestrator's accounted amount.

Root cause: priceInfo := oInfo.PriceInfo + the lv2v synthetic-pixel basis ignore oInfo.CapabilitiesPrices.

The per-cap price chain this completes (a capability registers a price → it is actually paid + metered):

  • adapter registers price_per_unit/currency per capability → orch stores it → advertises it in oInfo.CapabilitiesPrices as {Capability: Capability_BYOC, Constraint: <name>} (already works);
  • gateway sends the real capability on /generate-live-payment (Node Status Reporting #33);
  • (this PR) signer resolves that capability's price from oInfo.CapabilitiesPrices and charges capPrice * billableSecs, minting the ticket at that price;
  • computed_fee on the create_signed_ticket event becomes the per-cap value → OpenMeter bills correctly with no collector/pymthouse change.

Changes (file:line)

  • server/remote_signer.go
    • new pure resolveByocPrice(req, oInfo) — scans oInfo.CapabilitiesPrices for the Capability_BYOC entry whose Constraint == req.Capability; returns nil (→ base fallback) for no-match / empty capability / non-positive rate. Per-capability only (model_id is not used for price selection).
    • GenerateLivePayment: when ByocPerCapPricing is on and a cap price resolves, use it and write it back to oInfo.PriceInfo so it is the single source for state init, initialPrice, the max-price ceiling, the payment's ExpectedPrice (orch's fixed per-session price via ProcessPayment), and the doubling guard validatePrice (which reads sess.OrchestratorInfo.PriceInfo). Charge on seconds: pixels = ceil(billableSecs) with the resolved wei/sec rational (no calculateFee change).
  • core/livepeernode.go: ByocPerCapPricing bool.
  • cmd/livepeer/starter/{flags,starter}.go: -byocPerCapPricing CLI flag.

Flag default-OFF / zero-regression

-byocPerCapPricing defaults OFF. When OFF — or req.Capability == "" — or no usable cap price matches, the code is byte-identical to today (base price + lv2v synthetic pixels; oInfo untouched). Unconfigured/unknown capabilities always fall back to the base price. Ticket params (faceValue/winProb/recipientRandHash) come from oInfo.TicketParams unchanged; only the ticket count scales with the fee, so EV/ticket math stays valid. Writing the resolved price back to oInfo.PriceInfo keeps the doubling guard cap-vs-cap (prevents a false "price more than doubled" rejection when base and cap diverge).

Test plan

Hermetic, CGO-free tests in server/remote_signer_test.go:

  • TestResolveByocPrice: resolves per cap; falls back (nil) on unknown/empty/zero-rate; ignores non-BYOC entries with the same constraint.
  • TestGenerateLivePayment_ByocPerCapPricing: flag-OFF byte-identical base fee (zero-regression); flag-ON fee == capPrice * 60s; 2:1 tariff → fee(recraft-v4) == 2 * fee(nano-banana); unknown-cap fallback; base ≫ 2× cap returns 200 (doubling guard not tripped).
  • fix(signer): meter real BYOC capability + model_id for usage events #3966's TestResolveUsageLabels and TestGenerateLivePayment_LV2V_Succeeds still pass.

Verified locally in a Linux container (CGO ffmpeg via install_ffmpeg.sh): go build ./server/... + the four tests above → PASS (the server package needs CGO and does not build on macOS; relying on CI for the full suite).

E2E recipe (staging, owner-gated)

  1. Rebuild + deploy the signer image (feat-add-model-id-signer-kafka + fix(signer): meter real BYOC capability + model_id for usage events #3966 + this) with -byocPerCapPricing=false.
  2. Set USD per-cap tariffs (simple-infra config PR) and verify CapabilitiesPrices advertise the expected rates.
  3. Flip -byocPerCapPricing=true in staging; drive nano-banana vs recraft-v4; assert computed_fee(recraft-v4) ≈ 2 × computed_fee(nano-banana) and OpenMeter network_fee_usd_micros differ by pipeline+model.
  4. Regression: flag OFF → both charge today's flat fee.

Deployment note

The signer image (livepeer/go-livepeer:feat-add-model-id-signer-kafka) must be rebuilt to include this change, and the orchestrator must have a USD price feed for the USD→wei conversion, before the flag is enabled. Do not merge or enable the flag until #3966 lands and rollout is signed off.

Made with Cursor

seanhanca and others added 2 commits June 26, 2026 20:21
The remote signer hardcoded the create_signed_ticket `pipeline` to the
lv2v constant whenever the gateway sent `type:"lv2v"` (which BYOC jobs
always do for fee/pixel routing), and derived `model_id` only from the
LiveVideoToVideo capability constraints. For BYOC capabilities (e.g.
nano-banana) this emitted pipeline=live-video-to-video and an empty
model_id, which the OpenMeter collector recorded as
live-video-to-video / unknown.

Add two additive, backward-compatible fields to RemotePaymentRequest:
`capability` and `model_id`. When set by the gateway, they override the
metered pipeline + model_id labels, decoupling usage attribution from
`Type` (which still drives fee/pixel routing). When empty, behavior is
byte-identical to before (lv2v constant + capabilities-derived model id;
collector defaults empties to "unknown").

Label resolution is extracted into a pure resolveUsageLabels helper with
a hermetic table test (TestResolveUsageLabels) that runs without the CGO
ffmpeg toolchain.
The remote signer's GenerateLivePayment charged every BYOC generation a
flat, model-independent fee: it read only oInfo.PriceInfo (the base price)
and synthesized lv2v pixels (1280*720*30 * billableSecs), so nano-banana,
recraft-v4, ltx-*, etc. all cost the same regardless of the per-capability
prices the orchestrator already advertises in oInfo.CapabilitiesPrices.

Resolve the fee per capability instead, keyed on req.Capability (added for
metering by the stacked #3966): scan oInfo.CapabilitiesPrices for the
Capability_BYOC entry whose Constraint matches the capability and charge
that USD->wei/sec rate over compute-seconds (pixels = ceil(billableSecs)).
This aligns the gateway's paid amount with the orchestrator's existing
per-capability per-second accounting (byoc.JobPriceInfo * seconds).

The resolved price is written back to oInfo.PriceInfo so it is the single
source for state init, initialPrice, the max-price ceiling, the payment's
ExpectedPrice (which the orch uses to set its fixed per-session price), and
the price-doubling guard in validatePrice (which reads
sess.OrchestratorInfo.PriceInfo) -- keeping all of them cap-vs-cap
consistent and preventing a false "price more than doubled" rejection when
base and cap diverge.

Gated behind a new default-OFF flag (-byocPerCapPricing /
LivepeerNode.ByocPerCapPricing). When OFF, or when req.Capability is empty,
or when no usable cap price matches, behavior is byte-identical to the
base-price path (zero regression). resolveByocPrice is a pure, hermetically
testable function; ticket params (faceValue/winProb) are passed through
unchanged so EV/ticket math stays valid.

Tests: TestResolveByocPrice (resolution, fallback, non-BYOC/zero-rate
ignored) and TestGenerateLivePayment_ByocPerCapPricing (flag-off
zero-regression, per-cap seconds fee, 2:1 tariff ratio, unknown-cap
fallback, doubling-guard not tripped), both CGO-free.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 049895c2-eb6b-474e-b4ca-4ecfb3098349

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/byoc-per-cap-pricing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional (default-OFF) remote-signer path to charge BYOC live payments using the orchestrator-advertised per-capability price (keyed by request capability) and to bill on compute-seconds instead of LV2V synthetic pixels, aligning payment with orchestrator-side per-second accounting.

Changes:

  • Add per-capability BYOC price resolution and flag-gated use in GenerateLivePayment, with compute-second billing when enabled.
  • Plumb a ByocPerCapPricing boolean from CLI config into core.LivepeerNode.
  • Add hermetic unit tests for BYOC price resolution and end-to-end GenerateLivePayment behavior under the flag.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
server/remote_signer.go Resolve per-cap BYOC price from CapabilitiesPrices, optionally override PriceInfo, and bill on compute-seconds when enabled.
server/remote_signer_test.go Add tests for per-cap price resolution and flag ON/OFF fee behavior + ratios.
core/livepeernode.go Add ByocPerCapPricing toggle to node config.
cmd/livepeer/starter/starter.go Add config wiring for ByocPerCapPricing default + node assignment.
cmd/livepeer/starter/flags.go Add -byocPerCapPricing CLI flag.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/remote_signer.go
Comment thread server/remote_signer.go
Comment on lines 480 to +488
priceInfo := oInfo.PriceInfo
useByocPricing := false
if ls.LivepeerNode.ByocPerCapPricing && req.Capability != "" {
if capPrice := resolveByocPrice(&req, &oInfo); capPrice != nil {
priceInfo = capPrice
oInfo.PriceInfo = capPrice
useByocPricing = true
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the first part in 84c706a: BYOC pricing is now additionally gated on req.Type == RemoteType_LiveVideoToVideo, so it only applies to the lv2v job type BYOC jobs always use and can't change the billing basis for a non-lv2v request that happens to carry a capability.

On persisting the pricing mode in signed state: I left that out intentionally for now. The price is re-derived per request from oInfo.CapabilitiesPrices + req.Capability and written back to oInfo.PriceInfo, which is the single source for ExpectedPrice and the validatePrice doubling-guard, so a given request is internally consistent. -byocPerCapPricing is an operator/node-level flag (not per-session), so it can't flip mid-session; the only way to fall back to lv2v synthetic pixels within a session is if a later request omits Capability, which the gateway never does for BYOC. Threading the mode through the signed state blob is a heavier change (state is signed/validated) that I'd rather do as a dedicated follow-up if we want to defend against a misbehaving caller while the flag is on — happy to file that. Flagging for @eliteprox's call given it's default-OFF today.

Addresses Copilot review feedback on PR #3967:

- resolveByocPrice now skips a matched-but-invalid (non-positive rate) entry
  and continues scanning instead of returning nil, so a later valid duplicate
  entry for the same constraint (e.g. from a misconfiguration) is still
  honored. Added a regression test case. (Copilot, remote_signer.go:431)

- BYOC per-capability pricing is now additionally gated on
  Type==RemoteType_LiveVideoToVideo. Enabling it changes the billing basis
  (overrides req.InPixels and bypasses lv2v pixel synthesis), so it must only
  apply to the lv2v job type that BYOC jobs always use, never to a non-lv2v
  request that happens to carry a capability. (Copilot, remote_signer.go:488)
@seanhanca

Copy link
Copy Markdown
Author

Superseded by #3972, which combines this PR with the other stacked change into a single PR (BYOC per-capability pricing + real capability/model usage labels), carrying over all addressed Copilot/review fixes. Closing in favor of #3972. Branch left intact for reference.

@seanhanca seanhanca closed this Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go Pull requests that update Go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants