File Coverage

blib/lib/WebService/Ollama/Async.pm
Criterion Covered Total %
statement 51 163 31.2
branch 9 56 16.0
condition 9 53 16.9
subroutine 13 26 50.0
pod 15 16 93.7
total 97 314 30.8


line stmt bran cond sub pod time code
1             package WebService::Ollama::Async;
2              
3 3     3   139897 use 5.006;
  3         13  
4 3     3   20 use strict;
  3         7  
  3         93  
5 3     3   15 use warnings;
  3         7  
  3         266  
6              
7             our $VERSION = '0.08';
8              
9 3     3   659 use Moo;
  3         10452  
  3         18  
10 3     3   2945 use Exporter 'import';
  3         42  
  3         129  
11 3     3   2207 use Future;
  3         56157  
  3         2379  
12 3     3   586 use JSON::Lines;
  3         26939  
  3         35  
13              
14 3     3   1738 use WebService::Ollama::UA::Async;
  3         16  
  3         7593  
15              
16             our @EXPORT_OK = qw(ollama);
17              
18             # Singleton instance for functional API
19             my $_instance;
20              
21             sub ollama {
22 1     1 0 15372 my ($method, @args) = @_;
23            
24             # Initialize singleton on first call
25             $_instance //= __PACKAGE__->new(
26             base_url => $ENV{OLLAMA_URL} // 'http://localhost:11434',
27 1   50     32 model => $ENV{OLLAMA_MODEL} // 'llama3.2',
      50        
      33        
28             );
29            
30             # If first arg is a hashref, it's options for new instance
31 1 50       15 if (ref($method) eq 'HASH') {
32 1         58 $_instance = __PACKAGE__->new(%$method);
33 1         17 return $_instance;
34             }
35            
36             # Call method on singleton
37 0         0 return $_instance->$method(@args);
38             }
39              
40             has base_url => (
41             is => 'ro',
42             required => 1,
43             );
44              
45             has model => (
46             is => 'ro',
47             );
48              
49             has loop => (
50             is => 'ro',
51             predicate => 'has_loop',
52             );
53              
54             has ua => (
55             is => 'ro',
56             lazy => 1,
57             default => sub {
58             my $self = shift;
59             return WebService::Ollama::UA::Async->new(
60             base_url => $self->base_url,
61             ($self->has_loop ? (loop => $self->loop) : ()),
62             );
63             },
64             );
65              
66             has tools => (
67             is => 'ro',
68             default => sub { {} },
69             );
70              
71             sub register_tool {
72 1     1 1 58 my ($self, %args) = @_;
73              
74 1 50       9 my $name = $args{name} or die "Tool name required";
75 1   50     8 my $description = $args{description} // '';
76 1   50     7 my $parameters = $args{parameters} // { type => 'object', properties => {} };
77 1 50       4 my $handler = $args{handler} or die "Tool handler required";
78              
79 1         8 $self->tools->{$name} = {
80             name => $name,
81             description => $description,
82             parameters => $parameters,
83             handler => $handler,
84             };
85              
86 1         4 return $self;
87             }
88              
89             sub _format_tools_for_api {
90 1     1   391 my ($self, $tools) = @_;
91              
92 1   33     10 $tools //= $self->tools;
93 1 50       3 return [] unless keys %$tools;
94              
95             return [
96             map {
97 1         4 {
98             type => 'function',
99             function => {
100             name => $_->{name},
101             description => $_->{description},
102             parameters => $_->{parameters},
103             },
104             }
105 1         8 } values %$tools
106             ];
107             }
108              
109             sub version {
110 0     0 1 0 my ($self) = @_;
111 0         0 return $self->ua->get(url => '/api/version');
112             }
113              
114             sub available_models {
115 0     0 1 0 my ($self) = @_;
116 0         0 return $self->ua->get(url => '/api/tags');
117             }
118              
119             sub running_models {
120 0     0 1 0 my ($self) = @_;
121 0         0 return $self->ua->get(url => '/api/ps');
122             }
123              
124             sub create_model {
125 0     0 1 0 my ($self, %args) = @_;
126              
127 0 0       0 if (!$args{model}) {
128 0         0 return Future->fail("No model defined for create_model");
129             }
130              
131 0         0 return $self->ua->post(
132             url => '/api/create',
133             data => \%args,
134             );
135             }
136              
137             sub copy_model {
138 0     0 1 0 my ($self, %args) = @_;
139              
140 0 0       0 if (!$args{source}) {
141 0         0 return Future->fail("No source defined for copy_model");
142             }
143              
144 0 0       0 if (!$args{destination}) {
145 0         0 return Future->fail("No destination defined for copy_model");
146             }
147              
148 0         0 return $self->ua->post(
149             url => '/api/copy',
150             data => \%args,
151             );
152             }
153              
154             sub delete_model {
155 0     0 1 0 my ($self, %args) = @_;
156              
157 0 0       0 if (!$args{model}) {
158 0         0 return Future->fail("No model defined for delete_model");
159             }
160              
161 0         0 return $self->ua->delete(
162             url => '/api/delete',
163             data => \%args,
164             );
165             }
166              
167             sub load_completion_model {
168 0     0 1 0 my ($self, %args) = @_;
169              
170 0   0     0 $args{model} //= $self->model;
171              
172 0 0       0 if (!$args{model}) {
173 0         0 return Future->fail("No model defined for load_completion_model");
174             }
175              
176 0         0 return $self->ua->post(
177             url => '/api/generate',
178             data => \%args,
179             );
180             }
181              
182             sub unload_completion_model {
183 0     0 1 0 my ($self, %args) = @_;
184              
185 0   0     0 $args{model} //= $self->model;
186              
187 0 0       0 if (!$args{model}) {
188 0         0 return Future->fail("No model defined for unload_completion_model");
189             }
190              
191 0         0 $args{keep_alive} = 0;
192              
193 0         0 return $self->ua->post(
194             url => '/api/generate',
195             data => \%args,
196             );
197             }
198              
199             sub completion {
200 1     1 1 399 my ($self, %args) = @_;
201              
202 1   33     45 $args{model} //= $self->model;
203              
204 1 50       4 if (!$args{model}) {
205 0         0 return Future->fail("No model defined for completion");
206             }
207              
208 1 50       3 if (!$args{prompt}) {
209 1         5 return Future->fail("No prompt defined for completion");
210             }
211              
212 0         0 $args{stream} = \0;
213              
214 0 0       0 if (defined $args{image_files}) {
215 0         0 $args{images} = [];
216 0         0 push @{$args{images}}, @{$self->ua->base64_images(delete $args{image_files})};
  0         0  
  0         0  
217             }
218              
219 0         0 return $self->ua->post(
220             url => '/api/generate',
221             data => \%args,
222             );
223             }
224              
225             sub load_chat_model {
226 0     0 1 0 my ($self, %args) = @_;
227              
228 0   0     0 $args{model} //= $self->model;
229              
230 0 0       0 if (!$args{model}) {
231 0         0 return Future->fail("No model defined for load_chat_model");
232             }
233              
234 0         0 $args{messages} = [];
235              
236 0         0 return $self->ua->post(
237             url => '/api/chat',
238             data => \%args,
239             );
240             }
241              
242             sub unload_chat_model {
243 0     0 1 0 my ($self, %args) = @_;
244              
245 0   0     0 $args{model} //= $self->model;
246              
247 0 0       0 if (!$args{model}) {
248 0         0 return Future->fail("No model defined for unload_chat_model");
249             }
250              
251 0         0 $args{keep_alive} = 0;
252 0         0 $args{messages} = [];
253              
254 0         0 return $self->ua->post(
255             url => '/api/generate',
256             data => \%args,
257             );
258             }
259              
260             sub chat {
261 2     2 1 22 my ($self, %args) = @_;
262              
263 2   66     19 $args{model} //= $self->model;
264              
265 2 100       7 if (!$args{model}) {
266 1         19 return Future->fail("No model defined for chat");
267             }
268              
269 1 50       5 if (!$args{messages}) {
270 1         7 return Future->fail("No messages defined for chat");
271             }
272              
273 0           $args{stream} = \0;
274              
275             # Add tools if registered and not already provided
276 0 0 0       if (!$args{tools} && keys %{$self->tools}) {
  0            
277 0           $args{tools} = $self->_format_tools_for_api;
278             }
279              
280 0           for my $message (@{$args{messages}}) {
  0            
281 0 0         if ($message->{image_files}) {
282 0           $message->{images} = [];
283 0           push @{$message->{images}}, @{$self->ua->base64_images(delete $message->{image_files})};
  0            
  0            
284             }
285             }
286              
287 0           return $self->ua->post(
288             url => '/api/chat',
289             data => \%args,
290             );
291             }
292              
293             sub embed {
294 0     0 1   my ($self, %args) = @_;
295              
296 0   0       $args{model} //= $self->model;
297              
298 0 0         if (!$args{model}) {
299 0           return Future->fail("No model defined for embed");
300             }
301              
302 0           return $self->ua->post(
303             url => '/api/embed',
304             data => \%args,
305             );
306             }
307              
308             sub chat_with_tools {
309 0     0 1   my ($self, %args) = @_;
310              
311 0   0       $args{model} //= $self->model;
312              
313 0 0         if (!$args{model}) {
314 0           return Future->fail("No model defined for chat_with_tools");
315             }
316              
317 0 0         if (!$args{messages}) {
318 0           return Future->fail("No messages defined for chat_with_tools");
319             }
320              
321 0   0       my $max_iterations = $args{max_iterations} // 10;
322 0           my @messages = @{$args{messages}};
  0            
323 0           my @all_responses;
324 0   0       my $tools = $args{tools} // $self->_format_tools_for_api;
325 0           my $model = $args{model};
326              
327 0           my $iterate;
328             $iterate = sub {
329 0     0     my ($iteration) = @_;
330              
331 0 0         if ($iteration > $max_iterations) {
332 0           return Future->done($all_responses[-1]);
333             }
334              
335             return $self->chat(
336             model => $model,
337             messages => \@messages,
338             tools => $tools,
339             )->then(sub {
340 0           my ($response) = @_;
341 0           push @all_responses, $response;
342              
343 0           my $tool_calls = $response->extract_tool_calls;
344              
345             # Only stop if there are no tool calls to execute
346 0 0         if (!@$tool_calls) {
347 0           return Future->done($response);
348             }
349              
350             # Add assistant message
351             push @messages, {
352             role => 'assistant',
353 0   0       content => $response->message->{content} // '',
354             tool_calls => $tool_calls,
355             };
356              
357             # Execute tools
358 0           for my $tool_call (@$tool_calls) {
359 0           my $function = $tool_call->{function};
360 0           my $tool_name = $function->{name};
361 0           my $tool_args = $function->{arguments};
362              
363 0 0         if (!ref($tool_args)) {
364 0           my $json = JSON::Lines->new;
365 0   0       $tool_args = eval { $json->decode($tool_args)->[0] } // {};
  0            
366             }
367              
368 0           my $result;
369 0 0         if (my $handler = $self->tools->{$tool_name}{handler}) {
370 0           $result = eval { $handler->($tool_args) };
  0            
371 0 0         if ($@) {
372 0           $result = "Error executing tool: $@";
373             }
374             } else {
375 0           $result = "Unknown tool: $tool_name";
376             }
377              
378             push @messages, {
379             role => 'tool',
380             content => ref($result) ? JSON::Lines->new->encode([$result]) : "$result",
381 0 0 0       tool_call_id => $tool_call->{id} // $tool_name,
382             };
383             }
384              
385 0           return $iterate->($iteration + 1);
386 0           });
387 0           };
388              
389 0           return $iterate->(1);
390             }
391              
392             1;
393              
394             __END__