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
ENetHTTPClientExceptiongjø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.ProxySettingsog eventuelle sertifikatspørsmål. For debugging: logg alltid statuskode/headers (uten API-nøkkel). - Timeout-strategi:
ResponseTimeouter 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 avbruddsmekanisme (f.eks. flagg +
Http.CancelAlli 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.
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.