NAME
Langertha::Skeid::Protocol::Realtime - The OpenAI realtime route -- what a session's handshake and frames say, relayed untranslated
VERSION
version 0.004
DESCRIPTION
Serves GET /v1/realtime, the realtime route of the OpenAI face, used here for live transcription. It is a relayed route (ADR 0021, ADR 0029): a WebSocket the client keeps open is joined to a WebSocket Skeid opens to the node's realtime endpoint, and the frames go across as they are. This module translates nothing -- the beta dialect (transcription_session.update) and the GA dialect (session.update with a transcription session) are relayed as the client speaks them -- and it is the only place that knows the wire: the query parameters, the subprotocol that carries a browser's key, the event names, the places a client frame can name a model, and where a server frame reports its usage.
It holds three things Skeid has to know all the same.
The model of the session. A key's policy (ADR 0008) was applied to the model of the handshake. A frame that names another model would switch the session to one the key was never granted, so every client frame is decoded (never pre-filtered by substring: JSON spells the same text many ways, and the node decodes the frame again -- ADR 0021's two-parsers hazard) and every string
modelin it is compared: the session's own model is rewritten to the served one, any other is refused. What Skeid forwards is the document it inspected, encoded again, so Skeid and the node read one thing.The key of a browser. A page cannot set
Authorization; OpenAI's convention is a subprotocol entryopenai-insecure-api-key.<key>besiderealtime. It is read, and never answered: the subprotocol Skeid selects isrealtimeor none.Usage. The
usageblock of eachconversation.item.input_audio_transcription.completedevent is one transcription's. A session's usage is their sum.
Everything here is a class method.
route
my $path = Langertha::Skeid::Protocol::Realtime->route; # '/v1/realtime'
The client path of the realtime route.
upstream_path
The path below a node's URL it is relayed to, /realtime.
query_model
my ($model, $problem) = Langertha::Skeid::Protocol::Realtime->query_model($c->req->url->query);
The model of a handshake: exactly one non-empty model query parameter of at most 1024 bytes. Otherwise undef and a message for the client. intent=transcription alone names no model and is refused (ADR 0029): a slot is promised to a client that has said what it wants.
upstream_url
my $url = Langertha::Skeid::Protocol::Realtime->upstream_url(
'http://node:8000/v1/realtime', 'whisper-large', $c->req->url->query);
# ws://node:8000/v1/realtime?model=whisper-large&intent=transcription
The WebSocket URL of the node's realtime endpoint: http becomes ws, https wss, the model is the one that was served, and every other query parameter of the client goes on as it was sent.
subprotocol_key
my $key = Langertha::Skeid::Protocol::Realtime->subprotocol_key($c->req->headers->sec_websocket_protocol);
The API key a browser put in the Sec-WebSocket-Protocol header as openai-insecure-api-key.<key>, or undef. The first such entry counts.
subprotocol
The one subprotocol Skeid ever selects in its answer, realtime. Never an entry that carries a key.
client_frame
my $frame = Langertha::Skeid::Protocol::Realtime->client_frame($bytes, $requested, $served);
What to do with one text frame of the client, given the model the session asked for and the one it is served. $bytes are the frame's raw bytes. The answer is a hashref with an action:
forwardwithtext-- the frame to send to the node, the decoded document encoded again with the model rewritten to$servedwhere it named$requested.rewrittenis true when a model was changed.refusewithtext-- anerrorevent for the client; the frame is not forwarded and the session goes on, on the model it started with. A frame names another model.closewithcodeandreason-- a frame that is no JSON object (1003).
Every string model anywhere in the document counts, whatever the event: the dialects keep it in different places (session.model, session.input_audio_transcription.model, session.audio.input.transcription.model), and a place a node could read that Skeid does not is a bypass. An input_audio_buffer.append carries none; it is decoded like the rest, because the decoding is what makes Skeid and the node read the same document.
error_event
my $text = Langertha::Skeid::Protocol::Realtime->error_event($type, $code, $message, $client_event_id);
An error server event as the realtime protocol shapes it, as JSON text.
server_frame
my $info = Langertha::Skeid::Protocol::Realtime->server_frame($bytes);
# { type => '...', usage => {...}|undef, delta_text => '...'|undef, error => 0|1 }
What Skeid reads off one text frame of the node, for metering: the usage block of a ...input_audio_transcription.completed event, the text of a ...input_audio_transcription.delta event, and whether it is an error event. A frame that does not decode is an empty answer; it is relayed all the same, as the bytes it came in.
add_usage
$sum = Langertha::Skeid::Protocol::Realtime->add_usage($sum, $block);
Adds the usage block of one completed transcription to a session's total, number by number (nested blocks such as input_token_details likewise). A value that is not a number is not added: type keeps the first one seen. Unlike the running totals of a stream (_merge_usage), each block is its own transcription's, so they are summed.
seconds
my $seconds = Langertha::Skeid::Protocol::Realtime->seconds($block);
The seconds of audio a usage block reports -- { type => 'duration', seconds => N } -- or undef when it does not say.
SEE ALSO
"Realtime route" in Langertha::Skeid::Proxy, Langertha::Skeid::Proxy::Realtime, Langertha::Skeid::Protocol::Audio
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.