Langertha-Skeid
view release on metacpan or search on metacpan
t/29-capacity-probe-prometheus.t
t/30-worker-share.t
t/31-stream-translation.t
t/32-capacity-probe-prometheus-engines.t
t/33-agent-flow.t
t/34-json-translation.t
t/35-frontend-share.t
t/36-usage-cached-tokens.t
t/37-anthropic-builtin-tools.t
t/38-anthropic-errors.t
t/39-upstream-sse-content-type.t
t/39-upstream-sse-data-spacing.t
t/40-anthropic-tool-call-stop-reason.t
t/41-anthropic-stream-event-type.t
t/42-utf8-nested-json.t
t/43-ollama-tool-history.t
t/44-stream-content-bytes.t
t/45-usage-content-bytes.t
t/46-provider-manifest.t
t/47-config-reload-change-detection.t
t/48-config-reload-failure.t
lib/Langertha/Skeid/Protocol/MCP.pm view on Meta::CPAN
# The transports a server entry can name. Only one of them is relayed; the other is a hint for
# a server on the customer's own machine.
my %TRANSPORT = (
'streamable-http' => { relayed => 1 },
'stdio' => { relayed => 0 },
);
# The client's request headers that travel upstream, beside every Mcp-* one. Nothing else of
# the client's does -- its Authorization least of all.
my %REQUEST_HEADER = map { $_ => 1 } qw( content-type accept last-event-id );
# Header names a server's static `headers:` may not set, and why. A credential in the config
# would be a secret on disk (ADR 0003); the rest belong to the relay or to the connection.
my $CREDENTIAL = 'a credential is named by api_key_ref or api_key_env, never written into the config';
my $RELAYED = 'the relay passes it on from the client';
my $FRAMING = 'it belongs to the connection';
my %CONFIG_HEADER_PROBLEM = (
(map { $_ => $CREDENTIAL } qw( authorization proxy-authorization x-api-key cookie )),
(map { $_ => $RELAYED } keys %REQUEST_HEADER),
(map { $_ => $FRAMING } qw( host content-length transfer-encoding content-encoding accept-encoding
connection keep-alive te trailer upgrade expect )),
);
# The upstream's response headers that reach the client, beside every Mcp-* one. Whatever else
# the server sets -- cookies, Server, CORS, HSTS -- is about the server and its own site, not
# for a client of this gateway. WWW-Authenticate is not here: it would come only with an
# upstream 401, which is answered 502 and never relayed.
my %RESPONSE_HEADER = map { $_ => 1 } qw( content-type cache-control retry-after );
sub response_header_allowed {
my ($class, $name) = @_;
my $lc = lc($name // '');
return ($RESPONSE_HEADER{$lc} || $lc =~ /\Amcp-/) ? 1 : 0;
}
sub transports { return sort keys %TRANSPORT }
lib/Langertha/Skeid/Proxy.pm view on Meta::CPAN
$upstream_failed = 1;
$upstream_error_body .= $bytes;
return;
}
$c->res->code($status);
for my $name (@{$tx->res->headers->names}) {
my $lc = lc($name);
next if _skip_response_header($lc);
# A translated stream is not the upstream's media type any more. Relaying
# text/event-stream to an Ollama client tells it to parse something it does not speak.
next if $stream && $lc eq 'content-type';
$c->res->headers->header($name => $tx->res->headers->header($name));
}
$c->res->headers->header('content-type' => $stream->content_type) if $stream;
$c->res->headers->header('x-skeid-node' => $node_id);
$c->res->headers->header('x-request-id' => $request_id);
$headers_sent = 1;
}
# Parse SSE lines and accumulate usage + content bytes. Without a translator the relayed
# bytes are never modified -- this reads along, it does not rewrite.
$pending .= $bytes;
my $translated = '';
while ($pending =~ s/\A([^\n]*)\n//) {
lib/Langertha/Skeid/Proxy.pm view on Meta::CPAN
# The body is encoded when the request is built, after the caller has set `model` and `stream`
# on it. undef for a request without tools; a body whose tools no longer line up with the
# client's goes up encoded as before.
sub _schema_relay {
my ($c, $openai_body, @path) = @_;
my $texts = Langertha::Skeid::Protocol::RawBody::tool_schemas($c->req->body, @path) or return undef;
return { request => sub {
my ($ua, $url, $headers) = @_;
my $bytes = Langertha::Skeid::Protocol::RawBody::encode_with_schemas($openai_body, $texts);
return $ua->build_tx(POST => $url, $headers, json => $openai_body) unless defined $bytes;
delete @{$headers}{ grep { lc($_) eq 'content-type' } keys %$headers };
return $ua->build_tx(POST => $url, { %$headers, 'Content-Type' => 'application/json' }, $bytes);
} };
}
# The upstream request of an admitted call: the one JSON POST every translated route makes (ADR
# 0001), or what a relayed route's own `request` builder returns (ADR 0021).
sub _upstream_tx {
my ($c, $url, $headers, $body, $relay) = @_;
my $ua = $c->app->ua;
return $relay->{request}->($ua, $url, $headers) if $relay && $relay->{request};
misc/agent-context/.claude/skills/skeid-protocols/SKILL.md view on Meta::CPAN
`_proxy_openai_stream` serves every face. The upstream body is read raw (`Proxy::RelayContent`, so
an unchunked `text/event-stream` is not swallowed by Mojolicious's SSE parser); `data: {â¦}` lines
are parsed across read boundaries for content size and the verbatim `usage` block; one usage event
on completion. Without a translator the bytes are relayed as they came; with one
(`Protocol::*::Stream`) each parsed chunk goes through `->delta` and the client gets the
translator's framing and content type.
- Never rewrite a chunk on the OpenAI face. The relay is byte-transparent; anything else breaks
client parsers and makes TTFT unmeasurable.
- `content-length`, `transfer-encoding`, `content-encoding` are stripped from relayed headers (a
translated stream also drops `content-type`); `x-skeid-node` is added.
- Chunks go out through a drain queue, not one `write_chunk` per read â a dynamic response with no
drain callback ends when its queue empties.
- Headers go out on the first chunk. On a translated face an upstream error status before that is
a plain HTTP error in the client's shape; after it the failure travels in-band (Anthropic
`error` event, Ollama error line) and the stream is `ok = 0`, as is a body cut short of its own
framing. Every path calls `request.finish` exactly once.
- Anthropic and Ollama streams go upstream with `stream_options.include_usage`; the OpenAI face
forwards the client's body as is. Missing usage is expected: the event gets zeroed tokens.
## Engine IDs
t/28-capacity-probe.t view on Meta::CPAN
'Anthropic spelling works too, and nothing left means nothing admitted';
# Remaining with no limit still has to stop admission at zero -- that is the case that matters.
my $skeid2 = Langertha::Skeid->new;
$skeid2->add_node(id => 'cloud', url => 'http://x/v1', model => 'm');
$skeid2->observe_response_headers('cloud', { 'x-ratelimit-remaining' => '0' });
is $skeid2->route_state(model => 'm')->{has_available}, 0, 'remaining 0 without a limit blocks';
$skeid2->observe_response_headers('cloud', { 'x-ratelimit-remaining' => '7' });
is $skeid2->route_state(model => 'm')->{has_available}, 1, 'and a positive remaining does not';
ok !$skeid2->observe_response_headers('cloud', { 'content-type' => 'application/json' }),
'a response that says nothing about rate limits records nothing';
# Providers meter requests and tokens separately, and for an LLM API the token budget is
# usually what runs out first. Reading only the request quota would keep sending to a node
# with plenty of requests left and no tokens, which answers 429 all the same.
my $skeid3 = Langertha::Skeid->new;
$skeid3->add_node(id => 'cloud', url => 'http://x/v1', model => 'm');
my $tokens_tight = $skeid3->observe_response_headers('cloud', {
'x-ratelimit-limit-requests' => '1000',
'x-ratelimit-remaining-requests' => '990', # 1% used
t/38-anthropic-errors.t view on Meta::CPAN
my $upstream_daemon = Mojo::Server::Daemon->new(app => $upstream, listen => ['http://127.0.0.1'], silent => 1);
$upstream_daemon->start;
my $up = 'http://127.0.0.1:' . $upstream_daemon->ports->[0] . '/v1';
# A port nothing listens on: the connection is refused, which is an upstream transport error.
my $dead_port = Mojo::IOLoop::Server->generate_port;
# Raw-socket upstreams for Content-Length framing, which a Mojolicious app cannot be made to
# violate: one sends exactly the bytes it announced, the other announces more than it sends and
# hangs up. The charset-less media type Mojo's own SSE parser grabs is covered by
# t/39-upstream-sse-content-type.t (skeid karr #30).
my $SSE = join '', map { "data: $_\n\n" }
q({"id":"c","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}),
q({"id":"c","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}),
'[DONE]';
my %raw_port;
for my $case ([ 'cl-full-model', length $SSE ], [ 'cl-cut-model', 50 + length $SSE ]) {
my ($model, $announced) = @$case;
my $raw = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\n"
. "Content-Length: $announced\r\n\r\n$SSE";
my $id = Mojo::IOLoop->server({ address => '127.0.0.1' } => sub {
t/61-node-key-fail-closed.t view on Meta::CPAN
my $stack = build_stack(
node => $case->{node},
($case->{broker} ? ( broker => $case->{broker}->() ) : ()),
);
my $t = request($stack, $face);
is scalar(@{$stack->{hits}}), 0, 'the upstream is never called'
or diag explain $stack->{hits};
$t->status_is(503);
$t->header_like('content-type' => qr{\Aapplication/json}, 'answered as a plain JSON error');
if ($face->{shape} eq 'anthropic') {
$t->json_is('/type' => 'error', 'in the Anthropic envelope')
->json_is('/error/type' => 'api_error')
->json_like('/error/message' => qr/key/i);
} elsif ($face->{shape} eq 'ollama') {
my $error = $t->tx->res->json('/error');
ok defined($error) && !ref($error) && $error =~ /key/i, 'in the Ollama error shape';
} else {
$t->json_is('/error/type' => 'upstream_key_unavailable', 'in the OpenAI error shape')
->json_like('/error/message' => qr/key/i);
t/72-mcp-gateway.t view on Meta::CPAN
->header_is('x-request-id' => 'req-client-1', 'and the request id of this request')
->header_like('Content-Type' => qr{\Aapplication/json})
->json_is('/result/path' => '/mcp', 'the upstream\'s body');
ok !defined($t->tx->res->headers->header('x-skeid-node')), 'no node served this';
is scalar(@SEEN), 1, 'one upstream call';
my $seen = $SEEN[0];
is $seen->{method}, 'POST', 'the method';
is $seen->{path}, '/mcp', 'the configured path';
is $seen->{body}, $RPC, 'the body, byte for byte';
is $seen->{headers}{'content-type'}, 'application/json', 'Content-Type';
is $seen->{headers}{accept}, 'application/json, text/event-stream', 'Accept';
is $seen->{headers}{'mcp-session-id'}, 'sess-client', 'Mcp-Session-Id';
is $seen->{headers}{'mcp-protocol-version'}, '2025-06-18', 'Mcp-Protocol-Version, however it is spelled';
is $seen->{headers}{'last-event-id'}, 'ev-41', 'Last-Event-ID';
is $seen->{headers}{'mcp-future-header'}, 'kept', 'any other Mcp-* header';
is $seen->{headers}{authorization}, "Bearer $ENV_SECRET", 'the upstream gets its own credential';
is $seen->{headers}{'x-tenant'}, 'citilan', 'and the configured static header';
for my $name (qw( x-api-key cookie x-custom x-forwarded-for x-request-id x-skeid-key-id )) {
ok !exists $seen->{headers}{$name}, "$name does not go upstream";
}
t/scenarios/13-streaming.yml view on Meta::CPAN
fallback: { answer: silent }
config:
pricing:
m: { input_per_million: 1000000, output_per_million: 2000000 }
steps:
# OpenAI: the text arrives in the node's pieces of four characters (5 events), the client asked for usage
- chat: { protocol: openai, model: m, prompt: count me, stream: true, include_usage: true }
expect:
text: Hello streamed world
finish_reason: stop
headers_like: { content-type: text/event-stream }
headers: { x-skeid-node: gpu }
json: { events: 5, stopped: 1, usage.prompt_tokens: 3, usage.completion_tokens: 5, usage.total_tokens: 8 }
usage:
- { api_format: openai, ok: 1, input_tokens: 3, output_tokens: 5, total_tokens: 8, cost_total_usd: 13, content_bytes: 20 }
# OpenAI without include_usage: the face never changes what the client asked for (ADR 0021), so nothing is metered
- chat: { protocol: openai, model: m, prompt: count me, stream: true }
expect:
text: Hello streamed world
absent: [ usage ]
usage:
- { input_tokens: 3 }
- { api_format: openai, ok: 1, input_tokens: 0, output_tokens: 0, content_bytes: 20 }
# Anthropic: message events, the stop reason translated, counts from message_start/message_delta
- chat: { protocol: anthropic, model: m, prompt: count me, stream: true }
expect:
text: Hello streamed world
finish_reason: end_turn
headers_like: { content-type: text/event-stream }
json: { stopped: 1, usage.output_tokens: 5 }
usage:
- { input_tokens: 3 }
- { input_tokens: 0 }
- { api_format: anthropic, ok: 1, input_tokens: 3, output_tokens: 5, total_tokens: 8, cost_total_usd: 13 }
# Ollama: newline-delimited objects, counts on the closing one
- chat: { protocol: ollama, model: m, prompt: count me, stream: true }
expect:
text: Hello streamed world
finish_reason: stop
( run in 1.297 second using v1.01-cache-2.11-cpan-99fa8243a42 )