NAME

Langertha::Skeid::Protocol::Anthropic - Translate between the Anthropic Messages format and the upstream OpenAI call

VERSION

version 0.003

DESCRIPTION

Serves POST /v1/messages. Anthropic-specific field names live here and nowhere else in Skeid.

Tool shapes are not translated by hand — Langertha::Tool, Langertha::ToolCall and Langertha::ToolChoice own what a tool looks like in each dialect, including recovering Hermes-style <tool_call> blocks from plain text. A format Langertha cannot express is a Langertha ticket, not a parser here.

Streaming is translated, not refused: stream: true is rewritten to an OpenAI stream with stream_options.include_usage set, so the token counts an Anthropic client reads from message_delta actually arrive; the response is re-emitted at the client edge as the Anthropic event protocol — message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop. The same single upstream call (ADR 0001) carries the load; the format-specific framing is the only thing that changes between the wire Skeid speaks and the wire the client reads. See Langertha::Skeid::Protocol::Anthropic::Stream for the per-chunk rewrite and the usage accumulator, and t/31-stream-translation.t for what the wire looks like end-to-end.

finish_reason mapping is the same as the non-streaming path: tool_calls becomes tool_use, length becomes max_tokens, anything else becomes end_turn -- and a reply that carries tool calls but finished with stop reports tool_use. Tool calls in a stream are re-emitted as tool_use content blocks with input_json_delta deltas, one block per parallel call.

request_to_openai

my $openai_body = Langertha::Skeid::Protocol::Anthropic->request_to_openai($body);

Turns an Anthropic Messages request into the OpenAI chat-completions body Skeid forwards: model, the messages, and max_tokens, temperature and top_p when given. Nothing else of the request is carried -- stop_sequences, metadata, thinking and cache_control included; stream is set by the proxy.

system (a string or a block array) becomes a leading system message. Content block arrays fold to text, unless a user message carries an image block: then its text and image blocks become an OpenAI content array in the order the client sent them, text as text parts and each image as an image_url part -- a base64 source as a data: URL with its media_type, a url source as that URL. An image source of any other type (a Files API file_id) makes it die, answered as a 400. tool_use blocks become tool_calls on the assistant message; tool_result blocks become their own role = 'tool'> message carrying tool_call_id, which is why a single Anthropic message can expand into several OpenAI ones. A structured tool_result content is sent as its JSON text.

An OpenAI tool message cannot carry images, so the image blocks of a tool_result are lifted out of it: its tool message keeps the other blocks as JSON text (or, when there are none, a sentence saying the result is the images in the next message), and right after the run of tool messages one user message follows with the images of every tool_result in that Anthropic message, each result's images as image_url parts after a text part Images from tool result <tool_use_id>:. The user message goes after all the tool messages because an OpenAI conversation wants the answers to an assistant's tool calls directly after it. The client's own text and images in the same message follow as their own user message.

Only function tools are translated. A provider built-in in tools (web_search_20250305, bash_20250124, text_editor_*, computer_*, mcp_toolset, ...) or a definition "classify" in Langertha::Tool does not recognise, and an image source that is neither base64 nor a url, make it throw a Langertha::Skeid::Protocol::Refusal with a one-line message naming the tool type and its category; the proxy answers that as a 400 invalid_request_error carrying the message. Any other failure to translate the request is answered as a 400 with the fixed text Invalid request: that exception's text can quote the request, so it is neither sent nor logged.

response_from_openai

my $anthropic = Langertha::Skeid::Protocol::Anthropic->response_from_openai($res, $model);

Turns the upstream OpenAI response into an Anthropic message. Content becomes a text block, tool calls become tool_use blocks, and finish_reason maps tool_calls to tool_use, length to max_tokens, everything else to end_turn -- except that a reply carrying tool calls with stop (gpt-oss on vLLM-style servers) reports tool_use.

$model is the model the client asked for, used only when the upstream omits it.

error_type_for_status

my $type = Langertha::Skeid::Protocol::Anthropic->error_type_for_status(429);  # rate_limit_error

The error.type an Anthropic client expects for an HTTP status, from Anthropic's Messages API error reference: 400 invalid_request_error, 401 authentication_error, 402 billing_error, 403 permission_error, 404 not_found_error, 409 conflict_error, 413 request_too_large, 429 rate_limit_error, 500 api_error, 504 timeout_error, 529 overloaded_error. A status the reference does not list falls back by class: any other 5xx (Skeid's own 502 and 503) is api_error, any other 4xx invalid_request_error. The SDKs choose their exception class from the HTTP status first, so the fallback only decides the type string.

error_body

my $body = Langertha::Skeid::Protocol::Anthropic->error_body(429, 'Timed out waiting ...');
my $body = Langertha::Skeid::Protocol::Anthropic->error_body(500, $message, $upstream_type);

The Anthropic error envelope, { type => 'error', error => { type, message } }, with the type taken from "error_type_for_status". An optional third argument, an error type the upstream reported, wins when it is one of the types in that table -- an OpenAI-dialect upstream often spells a rate limit or a bad request the same way -- and is ignored otherwise, so a foreign type such as server_error never reaches an Anthropic client. Every error Skeid answers on /v1/messages is rendered from this -- also the data of a mid-stream event: error frame (see "error_event" in Langertha::Skeid::Protocol::Anthropic::Stream) -- so an Anthropic SDK can parse it and raise the matching exception.

manifest_endpoint

my $spec = Langertha::Skeid::Protocol::Anthropic->manifest_endpoint;
# { dialect => 'anthropic-compat', path => '', capabilities => [ ... ] }

How this face appears in the provider manifest (skeid #29): anthropic-compat at the public root, and the capability flags "request_to_openai" actually carries to the upstream -- system, function tools, tool_choice (auto, any, none, a named tool), max_tokens, temperature, stream and image blocks (image_input). A model is published here only with the capabilities declared for it that are in this list.

It is anthropic-compat, not anthropic: output_config.format is not translated, so a client takes the synthetic-tool path for structured output, which is what that dialect tells it. Not carried, so never claimed: structured output, thinking (reasoning), cache_control (prompt cache), and disable_parallel_tool_use.

SEE ALSO

Langertha::Skeid::Protocol::Anthropic::Stream, Langertha::Skeid::Protocol, Langertha::Skeid::Proxy

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.