Skip to content

Commit

Permalink
Merge pull request #3 from xp-forge/feature/tool-calling
Browse files Browse the repository at this point in the history
Implement function calling
  • Loading branch information
thekid authored Oct 19, 2024
2 parents ae9f872 + 2dc3a3f commit b8f1a90
Show file tree
Hide file tree
Showing 8 changed files with 511 additions and 2 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ Console::writeLine($ai->api('/embeddings')->invoke([
));
```

Functions
---------
Tool calls
----------
*Coming soon*

Azure OpenAI
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"require" : {
"xp-framework/core": "^12.0 | ^11.0 | ^10.0",
"xp-framework/logging": "^11.2",
"xp-framework/reflection": "^3.0 | ^2.0",
"xp-forge/rest-client": "^5.6",
"php" : ">=7.4.0"
},
Expand Down
38 changes: 38 additions & 0 deletions src/main/php/com/openai/Tools.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php namespace com\openai;

use com\openai\tools\Functions;

/**
* Tools
*
* @test com.openai.unittest.ToolsTest
* @see https://platform.openai.com/docs/assistants/tools
*/
class Tools {
public $selection= [];

/**
* Creates a new tools list from tools like `file_search` and `code_interpeter`
* as well as user functions register in a `Functions` instance.
*
* @param (string|[:var]|com.openai.tools.Functions)... $selected
*/
public function __construct(...$selected) {
foreach ($selected as $select) {
if ($select instanceof Functions) {
foreach ($select->schema() as $name => $function) {
$this->selection[]= ['type' => 'function', 'function' => [
'name' => $name,
'description' => $function['description'],
'parameters' => $function['input'],
]];
}
} else {
$this->selection[]= is_string($select) ? ['type' => $select] : $select;
}
}
}

/** @return var */
public function __serialize() { return $this->selection; }
}
159 changes: 159 additions & 0 deletions src/main/php/com/openai/tools/Functions.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php namespace com\openai\tools;

use com\openai\tools\{Param, Context};
use lang\reflection\TargetException;
use lang\{Type, Reflection, Value, IllegalArgumentException};
use util\{Comparison, Objects};

/**
* Function calling
*
* @test com.openai.unittest.FunctionsTest
*/
class Functions implements Value {
use Comparison;

private $instances= [];
private $methods= [];

/**
* Registers public instance methods for a given implementation
*
* @param string $namespace
* @param string|object $impl
* @return self
*/
private function methods($namespace, $impl) {
$this->methods[$namespace]= [];
foreach (Reflection::type($impl)->methods() as $name => $method) {
$mod= $method->modifiers();
if ($mod->isStatic() || !$mod->isPublic()) continue;

$this->methods[$namespace][$name]= $method;
}
return $this;
}

/** Registers the given instance using a defined namespace */
public function register(string $namespace, object $instance): self {
$this->instances[$namespace]= [$instance, null];
return $this->methods($namespace, $instance);
}

/**
* Register the given type using a defined namespace
*
* @param string $namespace
* @param string|lang.Type $impl
* @param ?function(lang.Type): object $new
* @return self
*/
public function with(string $namespace, $impl, $new= null): self {
$t= $impl instanceof Type ? $impl : Type::forName($impl);
$this->instances[$namespace]= [null, fn() => $new ? $new($t) : $t->newInstance()];
return $this->methods($namespace, $impl);
}

/** Selects functions matching the given selectors */
public function select(array $selectors): self {
$self= new self();
foreach ($selectors as $selector) {
[$namespace, $name]= explode('_', $selector);
if (!isset($this->methods[$namespace])) continue;

$methods= $this->methods[$namespace];
if ('*' === $name) {
$self->methods[$namespace]= $methods;
} else if ($method= $methods[$name] ?? null) {
$self->methods[$namespace]??= [];
$self->methods[$namespace][$name]= $method;
}
}
return $self;
}

/** Yields descriptions for all methods registered */
public function schema(): iterable {
foreach ($this->methods as $namespace => $methods) {
foreach ($methods as $name => $method) {

// Use annotated parameters if possible
$properties= $required= [];
foreach ($method->parameters() as $param => $reflect) {
$annotations= $reflect->annotations();
if ($annotations->provides(Context::class)) {
continue;
} else if ($annotation= $annotations->type(Param::class)) {
$properties[$param]= $annotation->newInstance()->schema();
} else {
$properties[$param]= ['type' => 'string', 'description' => ucfirst($param)];
}
$reflect->optional() || $required[]= $param;
}

yield $namespace.'_'.$name => [
'description' => $method->comment() ?? ucfirst($name),
'input' => [
'type' => 'object',
'properties' => $properties ?: (object)[],
'required' => $required,
],
];
}
}
}

/**
* Invokes the given tool from a tool call
*
* @param string $call
* @param [:var] $arguments
* @param [:var] $context
* @return var
* @throws lang.Throwable
*/
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();
}
}

/** @return string */
public function toString() {
$s= nameof($this)." [\n";
foreach ($this->descriptions() as $name => $description) {
$s.= ' '.$name.'() -> '.Objects::stringOf($description, ' ')."\n";
}
return $s.']';
}
}
23 changes: 23 additions & 0 deletions src/main/php/com/openai/tools/Param.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php namespace com\openai\tools;

class Param {
private $description, $type;

/**
* Creates a new param annotation
*
* @see https://json-schema.org/understanding-json-schema/reference/type
* @see https://json-schema.org/understanding-json-schema/reference/enum
* @param ?string $description
* @param string|[:var] $type
*/
public function __construct($description= null, $type= 'string') {
$this->description= $description;
$this->type= is_array($type) ? $type : ['type' => $type];
}

/** Returns parameter schema */
public function schema(): array {
return null === $this->description ? $this->type : $this->type + ['description' => $this->description];
}
}
Loading

0 comments on commit b8f1a90

Please sign in to comment.