NAME
Langertha::Skeid::Protocol::Audio - The OpenAI audio routes -- the upload form relayed, the answer's usage read
VERSION
version 0.004
DESCRIPTION
Serves POST /v1/audio/transcriptions and POST /v1/audio/translations, the audio routes of the OpenAI face. They are relayed routes (ADR 0021): there is one dialect for the request, a multipart/form-data upload, and the node's answer goes back as it came, so this module translates nothing. It holds what Skeid has to know about that wire all the same, and it is the only place that knows it: which form fields Skeid reads, how the upstream form is put together from the parts the server parsed, and where an answer -- JSON or one frame of an event stream -- says what it cost.
The nodes behind these routes are vLLM (Whisper) and speaches (faster-whisper), which both serve OpenAI's endpoints; what differs is the answer. vLLM's JSON carries usage => { type => 'duration', seconds => N }, its stream is transcription.chunk frames in the chat stream's shape, ending in [DONE]. speaches' JSON carries no usage, its stream is OpenAI's own transcript.text.delta / transcript.text.done events and ends without [DONE]. A verbose_json answer of either has a top-level duration. OpenAI itself reports usage => { type => 'tokens', ... } or the duration block, depending on the model.
Everything here is a class method.
routes
my @paths = Langertha::Skeid::Protocol::Audio->routes;
The client paths of the audio routes, sorted: /v1/audio/transcriptions and /v1/audio/translations.
upstream_path
my $path = Langertha::Skeid::Protocol::Audio->upstream_path('/v1/audio/translations');
# '/audio/translations'
The path below a node's URL that a client path is relayed to, or undef for a path that is not an audio route.
form_fields
my ($fields, $problem) = Langertha::Skeid::Protocol::Audio->form_fields($req->content);
# $fields = { model => [ { index => 0, value => 'whisper' } ], stream => [ ... ] }
The form fields Skeid reads off an upload itself, model and stream, from a parsed Mojo::Content::MultiPart: per name the parts that carry it, in form order, each with its index in the form and its value as raw bytes -- undef for a value above 1024 bytes, which is neither a model name nor a boolean and is not read into memory. A part with a file name is a file, whatever it is called, and is not a field.
The parts are read directly rather than through the request's parameters, which would load every other field (a long prompt) into memory and know only a quoted name. A part's name is taken from its Content-Disposition as liberally as any form parser a node may run takes it: the parameter name in any case, white space around the =, the value quoted or bare, every name of a header that repeats it. The form is parsed twice, here and by the node, and the two must not disagree about which part is the model -- Skeid routes, and applies the key's policy, by the one it sees (ADR 0008). Whatever a node could take for the model field counts as one here.
$problem is a reason to refuse the whole form, else undef: a part that is itself a multipart, which cannot be relayed as one asset, and a field name in the extended notation of RFC 2231 / RFC 5987 (name*), which RFC 7578 rules out for a form and Skeid would have to guess at.
disposition_names
my ($names, $is_file, $extended) =
Langertha::Skeid::Protocol::Audio->disposition_names($part->headers->content_disposition);
What a part's Content-Disposition says the part is: every value of its name parameter, read as liberally as "form_fields" says, whether it has a file name, and whether a name is given in the extended notation (name*, name*0). The documents routes read their form with it too.
is_true
my $streamed = Langertha::Skeid::Protocol::Audio->is_true($value);
Whether a boolean form field is on. A form has only strings; the nodes read theirs through pydantic, for which 1, true, on, yes, t and y are true, in any case. Skeid has to agree with the node about stream: read as off here and on there, the event stream would be held back until it is complete and its usage never read.
upstream_parts
my $parts = Langertha::Skeid::Protocol::Audio->upstream_parts($req->content, { 0 => $bytes });
my $tx = $ua->build_tx(POST => $url, \%headers, multipart => $parts);
The parts of the upstream form, for the multipart generator of Mojo::UserAgent::Transactor: every part of the client's form with its own headers and its own asset, in the client's order -- unknown and repeated fields included. A part is its headers and an asset, and an upload above 256 KiB is an asset on disk, so the file is sent from there and never copied into memory. \%replace maps a part's index to the bytes it carries instead (the served model of an alias tier); that part keeps its headers but for a Content-Length that no longer holds. The boundary is not set here: the upstream request carries the client's Content-Type, and the body is written with the boundary that names.
usage_units
my %units = Langertha::Skeid::Protocol::Audio->usage_units($payload);
# ( audio_seconds => 12 ), or ()
The usage unit of the audio routes off a decoded answer -- or off a hash holding only the usage block a stream carried: the seconds of audio the node worked on, as the event field audio_seconds. usage => { type => 'duration', seconds => N } is what vLLM and OpenAI's whisper-1 answer with; a verbose_json answer carries a top-level duration instead. With neither, the node did not say, and nothing is returned: the event then has no audio_seconds rather than a zero nobody measured. A value that is not a finite number of at least zero counts as not said.
Token usage is not read here; it goes through metrics.normalize like every other answer's. This reads a number off an answer Skeid already holds and translates nothing.
delta_text
my $text = Langertha::Skeid::Protocol::Audio->delta_text($frame);
The text of one decoded frame of a transcription stream in OpenAI's own event dialect, which speaches speaks too -- { type => 'transcript.text.delta', delta => '...' } -- or undef for any other frame. The relay counts it into content_bytes. vLLM streams chat-shaped chunks (choices[0].delta.content) instead, which the relay counts already.
SEE ALSO
"Audio routes" in Langertha::Skeid::Proxy, Langertha::Skeid::Protocol, "Pluggable Usage Storage" in Langertha::Skeid
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/langertha-skeid/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.