Dal tema della rivista alla pratica di progetto
Pagine di servizi e tecniche correlate all'articolo
Perché „ChatGPT API con Delphi FMX/VCL“ nella pratica non è solo un POST
Chi vuole integrare la ChatGPT API con Delphi FMX/VCL arriva facilmente a un semplice HTTP-POST. In ambienti software aziendali reali questo però fallisce in tre punti: (1) timeout e retry devono essere deterministici, altrimenti gli utenti sperimentano una UI «bloccata», (2) lo streaming (Server-Sent Events, cioè SSE) è spesso necessario per una buona UX, ma nel threading Delphi diventa rapidamente soggetto a errori, e (3) JSON non è solo «un oggetto»: messaggi di errore, problemi di quota, campi vuoti o risposte leggermente modificate devono essere gestiti in modo robusto.
Lo snippet di codice seguente mostra un approccio che funziona sia in FMX sia in VCL: un client separato e testabile che può operare in modalità non-streaming o streaming, marshala correttamente gli aggiornamenti UI (ossia esegue la sincronizzazione sul thread principale) e registra in modo significativo gli errori. È inoltre progettato per inserirsi in strutture a layer consolidate (ad es. un „API-Client“ nello strato di integrazione, lasciando l’interfaccia utente sottile).
Schema architetturale: disaccoppiare la UI, mantenere il client testabile
Nei progetti Delphi con una lunga storia si trova spesso l“HTTP nel ButtonClick“. Funziona fino al primo incidente. Raccomandabile è un piccolo client con:
- Configurazione: Base-URL, API-Key, modello, Timeouts.
- Strato di trasporto: request/response HTTP, retry, timeout, opzioni Proxy/SSL (a seconda dell’ambiente operativo).
- Parser: decodifica JSON, oggetti di errore, estrazione dei risultati.
- UI-Hooks: callback per token-/text-streaming, ma senza dipendenza stretta dai controlli VCL/FMX.
In questo modo l’integrazione in software aziendali personalizzati può essere gestita in modo ordinato: il client è riutilizzabile in servizi, client desktop, strumenti di amministrazione o test harness.
Source-Schnipsel: Delphi-Client con SSE-Streaming, Timeout/Retry e JSON robusto
Il codice utilizza THTTPClient (System.Net.HttpClient) ed esegue il parsing in modo volutamente minimale con System.JSON. Per SSE si legge riga per riga e si reagisce a „data: …“. Non è un „WebSocket“, ma uno stream di risposta HTTP che fornisce continuamente linee di testo. Importante: leggiamo in un worker-thread e facciamo il marshalling degli aggiornamenti UI nel thread principale.
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: qui la API key come ‚Authorization: Bearer …‘.
// In ambienti aziendali prestare inoltre attenzione che le chiavi non finiscano nei log.
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));
// Struttura minima di 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 comune: { „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(‚Risposta JSON inattesa (non un oggetto).‘, 0, AJsonText);
Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Risposta JSON inattesa: mancano/sono vuote le 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(‚Risposta JSON inattesa: manca 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
// Errori rete/TLS/timeout: retry semplice con 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(‚Errore 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
// Avviare lo streaming intenzionalmente in modo asincrono, così FMX/VCL non viene bloccato.
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
// In caso di errori nello streaming, il contenuto è spesso comunque JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Avvio dello streaming fallito (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;
// Formato SSE: righe come „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;
// Analizzare il chunk JSON: 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.
A cosa serve l’approccio
Il codice risolve tre classi di problemi tipiche che in VCL/FMX possono diventare rapidamente costose:
- Streaming senza blocchi della UI: lo stream HTTP viene letto in background; gli aggiornamenti della UI avvengono tramite
TThread.Queue(asincrono nel thread principale). Questo è il percorso standard robusto in FMX e VCL. - Retry per errori di rete: in caso di
ENetHTTPClientExceptionsi tenta nuovamente con backoff. È una soluzione intenzionalmente semplice e può essere estesa in seguito per considerare codici di stato (429/5xx). - Parsing JSON robusto: invece di effettuare cast diretti dei campi, si verifica passo dopo passo. Questo riduce gli errori «Invalid type cast» derivanti da risposte particolari.
Vincoli, insidie e varianti
- SSE non è un „JSON normale“: nello streaming arriva una sequenza di eventi, non una singola risposta JSON. Per questo la lettura riga per riga e il riconoscimento di
[DONE]sono centrali. - THTTPClient e proxy/SSL: nelle reti di amministrazione sono comuni TLS-Inspection e obblighi di proxy. Pianificate
THTTPClient.ProxySettingse, se necessario, le questioni sui certificati. Debugging: registrate sempre status code/headers (senza API-Key). - Strategia di timeout:
ResponseTimeoutè delicato nello streaming: se lo impostate troppo breve il client tronca risposte lunghe. Negli strumenti con UI spesso è preferibile un timeout più lungo e un pulsante «Annulla» piuttosto che una scelta «breve e rigida». - Interruzione del thread: lo snippet non mostra un Cancel-Token. Per strumenti di produzione conviene un meccanismo di cancellazione (ad es. flag +
Http.CancelAllnelle più recenti Delphi-versioni o tramite interruzione controllata dello stream). - Evoluzione di modelli e API: la struttura delle risposte può cambiare. Tenete i parser difensivi e centralizzati, non distribuiti nel codice dei form.
Debugging in client Delphi consolidati: cosa dovreste realmente registrare
Nei progetti di integrazione la messa in servizio iniziale fallisce raramente a causa del JSON, ma per dettagli ambientali. In un log tecnico (file, Eventlog, logger centrale) nella pratica dovrebbero essere registrati:
- Request-ID (autogenerata), timestamp, URL di destinazione (senza query string contenenti segreti).
- Codice di stato HTTP, Content-Type, lunghezza della risposta, durata.
- Un body di risposta troncato in caso di errori (es. max. 4–8 KB), per poter individuare errori di quota/policy.
- Contrassegno esplicito dei tentativi di retry: tentativo, ritardo, classe dell’eccezione.
La chiave API non deve mai finire nel log. Se registrate il body della richiesta, fatelo solo in build di diagnostica e con mascheramento, perché i prompt possono contenere dati personali o informazioni aziendali.
Inquadramento per situazioni legacy: VCL, FMX e Layer-3 Architektur
Molte applicazioni Delphi operano in una logica classica a 3 livelli („Layer-3 Architektur„: UI, logica di business, dati/integrazione). Per il collegamento a ChatGPT questo è utile: il client mostrato appartiene allo strato di integrazione; la logica di business decide cosa venga richiesto; la UI mostra solo cronologia e stato. In questo modo evitate che un cambiamento futuro (altro provider, proxy on‑prem, nuovi endpoint) comprometta i form.
Anche per la modernizzazione di Delphi questo è un buon punto di partenza: prima un client stabile, poi miglioramenti della UI (streaming, annullamento, cronologia), e infine funzionalità «più intelligenti» come risposte strutturate o invocazioni di tool.
Conclusione: base solida, ma non ogni applicazione necessita di streaming
Integrare correttamente la ChatGPT API con Delphi FMX/VCL conviene particolarmente dove sono importanti reattività dell’interfaccia, sicurezza operativa e capacità di debug: strumenti di amministrazione, client desktop prossimi ai processi o strumenti di supporto nelle soluzioni aziendali digitali. Lo snippet mostrato è deliberatamente pragmatico: SSE-Streaming senza librerie speciali, retry solo per veri errori di rete, JSON parsato in modo difensivo.
Limiti d’impiego: se avete vincoli di compliance stringenti, governance centrale dei prompt, multi-tenancy o audit trail dettagliati, un «client sul desktop» di norma non basta. In questi casi l’integrazione appartiene tipicamente a un server controllato (p.es. un proprio REST-Service), che implementa centralmente policy, logging e controllo degli accessi. Per molte installazioni Delphi però il client mostrato qui è un punto di partenza affidabile, che può essere progressivamente integrato in un’architettura complessiva più pulita.
Nel contesto specialistico assumono inoltre un ruolo importante l’Openai API in Delphi e il Http Client Timeout Retry di Delphi, quando integrazioni, flussi di dati e sviluppo devono funzionare in modo coerente.
Discutere un progetto o un intervento di modernizzazione con Net-Base.
Passo successivo
Quando un tema diventa un progetto reale, architettura, patrimonio esistente e operatività dovrebbero essere considerati insieme fin dall'inizio.
Non forniamo solo supporto per questioni isolate, ma anche quando da frammenti di codice sorgente, tematiche legacy o idee di portale deve nascere un progetto aziendale solido.
- Stato attuale, stato obiettivo e rischi tecnici vengono valutati insieme.
- REST, l'accesso ai dati, i portali e il rollout non vengono posticipati a fasi successive.
- Vede in anticipo quale percorso è sostenibile dal punto di vista economico e operativo.