Net-Base Magazín

15.07.2026

ChatGPT API s Delphi pro FMX/VCL: robustní napojení se streamováním, retry a čistým parsováním JSON

Takto robustně připojíte ChatGPT API k Delphi FMX/VCL: HTTP klient s časovými limity a opakovanými pokusy, SSE streamování bez zamrznutí uživatelského rozhraní, a robustní JSON parsování pro volání nástrojů a ošetření chyb.

15.07.2026

Od tématu magazínu k projektové praxi

Vhodné stránky služeb a technické stránky k příspěvku

Proč „ChatGPT API s Delphi FMX/VCL“ v praxi není jen POST

Kdo chce připojit ChatGPT API s Delphi FMX/VCL, často skončí u jednoduchého HTTP-POSTu. V reálném podnikové softwarové vrstvě se to však na třech místech pokazí: (1) Timeouts a retries musí být deterministické, protože jinak uživatelé zažijí „zamrzlé“ UI, (2) streaming (Server-Sent Events, zkráceně SSE) je pro dobrou UX často užitečný, ale v Delphi-threadingu rychle náchylný k chybám, a (3) JSON není jen „jeden objekt“: chybové zprávy, problémy s kvótami, prázdná pole nebo lehce pozměněné formy odpovědí je třeba robustně zpracovat.

Následující ukázka zdrojového kódu demonstruje přístup, který funguje ve FMX i VCL stejně: vlastní, testovatelný klient, který pracuje volitelně v režimu non-streaming nebo streaming, přenáší UI-aktualizace korektně do hlavního vlákna (tj. přes synchronizaci hlavního vlákna) a při chybách zapisuje výstižné logy. Zároveň je navržen tak, aby se hladce začlenil do existujících vrstev (např. „API-Client“ v integrační vrstvě, UI zůstává tenká).

Náčrt architektury: oddělit UI, udržet klienta testovatelným

V Delphi-projektech s dlouhou historií se často nachází „HTTP v ButtonClicku“. To funguje až do prvního incidentu. Doporučitelný je malý klient s:

  • Konfigurace: Base-URL, API-Key, model, timeouts.
  • Transportní vrstva: HTTP request/response, retry, timeout, proxy/SSL možnosti (dle provozu).
  • Parser: JSON-dekódování, chybové objekty, extrakce výsledků.
  • UI-Hooks: callback pro token-/text-streaming, ale bez pevné závislosti na VCL/FMX ovládacích prvcích.

Tak lze integraci do individuální podnikové softwarové infrastruktury provozovat čistě: klient lze znovu použít ve službách, desktop klientech, administračních nástrojích nebo v test-harnessích.

Ukázka zdrojového kódu: Delphi-Client se SSE-streamingem, Timeout/Retry a robustním JSONem

Zdroj využívá THTTPClient (System.Net.HttpClient) a pars(t) vědomě jen minimalisticky pomocí System.JSON. Pro SSE se čte po řádcích a reaguje se na „data: …“. Nejedná se o „WebSocket“, ale o HTTP-response-stream, který kontinuálně dodává textové řádky. Důležité: čteme v pracovním vlákně a přenášíme UI-aktualizace do hlavního vlákna.

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: zde API-klíč jako „Authorization: Bearer …“.
// V podnikovém prostředí dbejte také na to, aby klíče nebyly zapisovány do logů.
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));

// Minimální 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
// Běžný tvar: { „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čekávaná JSON odpověď (není objekt).‘, 0, AJsonText);

Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Neočekávaná JSON odpověď: choices chybí nebo je prázdné.‘, 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čekávaná JSON odpověď: message chybí.‘, 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
// Netzwerk-/TLS-/Timeout-Fehler: einfacher Retry mit Backoff.
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 chyba ‚ + 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 záměrně spouštět asynchronně, aby FMX/VCL nebylo blokováno.
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
// Při chybách ve streamu bývá obsah často přesto JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Spuštění streamu selhalo (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;

// Formát SSE: řádky jako „data: {…}“ nebo „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;

// Vyhodnotit JSON fragment: 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.

K čemu je tento přístup dobrý

Kód řeší tři typické třídy problémů, které se ve VCL/FMX rychle prodraží:

  • Streaming bez zamrznutí uživatelského rozhraní: HTTP‑stream se čte na pozadí; aktualizace UI probíhají přes TThread.Queue (asynchronně do hlavního vlákna). To je ve FMX a VCL robustní standardní postup.
  • Opakování při síťových chybách: Při ENetHTTPClientException se provede opakování s backoffem. Je to úmyslně jednoduché a lze to později rozšířit o kontrolu stavových kódů (429/5xx).
  • Robustní parsování JSON: Místo slepého přetypování polí se provádějí postupné kontroly. To snižuje chyby „Invalid type cast“ u zvláštních odpovědí.

Východiska, úskalí a varianty

  • SSE není „běžné JSON“: Při streamingu přichází posloupnost událostí, ne jedna JSON odpověď. Proto je čtení po řádcích a rozpoznání [DONE] klíčové.
  • THTTPClient a proxy/SSL: V administrativních sítích jsou reálné TLS‑inspekce a povinnost proxy. Plánujte THTTPClient.ProxySettings a případné záležitosti s certifikáty. Pro debugování: vždy logujte Statuscode/Headers (bez API‑Key).
  • Strategie timeoutů: ResponseTimeout je u streamingu citlivé: pokud zvolíte příliš krátký interval, klient odřízne dlouhé odpovědi. Ve UI nástrojích bývá smysluplnější delší timeout a tlačítko „Zrušit“ než přísný krátký limit.
  • Přerušení vlákna: Ukázka neobsahuje Cancel‑Token. Pro produkční nástroje se vyplatí mechanismus přerušení (např. příznak + Http.CancelAll v novějších Delphi‑verzích nebo kontrolované přerušení streamu).
  • Vývoj modelu a API: Struktura response se může lišit. Držte parser defensivní a centralizovaný, ne rozptýlený v kódu formulářů.

Debugování v existujících Delphi klientech: co byste měli skutečně logovat

V integračních projektech první spuštění zřídka selže kvůli JSONu, častěji kvůli detailům prostředí. Do technického logu (soubor, Eventlog, centrální logger) patří v praxi:

  • Request‑ID (vlastní), časová značka, cílové URL (bez query stringů obsahujících tajné údaje).
  • HTTP stavový kód, Content‑Type, délka odpovědi, doba běhu.
  • Ořezané tělo response při chybách (např. max. 4–8 KB), aby bylo možné rozpoznat chyby kvót/politik.
  • Výslovné označení pokusů o retry: pokus, prodleva, třída výjimky.

API‑Key nikdy nepatří do logu. Pokud logujete Request‑Body, dělejte to pouze v diagnostických sestaveních a s maskováním, protože prompty mohou obsahovat osobní nebo obchodní údaje.

Zařazení do legacy situací: VCL, FMX a Layer-3 architektura

Mnoho Delphi aplikací běží v klasické třívrstvé logice („Layer-3 architektura“: UI, obchodní logika, data/integrace). Pro napojení ChatGPT je to výhodné: ukázaný klient patří do integrační vrstvy; obchodní logika rozhoduje, co se má dotazovat; UI zobrazuje pouze průběh a stav. Tím zabráníte tomu, aby pozdější změna (jiný poskytovatel, On‑Prem proxy, nové endpointy) „roztrhala“ formuláře.

I pro modernizaci Delphi je to dobrý výchozí bod: nejprve stabilní klient, poté vylepšení UI (streaming, zrušení, historie), a až potom „inteligentnější“ funkce jako strukturované odpovědi nebo volání nástrojů.

Závěr: solidní základ, ale ne každá aplikace potřebuje streaming

Čisté napojení ChatGPT API pomocí Delphi FMX/VCL se vyplatí zejména tam, kde jsou důležité reakční schopnosti uživatelského rozhraní, provozní bezpečnost a možnost ladění: nástroje pro administraci, procesně blízké desktopové klienty nebo podpůrné nástroje v digitálních podnikových řešeních. Ukázkový útržek je záměrně pragmatický: SSE-streaming bez speciálních knihoven, Retry pouze při skutečných síťových chybách, JSON parsovaný defenzivně.

Meze použití: Pokud potřebujete přísné požadavky na compliance, centrální Prompt-Governance, podporu více nájemců nebo podrobné auditní stopy, obvykle „klient na desktopu“ nestačí. V takovém případě patří napojení typicky do kontrolovaného serveru (např. vlastní REST‑služby), který centrálně implementuje politiky, logging a řízení přístupu. Pro mnoho instalací Delphi je však zde ukázaný klient spolehlivým výchozím bodem, který lze postupně převést do čistší celkové architektury.

V odborném prostředí hrají rovněž důležitou roli Openai API v Delphi a Delphi Http Client Timeout Retry, pokud musí integrace, datové toky a další vývoj přesně spolupracovat.

Projekt nebo modernizační záměr projednat s Net-Base.

Další krok

Když z tématu vznikne reálný projekt, je třeba brzy společně zvážit architekturu, stávající prostředí a provoz.

Podporujeme nejen při jednotlivých otázkách, ale i v případě, že se z útržků zdrojového kódu, legacy témat nebo nápadů na portál má vyvinout robustní podnikový projekt.

  • Současný stav, cílový stav a technická rizika jsou hodnoceny společně.
  • REST, přístup k datům, portály a Rollout nebudou odloženy do pozdějších fází.
  • Vidíte brzy, která cesta je ekonomicky a provozně životaschopná.

Sdílet příspěvek

Sdílet tento příspěvek přímo

LinkedIn, X, XING, Facebook, WhatsApp a e-mail jsou okamžitě k dispozici. Pro Instagram připravíme odkaz a krátký text.

E-mail

Instagram se otevře v nové záložce. Odkaz a krátký text budou předtím zkopírovány do schránky.