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 model in 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 entry openai-insecure-api-key.<key> beside realtime. It is read, and never answered: the subprotocol Skeid selects is realtime or none.

  • Usage. The usage block of each conversation.item.input_audio_transcription.completed event 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:

  • forward with text -- the frame to send to the node, the decoded document encoded again with the model rewritten to $served where it named $requested. rewritten is true when a model was changed.

  • refuse with text -- an error event for the client; the frame is not forwarded and the session goes on, on the model it started with. A frame names another model.

  • close with code and reason -- 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.