Delphi
Example with System.Net.HttpClient (THTTPClient) and FireDAC, without third-party components. Works in Delphi 10.3 Rio or newer, in VCL, FMX or service applications.
In a desktop application installed on the user's machine, anyone can extract the API key from the executable. Make the call to Datafala from a server of yours (a DataSnap service, Horse, RAD Server or another API) and let the application talk only to that server. See Authentication.
Client
unit Datafala.Client;
interface
uses
System.SysUtils, System.Classes, System.JSON, System.Hash,
System.Net.HttpClient, System.Net.URLClient;
type
EDatafala = class(Exception)
public
Status: Integer;
Code: string; // stable API code: sql_rejected, no_answer, rate_limited...
RetryAfter: Integer; // seconds, when the API sends Retry-After
RequestId: string;
end;
TDatafalaAnswer = record
Sql: string;
Params: TJSONObject; // owned by the caller: free it with Params.Free
Dialect: string;
Explanation: string;
RequestId: string;
end;
TDatafalaClient = class
private
FBaseUrl, FApiKey, FEndUserSecret: string;
function Post(const Path: string; Body: TJSONObject): IHTTPResponse;
procedure RaiseError(const Response: IHTTPResponse);
public
constructor Create(const BaseUrl, ApiKey, EndUserSecret: string);
function EndUserId(const UserId: string): string;
function Ask(const Question, UserId: string; MaxRows: Integer = 0): TDatafalaAnswer;
procedure Feedback(const RequestId, Rating: string; const Comment: string = '');
end;
implementation
constructor TDatafalaClient.Create(const BaseUrl, ApiKey, EndUserSecret: string);
begin
inherited Create;
FBaseUrl := BaseUrl; // https://app.datafala.com.br
FApiKey := ApiKey; // read from an environment variable or vault, never from the code
FEndUserSecret := EndUserSecret; // fixed server secret; do not change it later
end;
function TDatafalaClient.EndUserId(const UserId: string): string;
begin
// HMAC-SHA256 of the internal ID: 64 hexadecimal characters, no personal data.
Result := THashSHA2.GetHMAC(UserId, FEndUserSecret, THashSHA2.TSHA2Version.SHA256);
end;
function TDatafalaClient.Post(const Path: string; Body: TJSONObject): IHTTPResponse;
var
Http: THTTPClient;
Content: TStringStream;
begin
Http := THTTPClient.Create;
Content := TStringStream.Create(Body.ToJSON, TEncoding.UTF8);
try
Http.ConnectionTimeout := 5000;
Http.ResponseTimeout := 60000; // the LLM can take several seconds
Http.ContentType := 'application/json';
Http.CustomHeaders['Authorization'] := 'Bearer ' + FApiKey;
Result := Http.Post(FBaseUrl + Path, Content);
finally
Content.Free;
Http.Free;
end;
end;
procedure TDatafalaClient.RaiseError(const Response: IHTTPResponse);
var
Body: TJSONValue;
Error: EDatafala;
begin
Body := TJSONObject.ParseJSONValue(Response.ContentAsString(TEncoding.UTF8));
try
if Body is TJSONObject then
Error := EDatafala.Create(TJSONObject(Body).GetValue<string>('message', ''))
else
Error := EDatafala.Create('HTTP ' + Response.StatusCode.ToString);
Error.Status := Response.StatusCode;
if Body is TJSONObject then
Error.Code := TJSONObject(Body).GetValue<string>('code', 'unknown')
else
Error.Code := 'unknown';
Error.RetryAfter := StrToIntDef(Response.HeaderValue['Retry-After'], 0);
Error.RequestId := Response.HeaderValue['X-Request-ID'];
finally
Body.Free;
end;
raise Error;
end;
function TDatafalaClient.Ask(const Question, UserId: string; MaxRows: Integer): TDatafalaAnswer;
var
Body, Json: TJSONObject;
Response: IHTTPResponse;
begin
Body := TJSONObject.Create;
try
Body.AddPair('question', Question);
Body.AddPair('end_user', EndUserId(UserId));
if MaxRows > 0 then
Body.AddPair('max_rows', TJSONNumber.Create(MaxRows));
Response := Post('/v1/ask', Body);
finally
Body.Free;
end;
if Response.StatusCode <> 200 then
RaiseError(Response);
Json := TJSONObject.ParseJSONValue(Response.ContentAsString(TEncoding.UTF8)) as TJSONObject;
try
Result.Sql := Json.GetValue<string>('sql');
Result.Dialect := Json.GetValue<string>('dialect');
// explanation can be null
if not Json.TryGetValue<string>('explanation', Result.Explanation) then
Result.Explanation := '';
Result.RequestId := Json.GetValue<string>('request_id');
Result.Params := Json.GetValue<TJSONObject>('params').Clone as TJSONObject;
finally
Json.Free;
end;
end;
procedure TDatafalaClient.Feedback(const RequestId, Rating, Comment: string);
var
Body: TJSONObject;
Response: IHTTPResponse;
begin
Body := TJSONObject.Create;
try
Body.AddPair('request_id', RequestId);
Body.AddPair('rating', Rating); // 'good' or 'bad'
if Comment <> '' then
Body.AddPair('comment', Copy(Comment, 1, 500));
Response := Post('/v1/feedback', Body);
finally
Body.Free;
end;
if Response.StatusCode <> 204 then
RaiseError(Response);
end;
end.
Convert the placeholders for FireDAC
Datafala returns %(name)s and escapes every literal % as %%. FireDAC uses :name. Convert in a single pass, from left to right, so that a %% inside a literal is never mistaken for a placeholder:
unit Datafala.Sql;
interface
// %(name)s -> :name ; %% -> %
function ToFireDAC(const Sql: string): string;
implementation
uses
System.SysUtils;
function IsNameChar(C: Char; First: Boolean): Boolean;
begin
Result := CharInSet(C, ['A'..'Z', 'a'..'z', '_']) or (not First and CharInSet(C, ['0'..'9']));
end;
function ToFireDAC(const Sql: string): string;
var
I, J: Integer;
Builder: TStringBuilder;
begin
Builder := TStringBuilder.Create(Length(Sql));
try
I := 1;
while I <= Length(Sql) do
begin
if (Sql[I] = '%') and (I < Length(Sql)) and (Sql[I + 1] = '%') then
begin
Builder.Append('%');
Inc(I, 2);
end
else if (Sql[I] = '%') and (I < Length(Sql)) and (Sql[I + 1] = '(') then
begin
J := I + 2;
while (J <= Length(Sql)) and IsNameChar(Sql[J], J = I + 2) do
Inc(J);
if (J < Length(Sql)) and (J > I + 2) and (Sql[J] = ')') and (Sql[J + 1] = 's') then
begin
Builder.Append(':').Append(Copy(Sql, I + 2, J - I - 2));
I := J + 2;
end
else
raise Exception.Create('Invalid placeholder in the Datafala SQL');
end
else
begin
Builder.Append(Sql[I]);
Inc(I);
end;
end;
Result := Builder.ToString;
finally
Builder.Free;
end;
end;
end.
Run with FireDAC
Use a TFDConnection with a read-only user and a read-only transaction. Turn off FireDAC's macros and escape sequences: the Datafala SQL is plain SQL, and an &, ! or { inside a literal must not be interpreted by FireDAC.
uses
System.JSON, Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Option,
Datafala.Client, Datafala.Sql;
procedure BindParams(Query: TFDQuery; Params: TJSONObject);
var
Pair: TJSONPair;
Param: TFDParam;
begin
for Pair in Params do
begin
Param := Query.ParamByName(Pair.JsonString.Value);
if Pair.JsonValue is TJSONNull then
begin
Param.DataType := ftString;
Param.Clear;
end
else if Pair.JsonValue is TJSONBool then
Param.AsBoolean := TJSONBool(Pair.JsonValue).AsBoolean
else if Pair.JsonValue is TJSONNumber then
begin
if Pair.JsonValue.Value.IndexOfAny(['.', 'e', 'E']) >= 0 then
Param.AsFloat := TJSONNumber(Pair.JsonValue).AsDouble
else
Param.AsLargeInt := TJSONNumber(Pair.JsonValue).AsInt64;
end
else
Param.AsString := Pair.JsonValue.Value; // dates arrive as ISO text (2026-10-22)
end;
end;
function Ask(Client: TDatafalaClient; Connection: TFDConnection;
const Question, UserId: string): TFDQuery;
var
Answer: TDatafalaAnswer;
begin
Answer := Client.Ask(Question, UserId);
try
Result := TFDQuery.Create(nil);
try
Result.Connection := Connection;
Result.ResourceOptions.MacroCreate := False;
Result.ResourceOptions.MacroExpand := False;
Result.ResourceOptions.EscapeExpand := False;
Result.ResourceOptions.CmdExecMode := amCancelable;
Result.ResourceOptions.CmdExecTimeout := 5000; // ms
Result.SQL.Text := ToFireDAC(Answer.Sql);
BindParams(Result, Answer.Params);
Result.Open;
except
Result.Free;
raise;
end;
finally
Answer.Params.Free;
end;
end;
Read-only connection configuration:
// Firebird
FDConexaoLeitura.Params.DriverID := 'FB';
FDConexaoLeitura.Params.UserName := 'DATAFALA_RO'; // GRANT SELECT only on the registered tables
FDConexaoLeitura.TxOptions.ReadOnly := True;
// PostgreSQL: DriverID 'PG'; MySQL: DriverID 'MySQL'; Firebird: DriverID 'FB'. The user must be read-only.
Handle errors
try
Query := Ask(Client, FDConexaoLeitura, EditQuestion.Text, IntToStr(LoggedUser.Id));
except
on E: EDatafala do
if (E.Code = 'no_answer') or (E.Code = 'sql_rejected') then
ShowMessage(E.Message) // already translated: ask the user to rephrase
else if E.Code = 'rate_limited' then
ShowMessage(Format('Too many questions. Try again in %d s.', [E.RetryAfter]))
else
raise;
end;
Reminders:
- Do not automatically retry an
Askthat exceeded the time limit: each attempt spends tokens from your LLM. - Keep
Answer.RequestIdto send 👍/👎 withClient.Feedback(RequestId, 'good'). - See the full list of codes in Errors.