OpenAI, Claude, Gemini, DeepSeek a Grok se neshodnou skoro na ničem: jiný endpoint, jiná autentizace, jiný tvar požadavku i odpovědi. Knihovna AI Access je sjednocuje do jednoho PHP rozhraní bez jediné závislosti, ve kterém je přechod od jednoho poskytovatele k druhému změnou jednoho řádku.

Jak to začalo

Jsem nadšenec do jazykových modelů a první doláče za API jsem utrácel už v roce 2022. Pro OpenAI existovala komunitní knihovna pro PHP, sice měla víc souborů než celé Nette, ale fungovala 🙂

Pak jsem se začátkem roku 2024 zamiloval do Claude. A Claude žádnou knihovnu pro PHP neměl. Tak jsem si napsal triviální dvě třídy, protože co jiného.

Jenže na jednu věc je vždycky lepší jeden model a na druhou druhý, takže jsem potřeboval přepínat oba poskytovatele, a začal vznikat wrapper. Časem přibyla podpora embeddingů (ty tady třeba doporučují podobné články, jak vidíte pod tímhle textem), pak přišly obrázky, kód se rozrůstal a v jednu chvíli bylo jasné, že vzniká regulérní knihovna.

Přidal jsem ještě podporu pro Gemini, protože jejich cenová politika (zadarmo) mě motivovala protočit tam pár desítek milionů tokenů. Později taky DeepSeek a Grok, u kterých dodnes nevím proč 🙂

V dubnu 2025 jsem knihovně vybral jméno a pustil ji na GitHub ve verzi 0.1.

Od té doby uplynulo šestnáct měsíců. Vydat jedničku znamená slíbit, že se rozhraní jen tak nezmění, a ten slib se těžko dává, když se pod rukama neustále mění, co vlastně modely umí. OpenAI mezitím přesunulo chat na úplně jiný endpoint a s přemýšlejícími modely přišly thought signatures, které se musí vracet nezměněné zpátky. Streamování odpovědí přibylo do knihovny teprve letos v létě, protože pět firem má pět různých představ o tom, jak takový proud dat vlastně skončí.

Teď se to usadilo natolik, že jedničku dávám ven.

Instaluje se přes composer require ai-access/ai-access a chce PHP 8.3 plus rozšíření curl, json a fileinfo. To je celý seznam požadavků: žádné Guzzle, žádná sada PSR rozhraní, žádný konflikt verzí se zbytkem projektu.

Pět API, která se neshodnou na ničem

Když si nad providery píšete vlastní obálku, první dva jsou legrace. U třetího pochopíte, že opět začínáte od nuly:

co se liší Claude OpenAI Gemini
endpoint v1/messages v1/responses :generateContent
autentizace x-api-key Bearer x-goog-api-key
tvar požadavku messages[] input[] a instructions contents[].parts[]
jak se jmenuje role modelu assistant assistant model
kde je důvod ukončení stop_reason status, pak incomplete_details finishReason

Takových detailů jsou desítky a ani jeden z nich není zajímavá práce. Je to práce, kterou bych dnes dal AI agentovi.

Přepnutí providera je jedna řádka

Tohle je ústřední motiv knihovny, tak ať je vidět hned. Takhle pracuju s OpenAI:

$client = new AIAccess\Provider\OpenAI\Client($apiKey, chatModel: 'gpt-5.6-luna');

$response = $client->createChat()
	->sendMessage('Napiš haiku o PHP.');

echo $response->getText();

A tady je Claude:

$client = new AIAccess\Provider\Claude\Client($apiKey, chatModel: 'claude-sonnet-5');
$chat = $client->createChat();

Klientů je šest: pět pro konkrétní providery a jeden generický pro všechno, co mluví dialektem OpenAI, tedy Ollama na vašem notebooku, OpenRouter, Mistral nebo Azure. Co který umí, se pozná podle rozhraní, která implementuje. AIAccess\Chat\Service mají všichni, AIAccess\Embedding\Service jen OpenAI a Gemini, a tak dál.

Když píšete kód, který má fungovat s libovolným providerem, typujte parametr na takové rozhraní, nikoliv na konkrétní třídu:

public function __construct(
	private AIAccess\Chat\Service $client,
) {
}

Aplikace pak o volbě providera neví vůbec nic a přepnutí je změna v konfiguraci DI kontejneru, ne v kódu:

services:
	- AIAccess\Provider\Claude\Client(%anthropicApiKey%, chatModel: 'claude-sonnet-5')

A potřebujete providera, který zvládne konverzaci i obrázky zároveň? Řekněte si o obě rozhraní najednou:

public function __construct(
	private AIAccess\Chat\Service&AIAccess\Image\Service $client,
) {
}

(Takovému parametru vyhoví OpenAI, Gemini a Grok.)

Co všechno to umí

Chat je jen začátek. Knihovna pokrývá celý pracovní postup:

  • Streaming, aby uživatel nekoukal deset vteřin na prázdnou stránku. Čte se foreachem a dá se v půlce zastavit.
  • Tool calling, kdy si model řekne o zavolání vaší funkce, dostane výsledek a pokračuje. Celou smyčku odbaví jediné sendMessage().
  • Structured output, tedy JSON schéma místo prosby v promptu. Tvar odpovědi vynucuje provider, ne dobrá vůle modelu.
  • Obrázky a dokumenty na vstupu. Přiložíte fotku účtenky nebo PDF smlouvy a ptáte se na obsah.
  • Generování obrázků u OpenAI, Gemini a Grok, ačkoli Gemini žádný endpoint na obrázky nemá a kreslí přes obyčejný chat. Vy o tom nemusíte vědět.
  • Embeddingy pro vyhledávání a doporučování podle významu, ne podle shody slov.
  • Batch processing za zhruba poloviční cenu, když odpověď nepotřebujete hned.

Abstrakce, která nelže

Sjednotit se dá to, co mají provideři společné. Zbytek ne, a knihovna se netváří, že rozdíly zmizely.

Nastavení, které má jen jeden z nich, je pojmenovaný argument metody setOptions() na konkrétní třídě, ne klíč ve sdíleném poli:

$chat->setOptions(maxOutputTokens: 1024, store: false); // OpenAI

Rozdíl oproti poli ucítíte při psaní. IDE vám nabídne přesně to, co daný provider umí, a překlep chytí samo PHP.

Jediná otázka, na které v produkci záleží

Když volání cizího API selže, můžete se ptát na spoustu věcí. V běžící aplikaci rozhoduje jediná: mám to zopakovat, nebo je to marné?

Podle toho jsou postavené výjimky:

  • CommunicationException znamená, že jsme se nedovolali. Opakování skoro vždy pomůže.
  • ApiException nese v getCode() HTTP status, takže 429 znamená „počkej a zkus to znovu“, zatímco 401 je špatný klíč a stokrát opakovaný bude pořád špatný.
  • UnexpectedResponseException říká, že odpověď nemá očekávaný tvar. To je na podívání, ne na opakování.
  • LogicException je chyba ve vašem kódu a schválně stojí mimo tenhle strom, protože vlastní chybu nemá produkce odchytávat a jít dál.

Samotné opakování psát nemusíte. Knihovna má dekorátory HTTP vrstvy, které se skládají jako matrjošky:

$client = new AIAccess\Provider\OpenAI\Client(
	$apiKey,
	new AIAccess\Http\RetryClient(new AIAccess\Http\CurlClient),
);

RetryClient opakuje jen to, co má smysl opakovat, respektuje hlavičku Retry-After a exponenciálně prodlužuje čekání s trochou náhody, aby tisíc paralelních procesů neuhodilo na providera v tutéž chvíli. Nejzajímavější pravidlo se týká streamování: jakmile dorazil první kus odpovědi, opakování se vypne. Model už píše a vy už za to platíte, takže druhý pokus by vám odpověď doručil dvakrát a dvakrát ji naúčtoval.

Vedle toho je ObservableClient, který hlásí každý požadavek i to, jak dlouho trval, a CachingClient, který při vývoji nedovolí, abyste za padesáté spuštění téhož skriptu platili padesátkrát.

A pak je tu dokumentace

Dokumentace má čtrnáct kapitol a je to text, který se dá číst od začátku do konce a po kterém víte, co děláte. Snažil jsem se, aby ho pochopil i laik. Takže třeba vysvětluje, co je vlastně embedding, bez použití lineární algebry.

Jsou tam věci, na které se přijde až v provozu, a to obvykle na faktuře. Třeba že obrázek v historii konverzace se platí znovu v každém dalším kole. Říká, proč break ve streamu generování nezastaví a cancel() ano.

Druhá půlka téhož je adresář examples/ přímo v distribuci: přes dvacet spustitelných programů v jedenácti tematických složkách, každý o jednom pojmu. Jsou to skutečné skripty, které berou providera jako první argument:

php examples/chat/streaming.php openai
php examples/chat/streaming.php gemini

Tentýž soubor tedy mluví s pěti různými firmami a vy vidíte na vlastní oči, že se výstup neliší. Kde nějaký provider funkci nemá, příklad to řekne a slušně skončí, místo aby spadl na chybě z API.

Jděte si hrát

Zkopírujte examples/.env.example na examples/.env, doplňte klíč providera, kterého chcete zkusit, a pusťte kterýkoli skript.

Hezké generování 🙂

Přečtěte si určitě také článek Model, který se ptá, si nevymýšlí.