From magazine topic to project implementation
Relevant service and technical pages for this post
Why „ChatGPT API with Delphi FMX/VCL“ in practice is more than just a POST
Anyone who wants to integrate the ChatGPT API with Delphi FMX/VCL will quickly end up with a simple HTTP POST. In real business-software environments this fails in three places: (1) timeouts and retries must be deterministic, otherwise users experience a „hung“ UI, (2) streaming (Server-Sent Events, or SSE) is often useful for a good UX, but becomes error-prone quickly in Delphi threading, and (3) JSON is not just „an object“: error messages, quota issues, empty fields or slightly altered response formats must be handled robustly.
The following source snippet shows an approach that works equally in FMX and VCL: a dedicated, testable client that can operate either non-streaming or streaming, marshals UI updates cleanly (i.e. performs them via main-thread synchronization) and logs meaningful messages on errors. Incidentally, it is designed to fit into established layered architectures (e.g. an „API client“ in the integration layer, keeping the UI thin).
Architecture sketch: decouple the UI, keep the client testable
In Delphi projects with long histories you often find „HTTP in ButtonClick.“ That works until the first incident. A small client with the following is recommended:
- Configuration: Base-URL, API-Key, model, timeouts.
- Transport layer: HTTP request/response, retry, timeout, proxy/SSL options (depending on operation).
- Parser: JSON decoding, error objects, result extraction.
- UI hooks: callback for token/text streaming, but without a hard dependency on VCL/FMX Controls.
This allows integration into custom enterprise software to be carried out cleanly: the client can be reused in services, desktop clients, admin tools or test harnesses.
Source snippet: Delphi client with SSE streaming, Timeout/Retry and robust JSON
The code uses THTTPClient (System.Net.HttpClient) and parses — deliberately only minimally — with System.JSON. For SSE the response is read line by line and reacts to „data: …“. This is not a „WebSocket“, but an HTTP response stream that continuously delivers text lines. Important: we read in a worker thread and marshal UI updates to the 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: API key here as "Authorization: Bearer ...".
// In corporate environments also ensure keys do not end up in logs.
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));
// Minimal messages structure (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('Unexpected JSON response (not an object).', 0, AJsonText);
Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>('choices');
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create('Unexpected JSON response: choices missing/empty.', 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('Unexpected JSON response: message missing.', 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
// Network/TLS/timeout errors: simple retry with 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 error ' + 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
// Intentionally start streaming asynchronously so FMX/VCL is not blocked.
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
// On streaming errors the content is often still JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent('Streaming start failed (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: lines like "data: {...}" or "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;
// Evaluate JSON chunk: 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.
Use cases for the approach
The code addresses three typical problem classes that can quickly become costly in VCL/FMX:
- Streaming without UI freezes: The HTTP stream is read in the background; UI updates are performed via
TThread.Queue(asynchronously onto the main thread). This is the robust standard approach in FMX and VCL. - Retry for network errors: On
ENetHTTPClientExceptiona retry is attempted with backoff. This is intentionally simple and can later be extended to account for status codes (429/5xx). - Robust JSON parsing: Instead of blindly casting fields, checks are performed stepwise. That reduces “Invalid type cast” errors for special responses.
Constraints, pitfalls and variants
- SSE is not „regular JSON“: Streaming produces a sequence of events, not a single JSON response. Therefore, reading line by line and recognizing
[DONE]is central. - THTTPClient and proxies/SSL: In administrative networks TLS inspection and mandatory proxies are real. Plan for
THTTPClient.ProxySettingsand, if necessary, certificate issues. Debugging: always log status codes/headers (without the API key). - Timeout strategy:
ResponseTimeoutis delicate for streaming: if you choose it too short, the client will truncate long responses. In UI tools, a longer timeout and a „Cancel“ button are often more appropriate than a short, abrupt timeout. - Thread cancellation: The snippet shows no cancel token. For production tools a cancellation mechanism is worthwhile (e.g. a flag +
Http.CancelAllin newer Delphi versions or via controlled stream abort). - Model and API evolution: Response structures may vary. Keep parsers defensive and centralized, not scattered in form code.
Debugging in mature Delphi clients: What you should really log
In integration projects, initial commissioning seldom fails because of JSON, but rather due to environmental details. In a technical log (file, event log, central logger) the following should be recorded in practice:
- Request ID (self-assigned), timestamp, target URL (without secret query strings).
- HTTP status code, Content-Type, response length, duration.
- A truncated response body on errors (e.g. max. 4–8 KB), to be able to detect quota/policy errors.
- Explicit annotation of retry attempts: attempt number, delay, exception class.
The API key must never be logged. If you log the request body, do so only in diagnostic builds and with masking, because prompts may contain personal or business-sensitive content.
Context for legacy situations: VCL, FMX and Layer-3 architecture
Many Delphi applications run in a classic 3-tier logic („Layer-3 Architektur„: UI, business logic, data/integration). For the ChatGPT integration this is useful: the shown client belongs in the integration layer; the business logic decides what is asked; the UI only displays history and status. This prevents a later change (different provider, on-prem proxy, new endpoints) from breaking the forms.
For Delphi modernization this is also a good starting point: first a stable client, then UI improvements (streaming, cancel, history), and only then „smarter“ features such as structured responses or tool invocations.
Conclusion: Solid foundation, but not every application requires streaming
Integrating the ChatGPT API with Delphi FMX/VCL cleanly is particularly worthwhile where UI responsiveness, operational reliability and debuggability matter: admin tools, process-near desktop clients or support utilities in digital enterprise solutions. The snippet shown is deliberately pragmatic: SSE-Streaming without special libraries, retry only for actual network errors, JSON parsed defensively.
Limits of use: If you require strict compliance requirements, central prompt governance, multi-tenancy or detailed audit trails, „a client on the desktop“ is usually not sufficient. In that case the integration typically belongs in a controlled server (e.g., a dedicated REST service) that implements policies, logging and access control centrally. For many Delphi installations, however, the client shown here is a reliable starting point that can be migrated step by step into a cleaner overall architecture.
In the professional context, the Openai API in Delphi and Delphi HTTP client timeout/retry also play an important role when integrations, data flows and further development need to work together cleanly.
Discuss a project or modernization initiative with Net-Base.
Nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
We support not only with individual issues, but also when source snippets, legacy topics, or portal ideas are to be turned into a robust enterprise project.
- Current state, target state and technical risks are assessed jointly.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.