Vom Magazinthema zur Projektpraxis
Passende Leistungs- und Technikseiten zum Beitrag
Warum „ChatGPT API mit Delphi FMX/VCL“ in der Praxis nicht nur ein POST ist
Wer die ChatGPT API mit Delphi FMX/VCL anbinden will, landet schnell bei einem simplen HTTP-POST. In echten Business-Software-Umgebungen kippt das aber an drei Stellen: (1) Timeouts und Retries müssen deterministisch sein, weil Anwender sonst „hängende“ UI erleben, (2) Streaming (Server-Sent Events, kurz SSE) ist für gute UX oft sinnvoll, aber im Delphi-Threading schnell fehleranfällig, und (3) JSON ist nicht nur „ein Objekt“: Fehlermeldungen, Quotenprobleme, leere Felder oder leicht veränderte Antwortformen müssen robust gehandhabt werden.
Der folgende Source-Schnipsel zeigt einen Ansatz, der in FMX und VCL gleichermaßen funktioniert: Ein eigener, testbarer Client, der wahlweise nicht-streaming oder streaming arbeitet, UI-Updates sauber marshalt (also über die Main-Thread-Synchronisation ausführt) und bei Fehlern aussagekräftig loggt. Nebenbei ist er so gebaut, dass er sich in gewachsene Layer-Strukturen einfügt (z. B. „API-Client“ in der Integrationsschicht, UI bleibt dünn).
Architektur-Skizze: UI entkoppeln, Client testbar halten
In Delphi-Projekten mit langer Historie findet man häufig „HTTP im ButtonClick“. Das funktioniert bis zum ersten Incident. Empfehlenswert ist ein kleiner Client mit:
- Konfiguration: Base-URL, API-Key, Modell, Timeouts.
- Transportschicht: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (je nach Betrieb).
- Parser: JSON-Decoding, Fehlerobjekte, Ergebnisextraktion.
- UI-Hooks: Callback für Token-/Text-Streaming, aber ohne harte Abhängigkeit auf VCL/FMX Controls.
So kann die Integration in individuelle Unternehmenssoftware sauber betrieben werden: Der Client lässt sich in Services, Desktop-Clients, Admin-Tools oder Test-Harnesses wiederverwenden.
Source-Schnipsel: Delphi-Client mit SSE-Streaming, Timeout/Retry und robustem JSON
Der Code nutzt THTTPClient (System.Net.HttpClient) und pars(t) bewusst nur minimalistisch mit System.JSON. Für SSE wird zeilenweise gelesen und auf „data: …“ reagiert. Das ist kein „WebSocket“, sondern ein HTTP-Response-Stream, der kontinuierlich Textzeilen liefert. Wichtig: Wir lesen in einem Worker-Thread und marshalen UI-Updates in den Main Thread.
unit NetBase.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-Key als „Authorization: Bearer …“.
// In Unternehmensumgebungen zusätzlich darauf achten, dass Keys nicht ins Log geraten.
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));
// Minimaler Messages-Aufbau (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
// Häufige Form: { "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('Unerwartete JSON-Antwort (kein Objekt).', 0, AJsonText);
Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>('choices');
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create('Unerwartete JSON-Antwort: choices fehlt/leer.', 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('Unerwartete JSON-Antwort: message fehlt.', 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-Fehler ' + 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 bewusst asynchron starten, damit FMX/VCL nicht blockiert.
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
// Bei Streaming-Fehlern ist Content oft trotzdem JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent('Streaming-Start fehlgeschlagen (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: Zeilen wie "data: {...}" oder "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 auswerten: 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.
Wozu der Ansatz gut ist
Der Code löst drei typische Problemklassen, die in VCL/FMX schnell teuer werden:
- Streaming ohne UI-Freezes: Der HTTP-Stream wird im Hintergrund gelesen; UI-Updates laufen über
TThread.Queue(asynchron in den Main Thread). Das ist in FMX und VCL der robuste Standardweg. - Retry für Netzfehler: Bei
ENetHTTPClientExceptionwird mit Backoff erneut versucht. Das ist bewusst simpel und lässt sich später um Statuscodes (429/5xx) erweitern. - Robustes JSON-Parsing: Statt blind Felder zu casten, wird schrittweise geprüft. Das reduziert „Invalid type cast“-Fehler bei Sonderantworten.
Randbedingungen, Stolperfallen und Varianten
- SSE ist kein „normales JSON“: Bei Streaming kommt eine Folge von Events, nicht eine einzelne JSON-Antwort. Deshalb ist das zeilenweise Lesen und das Erkennen von
[DONE]zentral. - THTTPClient und Proxies/SSL: In Admin-Netzen sind TLS-Inspection und Proxy-Pflichten real. Planen Sie
THTTPClient.ProxySettingsund ggf. Zertifikatsthemen ein. Debugging: Statuscode/Headers immer mitloggen (ohne API-Key). - Timeout-Strategie:
ResponseTimeoutist bei Streaming heikel: Wenn Sie zu kurz wählen, schneidet der Client lange Antworten ab. In UI-Tools ist ein längerer Timeout und ein „Abbrechen“-Button oft sinnvoller als „kurz und hart“. - Thread-Abbruch: Der Schnipsel zeigt keinen Cancel-Token. Für produktive Tools lohnt sich ein Abbruchmechanismus (z. B. Flag +
Http.CancelAllin neueren Delphi-Versionen oder per kontrolliertem Stream-Abbruch). - Modell- und API-Weiterentwicklung: Die Struktur von Responses kann sich unterscheiden. Halten Sie Parser defensiv und zentral, nicht in Formularcode verteilt.
Debugging in gewachsenen Delphi-Clients: Was Sie wirklich loggen sollten
In Integrationsprojekten scheitert die erste Inbetriebnahme selten am JSON, sondern an Umgebungsdetails. In ein technisches Log (Datei, Eventlog, zentraler Logger) gehören in der Praxis:
- Request-ID (selbst vergeben), Timestamp, Ziel-URL (ohne Secret-Querystrings).
- HTTP-Statuscode, Content-Type, Antwortlänge, Laufzeit.
- Ein gekürzter Response-Body bei Fehlern (z. B. max. 4–8 KB), um Quota-/Policy-Fehler erkennen zu können.
- Explizite Kennzeichnung von Retry-Versuchen: Attempt, Delay, Exception-Klasse.
Der API-Key gehört nie ins Log. Wenn Sie den Request-Body loggen, dann nur in Diagnose-Builds und mit Maskierung, weil Prompts durchaus personenbezogene oder geschäftliche Inhalte enthalten können.
Einordnung für Legacy-Situationen: VCL, FMX und Layer-3 Architektur
Viele Delphi-Anwendungen laufen in einer klassischen 3-Schichten-Logik („Layer-3 Architektur“: UI, Geschäftslogik, Daten/Integration). Für die ChatGPT-Anbindung ist das nützlich: Der gezeigte Client gehört in die Integrationsschicht; die Geschäftslogik entscheidet, was gefragt wird; die UI zeigt nur Verlauf und Status. Damit vermeiden Sie, dass ein späterer Wechsel (anderer Provider, On-Prem-Proxy, neue Endpunkte) die Forms „zerreißt“.
Auch für Delphi-Modernisierung ist das ein guter Einstiegspunkt: Erst einen stabilen Client, dann UI-Verbesserungen (Streaming, Abbrechen, Verlauf), dann erst „intelligentere“ Features wie strukturierte Antworten oder Tool-Aufrufe.
Fazit: Solide Basis, aber nicht jede Anwendung braucht Streaming
Die ChatGPT API mit Delphi FMX/VCL sauber anzubinden lohnt sich besonders dort, wo UI-Reaktionsfähigkeit, Betriebssicherheit und Debuggability wichtig sind: Admin-Tools, prozessnahe Desktop-Clients oder Support-Werkzeuge in digitalen Unternehmenslösungen. Der gezeigte Schnipsel ist bewusst pragmatisch: SSE-Streaming ohne Spezialbibliotheken, Retry nur für echte Netzfehler, JSON defensiv geparst.
Einsatzgrenzen: Wenn Sie harte Compliance-Vorgaben, zentrale Prompt-Governance, Mandantenfähigkeit oder detaillierte Audit-Trails brauchen, reicht „ein Client im Desktop“ meist nicht aus. Dann gehört die Anbindung typischerweise in einen kontrollierten Server (z. B. eigener REST-Service), der Policies, Logging und Zugriffssteuerung zentral umsetzt. Für viele Delphi-Installationen ist der hier gezeigte Client aber ein belastbarer Startpunkt, der sich schrittweise in eine sauberere Gesamtarchitektur überführen lässt.
Im fachlichen Umfeld spielen auch Openai API In Delphi und Delphi Http Client Timeout Retry eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.
Nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.
- Bestand, Zielbild und technische Risiken werden zusammen bewertet.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.