How to use the OpenAI Assistants API in Laravel

https://gehri.dev/images/blog/pestdocs-header.png

Code example

In this tutorial we are going to build a Laravel application using the new OpenAI Assistants API.
The goal of the application is to build a tool which answers questions about the Pest Testing framework based on the current state of the Pest documentation.

One of the benefits of using the Assistants API is that you can provide data which is then used by the AI. This way we can provide the latest version of the documentation instead of only using the outdated knowledge of the base model.

To achieve our goal we are going to use the OpenAI PHP Client for Laravel developed by me and Nuno Maduro.
If you are using any other PHP framework you can use the underlying OpenAI PHP Client directly.

All the source for this tutorial is available at GitHub.

API overview / what to do

If you are not familiar with the Assistants API yet, here is a brief overview of the API and what we need to do in order to achieve our goal.

The basic structur of the Assistants API is as follows:

  • Assistants are the main resource. There we can attach files and tools the AI can use to answer questions. In our case we are going to attach the Pest docs.
  • Threads contain the actual questions and answers. We can create multiple threads for the same assistant. You can think of a thread as a conversation between a user and the AI.
  • Messages are either a user input / question or an answer from the AI.
  • Runs are the requests to start the AI work after a thread has been created and optionally a user message has been added. Unlike the Chat API, we do not get the answer directly. Instead, we get a run id which we can use to get the result later on.
  • Steps represent the progress of the AI. We can use them to get the current state of the AI. This is useful if we want to show the progress to the user or for debugging purposes. We are not going to use them in this tutorial.

For more in-depth information visit the OpenAI documentation.

Prerequisites

If you want to follow along you need to have an OpenAI account and an API key. You can create an account at https://openai.com/.

Create a new Laravel application and install the OpenAI PHP Client

We start by creating a new application using the Laravel installer.

laravel new pest-docs-bot

Next we install the OpenAI PHP Client for Laravel.

Note: You need to install the latest (beta) version of the package.

composer require openai-php/laravel:^0.8.0-beta.2`

Prepare the Pest docs

Before we can upload the documents to OpenAI, we have to prepare them, as the documentation consists of several files and OpenAI has a limit of 20 files per assistant.

Therefore, we will merge all doc files into a single file. Furthermore, we are going to exclude some files as they do not contain any useful information for our goal.

First we create a new command.

php artisan make:command PrepareDocs

We are fetching a list of all the files in the PestDoc repository using the GitHub REST API.

Then we are looping through all the files and filter out files which do not have a download url or the ones we want to exclude.

Finally, we retrieve the contents of the individual files and merge them into a single file. Then we save the file locally.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;

class PrepareDocs extends Command
{
    private static array $FILES_TO_IGNORE = [
        'LICENSE.md',
        'README.md',
        'announcing-pest2.md',
        'documentation.md',
        'pest-spicy-summer-release.md',
        'video-resources.md',
        'why-pest.md',
    ];

    protected $signature = 'app:prepare-docs';

    protected $description = 'Create one file containing the full Pest documentation';

    public function handle()
    {
        $files = Http::get('https://api.github.com/repos/pestphp/docs/contents')->collect();

        $fullDocs = $files->filter(fn (array $file) => $file['download_url'] !== null)
            ->filter(fn (array $file) => ! in_array($file['name'], self::$FILES_TO_IGNORE))
            ->map(fn (array $file) => Http::get($file['download_url'])->body())
            ->implode(PHP_EOL.PHP_EOL);

        Storage::disk('local')->put('full-pest-docs.md', $fullDocs);
    }
}

Upload the docs to OpenAI

Now we are ready to upload the docs to OpenAI. We are going to use the upload() method of the Files resource in a new UploadDocs command.

The file to upload has to be a resource stream. Therefore, we are using the readStream() method of the Storage facade.

As purpose we are using assistants as we want to use the file for an assistant.

As a result we get a File object which contains the id of the uploaded file. We are going to use this id later on when we create the assistant.
I would suggest to store this id somewhere in your application, for example in a AssistantFile model to keep track of your uploaded files.
For simplicity we are here just going to output the id to the console.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use OpenAI\Laravel\Facades\OpenAI;

class UploadDocs extends Command
{
    protected $signature = 'upload-docs';

    protected $description = 'Uploads the docs to OpenAI';

    public function handle()
    {
        $uploadedFile = OpenAI::files()->upload([
            'file' => Storage::disk('local')->readStream('full-pest-docs.md'),
            'purpose' => 'assistants',
        ]);

        $this->info('File ID: '.$uploadedFile->id);
    }
}

Then we run the command:

Upload docs output

Create the assistant

Now we need to create the assistant. We are going to use the create() method of the Assistants resource in a new CreateAssistant command.

We are giving the assistant a name, pass the id of the uploaded file from before and add the retrieval tool.
The Knowledge Retrieval tool is used to extract information from the uploaded file. For more information about the tool visit the OpenAI documentation.

Next we instruct the AI to use the uploaded file to answer questions. And set the model to gpt-4-1106-preview.
Note: The regular gpt-4 model does not work with the retrieval tool.

Finally, we output the id of the created assistant to use it later.

namespace App\Console\Commands;

use Illuminate\Console\Command;
use OpenAI\Laravel\Facades\OpenAI;

class CreateAssistant extends Command
{
    protected $signature = 'create-assistant {file_id}';

    protected $description = 'Creates the PestDocs assistant.';

    public function handle()
    {
        $assistant = OpenAI::assistants()->create([
            'name' => 'Pest Chat Bot',
            'file_ids' => [
                $this->argument('file_id'),
            ],
            'tools' => [
                [
                    'type' => 'retrieval',
                ],
            ],
            'instructions' => 'Your are a helpful bot supporting developers using the Pest Testing Framework.
                               You can answer questions about the framework, and help them find the right documentation. 
                               Use the uploaded files to answer questions.',
            'model' => 'gpt-4-1106-preview',
        ]);

        $this->info('Assistant ID: '.$assistant->id);
    }
}

Then we run the command:

Create assistant output

Create the interface

Now we are ready to create the interface. We are going to use the Livewire package to create a simple interface.

First we install livewire.

composer require livewire/livewire

Then we create a new Livewire component and a layout file.

php artisan make:livewire DocBot

php artisan livewire:layout

In our routes file routes/web.php we are registering the home route to the DocBot component.

Route::get('/', \App\Livewire\DocBot::class);

To style the component we are using Tailwind CSS with the Forms and Typography plugins.
All installation instructions can be found in the docs.

We are going to create a simple interface where the user can enter a question and the AI will answer it.

Because this tutorial isn’t about Livewire, here a brief list of the relevant parts:

  • wire:model directive to bind the input field to the $question property.
  • wire:submit.prevent directive to call the ask() method on form submit.
  • wire:loading directive to show a spinner while the AI is working.
  • When the AI has finished its work, we render the answer which comes as markdown with Spatie’s spatie/laravel-markdown library.

This is what the blade part of the component does look like. All the classes have been removed to keep it simple. You can find the full code at GitHub.

<div>
    <div>
        <h3>PEST Documentation Assistant</h3>
        <div>
            <p>Enter you question, and I will try to find an answer in the current Pest documentation.</p>
        </div>
        <form wire:submit.prevent="ask">
            <div>
                <label for="question">Question</label>
                <input type="text"
                       name="question"
                       wire:model="question"
                       placeholder="How to run a single test?"
                >
            </div>
            <button type="submit">
                <span wire:loading.class="invisible">Ask</span>
                <x-spinner class="absolute invisible" wire:loading.class.remove="invisible" />
            </button>
        </form>
        @if($answer)
            <h3 class="mt-8 mb-1 text-base font-semibold leading-6 text-gray-900">My answer</h3>
            <div class="mb-2 prose">
                <x-markdown>{!! $answer !!}</x-markdown>
            </div>
        @endif
    </div>
</div>

This gives us the following interface:

The form

Ask the AI

In the class part of the Livewire component we have two properties to store the user question and the AI answer and one more to store the error if necessary.

The ask() function is executed when the user submits the form. For now, it is empty. We are going to fill it in the next step.

<?php

namespace App\Livewire;

use Livewire\Component;
use OpenAI\Laravel\Facades\OpenAI;
use OpenAI\Responses\Threads\Runs\ThreadRunResponse;

class DocBot extends Component
{
    public string $question = '';
    public ?string $answer = null;
    public ?string $erro = null;

    public function ask()
    {
        // ...
    }

    public function render()
    {
        return view('livewire.doc-bot');
    }
}

When the user submits a question the ask() function does two things.
First it creates a new Thread and runs it, and then it loads the answer from the given run.

public function ask()
{
    $threadRun = $this->createAndRunThread();

    $this->loadAnswer($threadRun);
}

Create a new Thread and run it

Let’s start with the createAndRunThread() function. We are going to use the createAndRun() method of the Threads resource.
This endpoint allows us to create a new thread and run it in a single request. As an alternative we could also create a thread first and then run it in a next step. This is handy you want to prepare the thread synchronously but execute the run asynchronously.

We are passing the id of the assistant we created before and the question the user entered as the only message in the thread.

 private function createAndRunThread(): ThreadRunResponse
{
    return OpenAI::threads()->createAndRun([
        'assistant_id' => 'asst_6VzQhp0uC0ZouIRJlxhfxI0a',
        'thread' => [
            'messages' => [
                [
                    'role' => 'user',
                    'content' => $this->question,
                ],
            ],
        ],
    ]);
}

As a result we get a ThreadRunResponse object which contains the ids of the thread and the run. Besides that it has other properties like the status of the run.
At this point the status is always queued as the AI has not processed the thread yet.

Currently, there is no way yet to get the answer directly. Additionally, it is not possible to stream the request unlike the Chat API.
The only thing we can do is to periodically check the status of the run and wait until it is done.

OpenAI already announced that they are working on a streaming and notification options. So, this will change in the future.

Load the answer

Now we are going to implement the loadAnswer() function. We are going to use the retrieve() method of the ThreadsRuns resource to get the updated status of the run.

In a while loop we are fetching the run until the status is not queued or in_progress anymore.

Then we check if the run has terminated successfully. If not, we set the error message, which by the way is not handled in the GUI for simplicity.

When the run has completed successfully we are going to fetch the messages of the thread. The AI has added the result of the run as a new message.
Because new messages are added at the beginning of the list, we can access the answer at index 0.

Each message contains an array of content objects. These are either text or image objects. In our case there is always only one text object.
This text object has a text property which has a property value containing the actual answer we are interested in.
So, we are going to store this value in the $answer property of the component.

private function loadAnswer(ThreadRunResponse $threadRun)
{
    while(in_array($threadRun->status, ['queued', 'in_progress'])) {
        $threadRun = OpenAI::threads()->runs()->retrieve(
            threadId: $threadRun->threadId,
            runId: $threadRun->id,
        );
    }

    if ($threadRun->status !== 'completed') {
        $this->error = 'Request failed, please try again';
    }

    $messageList = OpenAI::threads()->messages()->list(
        threadId: $threadRun->threadId,
    );

    $this->answer = $messageList->data[0]->content[0]->text->value;
}

Using the bot

That’s all we have to do. Now we can ask the AI questions about the Pest documentation and see the result.

The form

Regular ChatGTP models are currently not able to answer this question correctly because throwsUnless has been added to Pest after the models have been trained.

Conclusion

In this tutorial we have seen how to use the new OpenAI Assistants API in a Laravel application.
We have seen how to upload files, create an assistant, create a thread and run it, and how to get the result.

Of course there is a lot more you can do with the Assistants API. For example, you can use the AI to analyse your data and draw a graph or extend the capabilities of the AI by giving it access to your software through tools.
If you want to learn more about the Assistants API visit the OpenAI documentation.

Additionally, we did not cover topics like error handling or testing. You can learn more about this in the PHP Client documentation.

I would love to hear your feedback. You can reach me on X / Twitter or by using the contact form.

If you like this tutorial I wouldn’t mind if you share it with your friends and colleagues. Thank you!

Laravel News Links