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.
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.ProxySettingse, 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.CancelAllem 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.