Net-Base Magazine

15.07.2026

API ChatGPT avec Delphi FMX/VCL : connexion robuste avec streaming, retry et parsing JSON propre

Comment intégrer l'API ChatGPT avec Delphi FMX/VCL de manière robuste : client HTTP avec timeouts et réessais, streaming SSE sans blocages de l'interface utilisateur, et analyse JSON robuste pour appels d'outils et cas d'erreur.

15.07.2026

Du thème du magazine à la pratique des projets

Pages de services et techniques pertinentes pour l'article

Pourquoi «ChatGPT API mit Delphi FMX/VCL» en pratique n’est pas seulement un POST

Qui veut connecter la ChatGPT API mit Delphi FMX/VCL arrive vite à un simple HTTP-POST. Dans des environnements de logiciel métier réels, cela pose cependant problème à trois niveaux : (1) les timeouts et les retries doivent être déterministes, sinon les utilisateurs vivent une UI « bloquée », (2) le streaming (Server-Sent Events, bref SSE) est souvent souhaitable pour une bonne UX, mais il devient rapidement source d’erreurs avec le Delphi-Threading, et (3) JSON n’est pas seulement « un objet » : les messages d’erreur, les problèmes de quotas, les champs vides ou des formes de réponse légèrement modifiées doivent être gérés de manière robuste.

L’extrait de code suivant montre une approche fonctionnant à la fois en FMX et en VCL : un client autonome et testable, qui peut opérer en non-streaming ou en streaming, qui marshale proprement les mises à jour UI (c’est‑à‑dire via la synchronisation du thread principal) et qui journalise les erreurs de manière explicite. Il est par ailleurs conçu pour s’insérer dans des architectures en couches héritées (par ex. « API-Client » dans la couche d’intégration, l’UI restant fine).

Schéma d’architecture : découpler l’UI, maintenir le client testable

Dans des projets Delphi à longue histoire, on trouve souvent du « HTTP im ButtonClick ». Cela fonctionne jusqu’au premier incident. Il est recommandé d’avoir un petit client avec :

  • Configuration : Base-URL, API-Key, Modell, Timeouts.
  • Couche transport : HTTP-Request/Response, Retry, Timeout, options Proxy/SSL (selon le contexte d’exploitation).
  • Parser : JSON-Decoding, objets d’erreur, extraction de résultat.
  • UI-Hooks : callback pour le token-/text-streaming, mais sans dépendance forte aux contrôles VCL/FMX.

Ainsi, l’intégration dans un logiciel d’entreprise personnalisé peut être opérée proprement : le client est réutilisable dans des services, des desktop-clients, des outils d’administration ou des test-harnesses.

Extrait de code : Delphi-Client avec SSE-streaming, Timeout/Retry et JSON robuste

Le code utilise THTTPClient (System.Net.HttpClient) et effectue un parsing délibérément minimaliste avec System.JSON. Pour SSE, la lecture se fait ligne par ligne et l’on réagit aux lignes commençant par « data: … ». Ce n’est pas un « WebSocket », mais un flux de réponse HTTP qui fournit continuellement des lignes de texte. Important : la lecture se fait dans un worker-thread et nous marshalons les mises à jour de l’UI vers le thread 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: hier API-Key als „Authorization: Bearer …“.
// In Unternehmensumgebungen zusätzlich darauf achten, dass Keys nicht ins Log geraten.
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));

// Structure minimale des 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
// Forme fréquente : { „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(‚Réponse JSON inattendue (pas d“objet).‘, 0, AJsonText);

Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Réponse JSON inattendue : choices manquant/vide.‘, 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(‚Réponse JSON inattendue : message manquant.‘, 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
// Netzwerk-/TLS-/Timeout-Fehler: einfacher Retry mit 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(‚Erreur 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
// Démarrer le streaming délibérément de manière asynchrone pour ne pas bloquer 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 cas d’erreur de streaming, le contenu est souvent quand même du JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Démarrage du streaming échoué (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: Zeilen wie „data: {…}“ oder „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.

À quoi sert cette approche

Le code résout trois classes de problèmes typiques qui peuvent rapidement devenir coûteuses dans VCL/FMX :

  • Streaming sans blocage de l’UI : Le flux HTTP est lu en arrière-plan ; les mises à jour de l’UI passent par TThread.Queue (asynchrone vers le thread principal). C’est la méthode standard robuste sous FMX et VCL.
  • Retry pour les erreurs réseau : En cas de ENetHTTPClientException, une nouvelle tentative est effectuée avec Backoff. C’est volontairement simple et peut être étendu ultérieurement pour prendre en compte des codes d’état (429/5xx).
  • Parsing JSON robuste : Plutôt que de caster aveuglément les champs, on vérifie étape par étape. Cela réduit les erreurs «Invalid type cast» en cas de réponses particulières.

Contraintes, pièges et variantes

  • SSE n’est pas un «JSON normal» : En streaming, on reçoit une suite d’événements, pas une unique réponse JSON. C’est pourquoi la lecture ligne par ligne et la détection de [DONE] sont centrales.
  • THTTPClient et proxies/SSL : Dans les réseaux administrés, la TLS-Inspection et l’obligation de proxy sont réelles. Prévoyez THTTPClient.ProxySettings et, le cas échéant, les questions de certificats. Pour le débogage : consignez toujours le code d’état/les headers (sans la clé API).
  • Stratégie de timeout : ResponseTimeout est délicat en streaming : si vous le réglez trop court, le client tronquera des réponses longues. Dans des outils UI, un timeout plus long et un bouton «Annuler» sont souvent préférables à une coupure «brutale et courte».
  • Interruption de thread : L’extrait ne montre aucun cancel token. Pour des outils en production, un mécanisme d’annulation est pertinent (p. ex. un flag + Http.CancelAll dans des versions récentes de Delphi ou via une interruption contrôlée du stream).
  • Évolution du modèle et de l’API : La structure des responses peut varier. Gardez les parseurs défensifs et centralisés, pas dispersés dans le code des formulaires.

Débogage dans des clients Delphi existants : ce que vous devez vraiment consigner

Dans les projets d’intégration, la mise en service échoue rarement à cause du JSON, mais plutôt à cause de détails d’environnement. Dans un log technique (fichier, eventlog, logger central), il convient en pratique de consigner :

  • ID de requête (attribuée par vous), horodatage (timestamp), URL cible (sans querystrings contenant des secrets).
  • Code HTTP, Content-Type, longueur de la réponse, durée.
  • Un corps de réponse tronqué en cas d’erreur (p. ex. max. 4–8 KB) pour pouvoir identifier des erreurs de quota/politique.
  • Marquage explicite des tentatives de retry : attempt, delay, classe d’exception.

La clé API ne doit jamais apparaître dans les logs. Si vous consignez le corps de la requête, ne le faites que dans des builds de diagnostic et avec masquage, car les prompts peuvent contenir des données personnelles ou commerciales.

Positionnement pour les situations legacy : VCL, FMX et architecture Layer-3

De nombreuses applications Delphi fonctionnent selon une logique classique à 3 couches («Layer-3 Architektur» : UI, logique métier, données/intégration). Pour l’intégration de ChatGPT, cela est utile : le client présenté appartient à la couche d’intégration ; la logique métier décide ce qui est demandé ; l’UI n’affiche que l’historique et l’état. Ainsi vous évitez qu’un changement ultérieur (autre provider, proxy on-prem, nouveaux endpoints) n’affecte les formulaires de façon destructive.

Pour la modernisation Delphi également, c’est un bon point d’entrée : d’abord un client stable, puis des améliorations de l’UI (streaming, annulation, historique), ensuite des fonctionnalités «plus intelligentes» comme des réponses structurées ou des appels d’outils.

Conclusion : une base solide, mais toutes les applications n’ont pas besoin du streaming

Il est particulièrement pertinent d’intégrer proprement l’API ChatGPT avec Delphi FMX/VCL là où la réactivité de l’interface utilisateur, la sûreté d’exploitation et la facilité de débogage sont importantes : outils d’administration, clients de bureau proches du processus ou outils d’assistance dans les solutions d’entreprise numériques. L’extrait montré est volontairement pragmatique : streaming SSE sans bibliothèques spécialisées, réessais uniquement pour les erreurs réseau réelles, JSON parsé de manière défensive.

Limites d’utilisation : si vous avez des exigences strictes en matière de conformité, une gouvernance centralisée des prompts, une capacité multi-locataire ou des pistes d’audit détaillées, un „client sur le poste“ n’est généralement pas suffisant. Dans ce cas, l’intégration relève typiquement d’un serveur contrôlé (p. ex. un service REST dédié), qui met en œuvre de manière centralisée les politiques, le logging et le contrôle d’accès. Pour de nombreuses installations Delphi, le client présenté ici reste néanmoins un point de départ robuste qui peut être progressivement transféré vers une architecture globale plus propre.

Dans le contexte métier, l’API Openai dans Delphi et les mécanismes de Http Client Timeout/Retry dans Delphi jouent également un rôle important lorsque intégrations, flux de données et évolutions doivent s’articuler proprement.

Discuter d’un projet ou d’une modernisation avec Net-Base.

Étape suivante

Lorsque le sujet devient un projet concret, il convient de considérer dès le départ l'architecture, l'existant et l'exploitation ensemble.

Nous n'intervenons pas seulement sur des questions ponctuelles, mais aussi lorsque des fragments de code source, des problématiques liées aux systèmes legacy ou des concepts de portail doivent se transformer en un projet d'entreprise robuste.

  • L'état des lieux, l'état cible et les risques techniques sont évalués conjointement.
  • REST, l'accès aux données, les portails et le déploiement ne seront pas relégués au rang de conséquences tardives.
  • Vous identifiez rapidement quelle voie est viable économiquement et opérationnellement.

Partager l'article

Partager directement cette publication

LinkedIn, X, XING, Facebook, WhatsApp et e-mail sont immédiatement disponibles. Pour Instagram, nous préparons le lien et un court texte.

Courriel

Instagram s'ouvre dans un nouvel onglet. Le lien et le court texte sont préalablement copiés dans le presse-papiers.