OpenAI, Claude, Gemini, DeepSeek and Grok agree on almost nothing: a different endpoint, different authentication, a different shape of request and response. The AI Access library unifies them into a single PHP interface with no dependencies at all, in which moving from one provider to another is a one-line change.
How It Started
I have been an enthusiast of language models for a long time and I spent my first dollars on their APIs back in 2022. There was a community PHP library for OpenAI; it came with more files than the whole of Nette, but it worked 🙂
Then, in early 2024, I fell in love with Claude. And Claude had no PHP library at all. So I wrote two trivial classes myself, because of course I did.
The trouble is that one model is always better at one thing and the other at something else, so I needed to switch between the two providers, and a wrapper started to grow. In time came embeddings (here on the blog they are what recommends the related articles you can see below this text), then images, the code kept growing, and at some point it was obvious that a real library was taking shape.
I also added Gemini, because their pricing policy (free) motivated me to push a few dozen million tokens through it. Later DeepSeek and Grok too, and to this day I do not know why 🙂
In April 2025 I picked a name for the library and released it on GitHub as version 0.1.
Sixteen months have passed since then. Releasing a 1.0 means promising that the interface will not change on a whim, and that promise is hard to make while what the models can actually do keeps shifting under your feet. In the meantime OpenAI moved chat to an entirely different endpoint, and thinking models brought thought signatures, which have to come back unchanged. Streaming responses landed in the library only this summer, because five companies have five different ideas about how such a stream of data actually ends.
Now things have settled enough for the 1.0 to go out.
It installs with composer require ai-access/ai-access and wants
PHP 8.3 plus the curl, json and fileinfo extensions. That is the entire list of
requirements: no Guzzle, no set of PSR interfaces, no version conflict with the
rest of your project.
Five APIs That Agree on Nothing
When you write your own wrapper over the providers, the first two are fun. With the third you realize you are starting from scratch again:
| what differs | Claude | OpenAI | Gemini |
|---|---|---|---|
| endpoint | v1/messages |
v1/responses |
:generateContent |
| authentication | x-api-key |
Bearer |
x-goog-api-key |
| shape of the request | messages[] |
input[] and instructions |
contents[].parts[] |
| what the model role is called | assistant |
assistant |
model |
| where the finish reason is | stop_reason |
status, then incomplete_details |
finishReason |
There are dozens of such details and not one of them is interesting work. It is the kind of work I would hand to an AI agent today.
Switching Provider Is One Line
This is the library's central theme, so let it be visible right away. Here is how I work with OpenAI:
$client = new AIAccess\Provider\OpenAI\Client($apiKey, chatModel: 'gpt-5.6-luna');
$response = $client->createChat()
->sendMessage('Write a haiku about PHP.');
echo $response->getText();
And here is Claude:
$client = new AIAccess\Provider\Claude\Client($apiKey, chatModel: 'claude-sonnet-5');
$chat = $client->createChat();
There are six clients: five for specific providers and one generic for
anything that speaks the OpenAI dialect, meaning Ollama on your laptop,
OpenRouter, Mistral or Azure. What each one can do is written in the interfaces
it implements. All of them have AIAccess\Chat\Service, only OpenAI
and Gemini have AIAccess\Embedding\Service, and so on.
When you write code that should work with any provider, type the parameter against such an interface rather than against a concrete class:
public function __construct(
private AIAccess\Chat\Service $client,
) {
}
Your application then knows nothing at all about the choice of provider, and switching is a change in the DI container configuration, not in code:
services:
- AIAccess\Provider\Claude\Client(%anthropicApiKey%, chatModel: 'claude-sonnet-5')
Need a provider that handles conversation and images at once? Ask for both interfaces together:
public function __construct(
private AIAccess\Chat\Service&AIAccess\Image\Service $client,
) {
}
(OpenAI, Gemini and Grok qualify.)
What It Can Do
Chat is only the beginning. The library covers the whole workflow:
- Streaming,
so the user is not staring at a blank page for ten seconds. You read it with a
foreachand can stop it half way. - Tool calling,
where the model asks for your function to be called, receives the result and
carries on. A single
sendMessage()handles the whole loop. - Structured output, meaning a JSON schema instead of pleading in the prompt. The shape of the answer is enforced by the provider, not by the model's good will.
- Images and documents as input. You attach a photo of a receipt or a PDF contract and ask about its contents.
- Image generation on OpenAI, Gemini and Grok, even though Gemini has no image endpoint at all and draws through ordinary chat. You never need to know that.
- Embeddings for searching and recommending by meaning rather than by matching words.
- Batch processing at roughly half the price, when you do not need the answer right now.
An Abstraction That Does Not Lie
What can be unified is what the providers genuinely share. The rest cannot be, and the library does not pretend the differences are gone.
A setting that only one of them has is a named argument of the
setOptions() method on that provider's own class, not a key in
a shared array:
$chat->setOptions(maxOutputTokens: 1024, store: false); // OpenAI
You feel the difference from an array as you type. The IDE offers exactly what that provider supports, and PHP itself catches a typo.
The Only Question That Matters in Production
When a call to someone else's API fails, you could ask plenty of things. In a running application only one of them decides what happens next: should I repeat it, or is it hopeless?
The exceptions are built around that:
CommunicationExceptionmeans we did not get through. Repeating almost always helps.ApiExceptioncarries the HTTP status ingetCode(), so 429 means “wait and try again”, while 401 is a wrong key and will still be wrong the hundredth time.UnexpectedResponseExceptionsays the response does not have the expected shape. That is one to look into, not to repeat.LogicExceptionis a mistake in your own code and deliberately sits outside this tree, because your own bug is not something production should catch and walk past.
The repeating itself is not something you have to write. The library ships HTTP layer decorators that nest like matryoshka dolls:
$client = new AIAccess\Provider\OpenAI\Client(
$apiKey,
new AIAccess\Http\RetryClient(new AIAccess\Http\CurlClient),
);
RetryClient repeats only what is worth repeating, honors the
Retry-After header and doubles the delay each time with a bit of
randomness, so that a thousand parallel processes do not hit the provider at the
same instant. The most interesting rule concerns streaming: once the first piece
of the answer has arrived, retrying is disabled. The model is already writing
and you are already paying for it, so a second attempt would deliver the answer
twice and bill it twice.
Alongside it there is ObservableClient, which reports every
request and how long it took, and CachingClient, which during
development stops you from paying fifty times for the fiftieth run of the same
script.
And Then There Is the Documentation
The documentation has fourteen chapters and it is a text you can read from beginning to end and know what you are doing afterwards. I tried to make it understandable even to a layman. So it explains, for instance, what an embedding actually is, without any linear algebra.
There are things in it that you otherwise discover only in production, and
usually on the invoice. Such as that an image in the conversation history is
paid for again in every further turn. It tells you why break in a
stream does not stop the generation while cancel() does.
The other half of the same is the examples/ directory right in
the distribution: over twenty runnable programs in eleven folders by topic, each
about a single concept. They are real scripts that take the provider as their
first argument:
php examples/chat/streaming.php openai
php examples/chat/streaming.php gemini
The very same file therefore talks to five different companies and you see with your own eyes that the output does not differ. Where a provider lacks a feature, the example says so and stops politely instead of failing on an API error.
Go and Play
Copy examples/.env.example to examples/.env, fill
in the key of the provider you want to try, and run any of the scripts.
Happy generating 🙂
Be sure to also read the article A Model That Asks Does Not Invent.
Leave a comment