Skip to content

feat(music): pass-through pricing, charged on the audio fal produced - #853

Merged
sweetmantech merged 1 commit into
mainfrom
feat/music-fal-passthrough-pricing
Aug 24, 2026
Merged

feat(music): pass-through pricing, charged on the audio fal produced#853
sweetmantech merged 1 commit into
mainfrom
feat/music-fal-passthrough-pricing

Conversation

@sweetmantech

@sweetmantechsweetmantech commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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.

requestedbeforeafterfal costmarkup
10s$0.15$0.02$0.0201.00x
30s$0.15$0.06$0.0601.00x
60s$0.30$0.12$0.1201.00x
300s$1.50$0.60$0.6001.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 creditsForCompletedGeneration in musicGenerationWorkflow; uses result.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 creditCostForDuration to 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.

Written for commit 1450f54. Summary will update on new commits.

Review in cubic

@vercel

vercelBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
apiReadyReadyPreviewAug 24, 2026 10:28pm

Request Review

@coderabbitai

coderabbitaiBot commented Aug 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 40 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 16d2364e-dc6f-4136-aacd-57771b17b4b4

📥 Commits

Reviewing files that changed from the base of the PR and between 729a125 and 1450f54.

⛔ Files ignored due to path filters (2)
  • lib/music/__tests__/creditCostForDuration.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/music/__tests__/creditsForCompletedGeneration.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (3)
  • app/workflows/music/musicGenerationWorkflow.ts
  • lib/music/creditCostForDuration.ts
  • lib/music/creditsForCompletedGeneration.ts

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.

@cubic-dev-aicubic-dev-aiBot 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.

4 issues found across 5 files

Confidence score: 3/5

  • app/workflows/music/musicGenerationWorkflow.ts recomputes the deduction from params.duration instead of durable params.creditsToCharge, so pricing changes during an in-flight run can charge more than the caller was quoted; use the persisted quote for deduction.
  • lib/music/creditCostForDuration.ts includes a comment treating output-second billing as confirmed even though this helper cannot establish fal’s invoice basis, which could mislead future pricing decisions; qualify or remove the claim.
  • lib/music/creditCostForDuration.ts understates the fractional-duration upper bound as $0.008, while the calculation can add nearly one full credit and approach $0.01; correct the documentation or enforce the stated bound.
  • app/workflows/music/musicGenerationWorkflow.ts exceeds the stated 100-line maintainability limit, making the billing flow harder to isolate and review; extract the billing/deduction block or split the workflow.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/workflows/music/musicGenerationWorkflow.ts">
<violation number="1" location="app/workflows/music/musicGenerationWorkflow.ts:75">
P2: When pricing constants change while a run is in flight, this call recomputes the quote from `params.duration` instead of applying durable `params.creditsToCharge`, so the deduction can exceed the caller's quote. Cap the computed actual charge with `params.creditsToCharge` or pass the quoted value into the helper.</violation>
<violation number="2" location="app/workflows/music/musicGenerationWorkflow.ts:75">
P2: Custom agent: **Enforce Clear Code Style and Maintainability Practices**
This workflow is 108 lines, violating Rule 3's explicit under-100-line limit. Extract the billing/deduction block or otherwise split the workflow so the file stays below 100 lines.</violation>
</file>
<file name="lib/music/creditCostForDuration.ts">
<violation number="1" location="lib/music/creditCostForDuration.ts:4">
P1: Custom agent: **Flag AI Slop and Fabricated Changes**
The added comment presents output-second billing as confirmed, but this helper only prices its input and cannot establish fal’s invoice basis. The PR still identifies invoice confirmation as outstanding, so document this as an assumption until verified.</violation>
<violation number="2" location="lib/music/creditCostForDuration.ts:22">
P3: For fractional durations, the documented `$0.008` maximum is incorrect. `creditCostForDuration` can add almost one full credit, so document the bound as less than `$0.01` (or derive it from an explicitly enforced duration precision).</violation>
</file>
Architecture diagram
sequenceDiagram
participant API as API / Gate
participant WF as musicGenerationWorkflow
participant Fal as Fal.ai (External API)
participant Calc as Pricing Logic
participant DB as Ledger / DB
Note over API,DB: Pre-flight: User is gated based on Requested Duration (Quote)
API->>WF: start(requestedDuration, quotedCredits)
WF->>Fal: Request generation (requestedDuration)
Fal-->>WF: Return result (audioUrl, actualSeconds)
WF->>Calc: NEW: creditsForCompletedGeneration(requested, actual)
rect rgb(240, 240, 240)
Note over Calc: Logic: 0.2 credits/sec (CHANGED: no floor)
alt actualSeconds is valid
Calc->>Calc: Calculate credits for actualSeconds
opt actualSeconds > requestedSeconds
Calc->>Calc: NEW: Cap at quotedCredits
end
else actualSeconds is missing or 0
Calc->>Calc: NEW: Fallback to quotedCredits
end
end
Calc-->>WF: finalCreditsToDeduct
WF->>DB: CHANGED: recordCreditDeduction(finalCreditsToDeduct)
Note right of DB: Deduction is now often < Quote
DB-->>WF: Success
WF-->>API: Completion status
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +4 to +5
* fal charges $0.002 per output second and 1 credit is $0.01, so 0.2 credits
* per second is fal's rate exactly — no markup (recoupable/chat#1999).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Custom agent: Flag AI Slop and Fabricated Changes

The added comment presents output-second billing as confirmed, but this helper only prices its input and cannot establish fal’s invoice basis. The PR still identifies invoice confirmation as outstanding, so document this as an assumption until verified.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/music/creditCostForDuration.ts, line 4:
<comment>The added comment presents output-second billing as confirmed, but this helper only prices its input and cannot establish fal’s invoice basis. The PR still identifies invoice confirmation as outstanding, so document this as an assumption until verified.</comment>
<file context>
@@ -1,27 +1,36 @@
- * is not a tenth as expensive to serve as a 100-second one.
+ * Credits per second of audio.
+ *
+ * fal charges $0.002 per output second and 1 credit is $0.01, so 0.2 credits
+ * per second is fal's rate exactly — no markup (recoupable/chat#1999).
*/
</file context>
Suggested change
*falcharges$0.002peroutputsecondand1creditis$0.01,so0.2credits
*persecondisfal's rate exactly — no markup (recoupable/chat#1999).
*Assumesfalcharges$0.002peroutputsecondand1creditis$0.01;confirmthisagainstfalinvoicesbeforecalling0.2creditspersecondfal's exact rate.

Comment on lines +75 to +78
const creditsToDeduct = creditsForCompletedGeneration({
requestedSeconds: params.duration,
actualSeconds: result.durationSeconds,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When pricing constants change while a run is in flight, this call recomputes the quote from params.duration instead of applying durable params.creditsToCharge, so the deduction can exceed the caller's quote. Cap the computed actual charge with params.creditsToCharge or pass the quoted value into the helper.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/workflows/music/musicGenerationWorkflow.ts, line 75:
<comment>When pricing constants change while a run is in flight, this call recomputes the quote from `params.duration` instead of applying durable `params.creditsToCharge`, so the deduction can exceed the caller's quote. Cap the computed actual charge with `params.creditsToCharge` or pass the quoted value into the helper.</comment>
<file context>
@@ -68,10 +69,18 @@ export async function musicGenerationWorkflow(generationId: string, params: Musi
+ // Charge for the audio fal actually produced, not the length requested.
+ // `params.creditsToCharge` is what the caller was gated and quoted on; it
+ // stays the ceiling, so the deduction only ever moves in their favour.
+ const creditsToDeduct = creditsForCompletedGeneration({
+ requestedSeconds: params.duration,
+ actualSeconds: result.durationSeconds,
</file context>
Suggested change
constcreditsToDeduct=creditsForCompletedGeneration({
requestedSeconds: params.duration,
actualSeconds: result.durationSeconds,
});
constcreditsToDeduct=Math.min(
params.creditsToCharge,
creditsForCompletedGeneration({
requestedSeconds: params.duration,
actualSeconds: result.durationSeconds,
}),
);

@@ -6,6 +6,7 @@ import { pollMusicGenerationStep } from "@/app/workflows/music/pollMusicGenerati
import { fetchMusicResultStep } from "@/app/workflows/music/fetchMusicResultStep";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Custom agent: Enforce Clear Code Style and Maintainability Practices

This workflow is 108 lines, violating Rule 3's explicit under-100-line limit. Extract the billing/deduction block or otherwise split the workflow so the file stays below 100 lines.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/workflows/music/musicGenerationWorkflow.ts, line 75:
<comment>This workflow is 108 lines, violating Rule 3's explicit under-100-line limit. Extract the billing/deduction block or otherwise split the workflow so the file stays below 100 lines.</comment>
<file context>
@@ -68,10 +69,18 @@ export async function musicGenerationWorkflow(generationId: string, params: Musi
+ // Charge for the audio fal actually produced, not the length requested.
+ // `params.creditsToCharge` is what the caller was gated and quoted on; it
+ // stays the ceiling, so the deduction only ever moves in their favour.
+ const creditsToDeduct = creditsForCompletedGeneration({
+ requestedSeconds: params.duration,
+ actualSeconds: result.durationSeconds,
</file context>

* @returns Whole credits to gate on and, once the song lands, to deduct.
* Rounds up because the ledger is integer cents: one credit buys exactly five
* seconds at fal's rate, so durations that are not multiples of five round to
* the next credit. The overcharge is at most $0.008 and is an artifact of the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: For fractional durations, the documented $0.008 maximum is incorrect. creditCostForDuration can add almost one full credit, so document the bound as less than $0.01 (or derive it from an explicitly enforced duration precision).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/music/creditCostForDuration.ts, line 22:
<comment>For fractional durations, the documented `$0.008` maximum is incorrect. `creditCostForDuration` can add almost one full credit, so document the bound as less than `$0.01` (or derive it from an explicitly enforced duration precision).</comment>
<file context>
@@ -1,27 +1,36 @@
- * @returns Whole credits to gate on and, once the song lands, to deduct.
+ * Rounds up because the ledger is integer cents: one credit buys exactly five
+ * seconds at fal's rate, so durations that are not multiples of five round to
+ * the next credit. The overcharge is at most $0.008 and is an artifact of the
+ * unit, not a margin — chat#2000 proposes micro-dollar credits, which would
+ * remove it. Rounding up rather than down keeps us from paying fal more than
</file context>
Suggested change
*thenextcredit.Theoverchargeisatmost$0.008andisanartifactofthe
*thenextcredit.Theoverchargeislessthan$0.01andisanartifactofthe

Two changes, both from the chat#1999 pricing decision.
Rate: 0.5 -> 0.2 credits per second, and the 15-credit floor is gone.
fal charges $0.002 per second and a credit is $0.01, so 0.2/s is fal's
rate exactly. A 60s song drops from 30 credits to 12. The floor made a
10s song $0.15 against $0.02 of cost, 7.5x, which cannot coexist with
pass-through pricing.
Basis: charge the actual output length, not the requested one. The model
routinely stops short — our first songs averaged 39.9s against a 60s
default — so billing the request would have earned ~1.5x while calling
itself pass-through. Capped at the quoted amount, because the model is
not hard-capped either: a 60s request came back at 60.07s, and billing
that overrun would exceed what the caller was quoted.
The gate still runs on the requested duration, so a caller cannot start a
300s generation on a balance that only covers 60s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fSvwazBitPfsTQvVqpi8q
@sweetmantech

sweetmantech commented Aug 24, 2026

Copy link
Copy Markdown
ContributorAuthor

Preview verification — api#853

Rebased onto main first: api#854 merged ahead of this and both edit musicGenerationWorkflow.ts. The conflict was the import block only — both the credit deduction and the notify calls are kept, and the hoisted generation from #854 survives. 87/87 green after, tsc and eslint clean, PR now MERGEABLE.

The decisive run

Requested 60 seconds. fal produced 15.6. That gap is the whole point of this PR, and it landed on its own.

creditsdollars
Old pricing (60s × 0.5, floor 15)30$0.30
New rate, charged on the request12$0.12
New rate, charged on the actual 15.6s4$0.04

4 credits were deducted. Confirmed twice, independently:

  • balance went 999993309 → 999993305, a delta of exactly 4
  • the newest usage_events row for the account reads 4

So all three behaviours are demonstrated by one generation: the rate is fal's (15.6 × $0.002 = $0.0312, rounded up to 4 credits), the basis is the audio actually produced rather than the length requested, and no floor applies — the old 15-credit minimum would have forced 15.

For contrast, the two generations from api#854 testing an hour earlier show 15 credits each in the same table: 20-second songs pinned at the old floor. The same songs cost 4 under this PR.

Documented vs actual

#ExpectedActualResult
1Rebase keeps both #853 and #854 behaviourdeduction, notify, hoisted generation all presentPASS
2Generation completes on the rebased buildcompleted, 15.6sPASS
3Charged on actual, not requested4, not 12PASS
4Not the old price4, not 30PASS
5No floor applied4 < 15PASS
6Rate matches fal's $0.002/sceil(15.6 × 0.2) = 4PASS
7usage_events records the charged amount4PASS
8Balance delta matches the eventboth 4PASS
9Unit suite after rebase87/87PASS

The blocker is now quantified, not theoretical

This run makes the open question concrete and expensive.

We charged the customer $0.04. If fal bills on output seconds, fal charged us $0.0312 and the pass-through is correct. If fal bills on the requested duration, fal charged us $0.12 and we just sold a song at a third of cost — losing $0.08 on a single generation, and more the further the model stops short. Our songs stop short routinely: 60→15.6 here, 60→25.9 earlier, averaging roughly two thirds of the request.

I still cannot settle it from the API. metrics reports only inference_time, and every billing endpoint I can find 404s for our key:

rest.alpha.fal.ai/billing/user_spending 404
api.fal.ai/v1/billing/usage 404
rest.alpha.fal.ai/usage 404

Resolved — see the comment below. fal's usage API confirms billing is on actual output seconds: the bucket covering these tests billed 35.62s, the exact sum of what was produced, against 80s requested. creditsForCompletedGeneration is correct as written and no code change was needed. The analysis above is left intact as the record of what was unknown at the time; the blocking instruction it ended with no longer applies.

Everything else here is verified and correct. This is the only thing standing between it and merge.

@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Blocker resolved — fal bills on actual output seconds

The new key reaches fal's usage API. GET https://api.fal.ai/v1/models/usage, Authorization: Key … — that is the endpoint; /billing returns only a username and /account/focus 403s ("FOCUS reports require additional permissions").

The 22:00 UTC bucket contains exactly the two generations from this session's testing:

{
"endpoint_id": "minimax/music-3",
"unit": "seconds",
"quantity": 35.62,
"unit_price": 0.002,
"cost_total": 0.07124
}
generationrequestedproduced
7d785aef (api#854 test)20s20.02s
fdb55449 (api#853 test)60s15.60s
80.00s35.62s

fal billed 35.62 seconds. That is the sum of what the model produced, to the hundredth. Had billing been on the requested duration it would read 80.

This is arithmetic, not inference — and fdb55449 is a clean discriminator on its own, since its requested and actual durations differ by 44 seconds.

What it means for this PR

creditsForCompletedGeneration charging the actual output length is correct. Pass-through is genuinely pass-through:

fal charged us for fdb55449$0.0312 (15.6s × $0.002)
we charged$0.0400 (4 credits)
effective1.28x, entirely the integer-cent rounding

The remaining 0.88 cents is the artifact called out in the PR description: one credit buys five seconds, so a 15.6-second song rounds up. chat#2000's micro-dollar ledger removes it. Rounding up is the right direction — under-rounding would have put us below fal's bill.

Had this shipped charging the requested duration instead, we would have taken $0.12 for a song that cost $0.03 — a 3.8x markup wearing pass-through's label, which is exactly what the "charge what fal charges us" rule existed to prevent.

Merge blocker cleared

The verification above stands at 9/9, the rebase onto api#854 is clean, 87/87 tests green, and the one open question is now answered from fal's own billing data. No code change needed — the implementation was already right.

Worth keeping: GET https://api.fal.ai/v1/models/usage returns per-model quantity, unit price and cost in hourly buckets. That is the tool for auditing provider cost against what we charge, and it is worth wiring into a periodic margin check rather than rediscovering it next time.

@sweetmantech
sweetmantech merged commit d204873 into mainAug 24, 2026
6 checks passed
sweetmantech added a commit to recoupable/app that referenced this pull request Aug 25, 2026
Tracks recoupable/api#853. Two changes to the form's quote.
Rate: 0.5 -> 0.2 credits per second and no floor, matching the API. A
60s song now quotes $0.12 rather than $0.30. A quote that disagrees with
what the API charges is worse than no quote.
Wording: "Costs $0.12" -> "Up to $0.12 ... You are charged for the audio
actually generated." The API now bills the audio fal produced, and the
model routinely stops short of the requested length, so a fixed figure
would overstate what most generations are billed. An upper bound is safe
in the only direction that matters: nobody is charged more than they
were shown.
Claude-Session: https://claude.ai/code/session_017fSvwazBitPfsTQvVqpi8q
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sweetmantech