Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Auto function execution #285

Open
samratfkt opened this issue Dec 4, 2023 · 15 comments
Open

Add Auto function execution #285

samratfkt opened this issue Dec 4, 2023 · 15 comments
Assignees

Comments

@samratfkt
Copy link

Hello, Is it possible to add auto function execution in this lib? We need to process the function manually now.

@gehrisandro
Copy link
Collaborator

Hi @samratfkt

Can you give an example how the API should look like?

@samratfkt
Copy link
Author

I don't think we can do it via api, but currently I am checking the function call and then using "call_user_func_array($functionName, $argumants)" function to processing the complete calling. So I think if you guys can add the whole function calling into it, it will be more helpful and easy to integrate.

@yguedidi
Copy link

yguedidi commented Dec 5, 2023

Maybe https://github.com/theodo-group/LLPhant#function can help here

@gehrisandro
Copy link
Collaborator

@samratfkt I meant, how you would like to use the package in an ideal world.

@yguedidi Thank you for the link.

A feature to convert PHP functions into a proper request and then execute them when requested is on our todo list.

@gehrisandro gehrisandro self-assigned this Dec 22, 2023
@hschimpf
Copy link

hschimpf commented Jan 9, 2024

Hello guys!

If it helps, I'll like to share what I did for the functions registration and execution.

I used Reflection to parse, register and execute the functions.
This way, I can define the functions as classes and keep the code clean.

Implementation

The implementation is something like this:

(I removed some parts to make it more readable, since it's a Laravel project, and I'm using models and other stuff to manage the Assistants and Functions from an admin panel):

Example/Description.php

This is just an attribute helper class to define the tool descriptions (see below).

<?php declare(strict_types=1);

namespace Example;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PARAMETER)]
final class Description {

    public function __construct(
        public string $value,
    ) {}

}

Functions management

Example/HasTools.php

This trait basically has the following methods:

  • register(array $function_classes) - Used to register the functions classes.
  • call(string $function_name, array $parameters = []) - Used to call a registered function.

Skip to next section before I overwhelm you with the implementation, then come back here 😅.

<?php declare(strict_types=1);

namespace Example;

use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use ReflectionClass;
use ReflectionEnum;
use ReflectionException;
use ReflectionParameter;
use RuntimeException;

trait HasFunctions {

    private array $registered_functions = [];

    /**
     * @throws ReflectionException
     */
    public function register(array $function_classes): void {
        foreach ($function_classes as $class_name) {
            if ( !class_exists($class_name)) {
                continue;
            }

            $function_class = new ReflectionClass($class_name);
            $function_name = Str::snake(basename(str_replace('\\', '/', $class_name)));

            if (! $function_class->hasMethod('handle')) {
                Log::warning(sprintf('Function class %s has no "handle" method', $function_class));

                continue;
            }

            $tool_definition = [
                'type'     => 'function',
                'function' => [ 'name' => $function_name ],
            ];

            // set function description, if it has one
            if ( !empty($descriptions = $function_class->getAttributes(Description::class))) {
                $tool_definition['function']['description'] = implode(
                    separator: "\n",
                    array: array_map(static fn($td) => $td->newInstance()->value, $descriptions),
                );
            }

            if ($function_class->getMethod('handle')->getNumberOfParameters() > 0) {
                $tool_definition['function']['parameters'] = $this->parseFunctionParameters($function_class);
            }

            $this->registered_functions[ $class_name ] = $tool_definition;
        }
    }

    /**
     * @throws ReflectionException
     */
    public function call(string $function_name, array $arguments = []): mixed {
        if (null === $function_class = array_key_first(array_filter($this->registered_functions, static fn($registered_function) => $registered_function['function']['name'] === $function_name))) {
            return null;
        }

        $function_class = new ReflectionClass($function_class);
        $handle_method = $function_class->getMethod('handle');

        $params = [];
        foreach ($handle_method->getParameters() as $parameter) {
            if ( !array_key_exists($parameter->name, $arguments) && !$parameter->isOptional() && !$parameter->isDefaultValueAvailable()) {
                throw new RuntimeException(sprintf('Parameter %s is required', $parameter->name));
            }

            // check if parameter type is an Enum and add fetch a valid value
            if (($parameter_type = $parameter->getType()) !== null && !$parameter_type->isBuiltin()) {
                if (enum_exists($parameter_type->getName())) {
                    $params[$parameter->name] = $parameter_type->getName()::tryFrom($arguments[$parameter->name]) ?? $parameter->getDefaultValue();

                    continue;
                }
            }

            $params[$parameter->name] = $arguments[$parameter->name] ?? $parameter->getDefaultValue();
        }

        return $handle_method->invoke(new $function_class->name, ...$params);
    }

    /**
     * @throws ReflectionException
     */
    private function parseFunctionParameters(ReflectionClass $tool): array {
        $parameters = [ 'type' => 'object' ];

        if (count($method_parameters = $tool->getMethod('handle')->getParameters()) > 0) {
            $parameters['properties'] = [];
        }

        foreach ($method_parameters as $method_parameter) {
            $property = [ 'type' => $this->getFunctionParameterType($method_parameter) ];

            // set property description, if it has one
            if (!empty($descriptions = $method_parameter->getAttributes(Description::class))) {
                $property['description'] = implode(
                    separator: "\n",
                    array: array_map(static fn($pd) => $pd->newInstance()->value, $descriptions),
                );
            }

            // register parameter to the required properties list if it's not optional
            if ( !$method_parameter->isOptional()) {
                $parameters['required'] ??= [];
                $parameters['required'][] = $method_parameter->getName();
            }

            // check if parameter type is an Enum and add it's valid values to the property
            if (($parameter_type = $method_parameter->getType()) !== null && !$parameter_type->isBuiltin()) {
                if (enum_exists($parameter_type->getName())) {
                    $property['type'] = 'string';
                    $property['enum'] = array_column((new ReflectionEnum($parameter_type->getName()))->getConstants(), 'value');
                }
            }

            $parameters['properties'][$method_parameter->getName()] = $property;
        }

        return $parameters;
    }

    private function getFunctionParameterType(ReflectionParameter $parameter): string {
        if (null === $parameter_type = $parameter->getType()) {
            return 'string';
        }

        if ( !$parameter_type->isBuiltin()) {
            return $parameter_type->getName();
        }

        return match ($parameter_type->getName()) {
            'bool'  => 'boolean',
            'int'   => 'integer',
            'float' => 'number',

            default => 'string',
        };
    }

}

Example functions

Example\Functions\MakeLead.php

That allows you to define your functions as a simple class, and all the above process will take care of building the
definitions to register it for OpenAI's API (You may want to customize the Trait to your needs).

<?php declare(strict_types=1);

namespace Example\Functions;

use Example\Description;

#[Description('Having all the necessary information, create the lead for the Sales department')]
final class MakeLead {

    public function handle(
        #[Description('Customer\'s name')]
        string $name,

        #[Description('Customer\'s surname')]
        string $surname,

        #[Description('Customers\' email')]
        string $email,

        #[Description('Customer\'s phone number')]
        string $phone,

        #[Description('Chosen product by the customer for purchase')]
        int $product_id,
    ): ?array {
    
        // TODO: do your function logic gere
        
        return null;
    }

}

Example App

Here it's just an example (I didn't test it). You register the functions, and then call them when needed.
Again, there is a lot of code that I removed for brevity (and still is a lot of it 😅).

The main idea is to keep your code organized and readable.

Example\App.php

<?php declare(strict_types=1);

namespace Example;

use Example\HasFunctions;
use Example\Functions\MakeLead;
use Example\Functions\SearchProducts;
use Example\Functions\AnotherUsefulFunction;

class App {
    use HasFunctions;

    public function __construct() {
        $this->register([
            MakeLead::class,
            SearchProducts::class,
            AnotherUsefulFunction::class,
            // ...
        ]);
    }
    
    public function run() {
        $response = $client->chat()->create([
            'model' => 'gpt-3.5-turbo-0613',
            'messages' => [
                // ... message history
                [ 'role' => 'user', 'content' => 'Yes, I want to purchase one coffee machine' ],
            ],
            'tools' => array_values($this->registered_functions),
        ]);
        
        $choice = $response->choices[0];
        if ($choice->finishReason === 'tool_calls') {        
            foreach ($choice->message->toolCalls as $tool_call) {
                // execute the function call
                $result = $this->call($tool_call->function->name, json_decode($tool_call->function->arguments));

                // TODO: do something with the result ...
            }
        }
    }

}

Hope that this helps you!

@vijaythecoder
Copy link

@hschimpf Love what you did for Function management. I am looking to organize my functions for so long and this is good starter, did you also add support for arrays? or nested parameter structure? would love to see your thoughts on how you approach.

@hschimpf
Copy link

hschimpf commented Apr 8, 2024

Hello @vijaythecoder, the documentation of OpenAI about function calling is constantly changing. As right now, there is no explanation about how the function's parameters must be specified.

image

Only browsing the examples of calling functions have something to work with.

... did you also add support for arrays? or nested parameter structure? ...

IDK if the model supports array as parameter (I didn't test it), cuz the docs regarding tool's parameters are vague for the moment. But if you need to process multiple items, for example the weather in different locations, the model will respond with multiple tool calls to make. Reference: Parallel function calling.

Also, I think that keeping the tool's definition simple will help with compatibility between the constant change of the models training. Maybe you manage to work with array parameters in one model, but then in the next model's version it may stop working. One way that I think you can try is to fine-tune a model to support array parameters.

@samuelgjekic
Copy link

Is the above being implemented already into this library?

@samuelgjekic
Copy link

Even if i am manually calling functions, how would it look like in php ? I know how to add the functions to the assistant, but how would i be able to make the ai run a php function i declared ? In .net i have done it however with a library built for it.

@hschimpf
Copy link

Hi @samuelgjekic, I showed an example in my response above

Even if i am manually calling functions, how would it look like in php ? I know how to add the functions to the assistant, but how would i be able to make the ai run a php function i declared ? In .net i have done it however with a library built for it.

See the Example App part of my response. After registering the functions and requesting a completion to the chat API, you check if the response has tool_calls and just call them using the call() method of the example trait.

Hope it helps.

@vijaythecoder
Copy link

@hschimpf Thanks for getting back, I really appreciate. May be my explanation is bad. Currently, I am trying to achieve the following. I have a tool(function) that has the following schema.

'items' => [
    'type' => 'object',
    'properties' => [
        'name' => [
            'type' => 'string',
            'description' => 'The name of the item',
        ],
        'variation' => [
            'type' => 'object',
            'properties' => [
                'id' => [
                    'type' => 'string',
                    'description' => 'id of the variation',
                ],
            ],
        ],
    ],
]

the part variations is an object or a nested parameter for Open AI. With your code, I am not sure how to define it as parameter along with name

public function handle(
        #[Description('The name of the item')]
        string $name,

        #[Description('Variation of the item')]
        string $variation, // But I need this to represent as an object parameter with child items. 
) {
...

let me know if you have a work around for that.

@samuelgjekic
Copy link

samuelgjekic commented Apr 12, 2024

Hi @samuelgjekic, I showed an example in my response above

Even if i am manually calling functions, how would it look like in php ? I know how to add the functions to the assistant, but how would i be able to make the ai run a php function i declared ? In .net i have done it however with a library built for it.

See the Example App part of my response. After registering the functions and requesting a completion to the chat API, you check if the response has tool_calls and just call them using the call() method of the example trait.

Hope it helps.

Thanks, after your reply and after looking into the app example i am starting to understand this a bit better. However i am using assistans api so i would check for tool calls in the thread or am i confused still?

`When you initiate a Run with a user Message that triggers the function, the Run will enter a pending status. After it processes, the run will enter a requires_action state which you can verify by retrieving the Run. The model can provide multiple functions to call at once using parallel function calling:

`

The above is from the assistant docs on function calling, so maybe its the "requires_action" state that i need to check for ? @hschimpf

@hschimpf
Copy link

the part variations is an object or a nested parameter for Open AI. With your code, I am not sure how to define it as parameter along with name

public function handle(
        #[Description('The name of the item')]
        string $name,

        #[Description('Variation of the item')]
        string $variation, // But I need this to represent as an object parameter with child items. 
) {
...

@vijaythecoder Oh! I see what you were saying now.

I think that we can tweak the parseFunctionParameters to allow the use of classes as parameter type. If the parameters is a class, we can access the properties of it thorugh ReflectionClass::getProperties() method, and doing this in a loop will allow support for infinite nested params.

@hschimpf
Copy link

When you initiate a Run with a user Message that triggers the function, the Run will enter a pending status. After it processes, the run will enter a requires_action state which you can verify by retrieving the Run. The model can provide multiple functions to call at once using parallel function calling:

The above is from the assistant docs on function calling, so maybe its the "requires_action" state that i need to check for ? @hschimpf

Hi @samuelgjekic. Yeah, if you are using the Assistant's API, you should check for required_action.type = "submit_tool_outputs".

You will need to tweak a bit my example, but the general functionality is the same. Check for tool calls, execute them and submit the results.

@samuelgjekic
Copy link

When you initiate a Run with a user Message that triggers the function, the Run will enter a pending status. After it processes, the run will enter a requires_action state which you can verify by retrieving the Run. The model can provide multiple functions to call at once using parallel function calling:

The above is from the assistant docs on function calling, so maybe its the "requires_action" state that i need to check for ? @hschimpf

Hi @samuelgjekic. Yeah, if you are using the Assistant's API, you should check for required_action.type = "submit_tool_outputs".

You will need to tweak a bit my example, but the general functionality is the same. Check for tool calls, execute them and submit the results.

Thank you ! I got it working

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants