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
ENetHTTPClientExceptionwordt 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.ProxySettingsen indien nodig certificaatonderwerpen in. Debugging: log altijd statuscode/headers (zonder API-Key). - Timeout-strategie:
ResponseTimeoutis 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.CancelAllin 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.
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.