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.
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
ENetHTTPClientExceptionse 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.ProxySettingsy, 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:
ResponseTimeoutes 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.CancelAllen 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.