Net-Base Revista

15.07.2026

ChatGPT API com Delphi FMX/VCL: integração robusta com streaming, retry e parsing JSON limpo

Como integrar de forma robusta a API do ChatGPT com Delphi FMX/VCL: cliente HTTP com timeouts e retentativas, streaming SSE sem congelamentos da interface do utilizador, e análise JSON robusta para chamadas de ferramentas e cenários de erro.

15.07.2026

Do tema da revista à prática do projeto

Páginas de serviços e técnicas correspondentes ao artigo

Por que “ChatGPT API com Delphi FMX/VCL” na prática não é apenas um POST

Quem quer integrar a ChatGPT API com Delphi FMX/VCL rapidamente recorre a um simples HTTP-POST. Em ambientes reais de software empresarial isso, contudo, falha em três pontos: (1) Timeouts e Retries devem ser determinísticos, porque caso contrário os usuários experimentarão uma UI „travada“, (2) Streaming (Server-Sent Events, ou SSE) costuma ser útil para uma boa UX, mas no Delphi-Threading torna-se rapidamente propenso a erros, e (3) JSON não é apenas „um objeto“: mensagens de erro, problemas de cota, campos vazios ou formas de resposta ligeiramente alteradas precisam ser tratadas de forma robusta.

O trecho de código a seguir mostra uma abordagem que funciona igualmente em FMX e VCL: um client próprio e testável, que opcionalmente opera em modo não-streaming ou streaming, realiza o marshaling das atualizações de UI corretamente (ou seja, executa via sincronização no thread principal) e registra logs informativos em caso de erros. Além disso, é projetado para se encaixar em estruturas de camadas existentes (por ex., „API-Client“ na camada de integração, UI permanece fina).

Esboço de arquitetura: desacoplar a UI, manter o Client testável

Em projetos Delphi com longa história costuma-se encontrar „HTTP no ButtonClick“. Isso funciona até o primeiro incidente. Recomenda-se um client pequeno com:

  • Configuração: Base-URL, API-Key, modelo, Timeouts.
  • Camada de transporte: HTTP-Request/Response, Retry, Timeout, opções de Proxy/SSL (conforme o ambiente).
  • Parser: decodificação JSON, objetos de erro, extração de resultado.
  • UI-Hooks: callback para Token-/Text-Streaming, mas sem dependência rígida em controles VCL/FMX.

Assim a integração em software empresarial personalizado pode ser realizada de forma limpa: o client pode ser reutilizado em services, desktop clients, ferramentas administrativas ou test-harnesses.

Trecho de código: Delphi-Client com SSE-Streaming, Timeout/Retry e JSON robusto

O código utiliza THTTPClient (System.Net.HttpClient) e faz parsing conscientemente apenas de forma minimalista com System.JSON. Para SSE lê‑se linha a linha e reage‑se a „data: …“. Isso não é um „WebSocket“, mas um HTTP-Response-Stream que fornece linhas de texto de forma contínua. Importante: lemos em um worker thread e fazemos marshaling das atualizações de UI para o thread principal.

Delphi
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
  // Token Bearer: aqui a API-Key como 'Authorization: Bearer ...'.
  // Em ambientes empresariais, certifique-se também de que as chaves não sejam registradas nos logs.
  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));

  // Estrutura 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 comum: { "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 (não é um objeto).', 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: o campo "choices" está ausente ou vazio.', 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: o campo "message" está ausente.', 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
        // Erros de rede/TLS/timeouts: retry simples com 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('Erro 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 streaming deliberadamente de forma assíncrona para não bloquear 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
              // Em erros de streaming, o conteúdo muitas vezes ainda é JSON.
              TThread.Queue(nil,
                procedure
                begin
                  AOnEvent('Falha ao iniciar streaming (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: linhas como "data: {...}" ou "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.

Para que serve esta abordagem

O código resolve três classes de problemas típicas que, em VCL/FMX, rapidamente se tornam onerosas:

  • Streaming sem bloqueios da UI: O fluxo HTTP é lido em segundo plano; atualizações da UI são executadas via TThread.Queue (assíncronas no Main Thread). Isso é, em FMX e VCL, o caminho padrão robusto.
  • Retentativa para erros de rede: Em caso de ENetHTTPClientException é feita nova tentativa com backoff. Isso é intencionalmente simples e pode ser estendido posteriormente para incluir códigos de status (429/5xx).
  • Parsing JSON robusto: Em vez de converter campos de forma cega, verifica-se progressivamente. Isso reduz erros „Invalid type cast“ em respostas especiais.

Condições, armadilhas e variantes

  • SSE não é um „JSON normal“: No streaming chega uma sequência de eventos, não uma única resposta JSON. Por isso, a leitura por linhas e o reconhecimento de [DONE] são essenciais.
  • THTTPClient e proxies/SSL: Em redes administrativas, TLS-Inspection e obrigações de proxy são reais. Planeje THTTPClient.ProxySettings e, se aplicável, questões de certificados. Depuração: registre sempre código de status e cabeçalhos (sem o API-Key).
  • Estratégia de timeout: ResponseTimeout é delicado no streaming: se escolher um valor muito curto, o cliente cortará respostas longas. Em ferramentas com UI, um timeout maior e um botão „Cancelar“ costumam ser mais apropriados do que uma abordagem „curta e rígida“.
  • Interrupção de thread: O trecho não mostra um cancel token. Para ferramentas produtivas vale a pena um mecanismo de cancelamento (p.ex. flag + Http.CancelAll em versões mais recentes de Delphi ou por cancelamento controlado do stream).
  • Evolução do modelo e da API: A estrutura das respostas pode variar. Mantenha os parsers defensivos e centralizados, não distribuídos no código dos formulários.

Depuração em clientes Delphi consolidados: o que você realmente deve registrar

Em projetos de integração, a primeira entrada em produção raramente falha por causa do JSON, e sim por detalhes do ambiente. Num log técnico (arquivo, Eventlog, logger central) devem constar na prática:

  • Request-ID (definida pelo próprio), Timestamp, URL de destino (sem query strings que contenham segredos).
  • Código de status HTTP, Content-Type, comprimento da resposta, tempo de execução.
  • Um corpo de resposta truncado em caso de erro (p.ex. máx. 4–8 KB), para poder identificar erros de cota/política.
  • Indicação explícita de tentativas de retry: Attempt, Delay, classe da exceção.

O API-Key nunca deve ir para o log. Se você registrar o request body, faça-o apenas em builds de diagnóstico e com mascaramento, pois prompts podem conter dados pessoais ou informações comerciais.

Contextualização para situações legadas: VCL, FMX e a arquitetura Layer-3

Muitas aplicações Delphi operam numa lógica clássica de 3 camadas („Layer-3 Architektur„: UI, lógica de negócio, dados/integração). Para a integração com o ChatGPT isso é útil: o cliente mostrado pertence à camada de integração; a lógica de negócio decide o que é perguntado; a UI apenas exibe histórico e estado. Assim evita-se que uma troca posterior (outro provider, proxy on-prem, novos endpoints) „desfaça“ os formulários.

Também para modernização Delphi esse é um bom ponto de partida: primeiro um cliente estável, depois melhorias na UI (streaming, cancelar, histórico), e só então recursos „mais inteligentes“ como respostas estruturadas ou chamadas de ferramenta.

Conclusão: base sólida, mas nem toda aplicação precisa de streaming

A integração limpa da ChatGPT API com Delphi FMX/VCL vale especialmente a pena onde a capacidade de resposta da UI, a segurança operacional e a capacidade de depuração são importantes: ferramentas administrativas, clientes desktop próximos ao processo ou ferramentas de suporte em soluções empresariais digitais. O trecho mostrado é propositalmente pragmático: SSE-streaming sem bibliotecas especiais, retry apenas para falhas reais de rede, JSON analisado de forma defensiva.

Limites de aplicação: Se precisar de requisitos rígidos de compliance, governança centralizada de prompts, multitenancy ou trilhas de auditoria detalhadas, um „cliente no desktop“ normalmente não é suficiente. Nesse caso, a integração pertence tipicamente a um servidor controlado (p. ex. um serviço REST próprio), que implementa políticas, registro e controle de acesso de forma centralizada. Para muitas instalações Delphi, porém, o cliente aqui mostrado é um ponto de partida robusto, que pode ser gradualmente transferido para uma arquitetura global mais limpa.

No contexto técnico também desempenham um papel importante a Openai API em Delphi e o timeout/retry do Http Client Delphi, quando integrações, fluxos de dados e evolução precisam funcionar em conjunto de forma organizada.

Discutir projeto ou iniciativa de modernização com Net-Base.

Próximo passo

Quando um tema se torna um projeto real, arquitetura, sistemas existentes e operação devem ser considerados em conjunto desde o início.

Não apenas apoiamos questões pontuais, mas também quando fragmentos de código-fonte, temas legados ou ideias de portais precisam evoluir para um projeto empresarial robusto.

  • Estado atual, estado-alvo e riscos técnicos são avaliados em conjunto.
  • REST, acesso a dados, portais e rollout não serão adiados para fases posteriores.
  • Você percebe cedo qual caminho é economicamente e operacionalmente viável.

Partilhar publicação

Compartilhar esta publicação diretamente

LinkedIn, X, XING, Facebook, WhatsApp e e-mail estão disponíveis imediatamente. Para o Instagram, preparamos o link e o texto curto de imediato.

E-mail

O Instagram abre numa nova aba. O link e o texto curto são copiados previamente para a área de transferência.