Skip to content

feat(audio): report transcription audio length as a cost basis - #868

Merged
jarvis9443 merged 3 commits into
mainfrom
audio-duration-cost-basis
Aug 3, 2026
Merged

feat(audio): report transcription audio length as a cost basis#868
jarvis9443 merged 3 commits into
mainfrom
audio-duration-cost-basis

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

Models in the whisper family bill by audio length, not tokens. Their response reports

{"text": "...", "usage": {"type": "duration", "seconds": 11}}

and no token counts at all. The emitted UsageEvent carried tokens only, so cp-api received a row with nothing to price it with — every whisper-class request settles at $0. Verified against real OpenAI through a real DP in api7/AISIX-Cloud#1138: whisper-1 recorded prompt=0 completion=0 while gpt-4o-transcribe (which does report tokens) recorded 26/12 on the same audio.

Change

UsageEvent gains audio_duration_seconds, populated on /v1/audio/transcriptions and /v1/audio/translations. Skipped on the wire when zero, so token-only events are byte-identical to before and an older cp-api ignores the field.

Two sources, in order:

Response format Duration source
json usage.seconds
verbose_json top-level duration
text / srt / vtt the uploaded file's own properties

The file fallback is not an optimisation. Those three formats answer with a body that is not JSON and carries no usage whatsoever, so without it the caller decides whether a request is metered by choosing a response format — the same shape of bypass as an unbilled stream.

Reading the file is metadata-only: lofty (MIT OR Apache-2.0, default-features = false, 4 new crates, no C dependency) parses container/codec headers without decoding, so an arbitrary caller upload costs microseconds and anything unrecognised yields no duration rather than an error. The probe runs only when the response carried no duration, so the common json path never pays for it.

This is the DP half of #457. cp-api pricing the field is the paired CP change.

Unchanged: /v1/audio/speech — TTS bills per input character, not by the length of the audio it produced.

Baseline

LiteLLM prices transcription as cost_per_second × duration, taking the duration from the response and falling back to computing it from the file with soundfile when absent (calculate_request_duration). Same two sources, same order; ours differs only in that the fallback is always available rather than an optional dependency.

Tests

  • Unit: both response shapes, the token-usage response yielding no duration, a real WAV probing to its true length, and unrecognised bytes degrading to None.
  • Handler: a whisper-shaped response emits audio_duration_seconds = 11 with zero tokens; a text response with no usage falls back to the uploaded file's 3s.
  • tests/e2e/src/cases/audio-duration-cost-basis-e2e.test.ts — real aisix binary + etcd + mock upstreams, asserting both paths through the OTLP fan-out. Fails before this change (the attribute does not exist → NaN).

Refs api7/AISIX-Cloud#1138, #457

Summary by CodeRabbit

  • New Features
    • Audio transcription and translation usage can now be calculated from audio duration when token usage is unavailable.
    • Reported upstream duration is used when available; otherwise, duration is detected from the uploaded audio.
    • Speech synthesis continues to use character-based billing.
  • Observability
    • Audio duration is now included in usage events and telemetry when applicable.
  • Tests
    • Added coverage for duration-based billing across supported response formats and audio inputs.

Models in the whisper family bill by audio length, not tokens: the
response reports `usage: {type: "duration", seconds: N}` and no token
counts at all. The emitted UsageEvent carried tokens only, so cp-api
received a row with nothing to price it with and every such request
settled at $0.

`UsageEvent` gains `audio_duration_seconds`, populated on
/v1/audio/transcriptions and /translations. It is skipped on the wire
when zero, so token-only events are byte-identical to before and an
older cp-api simply ignores the field.

The length is read from the upstream response where it exists — the
`json` format's `usage.seconds`, `verbose_json`'s top-level `duration`.
Neither is present on `text` / `srt` / `vtt`, whose bodies are not JSON,
so those fall back to reading the uploaded file's own properties. That
fallback is not an optimisation: without it the caller decides whether a
request is metered by choosing a response format, which is the same
shape of bypass as an unbilled stream.

Reading the file is metadata-only — `lofty` parses container and codec
headers without decoding, so an arbitrary caller upload costs
microseconds, and anything unrecognised yields no duration rather than
an error. The probe runs only when the response carried no duration, so
the common `json` path never pays for it.

TTS is unchanged: /v1/audio/speech bills per input character, not by the
length of the audio it produced.

Refs api7/AISIX-Cloud#1138, #457
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a8fc2054-ef79-4457-b7f7-a4d0a342eace

📥 Commits

Reviewing files that changed from the base of the PR and between 1118024 and b956ff7.

📒 Files selected for processing (3)
  • crates/aisix-proxy/src/audio.rs
  • crates/aisix-proxy/src/ebml.rs
  • crates/aisix-proxy/src/lib.rs
📝 Walkthrough

Walkthrough

Audio requests now derive duration from upstream metadata or uploaded-file metadata. The duration flows into usage events and OTLP spans. Tests cover supported response formats, fallback probing, invalid uploads, and end-to-end reporting.

Changes

Audio duration metering

Layer / File(s) Summary
Usage contracts and OTLP attributes
Cargo.toml, crates/aisix-obs/src/usage.rs, crates/aisix-obs/src/otlp_http_sink.rs
UsageEvent includes an optional serialized audio duration. OTLP spans export positive durations as doubleValue attributes.
Duration extraction and dispatch wiring
crates/aisix-proxy/Cargo.toml, crates/aisix-proxy/src/audio.rs
Audio dispatch uses upstream duration metadata first and uploaded-file probing as a fallback. The duration flows through transcription usage events, while speech events report zero.
Duration metering validation
crates/aisix-proxy/src/audio.rs, tests/e2e/src/cases/audio-duration-cost-basis-e2e.test.ts
Tests cover JSON and plaintext responses, verbose JSON, token-only usage, non-JSON audio formats, invalid uploads, upstream precedence, and OTLP reporting.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: moonming

Sequence Diagram(s)

sequenceDiagram
  participant MultipartDispatch
  participant UpstreamResponse
  participant lofty
  participant UsageEvent
  participant OTLPSpan
  MultipartDispatch->>UpstreamResponse: Read usage.seconds or duration
  MultipartDispatch->>lofty: Probe uploaded audio if duration is unavailable
  MultipartDispatch->>UsageEvent: Set audio_duration_seconds
  UsageEvent->>OTLPSpan: Export aisix.audio.duration_seconds
Loading
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning The E2E test covers only /v1/audio/transcriptions; the PR also changes /v1/audio/translations, and its OTLP parser accepts any value type instead of verifying doubleValue. Add a /v1/audio/translations E2E case, cover a fallback or reported duration, consume response bodies, and assert the received attribute contains doubleValue.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: reporting transcription audio length as a cost basis.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed PR adds audio-duration parsing and telemetry only; the diff has no new secret logging/storage, mutating endpoints, ownership checks, TLS, shared-resource, or secret-reference logic.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch audio-duration-cost-basis

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

#865 landed the SSE usage fallback on the same statement this branch
extends with the duration cost basis, and both branches appended tests
at the same anchor. Resolution keeps both: the usage extraction now
reads tokens from JSON, then from a streamed body's terminal event,
while the duration is measured independently.

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/aisix-proxy/src/audio.rs (1)

758-775: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid parsing the response body as JSON twice.

Line 758 parses body_bytes into a serde_json::Value for extract_token_usage. Line 767 calls upstream_duration_seconds(&body_bytes), which parses the same bytes again. Every json-format transcription or translation response pays for two full JSON parses of the same body.

Parse once and reuse the value for both extractions.

♻️ Proposed refactor to parse the body once
-    let usage = serde_json::from_slice::<Value>(&body_bytes)
-        .ok()
-        .as_ref()
-        .and_then(extract_token_usage)
-        .or_else(|| extract_sse_token_usage(&upstream_headers, &body_bytes));
+    let parsed_body = serde_json::from_slice::<Value>(&body_bytes).ok();
+    let usage = parsed_body
+        .as_ref()
+        .and_then(extract_token_usage)
+        .or_else(|| extract_sse_token_usage(&upstream_headers, &body_bytes));
 
     // Duration cost basis (`#457`): what the upstream reported, else what
     // the uploaded file says. The probe runs only when the response
     // carried nothing, so the common `json` path never pays for it.
-    let duration_seconds = upstream_duration_seconds(&body_bytes)
+    let duration_seconds = parsed_body
+        .as_ref()
+        .and_then(upstream_duration_from_value)
         .or_else(|| {
             fields
                 .iter()
                 .find(|(name, ..)| name == "file")
                 .and_then(|(.., data)| probe_audio_duration_seconds(data))
         })
         .unwrap_or(0.0);

And split upstream_duration_seconds so the byte-slice entry point (kept for the existing unit tests) delegates to a Value-based helper:

-fn upstream_duration_seconds(body: &[u8]) -> Option<f64> {
-    let json = serde_json::from_slice::<Value>(body).ok()?;
-    let from_usage = json
+fn upstream_duration_seconds(body: &[u8]) -> Option<f64> {
+    let json = serde_json::from_slice::<Value>(body).ok()?;
+    upstream_duration_from_value(&json)
+}
+
+fn upstream_duration_from_value(json: &Value) -> Option<f64> {
+    let from_usage = json
         .get("usage")
         .and_then(|u| u.get("seconds"))
         .and_then(Value::as_f64);
     let seconds = from_usage.or_else(|| json.get("duration").and_then(Value::as_f64))?;
     (seconds.is_finite() && seconds > 0.0).then_some(seconds)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/audio.rs` around lines 758 - 775, Refactor the
response handling around extract_token_usage and upstream_duration_seconds to
deserialize body_bytes into one serde_json::Value and reuse it for both
token-usage and duration extraction. Split upstream_duration_seconds so its
byte-slice API remains available for existing tests while delegating to a
Value-based helper, and update the main flow to call that helper with the shared
parsed value.
tests/e2e/src/cases/audio-duration-cost-basis-e2e.test.ts (1)

189-251: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add E2E coverage for an unsupported uploaded file.

The PR states that unsupported uploads must produce no duration without failing the request. The Rust unit tests cover this at the probe_audio_duration_seconds level, but this E2E suite does not exercise the full request path (200 response, audio_duration_seconds omitted from the emitted span) for a file lofty cannot parse.

Add a third case reusing the textUpstream (or a similar no-usage upstream): upload non-audio bytes as the file field with response_format: "text", assert the response is still 200, and assert the resulting span carries no aisix.audio.duration_seconds attribute (or reports 0).

As per coding guidelines, "Tests must cover boundary cases (empty values, min/max), invalid inputs, combination scenarios, and extreme cases (high load, failures)" for **/*.{test,spec}.{js,ts,jsx,tsx}.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/cases/audio-duration-cost-basis-e2e.test.ts` around lines 189 -
251, Add a third scenario in the duration-billing test after the existing text
response case, using the text-transcribe/no-usage upstream and non-audio bytes
as the uploaded file with response_format set to text. Assert the request
returns HTTP 200, retrieve its emitted span via the request ID, and verify
aisix.audio.duration_seconds is absent or zero.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-proxy/src/audio.rs`:
- Around line 1240-1263: Update probe_audio_duration_seconds to handle valid
WebM/Matroska uploads unsupported by lofty, either by adding duration coverage
for WebM or by preventing those uploads from receiving a zero billing duration.
Preserve the existing zero-cost behavior only for formats whose duration cannot
be identified, and ensure text, SRT, and VTT responses use the recovered WebM
duration.

---

Nitpick comments:
In `@crates/aisix-proxy/src/audio.rs`:
- Around line 758-775: Refactor the response handling around extract_token_usage
and upstream_duration_seconds to deserialize body_bytes into one
serde_json::Value and reuse it for both token-usage and duration extraction.
Split upstream_duration_seconds so its byte-slice API remains available for
existing tests while delegating to a Value-based helper, and update the main
flow to call that helper with the shared parsed value.

In `@tests/e2e/src/cases/audio-duration-cost-basis-e2e.test.ts`:
- Around line 189-251: Add a third scenario in the duration-billing test after
the existing text response case, using the text-transcribe/no-usage upstream and
non-audio bytes as the uploaded file with response_format set to text. Assert
the request returns HTTP 200, retrieve its emitted span via the request ID, and
verify aisix.audio.duration_seconds is absent or zero.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e452a567-8498-487d-99a5-6ded769daf7e

📥 Commits

Reviewing files that changed from the base of the PR and between 305602c and 1118024.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • Cargo.toml
  • crates/aisix-obs/src/otlp_http_sink.rs
  • crates/aisix-obs/src/usage.rs
  • crates/aisix-proxy/Cargo.toml
  • crates/aisix-proxy/src/audio.rs
  • tests/e2e/src/cases/audio-duration-cost-basis-e2e.test.ts

Comment thread crates/aisix-proxy/src/audio.rs
`lofty` reads every container the transcription endpoints accept except
Matroska/WebM, and WebM is not a corner case: `MediaRecorder` produces
`audio/webm;codecs=opus` by default, so it is what a browser uploads. A
WebM transcription asked for as `text` / `srt` / `vtt` therefore reported
no length and billed nothing — the exact bypass the file probe exists to
close.

Adds a header-only EBML reader: EBML root → Segment → Info, taking
Duration (a float in TimecodeScale units) and TimecodeScale (nanoseconds,
default 1,000,000). Clusters are never entered and no audio is decoded.

Two things make this a structural walk rather than a byte scan. A file's
SeekHead carries the Info id inside its SeekID payloads, so scanning for
the id lands on a pointer and reads garbage. And a live muxer — which is
what a browser is — leaves Segment at the unknown-size marker, so its
extent has to be inferred from the parent rather than read.

The input is a caller upload, so the walk is total: every read is
bounds-checked, declared sizes are clamped to the enclosing element
rather than trusted, and the element count is capped.

Reported by CodeRabbit on #868.
@jarvis9443
jarvis9443 merged commit 7011253 into main Aug 3, 2026
12 checks passed
@jarvis9443
jarvis9443 deleted the audio-duration-cost-basis branch August 3, 2026 10:39
Sign up for free to 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