File Coverage

blib/lib/AI/Anthropic.pm
Criterion Covered Total %
statement 45 123 36.5
branch 2 46 4.3
condition 9 43 20.9
subroutine 19 33 57.5
pod 4 4 100.0
total 79 249 31.7


line stmt bran cond sub pod time code
1             package AI::Anthropic;
2              
3 1     1   247709 use strict;
  1         2  
  1         46  
4 1     1   5 use warnings;
  1         2  
  1         87  
5 1     1   31 use 5.010;
  1         4  
6              
7             our $VERSION = '0.01';
8              
9 1     1   4 use Carp qw(croak);
  1         2  
  1         95  
10 1     1   4 use JSON::PP;
  1         2  
  1         57  
11 1     1   695 use HTTP::Tiny;
  1         50288  
  1         58  
12 1     1   624 use MIME::Base64 qw(encode_base64);
  1         1009  
  1         92  
13              
14             # Constants
15             use constant {
16 1         1497 API_BASE => 'https://api.anthropic.com',
17             API_VERSION => '2023-06-01',
18             DEFAULT_MODEL => 'claude-sonnet-4-20250514',
19 1     1   21 };
  1         7  
20              
21             =head1 NAME
22              
23             AI::Anthropic - Perl interface to Anthropic's Claude API
24              
25             =head1 SYNOPSIS
26              
27             use AI::Anthropic;
28              
29             my $claude = AI::Anthropic->new(
30             api_key => 'sk-ant-api03-your-key-here',
31             );
32              
33             # Simple message
34             my $response = $claude->message("What is the capital of France?");
35             print $response; # prints response text
36              
37             # Chat with history
38             my $response = $claude->chat(
39             messages => [
40             { role => 'user', content => 'Hello!' },
41             { role => 'assistant', content => 'Hello! How can I help you today?' },
42             { role => 'user', content => 'What is 2+2?' },
43             ],
44             );
45              
46             # With system prompt
47             my $response = $claude->chat(
48             system => 'You are a helpful Perl programmer.',
49             messages => [
50             { role => 'user', content => 'How do I read a file?' },
51             ],
52             );
53              
54             # Streaming
55             $claude->chat(
56             messages => [ { role => 'user', content => 'Tell me a story' } ],
57             stream => sub {
58             my ($chunk) = @_;
59             print $chunk;
60             },
61             );
62              
63             =head1 DESCRIPTION
64              
65             AI::Anthropic provides a Perl interface to Anthropic's Claude API.
66             It supports all Claude models including Claude 4 Opus, Claude 4 Sonnet,
67             and Claude Haiku.
68              
69             =head1 METHODS
70              
71              
72             =head1 AUTHOR
73              
74             Vugar Bakhshaliyev
75              
76             =head1 LICENSE
77              
78             This is free software; you can redistribute it and/or modify it under
79             the same terms as the Perl 5 programming language system itself.
80              
81             =head2 new
82              
83             my $claude = AI::Anthropic->new(
84             api_key => 'your-api-key', # required (or use ANTHROPIC_API_KEY env)
85             model => 'claude-sonnet-4-20250514', # optional
86             max_tokens => 4096, # optional
87             timeout => 120, # optional, seconds
88             );
89              
90             =cut
91              
92             sub new {
93 3     3 1 1653 my ($class, %args) = @_;
94            
95             my $api_key = $args{api_key} // $ENV{ANTHROPIC_API_KEY}
96 3 100 66     200 or croak "API key required. Set api_key parameter or ANTHROPIC_API_KEY environment variable";
97            
98             my $self = {
99             api_key => $api_key,
100             model => $args{model} // DEFAULT_MODEL,
101             max_tokens => $args{max_tokens} // 4096,
102             timeout => $args{timeout} // 120,
103             api_base => $args{api_base} // API_BASE,
104             _http => HTTP::Tiny->new(
105 2   50     44 timeout => $args{timeout} // 120,
      50        
      50        
      50        
      50        
106             ),
107             _json => JSON::PP->new->utf8->allow_nonref,
108             };
109            
110 2         335 return bless $self, $class;
111             }
112              
113             =head2 message
114              
115             Simple interface for single message:
116              
117             my $response = $claude->message("Your question here");
118             my $response = $claude->message("Your question", system => "You are helpful");
119            
120             print $response->text;
121              
122             =cut
123              
124             sub message {
125 0     0 1 0 my ($self, $content, %opts) = @_;
126            
127 0 0       0 croak "Message content required" unless defined $content;
128            
129 0         0 return $self->chat(
130             messages => [ { role => 'user', content => $content } ],
131             %opts,
132             );
133             }
134              
135             =head2 chat
136              
137             Full chat interface:
138              
139             my $response = $claude->chat(
140             messages => \@messages, # required
141             system => $system_prompt, # optional
142             model => $model, # optional, overrides default
143             max_tokens => $max_tokens, # optional
144             temperature => 0.7, # optional, 0.0-1.0
145             stream => \&callback, # optional, for streaming
146             tools => \@tools, # optional, for function calling
147             );
148              
149             =cut
150              
151             sub chat {
152 0     0 1 0 my ($self, %args) = @_;
153            
154             my $messages = $args{messages}
155 0 0       0 or croak "messages parameter required";
156            
157             # Build request body
158             my $body = {
159             model => $args{model} // $self->{model},
160             max_tokens => $args{max_tokens} // $self->{max_tokens},
161 0   0     0 messages => $self->_normalize_messages($messages),
      0        
162             };
163            
164             # Optional parameters
165 0 0       0 $body->{system} = $args{system} if defined $args{system};
166 0 0       0 $body->{temperature} = $args{temperature} if defined $args{temperature};
167 0 0       0 $body->{tools} = $args{tools} if defined $args{tools};
168 0 0       0 $body->{tool_choice} = $args{tool_choice} if defined $args{tool_choice};
169            
170             # Streaming or regular request
171 0 0       0 if (my $stream_cb = $args{stream}) {
172 0         0 return $self->_stream_request($body, $stream_cb);
173             } else {
174 0         0 return $self->_request($body);
175             }
176             }
177              
178             =head2 models
179              
180             Returns list of available models:
181              
182             my @models = $claude->models;
183              
184             =cut
185              
186             sub models {
187             return (
188 1     1 1 8 'claude-opus-4-20250514',
189             'claude-sonnet-4-20250514',
190             'claude-sonnet-4-5-20250929',
191             'claude-haiku-4-5-20251001',
192             'claude-3-5-sonnet-20241022',
193             'claude-3-5-haiku-20241022',
194             'claude-3-opus-20240229',
195             'claude-3-sonnet-20240229',
196             'claude-3-haiku-20240307',
197             );
198             }
199              
200             # ============================================
201             # Private methods
202             # ============================================
203              
204             sub _normalize_messages {
205 0     0   0 my ($self, $messages) = @_;
206            
207 0         0 my @normalized;
208 0         0 for my $msg (@$messages) {
209 0         0 my $content = $msg->{content};
210            
211             # Handle image content
212 0 0       0 if (ref $content eq 'ARRAY') {
213 0         0 my @parts;
214 0         0 for my $part (@$content) {
215 0 0 0     0 if ($part->{type} eq 'image' && $part->{path}) {
    0 0        
    0 0        
216             # Load image from file
217 0         0 push @parts, $self->_image_from_file($part->{path});
218             } elsif ($part->{type} eq 'image' && $part->{url}) {
219             # Load image from URL
220 0         0 push @parts, $self->_image_from_url($part->{url});
221             } elsif ($part->{type} eq 'image' && $part->{base64}) {
222             push @parts, {
223             type => 'image',
224             source => {
225             type => 'base64',
226             media_type => $part->{media_type} // 'image/png',
227             data => $part->{base64},
228             },
229 0   0     0 };
230             } else {
231 0         0 push @parts, $part;
232             }
233             }
234 0         0 push @normalized, { role => $msg->{role}, content => \@parts };
235             } else {
236 0         0 push @normalized, $msg;
237             }
238             }
239            
240 0         0 return \@normalized;
241             }
242              
243             sub _image_from_file {
244 0     0   0 my ($self, $path) = @_;
245            
246 0 0       0 open my $fh, '<:raw', $path
247             or croak "Cannot open image file '$path': $!";
248 0         0 local $/;
249 0         0 my $data = <$fh>;
250 0         0 close $fh;
251            
252             # Detect media type
253 0         0 my $media_type = 'image/png';
254 0 0       0 if ($path =~ /\.jpe?g$/i) {
    0          
    0          
255 0         0 $media_type = 'image/jpeg';
256             } elsif ($path =~ /\.gif$/i) {
257 0         0 $media_type = 'image/gif';
258             } elsif ($path =~ /\.webp$/i) {
259 0         0 $media_type = 'image/webp';
260             }
261            
262             return {
263 0         0 type => 'image',
264             source => {
265             type => 'base64',
266             media_type => $media_type,
267             data => encode_base64($data, ''),
268             },
269             };
270             }
271              
272             sub _image_from_url {
273 0     0   0 my ($self, $url) = @_;
274            
275             return {
276 0         0 type => 'image',
277             source => {
278             type => 'url',
279             url => $url,
280             },
281             };
282             }
283              
284             sub _request {
285 0     0   0 my ($self, $body) = @_;
286            
287             my $response = $self->{_http}->post(
288             $self->{api_base} . '/v1/messages',
289             {
290             headers => $self->_headers,
291 0         0 content => $self->{_json}->encode($body),
292             }
293             );
294            
295 0         0 return $self->_handle_response($response);
296             }
297              
298             sub _stream_request {
299 0     0   0 my ($self, $body, $callback) = @_;
300            
301 0         0 $body->{stream} = \1; # JSON true
302            
303 0         0 my $full_text = '';
304 0         0 my $response_data;
305            
306             # HTTP::Tiny doesn't support streaming well, so we use a data callback
307             my $response = $self->{_http}->post(
308             $self->{api_base} . '/v1/messages',
309             {
310             headers => $self->_headers,
311             content => $self->{_json}->encode($body),
312             data_callback => sub {
313 0     0   0 my ($chunk, $res) = @_;
314            
315             # Parse SSE events
316 0         0 for my $line (split /\n/, $chunk) {
317 0 0       0 next unless $line =~ /^data: (.+)/;
318 0         0 my $data = $1;
319 0 0       0 next if $data eq '[DONE]';
320            
321 0         0 eval {
322 0         0 my $event = $self->{_json}->decode($data);
323            
324 0 0       0 if ($event->{type} eq 'content_block_delta') {
    0          
325 0   0     0 my $text = $event->{delta}{text} // '';
326 0         0 $full_text .= $text;
327 0 0       0 $callback->($text) if $callback;
328             } elsif ($event->{type} eq 'message_stop') {
329 0         0 $response_data = $event;
330             }
331             };
332             }
333             },
334             }
335 0         0 );
336            
337 0 0       0 unless ($response->{success}) {
338 0         0 return $self->_handle_response($response);
339             }
340            
341             # Return a response object with the full text
342 0         0 return AI::Anthropic::Response->new(
343             text => $full_text,
344             raw_response => $response_data,
345             );
346             }
347              
348             sub _headers {
349 0     0   0 my ($self) = @_;
350            
351             return {
352             'Content-Type' => 'application/json',
353             'x-api-key' => $self->{api_key},
354 0         0 'anthropic-version' => API_VERSION,
355             };
356             }
357              
358             sub _handle_response {
359 0     0   0 my ($self, $response) = @_;
360            
361 0         0 my $data;
362 0         0 eval {
363 0         0 $data = $self->{_json}->decode($response->{content});
364             };
365            
366 0 0       0 unless ($response->{success}) {
367 0   0     0 my $error_msg = $data->{error}{message} // $response->{content} // 'Unknown error';
      0        
368 0         0 croak "Anthropic API error: $error_msg (status: $response->{status})";
369             }
370            
371             return AI::Anthropic::Response->new(
372             text => $data->{content}[0]{text} // '',
373             role => $data->{role},
374             model => $data->{model},
375             stop_reason => $data->{stop_reason},
376             usage => $data->{usage},
377 0   0     0 raw_response => $data,
378             );
379             }
380              
381             # ============================================
382             # Response class
383             # ============================================
384              
385             package AI::Anthropic::Response;
386              
387 1     1   7 use strict;
  1         2  
  1         27  
388 1     1   3 use warnings;
  1         2  
  1         62  
389 1     1   4 use overload '""' => \&text, fallback => 1;
  1         2  
  1         27  
390              
391             sub new {
392 1     1   538 my ($class, %args) = @_;
393 1         2 return bless \%args, $class;
394             }
395              
396 2     2   31 sub text { shift->{text} }
397 0     0   0 sub role { shift->{role} }
398 1     1   5 sub model { shift->{model} }
399 0     0   0 sub stop_reason { shift->{stop_reason} }
400 0     0   0 sub usage { shift->{usage} }
401 0     0   0 sub raw_response { shift->{raw_response} }
402              
403 2   50 2   12 sub input_tokens { shift->{usage}{input_tokens} // 0 }
404 2   50 2   11 sub output_tokens { shift->{usage}{output_tokens} // 0 }
405             sub total_tokens {
406 1     1   3 my $self = shift;
407 1         3 return $self->input_tokens + $self->output_tokens;
408             }
409              
410             1;
411              
412             __END__