Net-Base Magasin

15.07.2026

ChatGPT API med Delphi FMX/VCL: Robust integrering med streaming, gjenforsøk og presis JSON-parsing

Slik kobler du ChatGPT API til Delphi FMX/VCL på en robust måte: HTTP-klient med timeouts og gjenforsøk, SSE-streaming uten at UI-en fryser, samt robust JSON-parsing for verktøykall og feilsituasjoner.

15.07.2026

Fra magasinetema til prosjektpraksis

Egnede tjeneste- og tekniske sider for innlegget

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

Den som vil koble til ChatGPT API med Delphi FMX/VCL ender raskt opp med et enkelt HTTP-POST. I ekte forretningsprogramvare oppstår det imidlertid problemer på tre områder: (1) Timeouts og Retries må være deterministiske, ellers opplever brukeren en «hengende» UI, (2) Streaming (Server-Sent Events, kort SSE) er ofte hensiktsmessig for god UX, men i Delphi-trådmodell blir det raskt feilutsatt, og (3) JSON er ikke bare «et objekt»: Feilmeldinger, kvoteproblemer, tomme felt eller lett endrede svarformater må håndteres robust.

Følgende kodesnutt viser en tilnærming som fungerer likt i FMX og VCL: en egen, testbar klient som valgfritt arbeider i ikke-streaming eller streaming modus, marshalerer UI-oppdateringer på en ryddig måte (altså utfører dem via synkronisering på hovedtråden) og logger meningsfullt ved feil. I tillegg er den bygd slik at den passer inn i eksisterende lagdelte strukturer (f.eks. «API-Client» i integrasjonssjiktet, UI forblir tynn).

Arkitekturskisse: Avkoble UI, hold klienten testbar

I Delphi-prosjekter med lang historikk finner man ofte «HTTP i ButtonClick». Det fungerer inntil første incident. Anbefalt er en liten klient med:

  • Konfigurasjon: Base-URL, API-Key, modell, timeouts.
  • Transportsjikt: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-alternativer (avhengig av drift).
  • Parser: JSON-avkoding, feilmeldingsobjekter, resultatuttrekking.
  • UI-Hooks: Callback for token-/tekst-streaming, men uten hard avhengighet til VCL/FMX-kontroller.

Slik kan integrasjonen i individuell bedriftsprogramvare drives ryddig: klienten kan gjenbrukes i tjenester, desktop-klienter, admin-verktøy eller test-harnesses.

Kodeeksempel: Delphi-klient med SSE-streaming, Timeout/Retry og robust JSON

Koden bruker THTTPClient (System.Net.HttpClient) og parser bevisst bare minimalt med System.JSON. For SSE leses det linjevis og reageres på «data: …». Dette er ikke en «WebSocket», men en HTTP-respons-strøm som kontinuerlig leverer tekstlinjer. Viktig: Vi leser i en worker-tråd og marshalerer UI-oppdateringer 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: her API-nøkkel som „Authorization: Bearer …“.
// I bedriftsmiljøer må du også passe på at nøkler ikke havner i logger.
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 oppsett for 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
// Vanlig 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 (ikke et 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
// Nettverks-/TLS-/timeout-feil: enkel 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-feil ‚ + 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
// Starter streaming bevisst asynkront for å unngå at FMX/VCL blokkeres.
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-feil er innholdet ofte likevel JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Streaming-start mislyktes (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;

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

Hva tilnærmingen er egnet for

Koden løser tre typiske problemklasser som raskt blir kostbare i VCL/FMX:

  • Streaming uten at UI fryser: HTTP-strømmen leses i bakgrunnen; UI-oppdateringer kjøres via TThread.Queue (asynkront i hovedtråden). Dette er den robuste standardmåten i FMX og VCL.
  • Gjenforsøk ved nettverksfeil: Ved ENetHTTPClientException gjøres nye forsøk med backoff. Det er med vilje enkelt og kan senere utvides med håndtering av statuskoder (429/5xx).
  • Robust JSON-parsing: I stedet for å kaste felt blindt, kontrolleres de trinnvis. Det reduserer „Invalid type cast“-feil ved spesialtilfeller i svar.

Rammebetingelser, fallgruver og varianter

  • SSE er ikke et «vanlig JSON»: Ved streaming kommer en rekke hendelser, ikke ett enkelt JSON-svar. Derfor er linjevis lesing og gjenkjenning av [DONE] sentralt.
  • THTTPClient og Proxies/SSL: I administrerte nettverk er TLS-inspeksjon og proxy-krav vanlige. Planlegg for THTTPClient.ProxySettings og eventuelle sertifikatspørsmål. For debugging: logg alltid statuskode/headers (uten API-nøkkel).
  • Timeout-strategi: ResponseTimeout er sensitiv ved streaming: Hvis du velger for kort, kutter klienten lange svar. I UI-verktøy er ofte en lengre timeout og en «Avbryt»-knapp mer hensiktsmessig enn en «kort og hard» innstilling.
  • Trådavbrudd: Utdraget viser ingen cancel-token. For produksjonsverktøy lønner det seg med en avbrudds­mekanisme (f.eks. flagg + Http.CancelAll i nyere Delphi-versjoner eller via kontrollert strømavbrudd).
  • Modell- og API-videreutvikling: Strukturen i Responses kan variere. Hold parserne defensive og sentrale, ikke i formularkoden spredt.

Feilsøking i etablerte Delphi-klienter: Hva du faktisk bør logge

I integrasjonsprosjekter mislykkes sjelden første igangkjøring på grunn av JSON; det skyldes som regel miljødetaljer. I en teknisk logg (fil, eventlog, sentral logger) bør følgende føres i praksis:

  • Request-ID (egen generert), tidsstempel, destinasjons-URL (uten hemmelige query-strenger).
  • HTTP-statuskode, Content-Type, responstørrelse, varighet.
  • En forkortet response-body ved feil (f.eks. maks. 4–8 KB), for å kunne identifisere quota-/policy-feil.
  • Eksplisitt merking av gjenforsøk: forsøk, ventetid, unntaksklasse.

API-nøkkelen skal aldri logges. Hvis du logger request-body, gjør det kun i diagnose-builds og med maskering, fordi prompts kan inneholde personopplysninger eller forretningssensitiv informasjon.

Plassering for legacy-situasjoner: VCL, FMX og Layer-3-arkitektur

Mange Delphi-applikasjoner kjører i en klassisk tre-lags logikk («Layer-3 arkitektur»: UI, forretningslogikk, data/integrasjon). For ChatGPT-tilkobling er dette nyttig: Den viste klienten hører hjemme i integrasjonslaget; forretningslogikken avgjør hva som skal spørres; UI viser kun historikk og status. Dermed unngår du at et senere bytte (annen leverandør, On-Prem-Proxy, nye endepunkter) «river i stykker» skjemaene.

Også for Delphi-modernisering er dette et godt utgangspunkt: Først en stabil klient, deretter UI-forbedringer (Streaming, Avbryt, Historikk), og så mer «intelligente» funksjoner som strukturerte svar eller tool-kall.

Konklusjon: Solid basis, men ikke alle applikasjoner trenger streaming

Det lønner seg særlig å integrere ChatGPT API med Delphi FMX/VCL der UI-respons, driftssikkerhet og feilsøkbarhet er viktige: Admin-verktøy, prosessnære desktop-klienter eller supportverktøy i digitale virksomhetsløsninger. Det viste kodeutdraget er bevisst pragmatisk: SSE-strømming uten spesialbiblioteker, retry kun ved reelle nettverksfeil, JSON parset defensivt.

Bruksbegrensninger: Hvis dere har strenge krav til compliance, sentral prompt-governance, støtte for flere leietakere eller detaljerte audit-trails, er som regel ikke «en klient på skrivebordet» tilstrekkelig. Da bør integrasjonen typisk legges i en kontrollert server (f.eks. en egen REST-service) som implementerer policies, logging og tilgangsstyring sentralt. For mange Delphi-installasjoner er imidlertid klienten vist her et robust utgangspunkt som kan fases inn i en ryddigere totalarkitektur.

I faglig sammenheng spiller også Openai API i Delphi og Delphi-implementasjoner, samt HTTP-klientens timeout og retry, en viktig rolle når integrasjoner, dataflyt og videreutvikling må fungere sammen på en ryddig måte.

Drøft prosjekt eller moderniseringsprosjekt 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 bistår ikke bare med enkeltspørsmål, men også når kodesnutter, legacy-temaer eller portalideer skal utvikles til et robust virksomhetsprosjekt.

  • Eksisterende tilstand, målbildet og tekniske risikoer 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 innlegg

Del dette innlegget direkte

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

E-post

Instagram åpnes i en ny fane. Lenken og kortteksten kopieres først til utklippstavlen.