Net-Base Revista

15.07.2026

ChatGPT API con Delphi para FMX/VCL: integración robusta con streaming, reintentos y análisis JSON limpio

Cómo integrar de forma robusta la ChatGPT API con Delphi FMX/VCL: cliente HTTP con tiempos de espera (timeouts) y reintentos, streaming SSE sin bloqueos de la interfaz de usuario, y análisis JSON fiable para llamadas a herramientas y manejo de errores.

15.07.2026

Del tema de la revista a la práctica del proyecto

Páginas de servicios y técnicas relacionadas

Por qué „ChatGPT API con Delphi FMX/VCL“ en la práctica no es solo un POST

Quien quiera integrar la ChatGPT API con Delphi FMX/VCL suele recurrir rápidamente a un simple HTTP-POST. En entornos reales de software empresarial eso falla sin embargo en tres puntos: (1) Timeouts y Retries deben ser determinísticos, porque si no los usuarios experimentan una UI „colgada“, (2) el streaming (Server-Sent Events, abreviado SSE) suele ser útil para una buena UX, pero en el Delphi-threading resulta rápidamente propenso a errores, y (3) JSON no es solo „un objeto“: mensajes de error, problemas de cuota, campos vacíos o formatos de respuesta ligeramente alterados deben manejarse de forma robusta.

El siguiente fragmento de código muestra un enfoque que funciona por igual en FMX y VCL: un cliente propio y testeable que opcionalmente trabaja en sin streaming o en streaming, realiza correctamente el marshaling de las actualizaciones de UI (es decir, las ejecuta mediante la sincronización del hilo principal) y registra entradas de log informativas en caso de errores. Además, está diseñado para encajarse en estructuras de capas existentes (p. ej. „API-Client“ en la capa de integración, manteniendo la UI delgada).

Esquema de arquitectura: desacoplar la UI, mantener el cliente testeable

En proyectos Delphi con larga trayectoria con frecuencia se encuentra „HTTP en ButtonClick“. Eso funciona hasta el primer incidente. Es recomendable un cliente pequeño con:

  • Configuración: Base-URL, API-Key, Modelo, Timeouts.
  • Capa de transporte: HTTP-Request/Response, Retry, Timeout, opciones de Proxy/SSL (según el entorno).
  • Parser: decodificación JSON, objetos de error, extracción de resultados.
  • Hooks de UI: callback para streaming de tokens/texto, pero sin dependencia fuerte de los controles VCL/FMX.

Así la integración en software empresarial a medida puede gestionarse de forma ordenada: el cliente puede reutilizarse en servicios, clientes de escritorio, herramientas administrativas o Test-Harnesses.

Fragmento de código fuente: Delphi-Client con SSE-Streaming, Timeout/Retry y JSON robusto

El código utiliza THTTPClient (System.Net.HttpClient) y hace parsing deliberadamente de forma minimalista con System.JSON. Para SSE se lee línea a línea y se reacciona a „data: …“. Esto no es un „WebSocket“, sino un HTTP-Response-Stream que entrega líneas de texto de forma continua. Importante: leemos en un hilo de trabajo y hacemos el marshaling de las actualizaciones de UI al hilo 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
  // Bearer Token: aquí el API-Key como "Authorization: Bearer …".
  // En entornos empresariales, asegúrese además de que las claves no terminen en los registros.
  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 frecuente: { "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('Respuesta JSON inesperada (no es un objeto).', 0, AJsonText);

    Root := J as TJSONObject;
    Choices := Root.GetValue<TJSONArray>('choices');
    if (Choices = nil) or (Choices.Count = 0) then
      raise EChatApiError.Create('Respuesta JSON inesperada: el campo choices falta o está vacío.', 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('Respuesta JSON inesperada: falta el campo 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
        // Errores de red/TLS/tiempo de espera: reintento simple 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('HTTP-Fehler ' + 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 intencionalmente de forma asíncrona para que FMX/VCL no se bloquee.
  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 errores de streaming, el Content suele ser JSON de todos modos.
              TThread.Queue(nil,
                procedure
                begin
                  AOnEvent('Inicio de streaming fallido (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: líneas como "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;

                  // Analizar el 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.

Para qué sirve el enfoque

El código resuelve tres clases típicas de problemas que en VCL/FMX pueden volverse costosos rápidamente:

  • Streaming sin congelamientos de la UI: El HTTP-Stream se lee en segundo plano; las actualizaciones de la UI se realizan vía TThread.Queue (de forma asíncrona en el hilo principal). En FMX y VCL ese es el método estándar y robusto.
  • Reintentos por errores de red: En caso de ENetHTTPClientException se reintenta con backoff. Esto es deliberadamente sencillo y puede ampliarse posteriormente para manejar códigos de estado (429/5xx).
  • Parseo JSON robusto: En lugar de castear campos a ciegas, se comprueba paso a paso. Esto reduce los errores “Invalid type cast” ante respuestas especiales.

Condiciones, trampas y variantes

  • SSE no es un „JSON normal“: En streaming llega una secuencia de eventos, no una única respuesta JSON. Por eso la lectura por líneas y el reconocimiento de [DONE] son centrales.
  • THTTPClient y proxies/SSL: En redes administrativas son reales la inspección TLS y las obligaciones de proxy. Planifique THTTPClient.ProxySettings y, si procede, cuestiones de certificados. Depuración: registre siempre el código de estado y los headers (sin incluir la API-Key).
  • Estrategia de timeout: ResponseTimeout es delicado en streaming: si lo fija demasiado corto, el cliente truncará respuestas largas. En herramientas con UI suele ser más razonable un timeout prolongado y un botón de «Cancelar» en lugar de un comportamiento «corto y rígido».
  • Cancelación de hilo: El fragmento no muestra un cancel-token. Para herramientas productivas conviene un mecanismo de cancelación (p. ej. una bandera + Http.CancelAll en versiones más recientes de Delphi o mediante cierre controlado del stream).
  • Evolución del modelo y de la API: La estructura de las respuestas puede variar. Mantenga los parsers defensivos y centralizados, no distribuidos en el código de formularios.

Depuración en clientes Delphi consolidados: qué debe registrar realmente

En proyectos de integración la puesta en marcha inicial rara vez falla por el JSON, sino por detalles del entorno. En un log técnico (archivo, Eventlog, logger central) conviene registrar en la práctica:

  • ID de petición (asignada por usted), marca temporal, URL de destino (sin querystrings que contengan secretos).
  • Código de estado HTTP, Content-Type, longitud de la respuesta, duración.
  • Un cuerpo de respuesta truncado en caso de errores (p. ej. máx. 4–8 KB), para poder identificar errores de cuota/política.
  • Marcado explícito de intentos de reintento: intento, demora, clase de excepción.

La API-Key nunca debe registrarse en el log. Si registra el request-body, hágalo solo en builds de diagnóstico y con enmascaramiento, porque los prompts pueden contener datos personales o información sensible de negocio.

Contexto para situaciones heredadas: VCL, FMX y la arquitectura Layer-3

Muchas aplicaciones Delphi funcionan con una lógica clásica de 3 capas (“Layer-3 arquitectura”: UI, lógica de negocio, datos/integración). Para la integración con ChatGPT esto es útil: el cliente mostrado pertenece a la capa de integración; la lógica de negocio decide qué se solicita; la UI solo muestra el historial y el estado. Así evita que un cambio posterior (otro proveedor, proxy on‑prem, nuevos endpoints) ‘‘rompa’’ los formularios.

También para la modernización de Delphi es un buen punto de partida: primero un cliente estable, luego mejoras en la UI (streaming, cancelar, historial) y, después, funciones más «inteligentes» como respuestas estructuradas o llamadas a herramientas.

Conclusión: base sólida, pero no todas las aplicaciones necesitan streaming

Integrar la API de ChatGPT con Delphi FMX/VCL de forma limpia resulta especialmente rentable allí donde la capacidad de respuesta de la interfaz, la seguridad operativa y la depurabilidad son importantes: herramientas de administración, clientes de escritorio cercanos al proceso o utilidades de soporte en soluciones empresariales digitales. El fragmento mostrado es deliberadamente pragmático: SSE-Streaming sin bibliotecas especiales, reintentos solo para errores reales de red, JSON analizado de forma defensiva.

Límites de uso: si necesita exigentes requisitos de cumplimiento, gobernanza centralizada de prompts, multitenencia o pistas de auditoría detalladas, un „cliente en el escritorio“ normalmente no es suficiente. En ese caso la integración pertenece típicamente a un servidor controlado (p. ej. un servicio REST propio), que aplique centralmente políticas, registro y control de acceso. Para muchas instalaciones Delphi el cliente mostrado aquí es, no obstante, un punto de partida sólido que puede integrarse paso a paso en una arquitectura global más limpia.

En el ámbito técnico, Openai API In Delphi y Delphi Http Client Timeout Retry también juegan un papel importante cuando integraciones, flujos de datos y la evolución deben encajar de forma ordenada.

Discutir un proyecto o una iniciativa de modernización con Net-Base.

Siguiente paso

Cuando un tema se convierte en un proyecto real, la arquitectura, el estado actual y la operación deben considerarse en conjunto desde el inicio.

No solo apoyamos en consultas puntuales, sino también cuando, a partir de fragmentos de código fuente, temas heredados o ideas de portales, debe consolidarse un proyecto empresarial robusto.

  • La situación actual, el estado objetivo y los riesgos técnicos se evalúan conjuntamente.
  • REST, el acceso a datos, los portales y el despliegue no se pospondrán como consecuencias tardías.
  • Usted identifica pronto qué camino es viable económica y operativamente.

Compartir entrada

Compartir esta publicación directamente

LinkedIn, X, XING, Facebook, WhatsApp y correo electrónico están disponibles de inmediato. Para Instagram preparamos directamente el enlace y un texto breve.

Correo electrónico

Instagram se abre en una nueva pestaña. El enlace y el texto breve se copian previamente en el portapapeles.