Skip to content

Commit

Permalink
Merge pull request #12 from xp-forge/feature/calls-api
Browse files Browse the repository at this point in the history
Wrap function calling including error handling in `Calls` API
  • Loading branch information
thekid authored Oct 26, 2024
2 parents 9536e8c + c70d6a9 commit 56c037b
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 55 deletions.
15 changes: 3 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,10 @@ $payload= [
If tool calls are requested by the LLM, invoke them and return to next completion cycle. Arguments and return values are encoded as *JSON*. See https://platform.openai.com/docs/guides/function-calling/configuring-parallel-function-calling

```php
use lang\Throwable;
use util\cmd\Console;

// ...setup code from above...
$calls= $functions->calls()->catching(fn($t) => $t->printStackTrace());

complete: $result= $ai->api('/chat/completions')->invoke($payload));

Expand All @@ -181,20 +181,11 @@ if ('tool_calls' === ($result['choices'][0]['finish_reason'] ?? null)) {
$payload['messages'][]= $result['choices'][0]['message'];

foreach ($result['choices'][0]['message']['tool_calls'] as $call) {
try {
$return= $functions->invoke(
$call['function']['name'],
json_decode($call['function']['arguments'], true)
);
} catch (Throwable $t) {
$t->printStackTrace();
$return= ['error' => $t->compoundMessage()];
}

$return= $calls->call($call['function']['name'], $call['function']['arguments']);
$payload['messages'][]= [
'role' => 'tool',
'tool_call_id' => $call['id'],
'content' => json_encode($return),
'content' => $return,
];
}

Expand Down
95 changes: 95 additions & 0 deletions src/main/php/com/openai/tools/Calls.class.php
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);
}
}
54 changes: 16 additions & 38 deletions src/main/php/com/openai/tools/Functions.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,49 +103,27 @@ public function schema(): iterable {
}
}

/** @return com.openai.tools.Calls */
public function calls() { return new Calls($this); }

/**
* Invokes the given tool from a tool call
* Returns target for a given call
*
* @param string $call
* @param [:var] $arguments
* @param [:var] $context
* @return var
* @throws lang.Throwable
* @return var[]
* @throws lang.IllegalArgumentException if there is no such target registered
*/
public function invoke(string $call, array $arguments, array $context= []) {
try {
sscanf($call, "%[^_]_%[^\r]", $namespace, $name);
if (null === ($method= $this->methods[$namespace][$name] ?? null)) {
throw new IllegalArgumentException(isset($this->methods[$namespace])
? "Unknown function {$name} in {$namespace}"
: "Unknown namespace {$namespace}"
);
}

// Lazily create instance
list(&$instance, $new)= $this->instances[$namespace];
$instance??= $new();

$pass= [];
foreach ($method->parameters() as $param => $reflect) {
if ($reflect->annotations()->provides(Context::class)) {
$ptr= &$context[$param];
} else {
$ptr= &$arguments[$param];
}

if (isset($ptr)) {
$pass[]= $ptr;
} else if ($reflect->optional()) {
$pass[]= $reflect->default();
} else {
throw new IllegalArgumentException("Missing argument {$param} for {$call}");
}
}
return $method->invoke($instance, $pass);
} catch (TargetException $e) {
throw $e->getCause();
public function target($call) {
sscanf($call, "%[^_]_%[^\r]", $namespace, $name);
if (null === ($method= $this->methods[$namespace][$name] ?? null)) {
throw new IllegalArgumentException(isset($this->methods[$namespace])
? "Unknown function {$name} in {$namespace}"
: "Unknown namespace {$namespace}"
);
}

// Lazily create instance if not set
return [$this->instances[$namespace][0] ?? $this->instances[$namespace][1](), $method];
}

/** @return string */
Expand Down
100 changes: 100 additions & 0 deletions src/test/php/com/openai/unittest/CallsTest.class.php
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);
}
}
8 changes: 3 additions & 5 deletions src/test/php/com/openai/unittest/FunctionsTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,20 +220,18 @@ public function execute() { return 'executed'; }
#[Test, Values([[[], 'Hello World'], [['name' => 'Test'], 'Hello Test']])]
public function invoke($arguments, $expected) {
$fixture= (new Functions())->with('testing', HelloWorld::class);
$result= $fixture->invoke('testing_hello', $arguments);
$result= $fixture->calls()->invoke('testing_hello', $arguments);

Assert::equals($expected, $result);
}

#[Test, Expect(IllegalArgumentException::class)]
public function unknown_namespace() {
$fixture= (new Functions())->with('testing', HelloWorld::class);
$fixture->invoke('unknown_hello', []);
(new Functions())->with('testing', HelloWorld::class)->target('unknown_hello');
}

#[Test, Expect(IllegalArgumentException::class)]
public function unknown_method() {
$fixture= (new Functions())->with('testing', HelloWorld::class);
$fixture->invoke('testing_unknown', []);
(new Functions())->with('testing', HelloWorld::class)->target('testing_unknown');
}
}

0 comments on commit 56c037b

Please sign in to comment.