Obsługa API od Open AI i omówienie Batch processing w PHP

GPT (Generative Pre-trained Transformer) to rodzina modeli językowych opracowanych przez OpenAI, które potrafią rozumieć i generować tekst zbliżony do ludzkiego. Modele GPT są trenowane na ogromnych zbiorach danych i wykorzystywane do odpowiadania na pytania, tłumaczenia, generowania treści, podsumowań czy automatyzacji rozmów. Obsługuje polecenia wielojęzyczne, przetwarza obrazy czy dźwięk. GPT jest dziś podstawą wielu narzędzi AI, chatbotów i rozwiązań wspierających codzienną pracę programistów, marketerów oraz twórców treści.

W tym artykule przyjrzymy się tylko niektórym możliwością jakie oferują rozwiązania od Open AI oraz zbudujemy klienta w PHP.

Lista przydatnych linków z oficjalnej dokumentacji od Open AI.

Klient

Zbudujmy prostego klienta, którego później w miarę potrzeby rozbudujemy.

<?php

namespace OpenAI;

class Client
{
    private $baseUrl = 'https://api.openai.com/v1/';
    private $apiKey;

    public function __construct($apiKey)
    {
        $this->apiKey = $apiKey;
    }

    public function request(string $method,string $endpoint, array $params = []): array
    {
        $url = $this->baseUrl . $endpoint;

        $headers = [
            'Authorization: Bearer ' . $this->apiKey,
            'Content-Type: application/json',
        ];

        $curl = curl_init();

        $method = strtoupper($method);
        // Ustaw metodę i parametry zgodnie z metodą HTTP
        switch ($method) {

            case 'POST':
                curl_setopt($curl, CURLOPT_POST, true);
                if (!empty($params)) {
                    curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($params));
                }
                break;

            case 'GET':
                if (!empty($params)) {
                    $url .= (strpos($url, '?') === false ? '?' : '&') . http_build_query($params);
                }
                curl_setopt($curl, CURLOPT_HTTPGET, true);
                break;
 
            default:
                curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
                if (!empty($params)) {
                    curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($params));
                }
                break;
        }

        curl_setopt_array($curl, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_TIMEOUT => 30,
        ]);

        $response = curl_exec($curl);
        $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        $error = curl_error($curl);
        curl_close($curl);

        if ($error) {
            throw new \Exception("cURL Error: " . $error);
        }

        $res = json_decode($response, true);

        if ($httpCode >= 200 && $httpCode < 300) {
            return $res;
        }

        $msg = $res['error']['message'] ?? 'Unknown API error';

        throw new \Exception(
            "OpenAI API Error ($httpCode): $msg\n\nResponse: " . print_r($res, true),
            $httpCode
        );
    }
}

?>

Nie będę się rozwodził nad budową API, gdyż robiłem to wielokrotnie. W dużym skrócie – korzystamy z cURL, którym wysyłamy zapytania pod endpoint do konkretnego modelu i obsługujemy odpowiedź w formacie JSON. Przetestujmy API.

$client = new Client('API_KEY');

try {
    $response = $client->request( 'POST', 'responses', [
        'model' => 'gpt-4o', 
        'input' => 'Napisz żart o PHP.'
    ]);
    echo $response['output'][0]['content'][0]['text'];
    //print_r($response);
} catch (\Exception $e) {
    echo "Błąd: " . $e->getMessage();
}

W odpowiedzi otrzymamy żart „na poziomie” GPT-4o 😉

Jak się nazywa programista PHP na snowboardzie?

Sliding-Scale!

Modele

Aktualna lista modeli znajduje się w dokumentacji. Poniżej w menu mamy zaś ceny (za milion tokenów). W praktyce milion tokenów to naprawdę sporo zapytań. Korzystam z API od bardzo dawna i głównie z topowych modeli i większość zapytań liczę w ułamkach centa, choć to zależy od wielu czynników. W każdym momencie możemy sprawdzić łączne koszty jakie ponieśliśmy oraz ile kosztowało dane użycie.

Rodzaje modeli

  • tekstowe np. 4.1, 4o itd.
  • tekstowe (flex processing) – o3 i o4-mini – charakteryzują się zmienną stawką za token w zależności od poziomu skomplikowania zapytania
  • audio – do generowania dźwięku np. gpt-4o-audio-preview
  • graficzne -do generowania obrazów (gpt-image-1)
  • z opcją szkolenia na własnych danych (fine-tuning) – np. gpt-4o-2024-08-06
  • do kategoryzacji, porównania itd. (embeddings) np. text-embedding-3-large
  • do moderacji np. omni-moderation-latest (na dzień pisania tego artykułu są darmowe)

Endpoint responses

Aby zintegrować modele GPT i funkcje sztucznej inteligencji OpenAI z własnym projektem, musisz komunikować się z odpowiednimi „endpointami” API. Każdy endpoint odpowiada za inny zakres możliwości – od prostego generowania tekstu, przez obsługę multimodalną (tekst, obrazy, dźwięk), aż po zaawansowane funkcje narzędzi i integracji z zewnętrznymi systemami.

W najnowszych wersjach OpenAI coraz większy nacisk kładziony jest na uniwersalne i multimodalne interfejsy, które pozwalają realizować zaawansowane workflow w jednej rozmowie – niezależnie od rodzaju danych wejściowych czy potrzebnych narzędzi.

responses to aktualnie najnowszy i najbardziej zaawansowany endpoint OpenAI, łączący generowanie tekstu, obrazów oraz korzystanie z narzędzi w jednym interfejsie.
Zastępuje dotychczasowe /chat/completions oraz /images/generations w zastosowaniach multimodalnych i z narzędziami.

Przykładowo – aby prowadzić konwersację musieliśmy poprzednio wysyłać wszystkie wysłane i odebrane wiadomości przy każdym zapytaniu. Aktualnie wystarczy uzupełnić parametr previous_response_id w którym podajemy id ostatniej odpowiedzi.

Batch processing

Batch processing (przetwarzanie wsadowe) oznacza wysyłanie wielu zapytań naraz w jednej paczce – zamiast wykonywać pojedyncze żądania dla każdego promptu/osobnej wiadomości, możesz zrealizować całą serię operacji na jednym połączeniu z API.

OpenAI udostępnia specjalny endpoint /v1/batches, gdzie możesz przesłać plik lub listę żądań. Batch nie zwraca odpowiedzi natychmiast – wysyłasz zadanie i dostajesz batch_id, a po zakończeniu możesz pobrać wyniki.

Korzystając z tej metody zwykle zmniejszamy nasze koszty dwukrotnie, ale odpowiedź możemy odebrać do 24 godzin.

Aby skorzystać z tej opcji musimy najpierw utworzyć plik w formacie jsonl, a następnie go załadować na serwer i podpiąć pod zapytanie.

Zmodyfikujmy metodę request. Dodajmy dwa parametry – contentType oraz jsonRequest. Pierwszy pozwoli zmienić nam rodzaj przesyłanych danych, a drugi określi czy dane wysyłane w POST mają być zamieniane na json.

public function request(string $method,string $endpoint, array $params = [], $contentType='application/json', $jsonRequest = true): array

i teraz w nagłówku

$headers = [ ... 'Content-Type: ' . $contentType, ... ]

i obsłudze POST / default

if ($jsonRequest) {
    curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($params));
} else {
    curl_setopt($curl, CURLOPT_POSTFIELDS, $params);
}

Teraz możemy przygotować dane w formacie jsonl i wysłać je do serwera. Ważne, by w purpose podać batch (inaczej nie przejdzie).

Przygotujmy plik i zapiszmy go na dysku.

$data = [

    [
        'custom_id' => 'dowcip_1',
        'method' => 'POST',
        'url' => '/v1/responses',
        'body'  =>  [
            'model' => 'gpt-4o',
            'input' => 'Napisz żart o PHP.'
        ]
    ],
    [
        'custom_id' => 'dowcip_2',
        'method' => 'POST',
        'url' => '/v1/responses',
        'body'  =>  [
            'model' => 'gpt-4o',
            'input' => 'Napisz żart o PHP.'
        ]
    ],
];


file_put_contents(
    'batch.jsonl',
    implode("\n", array_map('json_encode', $data))
);

A teraz prześlijmy do API i usuńmy plik

try {
    $response = $client->request( 'POST', 'files', [
        'purpose' => 'batch', 
        'file' => new \CURLFile('batch.jsonl')
    ], 'multipart/form-data', false);
    print_r($response);

} catch (\Exception $e) {
    echo "Błąd: " . $e->getMessage();
}

unlink('batch.jsonl');

W odpowiedzi otrzymaliśmy identyfikator pliku, który użyjemy dalej.

[object] => file
[id] => file-S7HwKu4LUnchCzxw1jevSj
[purpose] => batch
[filename] => batch.jsonl
[bytes] => 249
[created_at] => 1749374001
[expires_at] =>
[status] => processed
[status_details] =>

Teraz możemy przekazać plik do przetworzenia przez batch. Cały proces przetwarzania trwa do 24 godzin.

$response = $client->request( 'POST', 'batches', [
        'completion_window' => '24h', 
        'endpoint' => '/v1/responses',
        'input_file_id' => 'file-S7HwKu4LUnchCzxw1jevSj'
]);

print_r($response);

W odpowiedzi otrzymamy id, którego użyjemy, aby uzyskać dane.

[id] => batch_6845585085d881908231ec2fef8d441e
[object] => batch
[endpoint] => /v1/responses
[errors] =>
[input_file_id] => file-S7HwKu4LUnchCzxw1jevSj
[completion_window] => 24h
[status] => validating
[output_file_id] =>
[error_file_id] =>
[created_at] => 1749375056
[in_progress_at] =>
[expires_at] => 1749461456
[finalizing_at] =>
[completed_at] =>
[failed_at] =>
[expired_at] =>
[cancelling_at] =>
[cancelled_at] =>
[request_counts] => Array
    (
        [total] => 0
        [completed] => 0
        [failed] => 0
    )

[metadata] =>

Status można sprawdzić wykorzystując GET i identyfikator.

$response = $client->request( 'GET', 'batches/batch_6845585085d881908231ec2fef8d441e');
print_r($response);

W odpowiedzi otrzymamy czy taski zostały wykonane i id pliku z wynikami

[id] => batch_6845585085d881908231ec2fef8d441e
[object] => batch
[endpoint] => /v1/responses
[errors] =>
[input_file_id] => file-S7HwKu4LUnchCzxw1jevSj
[completion_window] => 24h
[status] => completed
[output_file_id] => file-7TvgbcM53oBhqGVo2PWSpb
[error_file_id] =>
[created_at] => 1749375056
[in_progress_at] => 1749375057
[expires_at] => 1749461456
[finalizing_at] => 1749375060
[completed_at] => 1749375061
[failed_at] =>
[expired_at] =>
[cancelling_at] =>
[cancelled_at] =>
[request_counts] => Array
    (
        [total] => 2
        [completed] => 2
        [failed] => 0
    )

[metadata] =>

Wystarczy teraz pobrać plik

$response = $client->request( 'GET', 'files/file-7TvgbcM53oBhqGVo2PWSpb/content')

w odpowiedzi otrzymamy format jsonl

{"id": "batch_req_6845585504e0819093f881ccc3a041c8", "custom_id": "dowcip_1", "response": {"status_code": 200, "request_id": "45a06235724bed4fe6249f4c73e4a1d3", "body": {"id": "resp_68455852fda481978d1b2ac797a1fe45014eb1f25fb163a0", "object": "response", "created_at": 1749375059, "status": "completed", "background": false, "error": null, "incomplete_details": null, "instructions": null, "max_output_tokens": null, "model": "gpt-4o-2024-08-06", "output": [{"id": "msg_6845585396a881979a41229e2de4094b014eb1f25fb163a0", "type": "message", "status": "completed", "content": [{"type": "output_text", "annotations": [], "text": "Czemu programista PHP nie wraca z imprezy?\n\nBo nie mo\u017ce znale\u017a\u0107 funkcji `exit()`."}], "role": "assistant"}], "parallel_tool_calls": true, "previous_response_id": null, "reasoning": {"effort": null, "summary": null}, "service_tier": "default", "store": true, "temperature": 1.0, "text": {"format": {"type": "text"}}, "tool_choice": "auto", "tools": [], "top_p": 1.0, "truncation": "disabled", "usage": {"input_tokens": 15, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 24, "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 39}, "user": null, "metadata": {}}}, "error": null}    
{"id": "batch_req_6845585511508190b0440a7aa97ee3db", "custom_id": "dowcip_2", "response": {"status_code": 200, "request_id": "84de3eb5956185f0ec7544449177d145", "body": {"id": "resp_6845585374ec81958d526d3727e2341b0e22f2070521003c", "object": "response", "created_at": 1749375059, "status": "completed", "background": false, "error": null, "incomplete_details": null, "instructions": null, "max_output_tokens": null, "model": "gpt-4o-2024-08-06", "output": [{"id": "msg_68455853a37c81959674e940262f79090e22f2070521003c", "type": "message", "status": "completed", "content": [{"type": "output_text", "annotations": [], "text": "Dlaczego programi\u015bci PHP nigdy nie zagin\u0105 w lesie?\n\nBo zawsze znajd\u0105 \u201eecho\u201d drogi!"}], "role": "assistant"}], "parallel_tool_calls": true, "previous_response_id": null, "reasoning": {"effort": null, "summary": null}, "service_tier": "default", "store": true, "temperature": 1.0, "text": {"format": {"type": "text"}}, "tool_choice": "auto", "tools": [], "top_p": 1.0, "truncation": "disabled", "usage": {"input_tokens": 15, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 28, "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 43}, "user": null, "metadata": {}}}, "error": null}

I tutaj mała uwaga. Nasza metoda request wyrzuci błąd, bo zawsze próbuje dekodować jsona, a nie jest to typowy json, więc operacja się nie uda. Należy zmodyfikować request, albo jeszcze lepiej przenieść cały batch do osobnej metody / metod.

Aby przekonwertować jsonl można zrobić na przykład tak:

$response = "..."; // surowy tekst JSONL
$lines = explode("\n", $response);
$array = array_map('json_decode', array_filter($lines));

Na koniec usuwamy pliki. Aby pobrać listę wszystkich wywołujemy

$response = $client->request( 'GET', 'files');

W odpowiedzi otrzymamy:

    [object] => list
    [data] => Array
        (
            [0] => Array
                (
                    [object] => file
                    [id] => file-7TvgbcM53oBhqGVo2PWSpb
                    [purpose] => batch_output
                    [filename] => batch_6845585085d881908231ec2fef8d441e_output.jsonl
                    [bytes] => 2513
                    [created_at] => 1749375061
                    [expires_at] => 1751967061
                    [status] => processed
                    [status_details] =>
                )

            [1] => Array
                (
                    [object] => file
                    [id] => file-S7HwKu4LUnchCzxw1jevSj
                    [purpose] => batch
                    [filename] => batch.jsonl
                    [bytes] => 249
                    [created_at] => 1749374001
                    [expires_at] => 1751966001
                    [status] => processed
                    [status_details] =>
                )

        )

    [has_more] =>
    [first_id] => file-7TvgbcM53oBhqGVo2PWSpb
    [last_id] => file-S7HwKu4LUnchCzxw1jevSj

Wystarczy teraz wywołać delete

$client->request( 'DELETE', 'files/file-7TvgbcM53oBhqGVo2PWSpb');
$client->request( 'DELETE', 'files/file-S7HwKu4LUnchCzxw1jevSj');