Telemetry: Improve API response logging with function call details (#1064)
This commit is contained in:
parent
e717c51aa1
commit
ab932ffaa5
|
@ -27,8 +27,8 @@ import {
|
||||||
combinedUsageMetadata,
|
combinedUsageMetadata,
|
||||||
} from '../telemetry/loggers.js';
|
} from '../telemetry/loggers.js';
|
||||||
import {
|
import {
|
||||||
getResponseText,
|
getStructuredResponse,
|
||||||
getResponseTextFromParts,
|
getStructuredResponseFromParts,
|
||||||
} from '../utils/generateContentResponseUtilities.js';
|
} from '../utils/generateContentResponseUtilities.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -239,7 +239,7 @@ export class GeminiChat {
|
||||||
await this._logApiResponse(
|
await this._logApiResponse(
|
||||||
durationMs,
|
durationMs,
|
||||||
response.usageMetadata,
|
response.usageMetadata,
|
||||||
getResponseText(response),
|
getStructuredResponse(response),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.sendPromise = (async () => {
|
this.sendPromise = (async () => {
|
||||||
|
@ -437,7 +437,7 @@ export class GeminiChat {
|
||||||
allParts.push(...content.parts);
|
allParts.push(...content.parts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fullText = getResponseTextFromParts(allParts);
|
const fullText = getStructuredResponseFromParts(allParts);
|
||||||
await this._logApiResponse(
|
await this._logApiResponse(
|
||||||
durationMs,
|
durationMs,
|
||||||
combinedUsageMetadata(chunks),
|
combinedUsageMetadata(chunks),
|
||||||
|
|
|
@ -0,0 +1,323 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
getResponseText,
|
||||||
|
getResponseTextFromParts,
|
||||||
|
getFunctionCalls,
|
||||||
|
getFunctionCallsFromParts,
|
||||||
|
getFunctionCallsAsJson,
|
||||||
|
getFunctionCallsFromPartsAsJson,
|
||||||
|
getStructuredResponse,
|
||||||
|
getStructuredResponseFromParts,
|
||||||
|
} from './generateContentResponseUtilities.js';
|
||||||
|
import {
|
||||||
|
GenerateContentResponse,
|
||||||
|
Part,
|
||||||
|
FinishReason,
|
||||||
|
SafetyRating,
|
||||||
|
} from '@google/genai';
|
||||||
|
|
||||||
|
const mockTextPart = (text: string): Part => ({ text });
|
||||||
|
const mockFunctionCallPart = (
|
||||||
|
name: string,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
): Part => ({
|
||||||
|
functionCall: { name, args: args ?? {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockResponse = (
|
||||||
|
parts: Part[],
|
||||||
|
finishReason: FinishReason = FinishReason.STOP,
|
||||||
|
safetyRatings: SafetyRating[] = [],
|
||||||
|
): GenerateContentResponse => ({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts,
|
||||||
|
role: 'model',
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
finishReason,
|
||||||
|
safetyRatings,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
promptFeedback: {
|
||||||
|
safetyRatings: [],
|
||||||
|
},
|
||||||
|
text: undefined,
|
||||||
|
data: undefined,
|
||||||
|
functionCalls: undefined,
|
||||||
|
executableCode: undefined,
|
||||||
|
codeExecutionResult: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const minimalMockResponse = (
|
||||||
|
candidates: GenerateContentResponse['candidates'],
|
||||||
|
): GenerateContentResponse => ({
|
||||||
|
candidates,
|
||||||
|
promptFeedback: { safetyRatings: [] },
|
||||||
|
text: undefined,
|
||||||
|
data: undefined,
|
||||||
|
functionCalls: undefined,
|
||||||
|
executableCode: undefined,
|
||||||
|
codeExecutionResult: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateContentResponseUtilities', () => {
|
||||||
|
describe('getResponseText', () => {
|
||||||
|
it('should return undefined for no candidates', () => {
|
||||||
|
expect(getResponseText(minimalMockResponse(undefined))).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('should return undefined for empty candidates array', () => {
|
||||||
|
expect(getResponseText(minimalMockResponse([]))).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('should return undefined for no parts', () => {
|
||||||
|
const response = mockResponse([]);
|
||||||
|
expect(getResponseText(response)).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('should extract text from a single text part', () => {
|
||||||
|
const response = mockResponse([mockTextPart('Hello')]);
|
||||||
|
expect(getResponseText(response)).toBe('Hello');
|
||||||
|
});
|
||||||
|
it('should concatenate text from multiple text parts', () => {
|
||||||
|
const response = mockResponse([
|
||||||
|
mockTextPart('Hello '),
|
||||||
|
mockTextPart('World'),
|
||||||
|
]);
|
||||||
|
expect(getResponseText(response)).toBe('Hello World');
|
||||||
|
});
|
||||||
|
it('should ignore function call parts', () => {
|
||||||
|
const response = mockResponse([
|
||||||
|
mockTextPart('Hello '),
|
||||||
|
mockFunctionCallPart('testFunc'),
|
||||||
|
mockTextPart('World'),
|
||||||
|
]);
|
||||||
|
expect(getResponseText(response)).toBe('Hello World');
|
||||||
|
});
|
||||||
|
it('should return undefined if only function call parts exist', () => {
|
||||||
|
const response = mockResponse([
|
||||||
|
mockFunctionCallPart('testFunc'),
|
||||||
|
mockFunctionCallPart('anotherFunc'),
|
||||||
|
]);
|
||||||
|
expect(getResponseText(response)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseTextFromParts', () => {
|
||||||
|
it('should return undefined for no parts', () => {
|
||||||
|
expect(getResponseTextFromParts([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('should extract text from a single text part', () => {
|
||||||
|
expect(getResponseTextFromParts([mockTextPart('Hello')])).toBe('Hello');
|
||||||
|
});
|
||||||
|
it('should concatenate text from multiple text parts', () => {
|
||||||
|
expect(
|
||||||
|
getResponseTextFromParts([
|
||||||
|
mockTextPart('Hello '),
|
||||||
|
mockTextPart('World'),
|
||||||
|
]),
|
||||||
|
).toBe('Hello World');
|
||||||
|
});
|
||||||
|
it('should ignore function call parts', () => {
|
||||||
|
expect(
|
||||||
|
getResponseTextFromParts([
|
||||||
|
mockTextPart('Hello '),
|
||||||
|
mockFunctionCallPart('testFunc'),
|
||||||
|
mockTextPart('World'),
|
||||||
|
]),
|
||||||
|
).toBe('Hello World');
|
||||||
|
});
|
||||||
|
it('should return undefined if only function call parts exist', () => {
|
||||||
|
expect(
|
||||||
|
getResponseTextFromParts([
|
||||||
|
mockFunctionCallPart('testFunc'),
|
||||||
|
mockFunctionCallPart('anotherFunc'),
|
||||||
|
]),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFunctionCalls', () => {
|
||||||
|
it('should return undefined for no candidates', () => {
|
||||||
|
expect(getFunctionCalls(minimalMockResponse(undefined))).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('should return undefined for empty candidates array', () => {
|
||||||
|
expect(getFunctionCalls(minimalMockResponse([]))).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('should return undefined for no parts', () => {
|
||||||
|
const response = mockResponse([]);
|
||||||
|
expect(getFunctionCalls(response)).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('should extract a single function call', () => {
|
||||||
|
const func = { name: 'testFunc', args: { a: 1 } };
|
||||||
|
const response = mockResponse([
|
||||||
|
mockFunctionCallPart(func.name, func.args),
|
||||||
|
]);
|
||||||
|
expect(getFunctionCalls(response)).toEqual([func]);
|
||||||
|
});
|
||||||
|
it('should extract multiple function calls', () => {
|
||||||
|
const func1 = { name: 'testFunc1', args: { a: 1 } };
|
||||||
|
const func2 = { name: 'testFunc2', args: { b: 2 } };
|
||||||
|
const response = mockResponse([
|
||||||
|
mockFunctionCallPart(func1.name, func1.args),
|
||||||
|
mockFunctionCallPart(func2.name, func2.args),
|
||||||
|
]);
|
||||||
|
expect(getFunctionCalls(response)).toEqual([func1, func2]);
|
||||||
|
});
|
||||||
|
it('should ignore text parts', () => {
|
||||||
|
const func = { name: 'testFunc', args: { a: 1 } };
|
||||||
|
const response = mockResponse([
|
||||||
|
mockTextPart('Some text'),
|
||||||
|
mockFunctionCallPart(func.name, func.args),
|
||||||
|
mockTextPart('More text'),
|
||||||
|
]);
|
||||||
|
expect(getFunctionCalls(response)).toEqual([func]);
|
||||||
|
});
|
||||||
|
it('should return undefined if only text parts exist', () => {
|
||||||
|
const response = mockResponse([
|
||||||
|
mockTextPart('Some text'),
|
||||||
|
mockTextPart('More text'),
|
||||||
|
]);
|
||||||
|
expect(getFunctionCalls(response)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFunctionCallsFromParts', () => {
|
||||||
|
it('should return undefined for no parts', () => {
|
||||||
|
expect(getFunctionCallsFromParts([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('should extract a single function call', () => {
|
||||||
|
const func = { name: 'testFunc', args: { a: 1 } };
|
||||||
|
expect(
|
||||||
|
getFunctionCallsFromParts([mockFunctionCallPart(func.name, func.args)]),
|
||||||
|
).toEqual([func]);
|
||||||
|
});
|
||||||
|
it('should extract multiple function calls', () => {
|
||||||
|
const func1 = { name: 'testFunc1', args: { a: 1 } };
|
||||||
|
const func2 = { name: 'testFunc2', args: { b: 2 } };
|
||||||
|
expect(
|
||||||
|
getFunctionCallsFromParts([
|
||||||
|
mockFunctionCallPart(func1.name, func1.args),
|
||||||
|
mockFunctionCallPart(func2.name, func2.args),
|
||||||
|
]),
|
||||||
|
).toEqual([func1, func2]);
|
||||||
|
});
|
||||||
|
it('should ignore text parts', () => {
|
||||||
|
const func = { name: 'testFunc', args: { a: 1 } };
|
||||||
|
expect(
|
||||||
|
getFunctionCallsFromParts([
|
||||||
|
mockTextPart('Some text'),
|
||||||
|
mockFunctionCallPart(func.name, func.args),
|
||||||
|
mockTextPart('More text'),
|
||||||
|
]),
|
||||||
|
).toEqual([func]);
|
||||||
|
});
|
||||||
|
it('should return undefined if only text parts exist', () => {
|
||||||
|
expect(
|
||||||
|
getFunctionCallsFromParts([
|
||||||
|
mockTextPart('Some text'),
|
||||||
|
mockTextPart('More text'),
|
||||||
|
]),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFunctionCallsAsJson', () => {
|
||||||
|
it('should return JSON string of function calls', () => {
|
||||||
|
const func1 = { name: 'testFunc1', args: { a: 1 } };
|
||||||
|
const func2 = { name: 'testFunc2', args: { b: 2 } };
|
||||||
|
const response = mockResponse([
|
||||||
|
mockFunctionCallPart(func1.name, func1.args),
|
||||||
|
mockTextPart('text in between'),
|
||||||
|
mockFunctionCallPart(func2.name, func2.args),
|
||||||
|
]);
|
||||||
|
const expectedJson = JSON.stringify([func1, func2], null, 2);
|
||||||
|
expect(getFunctionCallsAsJson(response)).toBe(expectedJson);
|
||||||
|
});
|
||||||
|
it('should return undefined if no function calls', () => {
|
||||||
|
const response = mockResponse([mockTextPart('Hello')]);
|
||||||
|
expect(getFunctionCallsAsJson(response)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFunctionCallsFromPartsAsJson', () => {
|
||||||
|
it('should return JSON string of function calls from parts', () => {
|
||||||
|
const func1 = { name: 'testFunc1', args: { a: 1 } };
|
||||||
|
const func2 = { name: 'testFunc2', args: { b: 2 } };
|
||||||
|
const parts = [
|
||||||
|
mockFunctionCallPart(func1.name, func1.args),
|
||||||
|
mockTextPart('text in between'),
|
||||||
|
mockFunctionCallPart(func2.name, func2.args),
|
||||||
|
];
|
||||||
|
const expectedJson = JSON.stringify([func1, func2], null, 2);
|
||||||
|
expect(getFunctionCallsFromPartsAsJson(parts)).toBe(expectedJson);
|
||||||
|
});
|
||||||
|
it('should return undefined if no function calls in parts', () => {
|
||||||
|
const parts = [mockTextPart('Hello')];
|
||||||
|
expect(getFunctionCallsFromPartsAsJson(parts)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStructuredResponse', () => {
|
||||||
|
it('should return only text if only text exists', () => {
|
||||||
|
const response = mockResponse([mockTextPart('Hello World')]);
|
||||||
|
expect(getStructuredResponse(response)).toBe('Hello World');
|
||||||
|
});
|
||||||
|
it('should return only function call JSON if only function calls exist', () => {
|
||||||
|
const func = { name: 'testFunc', args: { data: 'payload' } };
|
||||||
|
const response = mockResponse([
|
||||||
|
mockFunctionCallPart(func.name, func.args),
|
||||||
|
]);
|
||||||
|
const expectedJson = JSON.stringify([func], null, 2);
|
||||||
|
expect(getStructuredResponse(response)).toBe(expectedJson);
|
||||||
|
});
|
||||||
|
it('should return text and function call JSON if both exist', () => {
|
||||||
|
const text = 'Consider this data:';
|
||||||
|
const func = { name: 'processData', args: { item: 42 } };
|
||||||
|
const response = mockResponse([
|
||||||
|
mockTextPart(text),
|
||||||
|
mockFunctionCallPart(func.name, func.args),
|
||||||
|
]);
|
||||||
|
const expectedJson = JSON.stringify([func], null, 2);
|
||||||
|
expect(getStructuredResponse(response)).toBe(`${text}\n${expectedJson}`);
|
||||||
|
});
|
||||||
|
it('should return undefined if neither text nor function calls exist', () => {
|
||||||
|
const response = mockResponse([]);
|
||||||
|
expect(getStructuredResponse(response)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStructuredResponseFromParts', () => {
|
||||||
|
it('should return only text if only text exists in parts', () => {
|
||||||
|
const parts = [mockTextPart('Hello World')];
|
||||||
|
expect(getStructuredResponseFromParts(parts)).toBe('Hello World');
|
||||||
|
});
|
||||||
|
it('should return only function call JSON if only function calls exist in parts', () => {
|
||||||
|
const func = { name: 'testFunc', args: { data: 'payload' } };
|
||||||
|
const parts = [mockFunctionCallPart(func.name, func.args)];
|
||||||
|
const expectedJson = JSON.stringify([func], null, 2);
|
||||||
|
expect(getStructuredResponseFromParts(parts)).toBe(expectedJson);
|
||||||
|
});
|
||||||
|
it('should return text and function call JSON if both exist in parts', () => {
|
||||||
|
const text = 'Consider this data:';
|
||||||
|
const func = { name: 'processData', args: { item: 42 } };
|
||||||
|
const parts = [
|
||||||
|
mockTextPart(text),
|
||||||
|
mockFunctionCallPart(func.name, func.args),
|
||||||
|
];
|
||||||
|
const expectedJson = JSON.stringify([func], null, 2);
|
||||||
|
expect(getStructuredResponseFromParts(parts)).toBe(
|
||||||
|
`${text}\n${expectedJson}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('should return undefined if neither text nor function calls exist in parts', () => {
|
||||||
|
const parts: Part[] = [];
|
||||||
|
expect(getStructuredResponseFromParts(parts)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -4,18 +4,116 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { GenerateContentResponse, Part } from '@google/genai';
|
import { GenerateContentResponse, Part, FunctionCall } from '@google/genai';
|
||||||
|
|
||||||
export function getResponseText(
|
export function getResponseText(
|
||||||
response: GenerateContentResponse,
|
response: GenerateContentResponse,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
return (
|
const parts = response.candidates?.[0]?.content?.parts;
|
||||||
response.candidates?.[0]?.content?.parts
|
if (!parts) {
|
||||||
?.map((part) => part.text)
|
return undefined;
|
||||||
.join('') || undefined
|
}
|
||||||
);
|
const textSegments = parts
|
||||||
|
.map((part) => part.text)
|
||||||
|
.filter((text): text is string => typeof text === 'string');
|
||||||
|
|
||||||
|
if (textSegments.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return textSegments.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResponseTextFromParts(parts: Part[]): string | undefined {
|
export function getResponseTextFromParts(parts: Part[]): string | undefined {
|
||||||
return parts?.map((part) => part.text).join('') || undefined;
|
if (!parts) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const textSegments = parts
|
||||||
|
.map((part) => part.text)
|
||||||
|
.filter((text): text is string => typeof text === 'string');
|
||||||
|
|
||||||
|
if (textSegments.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return textSegments.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFunctionCalls(
|
||||||
|
response: GenerateContentResponse,
|
||||||
|
): FunctionCall[] | undefined {
|
||||||
|
const parts = response.candidates?.[0]?.content?.parts;
|
||||||
|
if (!parts) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const functionCallParts = parts
|
||||||
|
.filter((part) => !!part.functionCall)
|
||||||
|
.map((part) => part.functionCall as FunctionCall);
|
||||||
|
return functionCallParts.length > 0 ? functionCallParts : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFunctionCallsFromParts(
|
||||||
|
parts: Part[],
|
||||||
|
): FunctionCall[] | undefined {
|
||||||
|
if (!parts) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const functionCallParts = parts
|
||||||
|
.filter((part) => !!part.functionCall)
|
||||||
|
.map((part) => part.functionCall as FunctionCall);
|
||||||
|
return functionCallParts.length > 0 ? functionCallParts : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFunctionCallsAsJson(
|
||||||
|
response: GenerateContentResponse,
|
||||||
|
): string | undefined {
|
||||||
|
const functionCalls = getFunctionCalls(response);
|
||||||
|
if (!functionCalls) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.stringify(functionCalls, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFunctionCallsFromPartsAsJson(
|
||||||
|
parts: Part[],
|
||||||
|
): string | undefined {
|
||||||
|
const functionCalls = getFunctionCallsFromParts(parts);
|
||||||
|
if (!functionCalls) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.stringify(functionCalls, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStructuredResponse(
|
||||||
|
response: GenerateContentResponse,
|
||||||
|
): string | undefined {
|
||||||
|
const textContent = getResponseText(response);
|
||||||
|
const functionCallsJson = getFunctionCallsAsJson(response);
|
||||||
|
|
||||||
|
if (textContent && functionCallsJson) {
|
||||||
|
return `${textContent}\n${functionCallsJson}`;
|
||||||
|
}
|
||||||
|
if (textContent) {
|
||||||
|
return textContent;
|
||||||
|
}
|
||||||
|
if (functionCallsJson) {
|
||||||
|
return functionCallsJson;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStructuredResponseFromParts(
|
||||||
|
parts: Part[],
|
||||||
|
): string | undefined {
|
||||||
|
const textContent = getResponseTextFromParts(parts);
|
||||||
|
const functionCallsJson = getFunctionCallsFromPartsAsJson(parts);
|
||||||
|
|
||||||
|
if (textContent && functionCallsJson) {
|
||||||
|
return `${textContent}\n${functionCallsJson}`;
|
||||||
|
}
|
||||||
|
if (textContent) {
|
||||||
|
return textContent;
|
||||||
|
}
|
||||||
|
if (functionCallsJson) {
|
||||||
|
return functionCallsJson;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue