Net-Base Magasin

15.07.2026

ChatGPT API med Delphi FMX/VCL: Robust integrering med Streaming, Retry og ren JSON-parsing

Så integrerer du ChatGPT API med Delphi FMX/VCL robust: HTTP-klient med timeouts og retry, SSE-streaming uden UI-frysninger samt robust JSON-parsing til tool-kald og fejltilfælde.

15.07.2026

Fra magasinets tema til projektpraksis

Passende service- og tekniske sider til artiklen

Hvorfor „ChatGPT API med Delphi FMX/VCL“ i praksis ikke kun er et POST

Den, der vil integrere ChatGPT API med Delphi FMX/VCL, ender hurtigt ved et simpelt HTTP-POST. I reelle business-software-miljøer bryder det dog sammen på tre punkter: (1) Timeouts og retries skal være deterministiske, fordi brugere ellers oplever en låst UI, (2) streaming (Server-Sent Events, kort SSE) giver ofte bedre UX, men er i Delphi-trådning hurtigt fejlbehæftet, og (3) JSON er ikke bare „et objekt“: fejlmeddelelser, kvoteproblemer, tomme felter eller let ændrede svarformer skal håndteres robust.

Det følgende kildeudsnit viser en tilgang, der fungerer lige godt i FMX og VCL: En dedikeret, testbar klient, som valgfrit arbejder i ikke-streaming eller streaming-mode, marshalerer UI-opdateringer korrekt (dvs. udfører dem via hovedtrådssynkronisation) og logger sigende ved fejl. Derudover er den bygget, så den kan indpasses i etablerede lagstrukturer (f.eks. „API-Client“ i integrationslaget, UI forbliver tynd).

Architektur-Skizze: UI entkoppeln, Client testbar halten

I Delphi-projekter med lang historie finder man ofte „HTTP im ButtonClick“. Det fungerer indtil den første hændelse. Anbefalingen er en lille klient med:

  • Konfiguration: Base-URL, API-Key, Modell, Timeouts.
  • Transportlag: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-indstillinger (afhængigt af driftsmiljø).
  • Parser: JSON-dekodning, fejlobjekter, resultatudtrækning.
  • UI-Hooks: Callback til token-/tekst-streaming, men uden hård afhængighed af VCL/FMX Controls.

På den måde kan integrationen i individuel virksomhedsoftware drives rent: Klienten kan genbruges i services, desktop-klienter, admin-værktøjer eller test-harnesses.

Source-Schnipsel: Delphi-Client mit SSE-Streaming, Timeout/Retry und robustem JSON

Koden bruger THTTPClient (System.Net.HttpClient) og parser bevidst kun minimalistisk med System.JSON. Til SSE læses der linjevis, og der reageres på „data: …“. Det er ikke en „WebSocket“, men en HTTP-response-strøm, der kontinuerligt leverer tekstlinjer. Vigtigt: Vi læser i en worker-tråd og marshalerer UI-opdateringer til hovedtråden.

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-nøglen føres her som ‚Authorization: Bearer …‘.
// I virksomhedsmiljøer skal man desuden sikre, at nøgler ikke kommer i logfilerne.
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 opbygning af messages (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
// Typisk 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(‚Uventet JSON-svar (intet objekt).‘, 0, AJsonText);

Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Uventet JSON-svar: choices mangler/er tom.‘, 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(‚Uventet JSON-svar: message mangler.‘, 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
// Netværks-/TLS-/timeout-fejl: simpel retry med 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-fejl ‚ + 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 startes bevidst asynkront, så FMX/VCL ikke blokeres.
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
// Ved streaming-fejl er indholdet ofte stadig JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Streaming-start mislykkedes (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: linjer som „data: {…}“ eller „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;

// Tolk 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.

Hvad tilgangen er god til

Koden løser tre typiske problemklasser, som i VCL/FMX hurtigt kan blive kostbare:

  • Streaming uden UI-frysninger: HTTP-streamen læses i baggrunden; UI-opdateringer sker via TThread.Queue (asynkront i main thread). Det er i både FMX og VCL den robuste standardløsning.
  • Genforsøg ved netfejl: Ved ENetHTTPClientException foretages et nyt forsøg med backoff. Det er bevidst simpelt og kan senere udvides til at tage højde for statuskoder (429/5xx).
  • Robust JSON-parsning: I stedet for blind cast af felter tjekkes der trinvis. Det reducerer „Invalid type cast“-fejl ved særlige svar.

Randbetingelser, faldgruber og varianter

  • SSE er ikke „almindelig JSON“: Ved streaming kommer en række events, ikke ét enkelt JSON-svar. Derfor er linjevis læsning og genkendelse af [DONE] centralt.
  • THTTPClient og Proxies/SSL: I administrationsnetværk er TLS-inspektion og proxy-krav en realitet. Planlæg THTTPClient.ProxySettings og eventuelt certifikatspørgsmål. Debugging: log altid statuskode/headers (uden API-Key).
  • Timeout-strategi: ResponseTimeout er følsomt ved streaming: Hvis timeout er for kort, afbryder klienten lange svar. I UI-værktøjer er en længere timeout og en „Annuller“-knap ofte mere hensigtsmæssig end en kort, hård indstilling.
  • Trådafbrydelse: Eksemplet viser ingen cancel-token. For produktionsværktøjer er en afbrydelsesmekanisme nyttig (f.eks. flag + Http.CancelAll i nyere Delphi-versioner eller via kontrolleret stream-afbrydelse).
  • Model- og API-udvikling: Strukturen af responses kan ændre sig. Hold parseren defensiv og centraliseret, ikke fordelt i formular-kode.

Debugging i eksisterende Delphi-clients: Hvad I reelt bør logge

I integrationsprojekter fejler den første idriftsættelse sjældent på JSON, men på miljødetaljer. I et teknisk log (fil, eventlog, central logger) bør følgende i praksis logges:

  • Request-ID (selv tildelt), timestamp, mål-URL (uden hemmelige query-strings).
  • HTTP-statuskode, Content-Type, svar-længde, køretid.
  • En forkortet response-body ved fejl (f.eks. maks. 4–8 KB), så man kan identificere quota-/policy-fejl.
  • Eksplícit markering af retry-forsøg: Attempt, Delay, Exception-Klasse.

API-Key hører aldrig i loggen. Hvis I logger request-body, så kun i diagnose-builds og med maskering, fordi prompts godt kan indeholde personfølsomme eller forretningskritiske oplysninger.

Vurdering for legacy-situationer: VCL, FMX og Layer-3 arkitektur

Mange Delphi-applikationer kører i en klassisk 3-lags-logik („Layer-3 Architektur„: UI, forretningslogik, data/integration). Til ChatGPT-tilslutning er det nyttigt: Den viste client hører hjemme i integrationslaget; forretningslogikken afgør, hvad der spørges om; UI viser kun historik og status. Derved undgår I, at et senere skift (anden provider, on-prem-proxy, nye endpoints) „river“ Forms itu.

Også ved modernisering af Delphi er dette et godt udgangspunkt: Først en stabil client, derefter UI-forbedringer (streaming, afbryd, historik), og først til sidst „mere intelligente“ features som strukturerede svar eller tool-kald.

Konklusion: Solid basis, men ikke alle applikationer har brug for streaming

Det kan betale sig at integrere ChatGPT API med Delphi FMX/VCL ordentligt, især dér hvor UI-reaktivitet, driftsstabilitet og mulighed for fejlsøgning er vigtige: admin‑værktøjer, procesnære desktop‑klienter eller supportværktøjer i digitale virksomheds­løsninger. Det viste kodeudsnit er bevidst pragmatisk: SSE‑streaming uden specialbiblioteker, retry kun ved reelle netværksfejl, JSON parses defensivt.

Begrænsninger: Hvis I har stramme compliance-krav, central prompt-governance, mandantisolering eller detaljerede audit-trails, er „en klient på skrivebordet“ som regel ikke tilstrækkelig. I så fald hører tilslutningen typisk hjemme på en kontrolleret server (f.eks. egen REST-service), som centralt implementerer policies, logging og adgangsstyring. For mange Delphi-installationer er den her viste klient dog et robust udgangspunkt, som trinvis kan overføres til en renere samlet arkitektur.

I det faglige miljø spiller også Openai API i Delphi og Delphi Http Client Timeout Retry en vigtig rolle, når integrationer, dataflow og videreudvikling skal fungere gnidningsfrit sammen.

Drøft projekt eller moderniseringsprojekt med Net-Base.

Nächster Schritt

Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.

Vi støtter ikke kun ved enkeltspørsmål, men også når kildekodeudsnit, legacy-komponenter eller portalidéer skal udvikles til et robust virksomhedsprojekt.

  • Eksisterende tilstand, målbillede og tekniske risici vurderes samlet.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

Del indlæg

Del dette indlæg direkte

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 åbner i en ny fane. Linket og kortteksten kopieres på forhånd til udklipsholderen.