Langertha-Skeid

 view release on metacpan or  search on metacpan

MANIFEST  view on Meta::CPAN

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 )