feat(music): pass-through pricing, charged on the audio fal produced
Rows 7 of the chat#1999 matrix. This is a price cut, so no customer is worse off and no migration is needed.
Rate: fal's own
CREDITS_PER_SECOND 0.5 → 0.2, and MIN_CREDIT_COST deleted. fal charges $0.002/s and a credit is $0.01, so 0.2 credits/s is fal's rate.
| requested | before | after | fal cost | markup |
|---|---|---|---|---|
| 10s | $0.15 | $0.02 | $0.020 | 1.00x |
| 30s | $0.15 | $0.06 | $0.060 | 1.00x |
| 60s | $0.30 | $0.12 | $0.120 | 1.00x |
| 300s | $1.50 | $0.60 | $0.600 | 1.00x |
The floor had to go: $0.15 for a 10-second song was 7.5x cost and flatly contradicted the policy. We absorb the workflow run, the storage write and the egress instead — deliberate, and noted on the issue.
Basis: what fal actually produced
We charged the requested duration. The model routinely stops short — across our first songs, output averaged 39.9s against a 60s default — so charging the request would have quietly earned ~1.5x while calling itself pass-through.
The deduction now prices result.durationSeconds. The plumbing was already right: credits are deducted after fetchMusicResultStep, so the real length was already in scope at that line.
Three cases the tests pin down:
- Capped at the quote. A 60s request came back at 60.07s — the model is not hard-capped. Billing the overrun would put the charge above what the caller was quoted, so the sub-cent difference is ours.
- Unknown length falls back to the request. Charging nothing would hand over a song fal has already billed us for; a missing duration is a reporting gap, not a free generation.
- The gate still uses the requested duration, so nobody starts a 300s generation on a balance covering 60s. Gate high, charge actual.
Tests
10 assertions across two files, TDD with both suites confirmed RED first. 76/76 green across lib/music and app/workflows; tsc and eslint clean.
Before merging
Confirm on the fal invoice whether billing is on output or requested seconds. The rule is "charge what fal charges us", and this PR implements the output reading — which is the usual convention for audio models and the natural reading of "per second". If fal in fact bills the requested duration, we lose money on every early stop, which is the one outcome this is not meant to produce. I could not settle it from the API: metrics reports only inference_time (41.2s for a 25.9s song, so not the billing unit) and fal's billing endpoints 404 for our key.
Rounding
The ledger is integer cents, so one credit buys exactly five seconds and other durations round up by at most $0.008. That is a unit artifact rather than margin; chat#2000 proposes micro-dollar credits, which removes it. Rounding up keeps us from paying fal more than we charged.
Related
The chat form still quotes the old price — next row, separate PR.
🤖 Generated with Claude Code
https://claude.ai/code/session_017fSvwazBitPfsTQvVqpi8q
Summary by cubic
Pass-through pricing for music generation, billed on the audio fal actually produced. Previously we charged the requested duration at 0.5 credits/s with a 15-credit floor; now we charge the actual output at 0.2 credits/s with no floor, capped at the quoted amount.
- Deduct after result via
creditsForCompletedGenerationinmusicGenerationWorkflow; usesresult.durationSeconds, falls back to the request when missing/<=0, and never exceeds the preflight quote (params.creditsToCharge). Gate and quote still use the requested duration. - Update
creditCostForDurationto 0.2 credits/s; remove the floor; round up to whole credits (integer-cent ledger); minimum of 1 credit for any non‑zero duration. - Tests cover cap-at-quote, unknown-length fallback, rounding, and short-output cases.
- Rollout: confirm with fal whether billing uses output or requested seconds; no migration—this is a price cut.