Langertha-Knarr
view release on metacpan or search on metacpan
lib/Langertha/Knarr/Handler/Passthrough.pm view on Meta::CPAN
);
has model_id => ( is => 'ro', isa => 'Str', default => 'passthrough' );
has loop => (
is => 'ro',
lazy => 1,
default => sub { IO::Async::Loop->new },
);
has _http => ( is => 'ro', lazy => 1, builder => '_build_http' );
sub _build_http {
my ($self) = @_;
my $h = Net::Async::HTTP->new;
$self->loop->add($h);
return $h;
}
has _json => ( is => 'ro', default => sub { JSON::MaybeXS->new( utf8 => 1, canonical => 1 ) } );
# Per-protocol path the request should hit upstream. We use the protocol
# defaults; this lookup table can be extended.
my %DEFAULT_PATH = (
openai => '/v1/chat/completions',
anthropic => '/v1/messages',
ollama => '/api/chat',
);
sub _upstream_url {
my ($self, $protocol_name) = @_;
my $base = $self->upstreams->{$protocol_name}
or die "Passthrough: no upstream configured for protocol '$protocol_name'\n";
$base =~ s{/+$}{};
my $path = $DEFAULT_PATH{$protocol_name}
or die "Passthrough: no default path for protocol '$protocol_name'\n";
return "$base$path";
}
sub _build_upstream_request {
my ($self, $request, $force_stream) = @_;
my $body = { %{ $request->raw || {} } };
$body->{stream} = $force_stream ? JSON::MaybeXS::true() : JSON::MaybeXS::false()
if defined $force_stream;
my $url = $self->_upstream_url( $request->protocol );
my $http_req = HTTP::Request->new( POST => $url );
$http_req->header( 'Content-Type' => 'application/json' );
# Forward client auth headers (captured by protocol parsers)
if ( my $fwd = $request->extra->{forward_headers} ) {
for my $h (keys %$fwd) {
$http_req->header( $h => $fwd->{$h} );
}
}
if ( my $auth = $self->default_auth ) {
$http_req->header( Authorization => $auth ) unless $http_req->header('Authorization');
}
$http_req->content( $self->_json->encode($body) );
return $http_req;
}
# Extract assistant text from an upstream response body for the protocol it
# came from. Bare-minimum extractor for sync mode; the streaming path
# forwards bytes verbatim and doesn't need this.
sub _extract_text {
my ($self, $protocol_name, $resp_body) = @_;
my $data = eval { $self->_json->decode($resp_body) };
return '' unless ref $data eq 'HASH';
if ( $protocol_name eq 'openai' ) {
return $data->{choices}[0]{message}{content} // '';
}
if ( $protocol_name eq 'anthropic' ) {
my $bits = '';
for my $b ( @{ $data->{content} || [] } ) {
$bits .= $b->{text} // '' if ($b->{type} // '') eq 'text';
}
return $bits;
}
if ( $protocol_name eq 'ollama' ) {
return $data->{message}{content} // '';
}
return '';
}
async sub handle_chat_f {
my ($self, $session, $request) = @_;
my $http_req = $self->_build_upstream_request( $request, 0 );
my $resp = await $self->_http->do_request( request => $http_req );
die "Passthrough upstream failed: " . $resp->status_line . "\n" unless $resp->is_success;
my $text = $self->_extract_text( $request->protocol, $resp->decoded_content );
return Langertha::Knarr::Response->new(
content => $text,
model => $request->model // $self->model_id,
);
}
async sub handle_stream_f {
my ($self, $session, $request) = @_;
my $http_req = $self->_build_upstream_request( $request, 1 );
my @queue;
my $pending;
my $finished = 0;
my $error;
my $buffer = '';
my $deliver = sub {
my ($v) = @_;
if ( $pending ) { my $p = $pending; $pending = undef; $p->done($v) }
else { push @queue, $v }
};
# Streaming request: hand the body chunks straight back as deltas. The
# upstream already speaks the same wire format the client requested, so
# we forward bytes 1:1 by extracting just the text content from each
# protocol-native chunk. The Knarr core then re-frames them via the
# client-side protocol's format_stream_chunk â keeping symmetry even
# when client and upstream use the same protocol.
my $proto_name = $request->protocol;
my $extract_chunk = sub {
my ($line) = @_;
if ( $proto_name eq 'openai' || $proto_name eq 'anthropic' ) {
return undef unless $line =~ /^data:\s*(.+)$/;
lib/Langertha/Knarr/Handler/Passthrough.pm view on Meta::CPAN
$deliver->($delta) if defined $delta && length $delta;
}
}
};
},
);
$f->on_fail( sub { $error = $_[0]; $finished = 1; $deliver->(undef) } );
$f->retain;
return Langertha::Knarr::Stream->new(
source => sub {
if ( @queue ) { return Future->done( shift @queue ) }
if ( $finished ) { return $error ? Future->fail($error) : Future->done(undef) }
$pending = Future->new;
return $pending;
},
);
}
sub list_models {
my ($self) = @_;
return [ { id => $self->model_id, object => 'model' } ];
}
__PACKAGE__->meta->make_immutable;
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
Langertha::Knarr::Handler::Passthrough - Knarr handler that forwards requests verbatim to an upstream HTTP API
=head1 VERSION
version 1.100
=head1 SYNOPSIS
use Langertha::Knarr::Handler::Passthrough;
my $handler = Langertha::Knarr::Handler::Passthrough->new(
upstreams => {
openai => 'https://api.openai.com',
anthropic => 'https://api.anthropic.com',
ollama => 'http://localhost:11434',
},
);
=head1 DESCRIPTION
Forwards the original wire-format request verbatim to a real upstream
API. The protocol's parser already turned the body into a
L<Langertha::Knarr::Request>; Passthrough rebuilds the upstream JSON
from C<$request-E<gt>raw> and re-POSTs it.
Both sync and streaming requests are supported. For streaming, the
upstream's protocol-native chunks are extracted into plain text deltas
which the front-side protocol then re-frames â keeping symmetry even
when client and upstream use the same protocol.
This is the building block behind Knarr's classic "configure your API
keys once, point everything at me" use case.
=head2 upstreams
Required. HashRef mapping protocol name (C<openai>, C<anthropic>,
C<ollama>) to upstream base URL. The protocol's default chat path is
appended.
=head2 default_auth
Optional. An C<Authorization> header value to inject when the client
didn't send one. Usually you let the client supply its own key.
=head2 model_id
Optional. Defaults to C<passthrough>.
=head1 SUPPORT
=head2 Issues
Please report bugs and feature requests on GitHub at
L<https://github.com/Getty/langertha-knarr/issues>.
=head2 IRC
Join C<#langertha> on C<irc.perl.org> or message Getty directly.
=head1 CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
=head1 AUTHOR
Torsten Raudssus <torsten@raudssus.de> L<https://raudssus.de/>
=head1 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.
=cut
( run in 0.527 second using v1.01-cache-2.11-cpan-600a1bdf6e4 )