Skip to content

fix(signer): meter real BYOC capability + model_id for usage events#3966

Closed
seanhanca wants to merge 3 commits into
livepeer:feat/add-model-id-signer-kafkafrom
seanhanca:fix/byoc-usage-attribution
Closed

fix(signer): meter real BYOC capability + model_id for usage events#3966
seanhanca wants to merge 3 commits into
livepeer:feat/add-model-id-signer-kafkafrom
seanhanca:fix/byoc-usage-attribution

Conversation

@seanhanca

Copy link
Copy Markdown

Problem

The remote signer's create_signed_ticket metering event recorded
pipeline=live-video-to-video / model_id=unknown for every BYOC
capability (e.g. nano-banana), so OpenMeter could not attribute BYOC usage to
the real pipeline + model.

Root cause (model attribution lost upstream at the gateway)

The gateway (livepeer-python-gatewaybyoc.py_create_byoc_payment)
hardcodes type:"lv2v" for every BYOC job (it needs lv2v fee/pixel routing —
BYOC has no per-job pixel count) and sent the real capability only as a bare
JSON field this signer dropped, with no model id. As a result
GenerateLivePayment here:

pipeline := ""
modelID := ""
if req.Type == RemoteType_LiveVideoToVideo {
    pipeline = PipelineLiveVideoToVideo
    modelID = streamParams.Capabilities.ModelIDForCapability(core.Capability_LiveVideoToVideo)
}

emitted pipeline=live-video-to-video (because Type=="lv2v") and an empty
model_id (because BYOC capabilities aren't Capability_LiveVideoToVideo).

The additive wire contract

Two optional string fields are added to RemotePaymentRequest
(POST /generate-live-payment):

field (json) meaning
capability the real BYOC capability (e.g. nano-banana)
model_id the specific provider model from the request payload/parameters

When set, they override the metered pipeline + model_id labels,
decoupling usage attribution from Type (which still drives fee/pixel routing).
When empty/absent, behavior is byte-identical to before: lv2v falls back to
the PipelineLiveVideoToVideo constant and the capabilities-derived model id;
non-lv2v stays empty (the collector defaults empties to "unknown"). The
gateway side that sends these fields is the cross-linked PR below.

Changes

  • Add Capability and ModelID (both omitempty) to RemotePaymentRequest
    (server/remote_signer.go).
  • Extract label resolution into a pure resolveUsageLabels(req, caps) helper
    and call it from GenerateLivePayment's emit block. Generic across
    capabilities (not only Capability_LiveVideoToVideo).

Zero-regression

  • Empty new fields ⇒ resolved pipeline/model_id are identical to the
    previous logic ⇒ byte-identical event for lv2v / non-BYOC callers.
  • The existing go-livepeer gateway caller (server/live_payment.go) does not
    set the new fields, so it is unaffected (omitempty).

Test plan

TestResolveUsageLabels (added to server/remote_signer_test.go) is a
hermetic table test asserting:

  • lv2v with no new fields → live-video-to-video + caps-derived/empty model
    (unchanged);
  • BYOC capability + model present → real pipeline + model_id (override);
  • capability present, empty model → caps-derived model fallback;
  • non-lv2v, no fields → both empty.

⚠️ Local build limitation (toolchain): the server (and transitively
core) packages require the CGO ffmpeg toolchain via
github.com/livepeer/lpms/ffmpeg, which does not compile against this
macOS host's system ffmpeg 8.x (avfilter_compare_sign_bypath undeclared) —
the same issue a prior go-livepeer PR hit. gofmt is clean and the referenced
core types/APIs were verified against source; the Go test is left to run in
CI. The label-resolution logic is isolated in a pure helper so it is fully
unit-testable once CI compiles the package.

End-to-end verification recipe

With this signer change and the gateway change deployed: drive a
nano-banana generation against the preview chain, then query OpenMeter
groupBy=pipeline_model — it should show the real pipeline + model id, not
live-video-to-video / unknown.

⚙️ Deployment note (which build must incorporate this for prod)

The signer DMZ + simple-infra signer are deployed from the Docker image
livepeer/go-livepeer:feat-add-model-id-signer-kafka, built from the
feat/add-model-id-signer-kafka branch (the pymthouse
docker/signer-dmz/Dockerfile* and scripts/build-local-signer.sh pin this
tag). This PR therefore targets that branch so the fix reaches production with
an image rebuild. For prod to be fixed, the
livepeer/go-livepeer:feat-add-model-id-signer-kafka image must be rebuilt
from this branch after merge and the signer redeployed.
(master carries an
older variant of the emit block without model_id; it can receive the
equivalent change separately, but it is not what is deployed.)

Cross-link

Gateway side (sends these fields):
livepeer/livepeer-python-gateway#33

Does not modify pymthouse or NaaP; the collector/read unknown fallbacks are
left in place as safety nets.

Made with Cursor

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.
@coderabbitai

coderabbitai Bot commented Jun 27, 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: f572b9dc-63e2-4a1a-a853-ff5d464f2965

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

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.

@seanhanca

Copy link
Copy Markdown
Author

Stacked follow-up: #3967 reuses the req.Capability added here to charge BYOC live payments at the per-capability price (default-OFF -byocPerCapPricing flag). Please review/merge this PR first; #3967 currently shows this commit plus its own on the shared feat/add-model-id-signer-kafka base.

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

This PR fixes usage metering attribution for remote-signer BYOC jobs by allowing the gateway to supply the true capability and model ID, instead of always emitting pipeline=live-video-to-video / model_id=unknown due to Type=="lv2v" fee-routing semantics.

Changes:

  • Added optional capability and model_id fields to RemotePaymentRequest to support BYOC attribution overrides.
  • Extracted metering label selection into a resolveUsageLabels(req, caps) helper and used it in the create_signed_ticket usage event emission.
  • Added a hermetic table-driven unit test for resolveUsageLabels.

Reviewed changes

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

File Description
server/remote_signer.go Adds request fields and factors out metering label resolution so BYOC can override pipeline/model_id while keeping lv2v fee routing intact.
server/remote_signer_test.go Adds table-driven unit coverage for the new label-resolution helper.

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

Comment thread server/remote_signer.go Outdated
Comment on lines +377 to +383
// pipeline + model. When those additive fields are empty, the labels fall back
// to the previous behavior: for lv2v the pipeline is the live-video-to-video
// constant and the model id is derived from the request capabilities; for any
// other request both remain empty (the collector then defaults them to
// "unknown"). This makes non-BYOC (lv2v) callers byte-identical to before and
// keeps usage attribution decoupled from `Type`, which still drives fee/pixel
// routing.

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.

Fixed in f7d2f83. Reworded the docstring: the req.Capability/req.ModelID overrides apply whenever set, regardless of Type (decoupled from fee/pixel routing), and clarified the empty-field fallback. Behavior is unchanged.

Comment thread server/remote_signer.go Outdated
Comment on lines +389 to +394
if req.Capability != "" {
pipeline = req.Capability
}
if req.ModelID != "" {
modelID = req.ModelID
}

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.

Good call — addressed in f7d2f83. Added sanitizeUsageLabel (TrimSpace + cap at 128 runes) applied to the gateway-supplied Capability/ModelID before they become the pipeline/model_id labels, bounding Kafka payload size and label cardinality. I went with a length cap rather than a strict allowlist because the whole point of BYOC is to support arbitrary new capabilities/models without a code change; 128 runes is generous for real identifiers while still bounding abuse. Added whitespace-only (ignored) and oversized (capped) test cases.

Comment thread server/remote_signer_test.go Outdated
Comment on lines +1718 to +1720
func TestResolveUsageLabels(t *testing.T) {
require := require.New(t)

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.

Fixed in f7d2f83. TestResolveUsageLabels now creates a fresh require.New(t) inside each t.Run closure so failures are attributed to the specific subtest.

Comment thread server/remote_signer.go
// `Type` (which stays "lv2v" for fee/pixel routing). Optional; empty
// preserves the previous behavior (pipeline falls back to the lv2v
// constant when Type=="lv2v").
Capability string `json:"capability,omitempty"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd want to try to avoid changing the format of RemotePaymentRequest if we can. There is a bytearray of capabilities already supported in the request.

This looks like a familiar byoc problem. It reminds me of what I resolved in an earlier PR #3869 that was never merged. The primary difference in how that PR handles capability and constraints in the new stream request is the older PR has a new /sign-byoc-job endpoint mirroring /generate-live-payment. The extra endpoint likely wasn't necessary, but the way that capability and constraint selection was done for BYOC jobs is correct. See also matching python-gateway changes livepeer/livepeer-python-gateway#6

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.

Thanks @eliteprox — good context on #3869 / the /sign-byoc-job approach and python-gateway#6.

A few reasons I went with the two additive fields here rather than reusing the existing Capabilities []byte blob:

  • That field is a protobuf-encoded net.Capabilities used for ticket capability/max-price selection (streamParams.Capabilities, GetCapabilitiesMaxPrice, ModelIDForCapability). It carries capability IDs + constraints, not the BYOC capability string the gateway routes on (e.g. nano-banana), and it doesn't carry the provider model_id at all — so it can't supply the two metering labels we need without also changing how that blob is produced/parsed.
  • Capability/ModelID are omitempty and purely additive: an older signer ignores them and an empty value is byte-identical to today. Type stays lv2v so fee/pixel routing is untouched — this PR only fixes the create_signed_ticket usage labels (previously live-video-to-video/unknown for every BYOC job).
  • I kept it to request fields rather than a new /sign-byoc-job endpoint to stay minimal and avoid a second signing surface, but I'm very happy to align with the feat(remote_signer): Add job token signing endpoint for byoc job types #3869 pattern if you'd prefer that as the long-term shape — especially if BYOC capability/constraint selection (not just metering) is going to move onto the signer. Let me know which direction you want and I'll follow up.

Comment thread server/remote_signer.go
pipeline = PipelineLiveVideoToVideo
modelID = streamParams.Capabilities.ModelIDForCapability(core.Capability_LiveVideoToVideo)
}
pipeline, modelID := resolveUsageLabels(&req, streamParams.Capabilities)

@eliteprox eliteprox Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I do like this change, I think supporting different specific, validated capabilities in the request is the right way to go.
https://github.com/seanhanca/go-livepeer/blob/692f79783ad3a825025feeb297b74cba36da50bc/core/capabilities.go#L134

The primary constraint is the way different pipelines are charged

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.

Agreed — and the per-pipeline charging constraint you flag is exactly what the stacked follow-up #3967 tackles: it resolves the BYOC fee from oInfo.CapabilitiesPrices keyed on req.Capability and bills compute-seconds at that per-capability rate (gated behind the default-OFF -byocPerCapPricing flag), instead of the flat lv2v-synthesized-pixel fee. This PR keeps charging identical and only fixes the metering labels; #3967 is where the pricing-per-capability lands.

Addresses Copilot review feedback on PR livepeer#3966:

- resolveUsageLabels now sanitizes the gateway-supplied capability/model_id
  override labels (TrimSpace + cap at 128 runes via sanitizeUsageLabel) before
  they are emitted to the create_signed_ticket usage event, bounding Kafka
  payload size and downstream label cardinality. req.Capability/req.ModelID
  come from the request body, so an unbounded value could otherwise inflate
  cardinality (pipeline was previously a small fixed set).

- Corrected the resolveUsageLabels docstring: the capability/model_id overrides
  apply regardless of Type, not only for lv2v; clarified the empty-field
  fallback wording.

- TestResolveUsageLabels now constructs a fresh require.New(t) inside each
  subtest so failures are attributed to the correct subtest, and adds cases for
  whitespace-only (ignored) and oversized (length-capped) override labels.
@seanhanca seanhanca requested a review from eliteprox June 30, 2026 22:57
@j0sh

j0sh commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this. Couple notes:

  1. A start-up flag to change the behavior of LV2V does not seem ideal. This means that clients need to know which "mode" the signer they are using is in, which is error-prone. A simpler method would be to use a separate "type == byoc" in the request (or thereabouts) rather than overloading the LV2V behavior based on the signer mode.

  2. I don't completely understand the reasoning for adding the new Capability / ModelID fields (what's the difference?) especially with the existing capability constraint / model name in the net.Capabilities struct. If the concern is that the orchestrator may be sending over multiple capabilities structs, then the client can (re-)construct or filter down to just the one it needs. Having a single capability can also be enforced on the signer if you'd like.

AFAIK the BYOC code is going to be phased out of the SDK and there is an intention to move the agent to the live runner, so I'm a little reluctant to add fields that will be almost immediately deprecated. It's hard to remove stuff from an API once it's added.

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

4 participants