Net-Base Magazin

15.07.2026

ChatGPT API mit Delphi FMX/VCL: Robust anbinden mit Streaming, Retry und sauberem JSON-Parsing

So binden Sie die ChatGPT API mit Delphi FMX/VCL robust an: HTTP-Client mit Timeouts und Retry, SSE-Streaming ohne UI-Freezes, sowie belastbares JSON-Parsing für Tool-Aufrufe und Fehlerfälle.

15.07.2026

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.

Delphi
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 ENetHTTPClientException wird 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.ProxySettings und ggf. Zertifikatsthemen ein. Debugging: Statuscode/Headers immer mitloggen (ohne API-Key).
  • Timeout-Strategie: ResponseTimeout ist 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.CancelAll in 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.

Beitrag teilen

Diesen Beitrag direkt weitergeben

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 oeffnet in einem neuen Tab. Link und Kurztext werden vorher in die Zwischenablage kopiert.