Files
MycLib/GeminiTest/Myc.Api.Gemini.pas
Michael Schimmel a62ba89bca Gemini Test
2026-01-08 14:13:41 +01:00

823 lines
27 KiB
ObjectPascal

unit Myc.Api.Gemini;
interface
uses
System.SysUtils,
System.Classes,
System.Net.HttpClient,
System.Net.Mime,
System.JSON,
System.NetEncoding,
System.Generics.Collections,
System.SyncObjs,
Myc.System.Quota; // Einbindung der Global Quota Unit
type
// Represents usage statistics for a request
TGeminiUsage = record
PromptTokens: Integer;
CandidatesTokens: Integer;
TotalTokens: Integer;
end;
// Represents the result of a generation request
TGeminiResult = record
Text: string;
IsThought: Boolean; // True if this part contains reasoning/thoughts
Usage: TGeminiUsage;
end;
TChatRole = (User, Model);
TChatItem = record
Role: TChatRole;
Text: string;
end;
// Callback for streaming: provides the new text delta and thought flag
TStreamCallback = reference to procedure(const TextDelta: string; IsThought: Boolean);
// Interface for a chat session (maintains history)
IGeminiChat = interface
['{5C2F64C8-4D18-47C0-9513-332715053673}']
function SendMessage(const Text: string): TGeminiResult;
function SendMessageStream(const Text: string; const OnDelta: TStreamCallback): TGeminiResult;
function GetHistory: TArray<TChatItem>;
end;
// Interface for the API Client
IGeminiClient = interface
['{E6721670-3498-4660-848D-82F3B1A2B5E2}']
// Standard Generation
function GenerateContent(const Prompt: string): TGeminiResult; overload;
function GenerateContent(const History: TArray<TChatItem>): TGeminiResult; overload;
// Streaming Generation
function GenerateContentStream(const Prompt: string; const OnDelta: TStreamCallback): TGeminiResult; overload;
function GenerateContentStream(const History: TArray<TChatItem>; const OnDelta: TStreamCallback): TGeminiResult; overload;
// Utilities
function ListModels: TArray<string>;
function StartChat: IGeminiChat;
// Context Caching
function CreateCache(const Content: string; const SysInstructions: string; const TTLSeconds: Integer = 300): string;
procedure SetActiveCache(const CacheName: string);
function GetActiveCache: string;
// Configuration
procedure SetSystemInstruction(const Value: string);
function GetSystemInstruction: string;
// Native Thinking / Reasoning
procedure SetEnableThinking(const Value: Boolean);
function GetEnableThinking: Boolean;
property ActiveCacheName: string read GetActiveCache write SetActiveCache;
property SystemInstruction: string read GetSystemInstruction write SetSystemInstruction;
property EnableThinking: Boolean read GetEnableThinking write SetEnableThinking;
end;
// Implementation of the API Client
TGeminiClient = class(TInterfacedObject, IGeminiClient)
private
class var
FTotalUsage: TGeminiUsage;
private
FApiKey: string;
FBaseUrl: string;
FStreamUrl: string;
FListUrl: string;
FModelName: string;
FActiveCacheName: string;
FSystemInstruction: string;
FEnableThinking: Boolean;
function ParseResponse(const JsonStr: string): TGeminiResult;
function ParseModels(const JsonStr: string): TArray<string>;
function BuildJsonBody(const History: TArray<TChatItem>): TJSONObject;
procedure ProcessStreamData(const Data: TBytes; var Buffer: string; const OnDelta: TStreamCallback; var TotalResult: TGeminiResult);
public
constructor Create(const AApiKey: string; const AModel: string = 'gemini-1.5-flash-001');
function GenerateContent(const Prompt: string): TGeminiResult; overload;
function GenerateContent(const History: TArray<TChatItem>): TGeminiResult; overload;
function GenerateContentStream(const Prompt: string; const OnDelta: TStreamCallback): TGeminiResult; overload;
function GenerateContentStream(const History: TArray<TChatItem>; const OnDelta: TStreamCallback): TGeminiResult; overload;
function ListModels: TArray<string>;
function StartChat: IGeminiChat;
function CreateCache(const Content: string; const SysInstructions: string; const TTLSeconds: Integer = 300): string;
procedure SetActiveCache(const CacheName: string);
function GetActiveCache: string;
procedure SetSystemInstruction(const Value: string);
function GetSystemInstruction: string;
procedure SetEnableThinking(const Value: Boolean);
function GetEnableThinking: Boolean;
// Static access to session-global usage (RAM only)
class property TotalUsage: TGeminiUsage read FTotalUsage;
end;
implementation
type
// Internal Chat Session Implementation
TChatSession = class(TInterfacedObject, IGeminiChat)
private
FClient: IGeminiClient;
FHistory: TList<TChatItem>;
public
constructor Create(const Client: IGeminiClient);
destructor Destroy; override;
function SendMessage(const Text: string): TGeminiResult;
function SendMessageStream(const Text: string; const OnDelta: TStreamCallback): TGeminiResult;
function GetHistory: TArray<TChatItem>;
end;
{ TGeminiClient }
constructor TGeminiClient.Create(const AApiKey: string; const AModel: string);
var
LModel: string;
encodedKey: string;
begin
inherited Create;
FApiKey := AApiKey.Trim;
LModel := AModel.Trim;
if LModel.IsEmpty then
LModel := 'gemini-1.5-flash-001';
FModelName := LModel;
if not FModelName.StartsWith('models/') then
FModelName := 'models/' + FModelName;
encodedKey := TNetEncoding.URL.EncodeQuery(FApiKey);
// Endpoint construction
FBaseUrl := Format('https://generativelanguage.googleapis.com/v1beta/%s:generateContent?key=%s', [FModelName, encodedKey]);
FStreamUrl := Format('https://generativelanguage.googleapis.com/v1beta/%s:streamGenerateContent?key=%s', [FModelName, encodedKey]);
FListUrl := Format('https://generativelanguage.googleapis.com/v1beta/models?key=%s', [encodedKey]);
FEnableThinking := False;
end;
// --- Property Setters/Getters ---
procedure TGeminiClient.SetEnableThinking(const Value: Boolean);
begin
FEnableThinking := Value;
end;
function TGeminiClient.GetEnableThinking: Boolean;
begin
Result := FEnableThinking;
end;
procedure TGeminiClient.SetActiveCache(const CacheName: string);
begin
FActiveCacheName := CacheName;
end;
function TGeminiClient.GetActiveCache: string;
begin
Result := FActiveCacheName;
end;
procedure TGeminiClient.SetSystemInstruction(const Value: string);
begin
FSystemInstruction := Value;
end;
function TGeminiClient.GetSystemInstruction: string;
begin
Result := FSystemInstruction;
end;
// --- Core Logic ---
function TGeminiClient.GenerateContent(const Prompt: string): TGeminiResult;
var
item: TChatItem;
begin
item.Role := TChatRole.User;
item.Text := Prompt;
Result := GenerateContent([item]);
end;
function TGeminiClient.GenerateContent(const History: TArray<TChatItem>): TGeminiResult;
var
httpClient: THTTPClient;
requestBody: TJSONObject;
response: IHTTPResponse;
stringStream: TStringStream;
begin
Result.Text := '';
Result.Usage := Default(TGeminiUsage);
Result.IsThought := False;
httpClient := THTTPClient.Create;
requestBody := BuildJsonBody(History);
try
stringStream := TStringStream.Create(requestBody.ToString, TEncoding.UTF8);
try
httpClient.CustomHeaders['Content-Type'] := 'application/json';
httpClient.ConnectionTimeout := 10000;
httpClient.ResponseTimeout := 30000;
try
response := httpClient.Post(FBaseUrl, stringStream);
except
on E: Exception do
begin
Result.Text := 'Error: Connection failed - ' + E.Message;
exit;
end;
end;
if (response.StatusCode = 200) then
begin
Result := ParseResponse(response.ContentAsString(TEncoding.UTF8));
// Update Usages
if (Result.Usage.TotalTokens > 0) then
begin
// RAM Counter
TInterlocked.Add(FTotalUsage.PromptTokens, Result.Usage.PromptTokens);
TInterlocked.Add(FTotalUsage.CandidatesTokens, Result.Usage.CandidatesTokens);
TInterlocked.Add(FTotalUsage.TotalTokens, Result.Usage.TotalTokens);
// Persistent System Counter
TGlobalQuota.AddTokens(Result.Usage.TotalTokens);
end;
end
else
begin
Result.Text :=
Format('Error: %d - %s (%s)', [response.StatusCode, response.StatusText, response.ContentAsString(TEncoding.UTF8)]);
end;
finally
stringStream.Free;
end;
finally
httpClient.Free;
requestBody.Free;
end;
end;
function TGeminiClient.GenerateContentStream(const Prompt: string; const OnDelta: TStreamCallback): TGeminiResult;
var
item: TChatItem;
begin
item.Role := TChatRole.User;
item.Text := Prompt;
Result := GenerateContentStream([item], OnDelta);
end;
function TGeminiClient.GenerateContentStream(const History: TArray<TChatItem>; const OnDelta: TStreamCallback): TGeminiResult;
var
httpClient: THTTPClient;
requestBody: TJSONObject;
stringStream: TStringStream;
buffer: string;
// Captured local variable for the result state to avoid E2555
LFullResult: TGeminiResult;
begin
// Init local result container
LFullResult.Text := '';
LFullResult.Usage := Default(TGeminiUsage);
LFullResult.IsThought := False;
buffer := '';
httpClient := THTTPClient.Create;
requestBody := BuildJsonBody(History);
try
stringStream := TStringStream.Create(requestBody.ToString, TEncoding.UTF8);
try
httpClient.CustomHeaders['Content-Type'] := 'application/json';
httpClient.ConnectionTimeout := 10000;
httpClient.ResponseTimeout := 60000;
// Use ReceiveDataExCallback to access raw data chunks efficiently
httpClient.ReceiveDataExCallback :=
procedure(
const Sender: TObject;
AContentLength: Int64;
AReadCount: Int64;
AChunk: Pointer;
AChunkLength: Cardinal;
var AAbort: Boolean
)
var
LBytes: TBytes;
begin
if AChunkLength > 0 then
begin
SetLength(LBytes, AChunkLength);
Move(AChunk^, LBytes[0], AChunkLength);
ProcessStreamData(LBytes, buffer, OnDelta, LFullResult);
end;
end;
try
httpClient.Post(FStreamUrl, stringStream);
except
on E: Exception do
begin
LFullResult.Text := LFullResult.Text + sLineBreak + '[Error: ' + E.Message + ']';
end;
end;
// Update Usages
if LFullResult.Usage.TotalTokens > 0 then
begin
// RAM Counter
TInterlocked.Add(FTotalUsage.PromptTokens, LFullResult.Usage.PromptTokens);
TInterlocked.Add(FTotalUsage.CandidatesTokens, LFullResult.Usage.CandidatesTokens);
TInterlocked.Add(FTotalUsage.TotalTokens, LFullResult.Usage.TotalTokens);
// Persistent System Counter
TGlobalQuota.AddTokens(LFullResult.Usage.TotalTokens);
end;
finally
stringStream.Free;
end;
finally
httpClient.Free;
requestBody.Free;
end;
Result := LFullResult;
end;
// --- JSON Body Construction ---
function TGeminiClient.BuildJsonBody(const History: TArray<TChatItem>): TJSONObject;
var
contentsArray, partsArray: TJSONArray;
contentObj, partObj: TJSONObject;
sysObj, sysPart: TJSONObject;
sysPartsArr: TJSONArray;
item: TChatItem;
genConfig, thinkConfig: TJSONObject;
begin
Result := TJSONObject.Create;
// 1. Caching Strategy
if not FActiveCacheName.IsEmpty then
begin
Result.AddPair('cachedContent', FActiveCacheName);
end
else
begin
// 2. Dynamic System Instructions (only if no cache active)
if not FSystemInstruction.IsEmpty then
begin
sysObj := TJSONObject.Create;
sysPartsArr := TJSONArray.Create;
sysPart := TJSONObject.Create;
sysPart.AddPair('text', FSystemInstruction);
sysPartsArr.Add(sysPart);
sysObj.AddPair('parts', sysPartsArr);
Result.AddPair('systemInstruction', sysObj);
end;
end;
// 3. Native Thinking Configuration
if FEnableThinking then
begin
thinkConfig := TJSONObject.Create;
thinkConfig.AddPair('includeThoughts', TJSONTrue.Create);
// 'thinkingLevel' omitted to use model default
genConfig := TJSONObject.Create;
genConfig.AddPair('thinkingConfig', thinkConfig);
Result.AddPair('generationConfig', genConfig);
end;
// 4. Chat Contents
contentsArray := TJSONArray.Create;
for item in History do
begin
contentObj := TJSONObject.Create;
// Delphi 13 inline if
contentObj.AddPair(
'role',
if item.Role = TChatRole.User then 'user'
else 'model'
);
partObj := TJSONObject.Create;
partObj.AddPair('text', item.Text);
partsArray := TJSONArray.Create;
partsArray.Add(partObj);
contentObj.AddPair('parts', partsArray);
contentsArray.Add(contentObj);
end;
Result.AddPair('contents', contentsArray);
end;
// --- Response Parsing (Streaming) ---
procedure TGeminiClient.ProcessStreamData(
const Data: TBytes;
var Buffer: string;
const OnDelta: TStreamCallback;
var TotalResult: TGeminiResult
);
var
chunkStr: string;
jsonStart, jsonEnd: Integer;
jsonSub: string;
jsonObj: TJSONObject;
candidates, parts: TJSONArray;
part, usageObj: TJSONObject;
deltaText: string;
isDeltaThought: Boolean;
braceCount, i: Integer;
foundEnd: Boolean;
begin
chunkStr := TEncoding.UTF8.GetString(Data);
Buffer := Buffer + chunkStr;
// Parse loop to extract complete JSON objects from buffer
while True do
begin
jsonStart := Buffer.IndexOf('{');
jsonEnd := -1;
if jsonStart < 0 then
break;
// Brackets matching to find end of JSON object
braceCount := 0;
foundEnd := False;
for i := jsonStart to Buffer.Length - 1 do
begin
if Buffer.Chars[i] = '{' then
inc(braceCount)
else if Buffer.Chars[i] = '}' then
dec(braceCount);
if (braceCount = 0) then
begin
jsonEnd := i;
foundEnd := True;
break;
end;
end;
if not foundEnd then
break; // Object incomplete
jsonSub := Buffer.Substring(jsonStart, jsonEnd - jsonStart + 1);
Buffer := Buffer.Substring(jsonEnd + 1);
jsonObj := TJSONObject.ParseJSONValue(jsonSub) as TJSONObject;
if Assigned(jsonObj) then
try
deltaText := '';
isDeltaThought := False;
candidates := jsonObj.GetValue('candidates') as TJSONArray;
if (Assigned(candidates)) and (candidates.Count > 0) then
begin
var cand := candidates.Items[0] as TJSONObject;
var content := cand.GetValue('content') as TJSONObject;
if Assigned(content) then
begin
parts := content.GetValue('parts') as TJSONArray;
if (Assigned(parts)) and (parts.Count > 0) then
begin
part := parts.Items[0] as TJSONObject;
deltaText := part.GetValue<string>('text');
// Check for Native Thinking flag "thought": true
part.TryGetValue<Boolean>('thought', isDeltaThought);
end;
end;
end;
usageObj := jsonObj.GetValue('usageMetadata') as TJSONObject;
if Assigned(usageObj) then
begin
usageObj.TryGetValue<Integer>('promptTokenCount', TotalResult.Usage.PromptTokens);
usageObj.TryGetValue<Integer>('candidatesTokenCount', TotalResult.Usage.CandidatesTokens);
usageObj.TryGetValue<Integer>('totalTokenCount', TotalResult.Usage.TotalTokens);
end;
if not deltaText.IsEmpty then
begin
// Append to full text (including thoughts)
if not isDeltaThought then
TotalResult.Text := TotalResult.Text + deltaText;
// Fire callback
if Assigned(OnDelta) then
OnDelta(deltaText, isDeltaThought);
end;
finally
jsonObj.Free;
end;
end;
end;
// --- Response Parsing (Synchronous) ---
function TGeminiClient.ParseResponse(const JsonStr: string): TGeminiResult;
var
jsonObj: TJSONObject;
candidates, parts: TJSONArray;
candidate, content, part, usageObj: TJSONObject;
isThought: Boolean;
begin
Result.Text := '';
Result.Usage := Default(TGeminiUsage);
Result.IsThought := False;
jsonObj := TJSONObject.ParseJSONValue(JsonStr) as TJSONObject;
if not Assigned(jsonObj) then
exit;
try
candidates := jsonObj.GetValue('candidates') as TJSONArray;
if (Assigned(candidates)) and (candidates.Count > 0) then
begin
candidate := candidates.Items[0] as TJSONObject;
content := candidate.GetValue('content') as TJSONObject;
if Assigned(content) then
begin
parts := content.GetValue('parts') as TJSONArray;
if (Assigned(parts)) and (parts.Count > 0) then
begin
part := parts.Items[0] as TJSONObject;
Result.Text := part.GetValue<string>('text');
// Check for thought flag
if part.TryGetValue<Boolean>('thought', isThought) then
Result.IsThought := isThought;
end;
end;
end;
usageObj := jsonObj.GetValue('usageMetadata') as TJSONObject;
if Assigned(usageObj) then
begin
usageObj.TryGetValue<Integer>('promptTokenCount', Result.Usage.PromptTokens);
usageObj.TryGetValue<Integer>('candidatesTokenCount', Result.Usage.CandidatesTokens);
usageObj.TryGetValue<Integer>('totalTokenCount', Result.Usage.TotalTokens);
end;
finally
jsonObj.Free;
end;
end;
// --- Model Listing ---
function TGeminiClient.ListModels: TArray<string>;
var
httpClient: THTTPClient;
response: IHTTPResponse;
begin
SetLength(Result, 0);
httpClient := THTTPClient.Create;
try
httpClient.ConnectionTimeout := 10000;
try
response := httpClient.Get(FListUrl);
if (response.StatusCode = 200) then
Result := ParseModels(response.ContentAsString(TEncoding.UTF8));
except
// Return empty array on error
end;
finally
httpClient.Free;
end;
end;
function TGeminiClient.ParseModels(const JsonStr: string): TArray<string>;
var
jsonObj: TJSONObject;
models, methods: TJSONArray;
model: TJSONObject;
i, j: Integer;
modelName: string;
canGen: Boolean;
list: TList<string>;
begin
SetLength(Result, 0);
jsonObj := TJSONObject.ParseJSONValue(JsonStr) as TJSONObject;
if not Assigned(jsonObj) then
exit;
list := TList<string>.Create;
try
models := jsonObj.GetValue('models') as TJSONArray;
if Assigned(models) then
begin
for i := 0 to models.Count - 1 do
begin
model := models.Items[i] as TJSONObject;
modelName := model.GetValue<string>('name');
if modelName.StartsWith('models/') then
modelName := modelName.Substring(7);
// Filter for Gemini models
if not modelName.ToLower.Contains('gemini') then
continue;
if modelName.ToLower.Contains('embedding') then
continue;
if modelName.ToLower.Contains('robotics') then
continue;
if modelName.ToLower.Contains('computer-use') then
continue;
// Filter for Generation capabilities
canGen := False;
methods := model.GetValue('supportedGenerationMethods') as TJSONArray;
if Assigned(methods) then
for j := 0 to methods.Count - 1 do
if (methods.Items[j].Value = 'generateContent') then
begin
canGen := True;
break;
end;
if canGen then
list.Add(modelName);
end;
end;
Result := list.ToArray;
finally
list.Free;
jsonObj.Free;
end;
end;
// --- Context Caching ---
function TGeminiClient.CreateCache(const Content: string; const SysInstructions: string; const TTLSeconds: Integer): string;
var
httpClient: THTTPClient;
requestBody: TJSONObject;
contentsArray, partsArray: TJSONArray;
contentObj, partObj, sysObj: TJSONObject;
sysPartsArr: TJSONArray;
sysPart: TJSONObject;
response: IHTTPResponse;
stringStream: TStringStream;
cacheUrl: string;
respJson: TJSONObject;
begin
Result := '';
cacheUrl := Format('https://generativelanguage.googleapis.com/v1beta/cachedContents?key=%s', [TNetEncoding.URL.EncodeQuery(FApiKey)]);
httpClient := THTTPClient.Create;
requestBody := TJSONObject.Create;
try
requestBody.AddPair('model', FModelName);
partObj := TJSONObject.Create;
partObj.AddPair('text', Content);
partsArray := TJSONArray.Create;
partsArray.Add(partObj);
contentObj := TJSONObject.Create;
contentObj.AddPair('parts', partsArray);
contentObj.AddPair('role', 'user');
contentsArray := TJSONArray.Create;
contentsArray.Add(contentObj);
requestBody.AddPair('contents', contentsArray);
if not SysInstructions.IsEmpty then
begin
sysObj := TJSONObject.Create;
sysPartsArr := TJSONArray.Create;
sysPart := TJSONObject.Create;
sysPart.AddPair('text', SysInstructions);
sysPartsArr.Add(sysPart);
sysObj.AddPair('parts', sysPartsArr);
requestBody.AddPair('systemInstruction', sysObj);
end;
requestBody.AddPair('ttl', IntToStr(TTLSeconds) + 's');
stringStream := TStringStream.Create(requestBody.ToString, TEncoding.UTF8);
try
httpClient.CustomHeaders['Content-Type'] := 'application/json';
try
response := httpClient.Post(cacheUrl, stringStream);
except
on E: Exception do
raise Exception.Create('Cache Creation Failed: ' + E.Message);
end;
// Success is 200 OK or 201 Created
if (response.StatusCode = 200) or (response.StatusCode = 201) then
begin
respJson := TJSONObject.ParseJSONValue(response.ContentAsString(TEncoding.UTF8)) as TJSONObject;
try
if Assigned(respJson) then
Result := respJson.GetValue<string>('name');
finally
respJson.Free;
end;
end
else
begin
raise Exception.CreateFmt('Cache Error %d: %s', [response.StatusCode, response.ContentAsString(TEncoding.UTF8)]);
end;
finally
stringStream.Free;
end;
finally
httpClient.Free;
requestBody.Free;
end;
end;
function TGeminiClient.StartChat: IGeminiChat;
begin
Result := TChatSession.Create(Self);
end;
// --- TChatSession Implementation ---
constructor TChatSession.Create(const Client: IGeminiClient);
begin
inherited Create;
FClient := Client;
FHistory := TList<TChatItem>.Create;
end;
destructor TChatSession.Destroy;
begin
FHistory.Free;
inherited;
end;
function TChatSession.SendMessage(const Text: string): TGeminiResult;
var
item: TChatItem;
begin
item.Role := TChatRole.User;
item.Text := Text;
FHistory.Add(item);
Result := FClient.GenerateContent(FHistory.ToArray);
if not Result.Text.IsEmpty and not Result.Text.StartsWith('Error') then
begin
item.Role := TChatRole.Model;
item.Text := Result.Text;
FHistory.Add(item);
end;
end;
function TChatSession.SendMessageStream(const Text: string; const OnDelta: TStreamCallback): TGeminiResult;
var
item: TChatItem;
begin
item.Role := TChatRole.User;
item.Text := Text;
FHistory.Add(item);
Result := FClient.GenerateContentStream(FHistory.ToArray, OnDelta);
if not Result.Text.IsEmpty and not Result.Text.StartsWith('Error') then
begin
item.Role := TChatRole.Model;
item.Text := Result.Text;
FHistory.Add(item);
end;
end;
function TChatSession.GetHistory: TArray<TChatItem>;
begin
Result := FHistory.ToArray;
end;
end.