Net-Base Časopis

15.07.2026

ChatGPT API s Delphi FMX/VCL: Pouzdano povezivanje sa streamanjem, retry mehanizmom i ispravnim parsiranjem JSON-a

Kako robustno integrirati ChatGPT API u Delphi FMX/VCL: HTTP-klijent s timeoutima i ponovnim pokušajima, SSE-streaming bez zamrzavanja korisničkog sučelja te robusno JSON-parsiranje za pozive alata i slučajeve pogrešaka.

15.07.2026

Od teme magazina do projektne prakse

Povezane stranice usluga i tehnologije za članak

Zašto „ChatGPT API s Delphi FMX/VCL“ u praksi nije samo POST

Tko želi povezati ChatGPT API s Delphi FMX/VCL, brzo završi s jednostavnim HTTP-POST. U stvarnim poslovnim softverskim okruženjima to puca na tri mjesta: (1) timeouti i ponovni pokušaji moraju biti deterministički, jer korisnici inače doživljavaju „zaglavljeno“ UI, (2) streaming (Server-Sent Events, skraćeno SSE) često je koristan za dobar UX, ali u Delphi-threadingu brzo postaje sklon pogreškama, i (3) JSON nije samo „jedan objekt“: poruke o pogrešci, problemi s kvotama, prazna polja ili blago izmijenjeni oblici odgovora moraju se robusno obraditi.

Sljedeći isječak izvornog koda pokazuje pristup koji djeluje jednako u FMX i VCL: vlastiti, testabilni klijent koji radi po izboru ne-streaming ili streaming, uredno prenosi UI-azuriranja (tj. izvršava ih kroz sinkronizaciju glavne niti) i kod pogrešaka zapisuje informativne logove. Usput je strukturiran tako da se uklapa u postojeće slojevite arhitekture (npr. „API-Client“ u integracijskom sloju, UI ostaje tanak).

Arhitektonska skica: razdvojiti UI, zadržati klijent testabilnim

U Delphi-projektima s dugom poviješću često se nalazi „HTTP u ButtonClick“. To funkcionira do prvog incidenta. Preporučljivo je mali klijent s:

  • Konfiguracija: Base-URL, API-Key, Modell, Timeouts.
  • Transportni sloj: HTTP-Request/Response, Retry, Timeout, opcije Proxy/SSL (ovisno o okruženju).
  • Parser: JSON-dekodiranje, objekti pogrešaka, ekstrakcija rezultata.
  • UI-hookovi: callback za token-/text-streaming, ali bez čvrste ovisnosti o VCL/FMX kontrolama.

Tako se integracija u prilagođeni poslovni softver može uredno održavati: klijent se može ponovno koristiti u servisima, desktop-klijentima, admin-alatima ili test-harnessima.

Isječak izvornog koda: Delphi-klijent s SSE-streamingom, Timeout/Retry i robusnim JSON-om

Kod koristi THTTPClient (System.Net.HttpClient) i namjerno parsira samo minimalno s System.JSON. Za SSE se čita po retku i reagira na „data: …“. To nije „WebSocket“, nego HTTP-Response-Stream koji kontinuirano isporučuje tekstualne retke. Važno: čitamo u radničkoj niti i maršaliramo UI-azuriranja u glavnu nit.

Delphi
unit Net-Base.OpenAI.ChatClient;

interface

uses
  System.SysUtils, System.Classes, System.Net.URLClient, System.Net.HttpClient,
  System.Net.HttpClientComponent, System.JSON, System.Threading,
  System.SyncObjs;

type
  EChatApiError = class(Exception)
  private
    FHttpStatus: Integer;
    FResponseText: string;
  public
    constructor Create(const Msg: string; AHttpStatus: Integer; const AResponseText: string);
    property HttpStatus: Integer read FHttpStatus;
    property ResponseText: string read FResponseText;
  end;

  TChatStreamEvent = reference to procedure(const AChunkText: string; AIsFinal: Boolean);

  TChatCompletionOptions = record
    Model: string;
    Temperature: Double;
    MaxTokens: Integer;
    constructor Create(const AModel: string; ATemperature: Double = 0.2; AMaxTokens: Integer = 512);
  end;

  TOpenAIChatClient = class
  private
    FBaseUrl: string;
    FApiKey: string;
    FConnectTimeoutMs: Integer;
    FResponseTimeoutMs: Integer;

    function BuildChatRequestBody(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
      AStream: Boolean): TJSONObject;

    function ExtractTextFromNonStreamingResponse(const AJsonText: string): string;
    function TryExtractErrorMessage(const AJsonText: string; out AMessage: string): Boolean;

    procedure ApplyAuthHeaders(ARequest: IHTTPRequest);

    function ExecuteWithRetry(const ADoRequest: TFunc): IHTTPResponse;
  public
    constructor Create(const ABaseUrl, AApiKey: string);

    property ConnectTimeoutMs: Integer read FConnectTimeoutMs write FConnectTimeoutMs;
    property ResponseTimeoutMs: Integer read FResponseTimeoutMs write FResponseTimeoutMs;

    function ChatOnce(const AUserPrompt: string; const AOptions: TChatCompletionOptions): string;

    procedure ChatStream(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
      const AOnEvent: TChatStreamEvent);
  end;

implementation

{ EChatApiError }

constructor EChatApiError.Create(const Msg: string; AHttpStatus: Integer; const AResponseText: string);
begin
  inherited Create(Msg);
  FHttpStatus := AHttpStatus;
  FResponseText := AResponseText;
end;

{ TChatCompletionOptions }

constructor TChatCompletionOptions.Create(const AModel: string; ATemperature: Double; AMaxTokens: Integer);
begin
  Model := AModel;
  Temperature := ATemperature;
  MaxTokens := AMaxTokens;
end;

{ TOpenAIChatClient }

constructor TOpenAIChatClient.Create(const ABaseUrl, AApiKey: string);
begin
  inherited Create;
  FBaseUrl := ABaseUrl.TrimRight(['/']);
  FApiKey := AApiKey;
  FConnectTimeoutMs := 8000;
  FResponseTimeoutMs := 60000;
end;

procedure TOpenAIChatClient.ApplyAuthHeaders(ARequest: IHTTPRequest);
begin
  // Bearer token: ovdje API ključ kao "Authorization: Bearer …".
  // U poslovnim okruženjima dodatno pripaziti da ključevi ne završe u logovima.
  ARequest.AddHeader('Authorization', 'Bearer ' + FApiKey);
  ARequest.AddHeader('Content-Type', 'application/json');
  ARequest.AddHeader('Accept', 'application/json');
end;

function TOpenAIChatClient.BuildChatRequestBody(const AUserPrompt: string;
  const AOptions: TChatCompletionOptions; AStream: Boolean): TJSONObject;
var
  Msgs: TJSONArray;
  Msg: TJSONObject;
begin
  Result := TJSONObject.Create;
  Result.AddPair('model', AOptions.Model);
  Result.AddPair('temperature', TJSONNumber.Create(AOptions.Temperature));
  Result.AddPair('max_tokens', TJSONNumber.Create(AOptions.MaxTokens));
  Result.AddPair('stream', TJSONBool.Create(AStream));

  // Minimalna struktura messages (Chat Completions): role/content.
  Msgs := TJSONArray.Create;
  Msg := TJSONObject.Create;
  Msg.AddPair('role', 'user');
  Msg.AddPair('content', AUserPrompt);
  Msgs.AddElement(Msg);
  Result.AddPair('messages', Msgs);
end;

function TOpenAIChatClient.TryExtractErrorMessage(const AJsonText: string; out AMessage: string): Boolean;
var
  J: TJSONValue;
  EObj: TJSONObject;
begin
  Result := False;
  AMessage := '';

  J := TJSONObject.ParseJSONValue(AJsonText);
  try
    if (J is TJSONObject) then
    begin
      // Uobičajeni oblik: { "error": { "message": "...", "type": "..." } }
      EObj := (J as TJSONObject).GetValue<TJSONObject>('error');
      if Assigned(EObj) then
      begin
        AMessage := EObj.GetValue<string>('message', '');
        Result := AMessage <> '';
      end;
    end;
  finally
    J.Free;
  end;
end;

function TOpenAIChatClient.ExtractTextFromNonStreamingResponse(const AJsonText: string): string;
var
  J: TJSONValue;
  Root: TJSONObject;
  Choices: TJSONArray;
  Choice0: TJSONObject;
  Msg: TJSONObject;
begin
  Result := '';

  J := TJSONObject.ParseJSONValue(AJsonText);
  try
    if not (J is TJSONObject) then
      raise EChatApiError.Create('Neočekivani JSON-odgovor (nije objekt).', 0, AJsonText);

    Root := J as TJSONObject;
    Choices := Root.GetValue<TJSONArray>('choices');
    if (Choices = nil) or (Choices.Count = 0) then
      raise EChatApiError.Create('Neočekivani JSON-odgovor: nedostaje/je prazan element choices.', 0, AJsonText);

    Choice0 := Choices.Items[0] as TJSONObject;
    // Chat Completions: choices[0].message.content
    Msg := Choice0.GetValue<TJSONObject>('message');
    if Msg = nil then
      raise EChatApiError.Create('Neočekivani JSON-odgovor: nedostaje message.', 0, AJsonText);

    Result := Msg.GetValue<string>('content', '');
  finally
    J.Free;
  end;
end;

function TOpenAIChatClient.ExecuteWithRetry(const ADoRequest: TFunc<IHTTPResponse>): IHTTPResponse;
const
  MaxAttempts = 3;
var
  Attempt: Integer;
  DelayMs: Integer;
begin
  DelayMs := 350;
  for Attempt := 1 to MaxAttempts do
  begin
    try
      Exit(ADoRequest());
    except
      on E: ENetHTTPClientException do
      begin
        // Mrežne/TLS/timeout pogreške: jednostavan retry s backoffom.
        if Attempt = MaxAttempts then
          raise;
        Sleep(DelayMs);
        DelayMs := DelayMs * 2;
      end;
    end;
  end;
  Result := nil;
end;

function TOpenAIChatClient.ChatOnce(const AUserPrompt: string; const AOptions: TChatCompletionOptions): string;
var
  Http: THTTPClient;
  Req: IHTTPRequest;
  Resp: IHTTPResponse;
  Body: TJSONObject;
  Payload: TStringStream;
  RespText: string;
  ErrMsg: string;
begin
  Http := THTTPClient.Create;
  try
    Http.ConnectionTimeout := FConnectTimeoutMs;
    Http.ResponseTimeout := FResponseTimeoutMs;

    Body := BuildChatRequestBody(AUserPrompt, AOptions, False);
    try
      Payload := TStringStream.Create(Body.ToJSON, TEncoding.UTF8);
      try
        Req := Http.GetRequest('POST', FBaseUrl + '/v1/chat/completions');
        ApplyAuthHeaders(Req);

        Resp := ExecuteWithRetry(
          function: IHTTPResponse
          begin
            Result := Http.Execute(Req, Payload);
          end
        );

        RespText := Resp.ContentAsString(TEncoding.UTF8);

        if (Resp.StatusCode < 200) or (Resp.StatusCode >= 300) then
        begin
          if TryExtractErrorMessage(RespText, ErrMsg) then
            raise EChatApiError.Create(ErrMsg, Resp.StatusCode, RespText)
          else
            raise EChatApiError.Create('HTTP pogreška ' + Resp.StatusCode.ToString, Resp.StatusCode, RespText);
        end;

        Result := ExtractTextFromNonStreamingResponse(RespText);
      finally
        Payload.Free;
      end;
    finally
      Body.Free;
    end;
  finally
    Http.Free;
  end;
end;

procedure TOpenAIChatClient.ChatStream(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
  const AOnEvent: TChatStreamEvent);
var
  Task: ITask;
begin
  // Streaming se namjerno pokreće asinkrono kako FMX/VCL ne bi bilo blokirano.
  Task := TTask.Run(
    procedure
    var
      Http: THTTPClient;
      Req: IHTTPRequest;
      Resp: IHTTPResponse;
      Body: TJSONObject;
      Payload: TStringStream;
      Stream: TStream;
      Reader: TStreamReader;
      Line, Data: string;
      J: TJSONValue;
      Delta, Choice0, Choices: TJSONValue;
      ContentChunk: string;
    begin
      Http := THTTPClient.Create;
      try
        Http.ConnectionTimeout := FConnectTimeoutMs;
        Http.ResponseTimeout := FResponseTimeoutMs;

        Body := BuildChatRequestBody(AUserPrompt, AOptions, True);
        try
          Payload := TStringStream.Create(Body.ToJSON, TEncoding.UTF8);
          try
            Req := Http.GetRequest('POST', FBaseUrl + '/v1/chat/completions');
            ApplyAuthHeaders(Req);
            Req.AddHeader('Accept', 'text/event-stream');

            Resp := ExecuteWithRetry(
              function: IHTTPResponse
              begin
                Result := Http.Execute(Req, Payload);
              end
            );

            if (Resp.StatusCode < 200) or (Resp.StatusCode >= 300) then
            begin
              // Kod grešaka pri streamingu sadržaj je često ipak JSON.
              TThread.Queue(nil,
                procedure
                begin
                  AOnEvent('Pokretanje streama nije uspjelo (HTTP ' + Resp.StatusCode.ToString + ').', True);
                end);
              Exit;
            end;

            Stream := Resp.ContentStream;
            Reader := TStreamReader.Create(Stream, TEncoding.UTF8, True, 4096, False);
            try
              while not Reader.EndOfStream do
              begin
                Line := Reader.ReadLine;
                if Line = '' then
                  Continue;

                // SSE-format: retci poput "data: {...}" ili "data: [DONE]"
                if Line.StartsWith('data:') then
                begin
                  Data := Line.Substring(5).Trim;
                  if SameText(Data, '[DONE]') then
                  begin
                    TThread.Queue(nil,
                      procedure
                      begin
                        AOnEvent('', True);
                      end);
                    Break;
                  end;

                  // Obrada JSON-chunka: choices[0].delta.content
                  J := TJSONObject.ParseJSONValue(Data);
                  try
                    ContentChunk := '';
                    if J <> nil then
                    begin
                      Choices := (J as TJSONObject).GetValue('choices');
                      if (Choices is TJSONArray) and (TJSONArray(Choices).Count > 0) then
                      begin
                        Choice0 := TJSONArray(Choices).Items[0];
                        Delta := (Choice0 as TJSONObject).GetValue('delta');
                        if (Delta is TJSONObject) then
                          ContentChunk := TJSONObject(Delta).GetValue<string>('content', '');
                      end;
                    end;
                  finally
                    J.Free;
                  end;

                  if ContentChunk <> '' then
                    TThread.Queue(nil,
                      procedure
                      begin
                        AOnEvent(ContentChunk, False);
                      end);
                end;
              end;
            finally
              Reader.Free;
            end;
          finally
            Payload.Free;
          end;
        finally
          Body.Free;
        end;
      finally
        Http.Free;
      end;
    end);
end;

end.

Za što je ovaj pristup dobar

Kod rješava tri tipične klase problema koje u VCL/FMX brzo postaju skupe:

  • Streaming bez zamrzavanja korisničkog sučelja: HTTP-stream se čita u pozadini; ažuriranja sučelja prolaze putem TThread.Queue (asinkrono u glavnu dretvu). To je u FMX i VCL robustan standardni pristup.
  • Pokušaji ponovnog slanja za mrežne greške: Kod ENetHTTPClientException pokušava se ponovno s backoffom. To je svjesno jednostavno i kasnije se može proširiti za statusne kodove (429/5xx).
  • Robusno parsiranje JSON‑a: Umjesto da se polja slijepo kastaju, provjera se radi korak po korak. Time se smanjuju pogreške „Invalid type cast“ kod posebnih odgovora.

Okvirni uvjeti, zamke i varijante

  • SSE nije „normalan JSON“: Kod streaminga dolazi niz događaja, a ne jedan JSON‑odgovor. Zato je čitanje po retku i prepoznavanje [DONE] ključno.
  • THTTPClient i proxyji/SSL: U administratorskim mrežama TLS‑inspekcija i obaveze korištenja proxyja su stvarne. Planirajte THTTPClient.ProxySettings i po potrebi pitanja certifikata. Za debugiranje: uvijek zapisujte statusni kod i zaglavlja (bez API‑Keya).
  • Strategija timeouta: ResponseTimeout je kod streaminga osjetljiv: ako odaberete prenisku vrijednost, klijent će prekinuti duge odgovore. U UI‑alatima je dulji timeout i gumb „Prekini“ često prikladniji od „kratko i strogo“.
  • Prekid dretve: Isječak ne prikazuje Cancel‑Token. Za produktivne alate isplati se implementirati mehanizam prekida (npr. flag + Http.CancelAll u novijim Delphi‑verzijama ili kontroliranim prekidom streama).
  • Razvoj modela i API‑ja: Struktura odgovora može varirati. Održavajte parsere defenzivnima i centraliziranima, a ne razbacane po kodu obrasca.

Debugiranje u postojećim Delphi‑klijentima: što biste stvarno trebali zapisivati

U integracijskim projektima prvo puštanje u rad rijetko zakaže zbog JSON‑a, već zbog detalja okoline. U tehnički log (datoteka, Eventlog, centralni logger) u praksi treba zapisivati:

  • Request‑ID (dodijeljena od strane vas), vremenska oznaka, ciljna URL (bez tajnih parametara upita).
  • HTTP statusni kod, Content‑Type, duljina odgovora, trajanje.
  • Skraćeno tijelo odgovora pri greškama (npr. max. 4–8 KB), kako bi se mogli prepoznati pogreške vezane uz kvote/politike.
  • Jasno označavanje pokušaja ponovnog slanja: broj pokušaja, odgoda, klasa iznimke.

API‑Key nikad ne smije završiti u logu. Ako zapisujete Request‑Body, radite to samo u dijagnostičkim buildovima i uz maskiranje, jer prompti mogu sadržavati osobne ili poslovne informacije.

Kontekst za legacy situacije: VCL, FMX i Layer-3 arhitektura

Mnoge Delphi‑aplikacije rade prema klasičnoj 3‑slojnoj logici („Layer-3 Architektur“: UI, poslovna logika, podaci/integracija). Za povezivanje s ChatGPT‑om to je korisno: prikazani klijent pripada integracijskom sloju; poslovna logika odlučuje, što se pita; UI prikazuje samo tijek i status. Time izbjegavate da kasnija promjena (drugi provider, on‑prem proxy, novi endpointi) naruši forme.

I za modernizaciju Delphi to je dobar početak: prvo stabilan klijent, zatim poboljšanja UI‑a (streaming, prekid, povijest), a tek potom „inteligentnije“ značajke poput strukturiranih odgovora ili poziva alata.

Zaključak: Solidan temelj, ali ne svaka aplikacija treba streaming

Vrijedi pažljivo povezati ChatGPT API s Delphi FMX/VCL naročito tamo gdje su važni reaktivnost UI-ja, operativna pouzdanost i mogućnost debugiranja: alati za administratore, desktop-klijenti bliski procesima ili alati za podršku u digitalnim poslovnim rješenjima. Prikazani isječak je namjerno pragmatičan: SSE-streaming bez specijaliziranih biblioteka, retry samo za stvarne mrežne pogreške, JSON parsiran defenzivno.

Ograničenja upotrebe: Ako trebate stroge zahtjeve usklađenosti, centralnu Prompt-Governance, podršku za višekorisničnost ili detaljne audit-trailove, „jedan klijent na desktopu” obično nije dovoljan. U tom slučaju povezivanje tipično pripada u kontrolirani poslužitelj (npr. vlastiti REST-servis), koji centralno provodi politike, logging i kontrolu pristupa. Za mnoge Delphi instalacije prikazani klijent ipak predstavlja pouzdanu polaznu točku koju se može postupno integrirati u čišću cjelokupnu arhitekturu.

U stručnom okruženju važnu ulogu igraju i Openai API u Delphi te Delphi Http Client Timeout Retry, kada integracije, protoci podataka i daljnji razvoj moraju uredno surađivati.

Razgovarajte o projektu ili modernizacijskom zahvatu s Net-Base.

Nächster Schritt

Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.

Podržavamo vas ne samo u pojedinačnim pitanjima, već i kada iz isječaka izvornog koda, naslijeđenih sustava ili ideja za portale treba nastati pouzdan poslovni projekt.

  • Postojeće stanje, ciljna slika i tehnički rizici procjenjuju se zajedno.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

Podijeli objavu

Izravno proslijedite ovu objavu

LinkedIn, X, XING, Facebook, WhatsApp und E-Mail sind sofort verfügbar. Für Instagram bereiten wir Link und Kurztext direkt vor.

E-pošta

Instagram se otvara u novoj kartici. Link i kratki tekst se prethodno kopiraju u međuspremnik.