Net-Base Revista

15.07.2026

ChatGPT API amb Delphi FMX/VCL: connexió robusta amb streaming, reintents i anàlisi JSON neta

Com integrar de manera robusta la API de ChatGPT amb Delphi FMX/VCL: client HTTP amb timeouts i reintents, SSE-streaming sense bloquejos de la IU, i parsing JSON robust per a crides d'eines i casos d'error.

15.07.2026

Del tema de la revista a la pràctica del projecte

Pàgines de serveis i tècniques pertinents per a l'article

Per què „ChatGPT API amb Delphi FMX/VCL“ a la pràctica no és només un POST

Qui vulgui integrar la ChatGPT API amb Delphi FMX/VCL acaba ràpidament en un HTTP-POST senzill. En entorns de software empresarial reals, això, però, es complica en tres aspectes: (1) els timeouts i els retries han de ser deterministes, perquè si no els usuaris experimenten una UI «penjada», (2) el streaming (Server-Sent Events, abreujat SSE) sovint és útil per a una bona UX, però en el filat de Delphi esdevé ràpidament propens a errors, i (3) JSON no és només «un objecte»: cal gestionar de manera robusta missatges d’error, problemes de quota, camps buits o formats de resposta lleugerament alterats.

El següent fragment de codi mostra un enfocament que funciona tant en FMX com en VCL: un client propi i testable, que pot operar en mode no-streaming o streaming, que gestiona de manera neta les actualitzacions de la UI (és a dir, les executa mitjançant la sincronització amb el fil principal) i que registra errors amb informació significativa. A més, està dissenyat per integrar-se en estructures de capes existents (p. ex., «API-Client» a la capa d’integració), mantenint la UI lleugera.

Arquitectura: desacoblar la UI, mantenir el client testable

En projectes Delphi amb una llarga trajectòria sovint es troba el patró de tenir „HTTP im ButtonClick“. Això funciona fins al primer incident. Recomanable és tenir un client petit amb:

  • Configuració: Base-URL, API-Key, Modell, Timeouts.
  • Capa de transport: HTTP-Request/Response, Retry, Timeout, opcions de Proxy/SSL (segons l’entorn).
  • Parser: JSON-Decoding, objectes d’error, extracció de resultats.
  • UI-Hooks: callback per a token-/text-streaming, però sense dependència rígida de controls VCL/FMX.

Això permet integrar-se netament en software empresarial a mida: el client es pot reutilitzar en serveis, clients d’escriptori, eines d’administració o bancs de proves.

Fragment de codi: Delphi-Client amb SSE-Streaming, Timeout/Retry i JSON robust

El codi utilitza THTTPClient (System.Net.HttpClient) i analitza conscienciosament de manera minimalista amb System.JSON. Per a SSE es llegeix línia a línia i es reacciona a «data: …». Això no és un «WebSocket», sinó un HTTP-Response-Stream que entrega línies de text de manera contínua. Important: llegim en un fil de treball i fem que les actualitzacions de la UI s’executin mitjançant la sincronització amb el fil principal.

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: aquí la clau API com a ‚Authorization: Bearer …‘.
// En entorns empresarials, cal assegurar-se addicionalment que les claus no apareguin als registres.
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));

// Estructura mínima de 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
// Forma habitual: { „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(‚Resposta JSON inesperada (no és un objecte).‘, 0, AJsonText);

Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Resposta JSON inesperada: falta o està buida la propietat choices.‘, 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(‚Resposta JSON inesperada: falta la propietat message.‘, 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
// Errors de xarxa/TLS/timeout: reintents senzills amb 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(‚Error HTTP ‚ + 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
// Iniciar l’streaming de forma explícita i asíncrona per evitar bloquejar FMX/VCL.
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
// En errors d’streaming, el contingut sovint és JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Inici de streaming fallit (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;

// Format SSE: línies com „data: {…}“ o „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.

Per a què serveix l’enfocament

El codi resol tres classes de problemes típiques que a VCL/FMX poden fer-se costoses ràpidament:

  • Streaming sense bloquejos de la interfície: El flux HTTP es llegeix en segon pla; les actualitzacions de la UI s’executen mitjançant TThread.Queue (de manera asíncrona al fil principal). Això és el camí estàndard robust tant en FMX com en VCL.
  • Reintents per errors de xarxa: En cas de ENetHTTPClientException s’intenta novament amb backoff. Això és deliberadament simple i es pot ampliar més endavant per tenir en compte codis d’estat (429/5xx).
  • Parsing JSON robust: En lloc de fer castings cecs de camps, es comprova pas a pas. Això redueix errors «Invalid type cast» davant respostes especials.

Condicions marc, errors habituals i variants

  • SSE no és un «JSON normal»: En l’streaming arriba una seqüència d’esdeveniments, no una única resposta JSON. Per això la lectura línia a línia i el reconeixement de [DONE] són centrals.
  • THTTPClient i proxies/SSL: En xarxes administratives són reals la inspecció TLS i l’obligatorietat de proxy. Planifiqueu THTTPClient.ProxySettings i, si cal, assumptes de certificats. Depuració: registreu sempre codi d’estat/headers (sense l’API-Key).
  • Estrategia de timeout: ResponseTimeout és delicat amb streaming: si trieu un valor massa curt, el client tallarà respostes llargues. En eines amb UI, un timeout més llarg i un botó «Cancel·lar» sovint són més adients que un enfocament «curt i dur».
  • Cancel·lació de fil: L’exemple no mostra cap token de cancel·lació. Per a eines en producció convé un mecanisme d’abort (p. ex. un flag + Http.CancelAll en versions més recents de Delphi o mitjançant una cancel·lació controlada del flux).
  • Evolució del model i de l’API: L’estructura de les respostes pot variar. Mantingueu els parsers defensius i centrals, no dispersos pel codi dels formularis.

Depuració en clients Delphi consolidats: què cal que registreu realment

En projectes d’integració, la primera posada en servei rarament falla pel JSON, sinó per detalls d’entorn. En un registre tècnic (fitxer, Eventlog, logger central) convé registrar pràcticament:

  • Request-ID (assignada per vosaltres mateixos), marca temporal, URL de destí (sense query strings amb secrets).
  • Codi d’estat HTTP, Content-Type, longitud de la resposta, durada.
  • Un body de la resposta abreujat en cas d’errors (p. ex. màx. 4–8 KB), per poder identificar errors de quota/política.
  • Identificació explícita dels intents de reintents: attempt, delay, classe d’excepció.

L’API-Key mai no ha d’aparèixer al registre. Si registreu el body de la petició, feu-ho només en builds de diagnosi i amb enmascarament, perquè els prompts poden contenir dades personals o informació empresarial.

Enquadrament per a situacions legacy: VCL, FMX i arquitectura Layer-3

Moltes aplicacions Delphi funcionen amb una lògica clàssica de 3 capes («Layer-3 arquitectura»: UI, lògica de negoci, dades/integració). Per a la integració amb ChatGPT això és útil: el client mostrat pertany a la capa d’integració; la lògica de negoci decideix què s’ha de demanar; la UI només mostra l’historial i l’estat. Això evita que un canvi posterior (un altre proveïdor, un proxy on-prem, nous endpoints) acabi trencant els formularis.

També per a la modernització de Delphi és un bon punt d’entrada: primer un client estable, després millores d’UI (streaming, cancel·lar, historial), i només després funcionalitats «més intel·ligents» com respostes estructurades o crides a eines.

Conclusió: Base sòlida, però no totes les aplicacions necessiten streaming

L’API de ChatGPT amb Delphi FMX/VCL val la pena integrar-la de manera neta especialment on la reactivitat de la UI, la fiabilitat operativa i la facilitat de depuració són rellevants: eines d’administració, clients d’escriptori propers al procés o eines de suport en solucions digitals per a empreses. L’exemple mostrat és deliberadament pragmàtic: SSE-streaming sense biblioteques especialitzades, reintents només per a errors reals de xarxa, JSON analitzat de forma defensiva.

Límits d’ús: si necessiteu requisits estrictes de compliment normatiu, governança centralitzada de prompts, capacitat multiclient o registres d’auditoria detallats, sovint „un client al desktop“ no és suficient. En aquests casos la connexió habitualment ha d’estar en un servidor controlat (p. ex., un servei propi REST), que implementi centralment polítiques, registre i control d’accés. Per a moltes instal·lacions Delphi, però, el client mostrat aquí és un punt de partida sòlid que es pot migrar pas a pas cap a una arquitectura global més neta.

En l’entorn tècnic també tenen un paper rellevant l’Openai API dins de Delphi i els mecanismes d’expiració i reintent del client HTTP de Delphi, quan cal que integracions, fluxos de dades i evolució es coordinin de forma neta.

Parlar d’un projecte o d’una iniciativa de modernització amb Net-Base.

Nächster Schritt

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

No només donem suport en qüestions puntuals, sinó també quan, a partir de fragments de codi font, temes de sistemes heredats o idees de portal, ha de sorgir un projecte empresarial sòlid.

  • L'estat actual, la visió objectiu i els riscos tècnics s'avaluen conjuntament.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

Comparteix la publicació

Comparteix aquesta publicació directament

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

Correu electrònic

Instagram s'obre en una pestanya nova. L'enllaç i el text curt es copien prèviament al porta-retalls.