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,
|
||||
} from '../telemetry/loggers.js';
|
||||
import {
|
||||
getResponseText,
|
||||
getResponseTextFromParts,
|
||||
getStructuredResponse,
|
||||
getStructuredResponseFromParts,
|
||||
} from '../utils/generateContentResponseUtilities.js';
|
||||
|
||||
/**
|
||||
|
@ -239,7 +239,7 @@ export class GeminiChat {
|
|||
await this._logApiResponse(
|
||||
durationMs,
|
||||
response.usageMetadata,
|
||||
getResponseText(response),
|
||||
getStructuredResponse(response),
|
||||
);
|
||||
|
||||
this.sendPromise = (async () => {
|
||||
|
@ -437,7 +437,7 @@ export class GeminiChat {
|
|||
allParts.push(...content.parts);
|
||||
}
|
||||
}
|
||||
const fullText = getResponseTextFromParts(allParts);
|
||||
const fullText = getStructuredResponseFromParts(allParts);
|
||||
await this._logApiResponse(
|
||||
durationMs,
|
||||
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
|
||||
*/
|
||||
|
||||
import { GenerateContentResponse, Part } from '@google/genai';
|
||||
import { GenerateContentResponse, Part, FunctionCall } from '@google/genai';
|
||||
|
||||
export function getResponseText(
|
||||
response: GenerateContentResponse,
|
||||
): string | undefined {
|
||||
return (
|
||||
response.candidates?.[0]?.content?.parts
|
||||
?.map((part) => part.text)
|
||||
.join('') || undefined
|
||||
);
|
||||
const parts = response.candidates?.[0]?.content?.parts;
|
||||
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 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