NAME

Langertha::Skeid::Protocol::Anthropic::Stream - Rewrites an OpenAI SSE stream as Anthropic streaming events

VERSION

version 0.003

DESCRIPTION

Skeid makes one shape of upstream call and translates at the client edge (ADR 0001). For a non-streaming response that is one function; for a stream it needs state, because Anthropic's format is an event protocol rather than a sequence of deltas: a client is told a message began, that a content block opened, then the text, then that both closed -- and the token counts arrive in the closing events.

An OpenAI stream says none of that. It sends deltas and stops. Everything else here is synthesised at the right moment from what the OpenAI stream does say.

my $stream = Langertha::Skeid::Protocol::Anthropic::Stream->new(model => 'claude-x');
$write->($stream->start);
$write->($stream->delta($openai_chunk)) for @chunks;
$write->($stream->finish);

Every method returns bytes to write, possibly empty. start and finish are idempotent, so a stream that fails mid-flight can still be closed correctly.

new

my $stream = Langertha::Skeid::Protocol::Anthropic::Stream->new(model => $requested_model);

model is the model named in message_start -- the one the client asked for. message_id defaults to msg_ plus the current time in milliseconds.

content_type

The Content-Type the client edge must send. Anthropic streams are SSE, same as OpenAI, but the events inside are not interchangeable -- which is exactly why this class exists.

start

Opens the message. Content blocks are not opened here -- they open lazily on the first delta of the kind that fills them, so a stream whose first content is a tool call does not emit an empty text block before the tool_use block.

delta

my $bytes = $stream->delta($openai_chunk);

Turns one decoded OpenAI chunk into whatever Anthropic events it implies: a content_block_start the first time a content block is seen (text or tool_use), one content_block_delta per delta of that block, and along the way the usage and finish reason that the closing events need. The first chunk for a tool_calls index opens its block; later arguments-only deltas for the same index extend it with partial_json.

The translator carries enough state for parallel tool calls: each OpenAI tool_calls index is mapped to its own Anthropic content block, allocated in order of first appearance, and the list of still-open blocks is closed in order when finish runs.

A chunk carrying an OpenAI error object instead of choices (how some servers report a failure inside an open stream) ends the stream with "error_event".

finish

Closes every still-open content block and then the message. The token count goes on message_delta, because an Anthropic client reads usage from there, and so does the stop_reason: tool_use whenever tool_use blocks were emitted and the upstream finished with stop or not at all, otherwise the mapping of the upstream finish_reason.

Idempotent, and it opens the message first if nothing ever did: a stream that produced no text and no tool calls still has to be a well-formed Anthropic message, or the client waits for an end that never comes. stop_reason => ... and output_tokens => ... override what the stream recorded.

error_event

my $bytes = $stream->error_event(500, 'Upstream error: ...');
my $bytes = $stream->error_event(500, $message, $upstream_type);

Ends the stream with an Anthropic event: error frame, the way Anthropic reports a failure after the stream has opened (the HTTP status is already 200 by then). The frame's data is "error_body" in Langertha::Skeid::Protocol::Anthropic, so its error.type follows the same status mapping as every other error on /v1/messages; an upstream error type Anthropic also uses (an error chunk's rate_limit_error, say) is kept.

The stream is finished afterwards: delta and finish return nothing, so no message_stop follows and a client cannot mistake a failed stream for a complete one. Open content blocks are left unclosed, as Anthropic leaves them. Returns nothing if the stream has already finished.

errored

True once "error_event" ended the stream, so the proxy records the request as failed even though the HTTP status was 200.

usage

my ($input, $output, $content_bytes) = $stream->usage;

What the stream carried, for the usage event. content_bytes (UTF-8 bytes of the text this translator wrote) becomes the event's content_bytes, recorded beside the token counts on every stream -- an observation, never an estimate of tokens.

SEE ALSO

Langertha::Skeid::Protocol::Anthropic, Langertha::Skeid::Protocol::Ollama::Stream

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.