Net-Base Magazine

15.07.2026

ChatGPT API with Delphi FMX/VCL: Robust integration with streaming, retry logic and clean JSON parsing

How to integrate the ChatGPT API robustly with Delphi FMX/VCL: HTTP client with timeouts and retries, SSE streaming without UI freezes, and resilient JSON parsing for tool invocations and error cases.

15.07.2026

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.

Delphi
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 ENetHTTPClientException a 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.ProxySettings and, if necessary, certificate issues. Debugging: always log status codes/headers (without the API key).
  • Timeout strategy: ResponseTimeout is 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.CancelAll in 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.

Share post

Share this post directly

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

Email

Instagram opens in a new tab. The link and short text are copied to the clipboard beforehand.