adk-typescript
Version:
TypeScript port of Google's Agent Development Kit (ADK)
698 lines (697 loc) • 29.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Gemini = void 0;
const BaseLlm_1 = require("./BaseLlm");
const BaseLlmConnection_1 = require("./BaseLlmConnection");
const GeminiLlmConnection_1 = require("./GeminiLlmConnection");
const LlmResponse_1 = require("./LlmResponse");
const generative_ai_1 = require("@google/generative-ai");
/**
* Helper function to convert our content type to Google GenAI content format
*/
function convertContent(content) {
// Convert role from model to assistant if needed
const role = content.role === 'model' ? 'assistant' : content.role;
// Convert parts
const parts = content.parts.map(part => {
if (part.text) {
return { text: part.text };
}
else if (part.inlineData) {
return {
inline_data: {
mime_type: part.inlineData.mimeType,
data: part.inlineData.data
}
};
}
else if (part.functionCall) {
return {
function_call: {
name: part.functionCall.name,
args: part.functionCall.args
}
};
}
else if (part.functionResponse) {
return {
function_response: {
name: part.functionResponse.name,
response: part.functionResponse.response
}
};
}
return {}; // Empty part as fallback
});
return { role, parts };
}
/**
* Helper function to convert our tools format to Google GenAI tools format
*/
function convertTools(tools) {
if (!tools || tools.length === 0) {
return [];
}
return tools.map(tool => {
// Handle snake_case format (functionDeclarations)
if (tool.functionDeclarations) {
return {
functionDeclarations: tool.functionDeclarations.map((func) => ({
name: func.name,
description: func.description || '',
parameters: func.parameters || {},
}))
};
}
return {};
}).filter(tool => tool.functionDeclarations && tool.functionDeclarations.length > 0);
}
/**
* Helper to convert response from Google GenAI to our expected format
*/
function convertResponse(response) {
// Check if the response is empty or invalid
if (!response || typeof response !== 'object') {
console.error('Received invalid response from Gemini API:', response);
return {
candidates: [{
content: {
role: 'model',
parts: [{ text: 'The API returned an invalid response. Please try again.' }]
},
finish_reason: 'ERROR',
finish_message: 'Invalid API response'
}]
};
}
// Handle VertexAI response format which nests the actual response inside a 'response' property
const actualResponse = response.response || response;
// Create result object
const result = {};
if (actualResponse.candidates) {
result.candidates = actualResponse.candidates.map((candidate) => ({
content: candidate.content,
finish_reason: candidate.finishReason,
grounding_metadata: candidate.groundingMetadata,
finish_message: candidate.finishMessage
}));
}
if (actualResponse.promptFeedback) {
result.prompt_feedback = {
block_reason: actualResponse.promptFeedback.blockReason,
block_reason_message: actualResponse.promptFeedback.blockReasonMessage
};
}
if (actualResponse.usageMetadata) {
result.usage_metadata = {
prompt_token_count: actualResponse.usageMetadata.promptTokenCount,
candidates_token_count: actualResponse.usageMetadata.candidatesTokenCount,
total_token_count: actualResponse.usageMetadata.totalTokenCount
};
}
// Extract text from first candidate if available
if (result.candidates &&
result.candidates[0] &&
result.candidates[0].content &&
result.candidates[0].content.parts &&
result.candidates[0].content.parts[0] &&
result.candidates[0].content.parts[0].text) {
result.text = result.candidates[0].content.parts[0].text;
}
// Extract function calls if available
if (result.candidates &&
result.candidates[0] &&
result.candidates[0].content &&
result.candidates[0].content.parts) {
const functionCalls = result.candidates[0].content.parts
.filter((part) => part.functionCall)
.map((part) => ({
name: part.functionCall.name,
args: part.functionCall.args
}));
if (functionCalls.length > 0) {
result.function_calls = functionCalls;
}
}
// ONLY If no candidates were returned but response exists, create a default one
if ((!result.candidates || result.candidates.length === 0) &&
(!result.prompt_feedback || !result.prompt_feedback.block_reason)) {
console.warn('Response missing candidates, creating a default candidate');
result.candidates = [{
content: {
role: 'model',
parts: [{ text: 'I received your message but encountered an issue generating a proper response. Please try again.' }]
},
finish_reason: 'DEFAULT_CANDIDATE',
finish_message: 'Created default candidate due to missing response data'
}];
}
// Check if the response has valid candidates but is asking for system instructions
if (result.candidates &&
result.candidates.length > 0 &&
result.candidates[0].content &&
result.candidates[0].content.parts &&
result.candidates[0].content.parts[0] &&
result.candidates[0].content.parts[0].text &&
result.candidates[0].content.parts[0].text.includes("Please provide the System Instruction")) {
console.warn('Model is asking for system instructions - this suggests the system instructions were not properly passed');
}
return result;
}
// GenAI Client for Gemini
class GenAIClient {
constructor(httpOptions) {
this.httpOptions = httpOptions;
this.vertexai = false;
// Initialize Google GenAI with API key from environment
this.genAI = new generative_ai_1.GoogleGenerativeAI(process.env.GOOGLE_API_KEY || '');
// Check if this is a Vertex AI environment
this.vertexai = !!process.env.VERTEX_AI || !!process.env.GOOGLE_CLOUD_PROJECT;
}
// Models API
async generateContent(model, contents, config) {
try {
// IMPORTANT: The JavaScript SDK handles system instructions differently than Python
// We need to pass systemInstruction in the model configuration
// not as a message in contents with role 'system'
// Extract system instructions from config
const systemInstructionText = config.systemInstruction;
// Filter out any system messages from contents
// as they're not supported by the JavaScript SDK
contents = contents.filter(content => content.role !== 'system');
// Create model instance with systemInstruction as a configuration parameter
const genModel = this.genAI.getGenerativeModel({
model: model,
generationConfig: {
temperature: config.temperature,
topP: config.topP,
topK: config.topK,
maxOutputTokens: config.maxOutputTokens,
candidateCount: config.candidateCount,
stopSequences: config.stopSequences,
},
systemInstruction: systemInstructionText,
tools: convertTools(config.tools),
});
// Log configuration for debugging
console.log(`Model configuration: ${JSON.stringify({
temperature: config.temperature,
topP: config.topP,
topK: config.topK,
maxOutputTokens: config.maxOutputTokens,
systemInstruction: systemInstructionText ? 'Set' : 'Not set'
}, null, 2)}`);
// Convert content format
const convertedContents = contents.map(convertContent);
console.log(`Sending ${convertedContents.length} content items to the model`);
try {
// Generate content
const response = await genModel.generateContent({
contents: convertedContents
});
// Convert response back to our expected format
return convertResponse(response);
}
catch (apiErrorUnknown) {
// Type cast the error
const apiError = apiErrorUnknown;
console.error("API Error generating content:", apiError);
// Create a fallback response with the error information
return {
candidates: [{
content: {
role: 'model',
parts: [{ text: `API Error: ${apiError.message || 'Unknown error occurred'}. Please try again.` }]
},
finish_reason: 'ERROR',
finish_message: apiError.message || 'Unknown error'
}]
};
}
}
catch (errorUnknown) {
// Type cast the error
const error = errorUnknown;
console.error("Error generating content:", error);
// Return a fallback response instead of throwing
return {
candidates: [{
content: {
role: 'model',
parts: [{ text: `An error occurred: ${error.message || 'Unknown error'}. Please try again.` }]
},
finish_reason: 'ERROR',
finish_message: error.message || 'Unknown error'
}]
};
}
}
async *generateContentStream(model, contents, config) {
try {
console.log(`Generating streaming content with model: ${model}`);
// IMPORTANT: The JavaScript SDK handles system instructions differently than Python
// We need to pass systemInstruction in the model configuration
// not as a message in contents with role 'system'
// Extract system instructions from config
const systemInstructionText = config.systemInstruction;
// Filter out any system messages from contents
// as they're not supported by the JavaScript SDK
contents = contents.filter(content => content.role !== 'system');
// Log information about system instructions
if (systemInstructionText) {
console.log(`Using system instruction for streaming: ${systemInstructionText}`);
}
// Create model instance with systemInstruction as a configuration parameter
const genModel = this.genAI.getGenerativeModel({
model: model,
generationConfig: {
temperature: config.temperature,
topP: config.topP,
topK: config.topK,
maxOutputTokens: config.maxOutputTokens,
candidateCount: config.candidateCount,
stopSequences: config.stopSequences,
},
systemInstruction: systemInstructionText,
tools: convertTools(config.tools),
});
// Log configuration for streaming
console.log(`Streaming model configuration: ${JSON.stringify({
temperature: config.temperature,
topP: config.topP,
topK: config.topK,
maxOutputTokens: config.maxOutputTokens,
systemInstruction: systemInstructionText ? 'Set' : 'Not set'
}, null, 2)}`);
// Convert content format
const convertedContents = contents.map(convertContent);
console.log(`Streaming ${convertedContents.length} content items to the model`);
// Generate streaming content
const responseStream = await genModel.generateContentStream({
contents: convertedContents
});
// Process and yield each chunk
for await (const chunk of responseStream.stream) {
yield convertResponse(chunk);
}
}
catch (errorUnknown) {
const error = errorUnknown;
console.error("Error in streaming content generation:", error);
// Return a fallback response instead of throwing
yield {
candidates: [{
content: {
role: 'model',
parts: [{ text: `An error occurred during streaming: ${error.message || 'Unknown error'}. Please try again.` }]
},
finish_reason: 'ERROR',
finish_message: error.message || 'Streaming error'
}]
};
}
}
// Live API
async connectLive(model, config) {
try {
console.log(`Connecting live to model: ${model}`);
// IMPORTANT: The JavaScript SDK handles system instructions differently than Python
// Filter out any system message content from history
const history = [];
let systemInstructionText = '';
// Check if we have a system instruction
if (config.systemInstruction) {
// If we have a system instruction, get the text from it
const systemMsg = config.systemInstruction;
if (systemMsg &&
systemMsg.parts &&
systemMsg.parts.length > 0 &&
systemMsg.parts[0].text) {
systemInstructionText = systemMsg.parts[0].text;
console.log(`Using system instruction for live connection: ${systemInstructionText}`);
}
}
// Create model instance with appropriate configuration
const genModel = this.genAI.getGenerativeModel({
model: model,
systemInstruction: systemInstructionText,
tools: convertTools(config.tools),
});
// Start chat session - don't pass system instruction in history
const chat = genModel.startChat({
history: [], // Don't include system instructions in history
tools: convertTools(config.tools),
});
// Create an AsyncSession wrapper around the chat
const session = {
async send(input) {
await chat.sendMessage(input);
},
async *receive() {
// In a real implementation, this would listen for responses
// This is a placeholder as the Google GenAI JS SDK doesn't
// have a direct equivalent to the Python SDK's receive() method
// Instead of getLastResponse, we'll use sendMessage to get a response
const response = await chat.sendMessage("continue");
yield convertResponse(response);
},
async close() {
// Clean up resources if needed
// The Google GenAI JS SDK doesn't have an explicit close method
}
};
return { session };
}
catch (error) {
console.error("Error connecting to Gemini model:", error);
throw error;
}
}
}
/**
* Integration for Gemini models.
*/
class Gemini extends BaseLlm_1.BaseLlm {
/**
* Constructor
* @param model The name of the Gemini model, defaults to 'gemini-2.0-flash'
*/
constructor(model = 'gemini-2.0-flash') {
super(model);
this.apiClientCache = null;
this.liveApiClientCache = null;
this.NEW_LINE = '\n';
this.EXCLUDED_PART_FIELD = { 'inline_data': { 'data': '' } };
}
/**
* List of supported models
* @returns An array of regex patterns for supported model names
*/
static supportedModels() {
return [
'gemini-1\\.5-flash(-\\d+)?',
'gemini-1\\.5-pro(-\\d+)?',
'gemini-2\\.0-flash-exp',
'gemini-2\\.0-flash',
'gemini-2\\.5-flash-preview-04-17',
'projects/.+/locations/.+/endpoints/.+', // finetuned vertex gemini endpoint
'projects/.+/locations/.+/publishers/google/models/gemini.+', // vertex gemini long name
];
}
/**
* Generate content asynchronously
* @param llmRequest The request to send to the Gemini model
* @param stream Whether to use streaming mode
* @returns AsyncGenerator yielding LlmResponse objects
*/
async *generateContentAsync(llmRequest, stream = false) {
// Make sure contents array exists
if (!llmRequest.contents) {
llmRequest.contents = [];
}
// Preserve original user message for logging
const originalUserMessages = llmRequest.contents
.filter(content => content.role === 'user')
.map(content => JSON.stringify(content));
if (originalUserMessages.length === 0) {
console.warn('No user message found in request - this is unusual');
}
// IMPORTANT: In the JavaScript SDK for Google Generative AI,
// system instructions should NOT be added as a message in contents
// with role 'system'. Instead, use the systemInstruction config parameter.
// Remove any system messages from contents array
llmRequest.contents = llmRequest.contents.filter(content => content.role !== 'system');
// Only append user content if absolutely necessary
this._maybeAppendUserContent(llmRequest);
// Sanity check - make sure we still have the original user message
const userMessagesAfter = llmRequest.contents
.filter(content => content.role === 'user')
.map(content => JSON.stringify(content));
if (originalUserMessages.length > 0 &&
!userMessagesAfter.some(msg => originalUserMessages.includes(msg))) {
console.error('ERROR: Original user message was lost during processing!');
// In this case, restore the first original user message to ensure it's not lost
if (originalUserMessages.length > 0) {
const firstOriginalMsg = JSON.parse(originalUserMessages[0]);
llmRequest.contents.push(firstOriginalMsg);
}
}
console.info(`Sending out request, model: ${llmRequest.model || this.model}, backend: ${this._apiBackend}, stream: ${stream}`);
console.info(this._buildRequestLog(llmRequest));
if (stream) {
const responses = this.apiClient.generateContentStream(llmRequest.model || this.model, llmRequest.contents, llmRequest.config);
let response = null;
let text = '';
// For streaming, mark text content as partial and accumulate text
for await (const resp of responses) {
console.info(this._buildResponseLog(resp));
response = resp;
const llmResponse = LlmResponse_1.LlmResponse.create(resp);
if (llmResponse.content &&
llmResponse.content.parts &&
llmResponse.content.parts[0]?.text) {
text += llmResponse.content.parts[0].text;
llmResponse.partial = true;
}
else if (text &&
(!llmResponse.content ||
!llmResponse.content.parts ||
!llmResponse.content.parts[0]?.inlineData)) {
// Yield accumulated text
const textResponse = new LlmResponse_1.LlmResponse();
textResponse.content = {
role: 'model',
parts: [{ text }]
};
yield textResponse;
text = '';
}
yield llmResponse;
}
// Yield final accumulated text if there's any and response finished with STOP
if (text &&
response &&
response.candidates &&
response.candidates[0]?.finish_reason === 'STOP') {
const finalResponse = new LlmResponse_1.LlmResponse();
finalResponse.content = {
role: 'model',
parts: [{ text }]
};
yield finalResponse;
}
}
else {
// Non-streaming mode
const response = await this.apiClient.generateContent(llmRequest.model || this.model, llmRequest.contents, llmRequest.config);
console.info(this._buildResponseLog(response));
yield LlmResponse_1.LlmResponse.create(response);
}
}
/**
* Get the API client
*/
get apiClient() {
if (!this.apiClientCache) {
this.apiClientCache = new GenAIClient({ headers: this._trackingHeaders });
}
return this.apiClientCache;
}
/**
* Get the API backend type
*/
get _apiBackend() {
return this.apiClient.vertexai ? 'vertex' : 'ml_dev';
}
/**
* Get tracking headers for API requests
*/
get _trackingHeaders() {
// In a real implementation, we would import the version from a version module
const version = '1.0.0';
const frameworkLabel = `google-adk/${version}`;
const languageLabel = 'gl-typescript/1.0.0';
const versionHeaderValue = `${frameworkLabel} ${languageLabel}`;
return {
'x-goog-api-client': versionHeaderValue,
'user-agent': versionHeaderValue,
};
}
/**
* Get the live API client
*/
get _liveApiClient() {
if (!this.liveApiClientCache) {
if (this._apiBackend === 'vertex') {
// Use default API version for Vertex
this.liveApiClientCache = new GenAIClient({ headers: this._trackingHeaders });
}
else {
// Use v1alpha for ML Dev
this.liveApiClientCache = new GenAIClient({
headers: this._trackingHeaders,
api_version: 'v1alpha'
});
}
}
return this.liveApiClientCache;
}
/**
* Connect to the Gemini model and create a connection
* This implementation is synchronous to match the BaseLlm interface
* The implementation itself uses async/await internally
*/
connect(llmRequest) {
if (!llmRequest.liveConnectConfig) {
llmRequest.liveConnectConfig = {};
}
// Cast to allow access to the properties we need
const liveConfig = llmRequest.liveConnectConfig;
// Set system instruction
if (llmRequest.config.systemInstruction) {
liveConfig.systemInstruction = {
role: 'system',
parts: [{ text: llmRequest.config.systemInstruction }]
};
}
// Set tools
liveConfig.tools = llmRequest.config.tools;
// Create a minimal implementation that will be replaced with the real one
// when the async operations complete
const dummyConnection = new class extends BaseLlmConnection_1.BaseLlmConnection {
constructor(connectPromise) {
super();
this.realConnection = null;
this.connectionPromise = connectPromise.then(({ session }) => {
this.realConnection = new GeminiLlmConnection_1.GeminiLlmConnection(session);
return this.realConnection;
}).catch(error => {
console.error('Error connecting to Gemini model:', error);
throw error;
});
}
async sendHistory(history) {
const conn = await this.connectionPromise;
return conn.sendHistory(history);
}
async sendContent(content) {
const conn = await this.connectionPromise;
return conn.sendContent(content);
}
async sendRealtime(blob) {
const conn = await this.connectionPromise;
return conn.sendRealtime(blob);
}
async *receive() {
const conn = await this.connectionPromise;
yield* conn.receive();
}
async close() {
const conn = await this.connectionPromise;
return conn.close();
}
}(this._liveApiClient.connectLive(llmRequest.model || this.model, llmRequest.liveConnectConfig));
return dummyConnection;
}
/**
* Appends a user content if needed
* @param llmRequest The request to modify
*/
_maybeAppendUserContent(llmRequest) {
// Make sure contents array exists
if (!llmRequest.contents) {
llmRequest.contents = [];
}
// If contents array is completely empty, add a default message
// This should only happen in very rare cases where no message was provided at all
if (llmRequest.contents.length === 0) {
console.warn('WARNING: No content provided in request. This is unusual and suggests an error in the calling code.');
llmRequest.contents.push({
role: 'user',
parts: [{
text: 'Hello'
}]
});
return;
}
// Add a continuation prompt only if the last message is not from the user
// This is to ensure that model responses are always triggered by a user message
if (llmRequest.contents.length > 0 &&
llmRequest.contents[llmRequest.contents.length - 1].role !== 'user') {
console.log('Last message is not from user, adding minimal continuation prompt');
llmRequest.contents.push({
role: 'user',
parts: [{
text: 'Continue please.'
}]
});
}
}
/**
* Build a log string for a function declaration
* @param funcDecl The function declaration
* @returns A string representation
*/
_buildFunctionDeclarationLog(funcDecl) {
let paramStr = '{}';
if (funcDecl.parameters && funcDecl.parameters.properties) {
paramStr = JSON.stringify(funcDecl.parameters.properties);
}
const returnStr = 'None'; // TypeScript version doesn't use return types in the same way
return `${funcDecl.name}: ${paramStr} -> ${returnStr}`;
}
/**
* Build a log string for an LlmRequest
* @param req The request
* @returns A formatted log string
*/
_buildRequestLog(req) {
const functionDecls = req.config.tools?.[0]?.functionDeclarations || [];
const functionLogs = functionDecls.map(decl => this._buildFunctionDeclarationLog(decl));
const contentsLogs = req.contents.map(content => {
// In a real implementation, we would exclude large binary data
// from inline_data fields before logging
return JSON.stringify(content);
});
return `
LLM Request:
-----------------------------------------------------------
System Instruction:
${req.config.systemInstruction || ''}
-----------------------------------------------------------
Contents:
${contentsLogs.join(this.NEW_LINE)}
-----------------------------------------------------------
Functions:
${functionLogs.join(this.NEW_LINE)}
-----------------------------------------------------------
`;
}
/**
* Build a log string for a response
* @param resp The response
* @returns A formatted log string
*/
_buildResponseLog(resp) {
const functionCallsText = [];
if (resp.function_calls) {
for (const funcCall of resp.function_calls) {
functionCallsText.push(`name: ${funcCall.name}, args: ${JSON.stringify(funcCall.args)}`);
}
}
return `
LLM Response:
-----------------------------------------------------------
Text:
${resp.text || ''}
-----------------------------------------------------------
Function calls:
${functionCallsText.join(this.NEW_LINE)}
-----------------------------------------------------------
Raw response:
${JSON.stringify(resp)}
-----------------------------------------------------------
`;
}
}
exports.Gemini = Gemini;