-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #12 from xp-forge/feature/calls-api
Wrap function calling including error handling in `Calls` API
- Loading branch information
Showing
5 changed files
with
217 additions
and
55 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
<?php namespace com\openai\tools; | ||
|
||
use Throwable as Any; | ||
use lang\reflection\TargetException; | ||
use lang\{Throwable, IllegalArgumentException}; | ||
|
||
/** @test com.openai.unittest.CallsTest */ | ||
class Calls { | ||
private $functions; | ||
private $catch= null; | ||
|
||
/** Creates a new instance */ | ||
public function __construct(Functions $functions) { $this->functions= $functions; } | ||
|
||
/** | ||
* Pass an error handler | ||
* | ||
* @param function(lang.Throwable): var $handler | ||
* @return self | ||
*/ | ||
public function catching(callable $handler): self { | ||
$this->catch= $handler; | ||
return $this; | ||
} | ||
|
||
/** | ||
* Converts a Throwable instance to an error representation | ||
* | ||
* @param lang.Throwable | ||
* @return var | ||
*/ | ||
private function error($t) { | ||
return ($this->catch ? ($this->catch)($t) : null) ?? [ | ||
'error' => nameof($t), | ||
'message' => $t->getMessage() | ||
]; | ||
} | ||
|
||
/** | ||
* Invoke the function with named arguments and a given context | ||
* | ||
* @param string $name | ||
* @param [:var] $arguments | ||
* @param [:var] $context | ||
* @return var | ||
* @throws lang.IllegalArgumentException | ||
* @throws lang.reflect.TargetException | ||
*/ | ||
public function invoke($name, $arguments, $context= []) { | ||
list($instance, $method)= $this->functions->target($name); | ||
|
||
$pass= []; | ||
foreach ($method->parameters() as $param => $reflect) { | ||
$annotations= $reflect->annotations(); | ||
if ($annotation= $annotations->type(Context::class)) { | ||
$ptr= &$context; | ||
$named= $annotation->argument('name') ?? $annotation->argument(0) ?? $param; | ||
} else { | ||
$ptr= &$arguments; | ||
$named= $param; | ||
} | ||
|
||
// Support NULL inside context or arguments | ||
if (array_key_exists($named, $ptr)) { | ||
$pass[]= $ptr[$named]; | ||
} else if ($reflect->optional()) { | ||
$pass[]= $reflect->default(); | ||
} else { | ||
throw new IllegalArgumentException("Missing argument {$named} for {$name}"); | ||
} | ||
} | ||
|
||
return $method->invoke($instance, $pass); | ||
} | ||
|
||
/** | ||
* Call the function, including handling JSON de- and encoding and converting | ||
* caught exceptions to a serializable form. | ||
* | ||
* @param string $name | ||
* @param string $arguments | ||
* @param [:var] $context | ||
* @return string | ||
*/ | ||
public function call($name, $arguments, $context= []) { | ||
try { | ||
$result= $this->invoke($name, json_decode($arguments, null, 512, JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR), $context); | ||
} catch (TargetException $e) { | ||
$result= $this->error($e->getCause()); | ||
} catch (Any $e) { | ||
$result= $this->error(Throwable::wrap($e)); | ||
} | ||
return json_encode($result); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
<?php namespace com\openai\unittest; | ||
|
||
use com\openai\tools\{Functions, Calls, Context}; | ||
use lang\{IllegalAccessException, IllegalArgumentException}; | ||
use test\{Assert, Before, Expect, Test, Values}; | ||
|
||
class CallsTest { | ||
private $functions; | ||
|
||
#[Before] | ||
public function functions() { | ||
$this->functions= (new Functions())->register('testing', new class() { | ||
|
||
/** Greets the user */ | ||
public function greet( | ||
#[Param] | ||
$name, | ||
#[Context('phrase')] | ||
$greeting= 'Hello' | ||
) { | ||
if (empty($name)) { | ||
throw new IllegalAccessException('Name may not be empty!'); | ||
} | ||
|
||
return "{$greeting} {$name}"; | ||
} | ||
}); | ||
} | ||
|
||
#[Test] | ||
public function can_create() { | ||
new Calls($this->functions); | ||
} | ||
|
||
#[Test] | ||
public function invoke_successfully() { | ||
Assert::equals( | ||
'Hello World', | ||
(new Calls($this->functions))->invoke('testing_greet', ['name' => 'World']) | ||
); | ||
} | ||
|
||
#[Test] | ||
public function context_passed() { | ||
Assert::equals( | ||
'Hallo World', | ||
(new Calls($this->functions))->invoke('testing_greet', ['name' => 'World'], ['phrase' => 'Hallo']) | ||
); | ||
} | ||
|
||
#[Test, Expect(class: IllegalArgumentException::class, message: 'Missing argument name for testing_greet')] | ||
public function missing_argument() { | ||
(new Calls($this->functions))->invoke('testing_greet', []); | ||
} | ||
|
||
#[Test] | ||
public function call_successfully() { | ||
Assert::equals( | ||
'"Hello World"', | ||
(new Calls($this->functions))->call('testing_greet', '{"name":"World"}') | ||
); | ||
} | ||
|
||
#[Test] | ||
public function call_invalid_json() { | ||
Assert::equals( | ||
'{"error":"lang.Error","message":"Control character error, possibly incorrectly encoded"}', | ||
(new Calls($this->functions))->call('testing_greet', '{"unclosed') | ||
); | ||
} | ||
|
||
#[Test, Values(['{"name":""}', '{"name":null}'])] | ||
public function call_converts_errors_from($arguments) { | ||
Assert::equals( | ||
'{"error":"lang.IllegalAccessException","message":"Name may not be empty!"}', | ||
(new Calls($this->functions))->call('testing_greet', $arguments) | ||
); | ||
} | ||
|
||
#[Test] | ||
public function catching_error() { | ||
$caught= null; | ||
(new Calls($this->functions)) | ||
->catching(function($t) use(&$caught) { $caught= $t; }) | ||
->call('testing_greet', '{"name":""}') | ||
; | ||
|
||
Assert::instance(IllegalAccessException::class, $caught); | ||
} | ||
|
||
#[Test] | ||
public function modifying_error() { | ||
$result= (new Calls($this->functions)) | ||
->catching(fn($t) => ['error' => $t->getMessage()]) | ||
->call('testing_greet', '{"name":""}') | ||
; | ||
|
||
Assert::equals('{"error":"Name may not be empty!"}', $result); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters