Langertha-Knarr

 view release on metacpan or  search on metacpan

t/70_passthrough.t  view on Meta::CPAN

use strict;
use warnings;
use Test2::V0;
use IO::Async::Loop;
use Net::Async::HTTP;
use HTTP::Request;
use JSON::MaybeXS;

use Langertha::Knarr;
use Langertha::Knarr::Handler::Code;
use Langertha::Knarr::Handler::Passthrough;

my $json = JSON::MaybeXS->new( utf8 => 1, canonical => 1 );
my $loop = IO::Async::Loop->new;

# --- Backend: a Knarr that answers OpenAI/Anthropic/Ollama with a known string,
#     plus token-by-token streaming so the passthrough's stream-extractor can
#     unwrap the protocol-native chunks.
my $backend_handler = Langertha::Knarr::Handler::Code->new(
  code => sub {
    my ($s, $r) = @_;
    my $u = $r->messages->[-1] // {};
    return "BACKEND: " . ( $u->{content} // '' );
  },
  stream_code => sub {
    my @parts = ('BACK', 'END:', ' hi');
    return sub { @parts ? shift @parts : undef };
  },
);
my $backend = Langertha::Knarr->new(
  handler => $backend_handler,
  loop    => $loop,
  port    => 0,
);
$backend->start;
my $bport = $backend->_server->read_handle->sockport;
ok( $bport, "backend on $bport" );

# --- Knarr in front of the backend with a Passthrough handler.
my $passthrough = Langertha::Knarr::Handler::Passthrough->new(
  upstreams => {
    openai    => "http://127.0.0.1:$bport",
    anthropic => "http://127.0.0.1:$bport",
    ollama    => "http://127.0.0.1:$bport",
  },
  loop => $loop,
);
my $front = Langertha::Knarr->new(
  handler => $passthrough,
  loop    => $loop,
  port    => 0,
);
$front->start;
my $fport = $front->_server->read_handle->sockport;
ok( $fport, "front on $fport" );

my $http = Net::Async::HTTP->new;
$loop->add($http);

sub post_json {
  my ($url, $body) = @_;
  my $req = HTTP::Request->new( POST => $url );
  $req->header( 'Content-Type' => 'application/json' );
  $req->content( $json->encode($body) );
  return $http->do_request( request => $req )->get;
}

# --- 1) OpenAI sync passthrough ---
{
  my $resp = post_json(
    "http://127.0.0.1:$fport/v1/chat/completions",
    { model => 'm', messages => [ { role => 'user', content => 'hi' } ] },
  );
  is( $resp->code, 200, 'openai passthrough sync 200' );
  my $d = $json->decode($resp->decoded_content);
  is( $d->{choices}[0]{message}{content}, 'BACKEND: hi', 'openai content via passthrough' );
}

# --- 2) Ollama sync passthrough ---
{
  my $resp = post_json(
    "http://127.0.0.1:$fport/api/chat",
    { model => 'm', messages => [ { role => 'user', content => 'yo' } ], stream => JSON::MaybeXS::false() },
  );
  is( $resp->code, 200, 'ollama passthrough sync 200' );
  my $d = $json->decode($resp->decoded_content);
  is( $d->{message}{content}, 'BACKEND: yo', 'ollama content via passthrough' );
}

# --- 3) Anthropic sync passthrough ---
{
  my $resp = post_json(
    "http://127.0.0.1:$fport/v1/messages",
    { model => 'm', messages => [ { role => 'user', content => 'sup' } ] },
  );
  is( $resp->code, 200, 'anthropic passthrough sync 200' );
  my $d = $json->decode($resp->decoded_content);
  is( $d->{content}[0]{text}, 'BACKEND: sup', 'anthropic content via passthrough' );
}

# --- 4) OpenAI streaming passthrough ---
{
  my $resp = post_json(
    "http://127.0.0.1:$fport/v1/chat/completions",
    { model => 'm', messages => [ { role => 'user', content => 'hi' } ], stream => JSON::MaybeXS::true() },
  );
  is( $resp->code, 200, 'openai passthrough stream 200' );
  is( $resp->header('Content-Type'), 'text/event-stream', 'SSE content type' );
  my $body = $resp->decoded_content;
  my @data = grep { /^data:/ && !/\[DONE\]/ } split /\n/, $body;
  ok( scalar(@data) >= 3, 'multiple SSE chunks forwarded' );
  like( $body, qr/BACK/, 'first chunk text present' );
  like( $body, qr/END:/, 'second chunk text present' );
}

# --- 5) Ollama streaming passthrough ---
{
  my $resp = post_json(
    "http://127.0.0.1:$fport/api/chat",
    { model => 'm', messages => [ { role => 'user', content => 'hi' } ], stream => JSON::MaybeXS::true() },
  );
  is( $resp->code, 200, 'ollama passthrough stream 200' );
  is( $resp->header('Content-Type'), 'application/x-ndjson', 'NDJSON content type' );
  my @lines = grep { length } split /\n/, $resp->decoded_content;
  ok( scalar(@lines) >= 4, 'multiple NDJSON lines forwarded' );
  my $last = $json->decode($lines[-1]);
  ok( $last->{done}, 'final ndjson chunk has done:true' );
}

# --- 6) list_models on passthrough ---
{
  is( $passthrough->list_models->[0]{id}, 'passthrough', 'default model id' );
}

done_testing;



( run in 1.572 second using v1.01-cache-2.11-cpan-600a1bdf6e4 )