Net-Base Magazine

15.07.2026

ChatGPT API met Delphi FMX/VCL: Robuust koppelen met streaming, Retry en nauwkeurig JSON-parsing

Zo koppelt u de ChatGPT API met Delphi FMX/VCL robuust: HTTP-client met timeouts en retry, SSE-streaming zonder UI-freezes, en robuuste JSON-parsing voor tool-aanroepen en foutgevallen.

15.07.2026

Van magazinethema naar projectpraktijk

Relevante dienst- en technische pagina's bij het artikel

Waarom „ChatGPT API mit Delphi FMX/VCL“ in de praktijk niet alleen een POST is

Wie de ChatGPT API mit Delphi FMX/VCL wil koppelen, komt snel uit bij een eenvoudige HTTP-POST. In echte business-softwareomgevingen knelt dat echter op drie punten: (1) Timeouts en herhaalpogingen moeten deterministisch zijn, want anders ervaren gebruikers een „bevroren“ UI, (2) streaming (Server-Sent Events, kort SSE) is voor een goede UX vaak nuttig, maar in Delphi-threading snel foutgevoelig, en (3) JSON is niet slechts „een object“: foutmeldingen, quota-problemen, lege velden of licht gewijzigde antwoordvormen moeten robuust worden afgehandeld.

Het volgende bronfragment toont een aanpak die zowel in FMX als VCL werkt: een eigen, testbare client die optioneel niet-streaming of streaming werkt, UI-updates netjes marshalt (dus via synchronisatie met de Main-Thread uitvoert) en bij fouten zinvolle logs produceert. Daarnaast is hij zodanig opgebouwd dat hij in bestaande laagstructuren past (bijv. „API-Client“ in de integratielaag, de UI blijft dun).

Architektur-Skizze: UI entkoppeln, Client testbar halten

In Delphi-projecten met een lange historie vind je vaak „HTTP im ButtonClick“. Dat werkt tot het eerste incident. Aan te raden is een kleine client met:

  • Konfiguration: Base-URL, API-Key, Modell, Timeouts.
  • Transportschicht: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (afhankelijk van de bedrijfsomgeving).
  • Parser: JSON-Decoding, Fehlerobjekte, Ergebnisextraktion.
  • UI-Hooks: Callback für Token-/Text-Streaming, aber ohne harte Abhängigkeit auf VCL/FMX Controls.

Zo kan de integratie in individuele bedrijfssoftware zuiver worden beheerd: de client is herbruikbaar in services, desktop-clients, admin-tools of test-harnesses.

Source-Schnipsel: Delphi-Client mit SSE-Streaming, Timeout/Retry und robustem JSON

De code gebruikt THTTPClient (System.Net.HttpClient) en parst bewust alleen minimaal met System.JSON. Voor SSE wordt regel voor regel gelezen en op „data: …“ gereageerd. Dit is geen „WebSocket“, maar een HTTP-Response-Stream die continu tekstregels levert. Belangrijk: we lezen in een worker-thread en marshalen UI-updates naar de Main Thread.

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: hier API-sleutel als ‚Authorization: Bearer …‘.
// In bedrijfsomgevingen erop letten dat sleutels niet in logs terechtkomen.
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));

// Minimale messages-opbouw (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
// Veelvoorkomende vorm: { „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(‚Onverwacht JSON-antwoord (geen object).‘, 0, AJsonText);

Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Onverwacht JSON-antwoord: choices ontbreekt of is leeg.‘, 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(‚Onverwacht JSON-antwoord: message ontbreekt.‘, 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
// Netwerk-/TLS-/time-out fouten: eenvoudige retry met 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-fout ‚ + 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 bewust asynchroon starten zodat FMX/VCL niet blokkeert.
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
// Bij streamingfouten is de content vaak toch JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Streaming-start mislukt (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-formaat: regels zoals „data: {…}“ of „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;

// JSON-chunk analyseren: 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.

Waar deze aanpak goed voor is

De code lost drie typische probleemklassen op, die in VCL/FMX snel kostbaar worden:

  • Streaming zonder UI-bevriezingen: De HTTP-stream wordt op de achtergrond gelezen; UI-updates lopen via TThread.Queue (asynchroon naar de Main Thread). Dit is in FMX en VCL de robuuste standaardweg.
  • Retry bij netwerkfouten: Bij ENetHTTPClientException wordt met backoff opnieuw geprobeerd. Dit is bewust simpel en kan later worden uitgebreid met statuscodes (429/5xx).
  • Robuuste JSON-parsing: In plaats van velden blind te casten, wordt stap voor stap gecontroleerd. Dat vermindert „Invalid type cast“-fouten bij uitzonderlijke antwoorden.

Randvoorwaarden, valkuilen en varianten

  • SSE is geen „normale JSON“: Bij streaming komt een reeks events, niet één enkele JSON-respons. Daarom is regelgewijs lezen en het herkennen van [DONE] essentieel.
  • THTTPClient en proxies/SSL: In admin-netwerken zijn TLS-inspectie en proxyverplichtingen reëel. Plan THTTPClient.ProxySettings en indien nodig certificaatonderwerpen in. Debugging: log altijd statuscode/headers (zonder API-Key).
  • Timeout-strategie: ResponseTimeout is bij streaming gevoelig: als u te kort kiest, knipt de client lange antwoorden af. In UI-tools is een langere timeout en een ‘Annuleren’-knop vaak zinvoller dan ‘kort en hard’.
  • Thread-afbreking: Het voorbeeld toont geen cancel-token. Voor productietools is een afbrekingsmechanisme aan te raden (bijv. vlag + Http.CancelAll in nieuwere Delphi-versies of via gecontroleerde stream-afbreking).
  • Model- en API-ontwikkeling: De structuur van responses kan variëren. Houd parsers defensief en centraal, niet verspreid in formuliercode.

Debugging in volwassen Delphi-clients: wat u echt zou moeten loggen

In integratieprojecten faalt de eerste ingebruikname zelden door JSON, maar door omgevingsdetails. In een technisch log (bestand, eventlog, centrale logger) horen in de praktijk:

  • Request-ID (zelf toegewezen), tijdstempel, doel-URL (zonder geheime querystrings).
  • HTTP-statuscode, Content-Type, antwoordlengte, doorlooptijd.
  • Een ingekorte response-body bij fouten (bijv. max. 4–8 KB), om quota-/policy-fouten te kunnen herkennen.
  • Expliciete aanduiding van retry-pogingen: attempt, delay, exception-klasse.

De API-Key hoort nooit in het log. Als u de request-body logt, doe dat alleen in diagnose-builds en met maskering, omdat prompts persoonlijke of zakelijke inhoud kunnen bevatten.

Plaatsing voor legacy-situaties: VCL, FMX en Layer-3-architectuur

Veel Delphi-applicaties draaien in een klassieke 3-laagslogica („Layer-3 architectuur“: UI, businesslogica, data/integratie). Voor de ChatGPT-koppeling is dat nuttig: de getoonde client hoort in de integratielaag; de businesslogica bepaalt wat gevraagd wordt; de UI toont alleen het verloop en de status. Zo voorkomt u dat een latere wijziging (andere provider, on-prem-proxy, nieuwe endpoints) de formulieren verstoort.

Ook voor Delphi-modernisering is dit een goed beginpunt: eerst een stabiele client, daarna UI-verbeteringen (streaming, annuleren, verloop), en vervolgens pas ‘intelligentere’ functies zoals gestructureerde antwoorden of tool-aanroepen.

Conclusie: solide basis, maar niet elke toepassing heeft streaming nodig

Het is vooral de moeite waard om de ChatGPT API met Delphi FMX/VCL degelijk te koppelen waar UI-reactiesnelheid, operationele betrouwbaarheid en debugbaarheid belangrijk zijn: beheertools, procesnabije desktopclients of supporttools in digitale bedrijfsoplossingen. Het getoonde codefragment is bewust pragmatisch: SSE-streaming zonder gespecialiseerde bibliotheken, herhaalpogingen alleen voor echte netwerkfouten, JSON defensief geparsed.

Toepassingsgrenzen: als u strikte compliance-eisen, centrale prompt-governance, multitenancy of gedetailleerde audit-trails nodig heeft, is „een client op de desktop“ meestal niet voldoende. In dat geval hoort de koppeling doorgaans thuis in een gecontroleerde server (bijv. een eigen REST-service), die policies, logging en toegangscontrole centraal uitvoert. Voor veel Delphi-installaties is de hier getoonde client echter een solide startpunt dat stapsgewijs in een schonere totaalarchitectuur kan worden overgezet.

In de vakinhoudelijke context spelen ook Openai API in Delphi en Delphi HTTP-client timeout/retry een belangrijke rol, wanneer integraties, gegevensstromen en doorontwikkeling naadloos moeten samenwerken.

Project of moderniseringsproject met Net-Base bespreken.

Nächster Schritt

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

We ondersteunen niet alleen bij individuele vragen, maar ook wanneer uit broncodefragmenten, legacy-onderwerpen of portalideeën een robuust bedrijfsproject moet ontstaan.

  • Huidige situatie, doelbeeld en technische risico's worden gezamenlijk beoordeeld.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

Bericht delen

Dit bericht direct delen

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

E-mail

Instagram opent in een nieuw tabblad. Link en korte tekst worden van tevoren naar het klembord gekopieerd.