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
ENetHTTPClientExceptionforetages 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.ProxySettingsog eventuelt certifikatspørgsmål. Debugging: log altid statuskode/headers (uden API-Key). - Timeout-strategi:
ResponseTimeouter 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.CancelAlli 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 virksomhedslø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.
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.