mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-25 04:24:51 -07:00
feat: Add Open Telemetric semantic standard compliant log (#11975)
This commit is contained in:
committed by
GitHub
parent
44bdd3ad11
commit
70996bfdee
@@ -298,6 +298,26 @@ Captures tool executions, output truncation, and Smart Edit behavior.
|
|||||||
- **Attributes**:
|
- **Attributes**:
|
||||||
- `correction` ("success" | "failure")
|
- `correction` ("success" | "failure")
|
||||||
|
|
||||||
|
- `gen_ai.client.inference.operation.details`: This event provides detailed
|
||||||
|
information about the GenAI operation, aligned with [OpenTelemetry GenAI
|
||||||
|
semantic conventions for events].
|
||||||
|
- **Attributes**:
|
||||||
|
- `gen_ai.request.model` (string)
|
||||||
|
- `gen_ai.provider.name` (string)
|
||||||
|
- `gen_ai.operation.name` (string)
|
||||||
|
- `gen_ai.input.messages` (json string)
|
||||||
|
- `gen_ai.output.messages` (json string)
|
||||||
|
- `gen_ai.response.finish_reasons` (array of strings)
|
||||||
|
- `gen_ai.usage.input_tokens` (int)
|
||||||
|
- `gen_ai.usage.output_tokens` (int)
|
||||||
|
- `gen_ai.request.temperature` (float)
|
||||||
|
- `gen_ai.request.top_p` (float)
|
||||||
|
- `gen_ai.request.top_k` (int)
|
||||||
|
- `gen_ai.request.max_tokens` (int)
|
||||||
|
- `gen_ai.system_instructions` (json string)
|
||||||
|
- `server.address` (string)
|
||||||
|
- `server.port` (int)
|
||||||
|
|
||||||
#### Files
|
#### Files
|
||||||
|
|
||||||
Tracks file operations performed by tools.
|
Tracks file operations performed by tools.
|
||||||
@@ -735,3 +755,5 @@ standardized observability across GenAI applications:
|
|||||||
|
|
||||||
[OpenTelemetry GenAI semantic conventions]:
|
[OpenTelemetry GenAI semantic conventions]:
|
||||||
https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-metrics.md
|
https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-metrics.md
|
||||||
|
[OpenTelemetry GenAI semantic conventions for events]:
|
||||||
|
https://github.com/open-telemetry/semantic-conventions/blob/8b4f210f43136e57c1f6f47292eb6d38e3bf30bb/docs/gen-ai/gen-ai-events.md
|
||||||
|
|||||||
@@ -5,15 +5,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
Candidate,
|
||||||
Content,
|
Content,
|
||||||
CountTokensParameters,
|
CountTokensParameters,
|
||||||
CountTokensResponse,
|
CountTokensResponse,
|
||||||
EmbedContentParameters,
|
EmbedContentParameters,
|
||||||
EmbedContentResponse,
|
EmbedContentResponse,
|
||||||
|
GenerateContentConfig,
|
||||||
GenerateContentParameters,
|
GenerateContentParameters,
|
||||||
GenerateContentResponseUsageMetadata,
|
GenerateContentResponseUsageMetadata,
|
||||||
GenerateContentResponse,
|
GenerateContentResponse,
|
||||||
} from '@google/genai';
|
} from '@google/genai';
|
||||||
|
import type { ServerDetails } from '../telemetry/types.js';
|
||||||
import {
|
import {
|
||||||
ApiRequestEvent,
|
ApiRequestEvent,
|
||||||
ApiResponseEvent,
|
ApiResponseEvent,
|
||||||
@@ -26,6 +29,7 @@ import {
|
|||||||
logApiResponse,
|
logApiResponse,
|
||||||
} from '../telemetry/loggers.js';
|
} from '../telemetry/loggers.js';
|
||||||
import type { ContentGenerator } from './contentGenerator.js';
|
import type { ContentGenerator } from './contentGenerator.js';
|
||||||
|
import { CodeAssistServer } from '../code_assist/server.js';
|
||||||
import { toContents } from '../code_assist/converter.js';
|
import { toContents } from '../code_assist/converter.js';
|
||||||
import { isStructuredError } from '../utils/quotaErrorDetection.js';
|
import { isStructuredError } from '../utils/quotaErrorDetection.js';
|
||||||
import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js';
|
import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js';
|
||||||
@@ -59,19 +63,66 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _getEndpointUrl(
|
||||||
|
req: GenerateContentParameters,
|
||||||
|
method: 'generateContent' | 'generateContentStream',
|
||||||
|
): ServerDetails {
|
||||||
|
// Case 1: Authenticated with a Google account (`gcloud auth login`).
|
||||||
|
// Requests are routed through the internal CodeAssistServer.
|
||||||
|
if (this.wrapped instanceof CodeAssistServer) {
|
||||||
|
const url = new URL(this.wrapped.getMethodUrl(method));
|
||||||
|
const port = url.port
|
||||||
|
? parseInt(url.port, 10)
|
||||||
|
: url.protocol === 'https:'
|
||||||
|
? 443
|
||||||
|
: 80;
|
||||||
|
return { address: url.hostname, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
const genConfig = this.config.getContentGeneratorConfig();
|
||||||
|
|
||||||
|
// Case 2: Using an API key for Vertex AI.
|
||||||
|
if (genConfig?.vertexai) {
|
||||||
|
const location = process.env['GOOGLE_CLOUD_LOCATION'];
|
||||||
|
if (location) {
|
||||||
|
return { address: `${location}-aiplatform.googleapis.com`, port: 443 };
|
||||||
|
} else {
|
||||||
|
return { address: 'unknown', port: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Default to the public Gemini API endpoint.
|
||||||
|
// This is used when an API key is provided but not for Vertex AI.
|
||||||
|
return { address: `generativelanguage.googleapis.com`, port: 443 };
|
||||||
|
}
|
||||||
|
|
||||||
private _logApiResponse(
|
private _logApiResponse(
|
||||||
|
requestContents: Content[],
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
model: string,
|
model: string,
|
||||||
prompt_id: string,
|
prompt_id: string,
|
||||||
|
responseId: string | undefined,
|
||||||
|
responseCandidates?: Candidate[],
|
||||||
usageMetadata?: GenerateContentResponseUsageMetadata,
|
usageMetadata?: GenerateContentResponseUsageMetadata,
|
||||||
responseText?: string,
|
responseText?: string,
|
||||||
|
generationConfig?: GenerateContentConfig,
|
||||||
|
serverDetails?: ServerDetails,
|
||||||
): void {
|
): void {
|
||||||
logApiResponse(
|
logApiResponse(
|
||||||
this.config,
|
this.config,
|
||||||
new ApiResponseEvent(
|
new ApiResponseEvent(
|
||||||
model,
|
model,
|
||||||
durationMs,
|
durationMs,
|
||||||
prompt_id,
|
{
|
||||||
|
prompt_id,
|
||||||
|
contents: requestContents,
|
||||||
|
generate_content_config: generationConfig,
|
||||||
|
server: serverDetails,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
candidates: responseCandidates,
|
||||||
|
response_id: responseId,
|
||||||
|
},
|
||||||
this.config.getContentGeneratorConfig()?.authType,
|
this.config.getContentGeneratorConfig()?.authType,
|
||||||
usageMetadata,
|
usageMetadata,
|
||||||
responseText,
|
responseText,
|
||||||
@@ -84,6 +135,9 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
error: unknown,
|
error: unknown,
|
||||||
model: string,
|
model: string,
|
||||||
prompt_id: string,
|
prompt_id: string,
|
||||||
|
requestContents: Content[],
|
||||||
|
generationConfig?: GenerateContentConfig,
|
||||||
|
serverDetails?: ServerDetails,
|
||||||
): void {
|
): void {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
const errorType = error instanceof Error ? error.name : 'unknown';
|
const errorType = error instanceof Error ? error.name : 'unknown';
|
||||||
@@ -94,7 +148,12 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
model,
|
model,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
durationMs,
|
durationMs,
|
||||||
prompt_id,
|
{
|
||||||
|
prompt_id,
|
||||||
|
contents: requestContents,
|
||||||
|
generate_content_config: generationConfig,
|
||||||
|
server: serverDetails,
|
||||||
|
},
|
||||||
this.config.getContentGeneratorConfig()?.authType,
|
this.config.getContentGeneratorConfig()?.authType,
|
||||||
errorType,
|
errorType,
|
||||||
isStructuredError(error)
|
isStructuredError(error)
|
||||||
@@ -116,7 +175,9 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
spanMetadata.input = { request: req, userPromptId, model: req.model };
|
spanMetadata.input = { request: req, userPromptId, model: req.model };
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const contents: Content[] = toContents(req.contents);
|
||||||
this.logApiRequest(toContents(req.contents), req.model, userPromptId);
|
this.logApiRequest(toContents(req.contents), req.model, userPromptId);
|
||||||
|
const serverDetails = this._getEndpointUrl(req, 'generateContent');
|
||||||
try {
|
try {
|
||||||
const response = await this.wrapped.generateContent(
|
const response = await this.wrapped.generateContent(
|
||||||
req,
|
req,
|
||||||
@@ -128,16 +189,29 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
};
|
};
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
this._logApiResponse(
|
this._logApiResponse(
|
||||||
|
contents,
|
||||||
durationMs,
|
durationMs,
|
||||||
response.modelVersion || req.model,
|
response.modelVersion || req.model,
|
||||||
userPromptId,
|
userPromptId,
|
||||||
|
response.responseId,
|
||||||
|
response.candidates,
|
||||||
response.usageMetadata,
|
response.usageMetadata,
|
||||||
JSON.stringify(response),
|
JSON.stringify(response),
|
||||||
|
req.config,
|
||||||
|
serverDetails,
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
this._logApiError(durationMs, error, req.model, userPromptId);
|
this._logApiError(
|
||||||
|
durationMs,
|
||||||
|
error,
|
||||||
|
req.model,
|
||||||
|
userPromptId,
|
||||||
|
contents,
|
||||||
|
req.config,
|
||||||
|
serverDetails,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -157,21 +231,33 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
spanMetadata.input = { request: req, userPromptId, model: req.model };
|
spanMetadata.input = { request: req, userPromptId, model: req.model };
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
this.logApiRequest(toContents(req.contents), req.model, userPromptId);
|
this.logApiRequest(toContents(req.contents), req.model, userPromptId);
|
||||||
|
const serverDetails = this._getEndpointUrl(
|
||||||
|
req,
|
||||||
|
'generateContentStream',
|
||||||
|
);
|
||||||
|
|
||||||
let stream: AsyncGenerator<GenerateContentResponse>;
|
let stream: AsyncGenerator<GenerateContentResponse>;
|
||||||
try {
|
try {
|
||||||
stream = await this.wrapped.generateContentStream(req, userPromptId);
|
stream = await this.wrapped.generateContentStream(req, userPromptId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
this._logApiError(durationMs, error, req.model, userPromptId);
|
this._logApiError(
|
||||||
|
durationMs,
|
||||||
|
error,
|
||||||
|
req.model,
|
||||||
|
userPromptId,
|
||||||
|
toContents(req.contents),
|
||||||
|
req.config,
|
||||||
|
serverDetails,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.loggingStreamWrapper(
|
return this.loggingStreamWrapper(
|
||||||
|
req,
|
||||||
stream,
|
stream,
|
||||||
startTime,
|
startTime,
|
||||||
userPromptId,
|
userPromptId,
|
||||||
req.model,
|
|
||||||
spanMetadata,
|
spanMetadata,
|
||||||
endSpan,
|
endSpan,
|
||||||
);
|
);
|
||||||
@@ -180,16 +266,18 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async *loggingStreamWrapper(
|
private async *loggingStreamWrapper(
|
||||||
|
req: GenerateContentParameters,
|
||||||
stream: AsyncGenerator<GenerateContentResponse>,
|
stream: AsyncGenerator<GenerateContentResponse>,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
userPromptId: string,
|
userPromptId: string,
|
||||||
model: string,
|
|
||||||
spanMetadata: SpanMetadata,
|
spanMetadata: SpanMetadata,
|
||||||
endSpan: () => void,
|
endSpan: () => void,
|
||||||
): AsyncGenerator<GenerateContentResponse> {
|
): AsyncGenerator<GenerateContentResponse> {
|
||||||
const responses: GenerateContentResponse[] = [];
|
const responses: GenerateContentResponse[] = [];
|
||||||
|
|
||||||
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined;
|
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined;
|
||||||
|
const serverDetails = this._getEndpointUrl(req, 'generateContentStream');
|
||||||
|
const requestContents: Content[] = toContents(req.contents);
|
||||||
try {
|
try {
|
||||||
for await (const response of stream) {
|
for await (const response of stream) {
|
||||||
responses.push(response);
|
responses.push(response);
|
||||||
@@ -201,11 +289,16 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
// Only log successful API response if no error occurred
|
// Only log successful API response if no error occurred
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
this._logApiResponse(
|
this._logApiResponse(
|
||||||
|
requestContents,
|
||||||
durationMs,
|
durationMs,
|
||||||
responses[0]?.modelVersion || model,
|
responses[0]?.modelVersion || req.model,
|
||||||
userPromptId,
|
userPromptId,
|
||||||
|
responses[0]?.responseId,
|
||||||
|
responses.flatMap((response) => response.candidates || []),
|
||||||
lastUsageMetadata,
|
lastUsageMetadata,
|
||||||
JSON.stringify(responses),
|
JSON.stringify(responses),
|
||||||
|
req.config,
|
||||||
|
serverDetails,
|
||||||
);
|
);
|
||||||
spanMetadata.output = {
|
spanMetadata.output = {
|
||||||
streamChunks: responses.map((r) => ({
|
streamChunks: responses.map((r) => ({
|
||||||
@@ -220,8 +313,11 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
this._logApiError(
|
this._logApiError(
|
||||||
durationMs,
|
durationMs,
|
||||||
error,
|
error,
|
||||||
responses[0]?.modelVersion || model,
|
responses[0]?.modelVersion || req.model,
|
||||||
userPromptId,
|
userPromptId,
|
||||||
|
requestContents,
|
||||||
|
req.config,
|
||||||
|
serverDetails,
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -634,27 +634,27 @@ export class ClearcutLogger {
|
|||||||
{
|
{
|
||||||
gemini_cli_key:
|
gemini_cli_key:
|
||||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_INPUT_TOKEN_COUNT,
|
EventMetadataKey.GEMINI_CLI_API_RESPONSE_INPUT_TOKEN_COUNT,
|
||||||
value: JSON.stringify(event.input_token_count),
|
value: JSON.stringify(event.usage.input_token_count),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
gemini_cli_key:
|
gemini_cli_key:
|
||||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_OUTPUT_TOKEN_COUNT,
|
EventMetadataKey.GEMINI_CLI_API_RESPONSE_OUTPUT_TOKEN_COUNT,
|
||||||
value: JSON.stringify(event.output_token_count),
|
value: JSON.stringify(event.usage.output_token_count),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
gemini_cli_key:
|
gemini_cli_key:
|
||||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CACHED_TOKEN_COUNT,
|
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CACHED_TOKEN_COUNT,
|
||||||
value: JSON.stringify(event.cached_content_token_count),
|
value: JSON.stringify(event.usage.cached_content_token_count),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
gemini_cli_key:
|
gemini_cli_key:
|
||||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_THINKING_TOKEN_COUNT,
|
EventMetadataKey.GEMINI_CLI_API_RESPONSE_THINKING_TOKEN_COUNT,
|
||||||
value: JSON.stringify(event.thoughts_token_count),
|
value: JSON.stringify(event.usage.thoughts_token_count),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
gemini_cli_key:
|
gemini_cli_key:
|
||||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_TOOL_TOKEN_COUNT,
|
EventMetadataKey.GEMINI_CLI_API_RESPONSE_TOOL_TOKEN_COUNT,
|
||||||
value: JSON.stringify(event.tool_token_count),
|
value: JSON.stringify(event.usage.tool_token_count),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import {
|
|||||||
} from '../index.js';
|
} from '../index.js';
|
||||||
import { OutputFormat } from '../output/types.js';
|
import { OutputFormat } from '../output/types.js';
|
||||||
import { logs } from '@opentelemetry/api-logs';
|
import { logs } from '@opentelemetry/api-logs';
|
||||||
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
|
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import {
|
import {
|
||||||
|
logApiError,
|
||||||
logApiRequest,
|
logApiRequest,
|
||||||
logApiResponse,
|
logApiResponse,
|
||||||
logCliConfiguration,
|
logCliConfiguration,
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
} from './loggers.js';
|
} from './loggers.js';
|
||||||
import { ToolCallDecision } from './tool-call-decision.js';
|
import { ToolCallDecision } from './tool-call-decision.js';
|
||||||
import {
|
import {
|
||||||
|
EVENT_API_ERROR,
|
||||||
EVENT_API_REQUEST,
|
EVENT_API_REQUEST,
|
||||||
EVENT_API_RESPONSE,
|
EVENT_API_RESPONSE,
|
||||||
EVENT_CLI_CONFIG,
|
EVENT_CLI_CONFIG,
|
||||||
@@ -64,6 +65,7 @@ import {
|
|||||||
EVENT_AGENT_START,
|
EVENT_AGENT_START,
|
||||||
EVENT_AGENT_FINISH,
|
EVENT_AGENT_FINISH,
|
||||||
EVENT_WEB_FETCH_FALLBACK_ATTEMPT,
|
EVENT_WEB_FETCH_FALLBACK_ATTEMPT,
|
||||||
|
ApiErrorEvent,
|
||||||
ApiRequestEvent,
|
ApiRequestEvent,
|
||||||
ApiResponseEvent,
|
ApiResponseEvent,
|
||||||
StartSessionEvent,
|
StartSessionEvent,
|
||||||
@@ -87,16 +89,13 @@ import {
|
|||||||
EVENT_EXTENSION_UPDATE,
|
EVENT_EXTENSION_UPDATE,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import * as metrics from './metrics.js';
|
import * as metrics from './metrics.js';
|
||||||
import {
|
import { FileOperation } from './metrics.js';
|
||||||
FileOperation,
|
|
||||||
GenAiOperationName,
|
|
||||||
GenAiProviderName,
|
|
||||||
} from './metrics.js';
|
|
||||||
import * as sdk from './sdk.js';
|
import * as sdk from './sdk.js';
|
||||||
import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest';
|
import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest';
|
||||||
import type {
|
import {
|
||||||
CallableTool,
|
FinishReason,
|
||||||
GenerateContentResponseUsageMetadata,
|
type CallableTool,
|
||||||
|
type GenerateContentResponseUsageMetadata,
|
||||||
} from '@google/genai';
|
} from '@google/genai';
|
||||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||||
import * as uiTelemetry from './uiTelemetry.js';
|
import * as uiTelemetry from './uiTelemetry.js';
|
||||||
@@ -316,12 +315,6 @@ describe('loggers', () => {
|
|||||||
const mockMetrics = {
|
const mockMetrics = {
|
||||||
recordApiResponseMetrics: vi.fn(),
|
recordApiResponseMetrics: vi.fn(),
|
||||||
recordTokenUsageMetrics: vi.fn(),
|
recordTokenUsageMetrics: vi.fn(),
|
||||||
getConventionAttributes: vi.fn(() => ({
|
|
||||||
'gen_ai.operation.name': GenAiOperationName.GENERATE_CONTENT,
|
|
||||||
'gen_ai.provider.name': GenAiProviderName.GCP_VERTEX_AI,
|
|
||||||
'gen_ai.request.model': 'test-model',
|
|
||||||
'gen_ai.response.model': 'test-model',
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -331,9 +324,6 @@ describe('loggers', () => {
|
|||||||
vi.spyOn(metrics, 'recordTokenUsageMetrics').mockImplementation(
|
vi.spyOn(metrics, 'recordTokenUsageMetrics').mockImplementation(
|
||||||
mockMetrics.recordTokenUsageMetrics,
|
mockMetrics.recordTokenUsageMetrics,
|
||||||
);
|
);
|
||||||
vi.spyOn(metrics, 'getConventionAttributes').mockImplementation(
|
|
||||||
mockMetrics.getConventionAttributes,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an API response with all fields', () => {
|
it('should log an API response with all fields', () => {
|
||||||
@@ -347,7 +337,47 @@ describe('loggers', () => {
|
|||||||
const event = new ApiResponseEvent(
|
const event = new ApiResponseEvent(
|
||||||
'test-model',
|
'test-model',
|
||||||
100,
|
100,
|
||||||
'prompt-id-1',
|
{
|
||||||
|
prompt_id: 'prompt-id-1',
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'Hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generate_content_config: {
|
||||||
|
temperature: 1,
|
||||||
|
topP: 2,
|
||||||
|
topK: 3,
|
||||||
|
responseMimeType: 'text/plain',
|
||||||
|
candidateCount: 1,
|
||||||
|
seed: 678,
|
||||||
|
frequencyPenalty: 10,
|
||||||
|
maxOutputTokens: 8000,
|
||||||
|
presencePenalty: 6,
|
||||||
|
stopSequences: ['stop', 'please stop'],
|
||||||
|
systemInstruction: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'be nice' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
address: 'foo.com',
|
||||||
|
port: 8080,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response_id: '',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'candidate 1' }],
|
||||||
|
},
|
||||||
|
finishReason: FinishReason.STOP,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
usageData,
|
usageData,
|
||||||
'test-response',
|
'test-response',
|
||||||
@@ -357,26 +387,40 @@ describe('loggers', () => {
|
|||||||
|
|
||||||
expect(mockLogger.emit).toHaveBeenCalledWith({
|
expect(mockLogger.emit).toHaveBeenCalledWith({
|
||||||
body: 'API response from test-model. Status: 200. Duration: 100ms.',
|
body: 'API response from test-model. Status: 200. Duration: 100ms.',
|
||||||
attributes: {
|
attributes: expect.objectContaining({
|
||||||
'session.id': 'test-session-id',
|
|
||||||
'user.email': 'test-user@example.com',
|
|
||||||
'installation.id': 'test-installation-id',
|
|
||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
'event.timestamp': '2025-01-01T00:00:00.000Z',
|
|
||||||
[SemanticAttributes.HTTP_STATUS_CODE]: 200,
|
|
||||||
model: 'test-model',
|
|
||||||
status_code: 200,
|
|
||||||
duration_ms: 100,
|
|
||||||
input_token_count: 17,
|
|
||||||
output_token_count: 50,
|
|
||||||
cached_content_token_count: 10,
|
|
||||||
thoughts_token_count: 5,
|
|
||||||
tool_token_count: 2,
|
|
||||||
total_token_count: 0,
|
|
||||||
response_text: 'test-response',
|
|
||||||
prompt_id: 'prompt-id-1',
|
prompt_id: 'prompt-id-1',
|
||||||
auth_type: 'oauth-personal',
|
}),
|
||||||
},
|
});
|
||||||
|
|
||||||
|
expect(mockLogger.emit).toHaveBeenCalledWith({
|
||||||
|
body: 'GenAI operation details from test-model. Status: 200. Duration: 100ms.',
|
||||||
|
attributes: expect.objectContaining({
|
||||||
|
'event.name': 'gen_ai.client.inference.operation.details',
|
||||||
|
'gen_ai.request.model': 'test-model',
|
||||||
|
'gen_ai.request.temperature': 1,
|
||||||
|
'gen_ai.request.top_p': 2,
|
||||||
|
'gen_ai.request.top_k': 3,
|
||||||
|
'gen_ai.input.messages':
|
||||||
|
'[{"role":"user","parts":[{"type":"text","content":"Hello"}]}]',
|
||||||
|
'gen_ai.output.messages':
|
||||||
|
'[{"finish_reason":"stop","role":"system","parts":[{"type":"text","content":"candidate 1"}]}]',
|
||||||
|
'gen_ai.response.finish_reasons': ['stop'],
|
||||||
|
'gen_ai.response.model': 'test-model',
|
||||||
|
'gen_ai.usage.input_tokens': 17,
|
||||||
|
'gen_ai.usage.output_tokens': 50,
|
||||||
|
'gen_ai.operation.name': 'generate_content',
|
||||||
|
'gen_ai.output.type': 'text',
|
||||||
|
'gen_ai.request.choice.count': 1,
|
||||||
|
'gen_ai.request.seed': 678,
|
||||||
|
'gen_ai.request.frequency_penalty': 10,
|
||||||
|
'gen_ai.request.presence_penalty': 6,
|
||||||
|
'gen_ai.request.max_tokens': 8000,
|
||||||
|
'server.address': 'foo.com',
|
||||||
|
'server.port': 8080,
|
||||||
|
'gen_ai.request.stop_sequences': ['stop', 'please stop'],
|
||||||
|
'gen_ai.system_instructions': '[{"type":"text","content":"be nice"}]',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockMetrics.recordApiResponseMetrics).toHaveBeenCalledWith(
|
expect(mockMetrics.recordApiResponseMetrics).toHaveBeenCalledWith(
|
||||||
@@ -433,6 +477,137 @@ describe('loggers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('logApiError', () => {
|
||||||
|
const mockConfig = {
|
||||||
|
getSessionId: () => 'test-session-id',
|
||||||
|
getTargetDir: () => 'target-dir',
|
||||||
|
getUsageStatisticsEnabled: () => true,
|
||||||
|
getTelemetryEnabled: () => true,
|
||||||
|
getTelemetryLogPromptsEnabled: () => true,
|
||||||
|
} as Config;
|
||||||
|
|
||||||
|
const mockMetrics = {
|
||||||
|
recordApiResponseMetrics: vi.fn(),
|
||||||
|
recordApiErrorMetrics: vi.fn(),
|
||||||
|
recordTokenUsageMetrics: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(metrics, 'recordApiResponseMetrics').mockImplementation(
|
||||||
|
mockMetrics.recordApiResponseMetrics,
|
||||||
|
);
|
||||||
|
vi.spyOn(metrics, 'recordApiErrorMetrics').mockImplementation(
|
||||||
|
mockMetrics.recordApiErrorMetrics,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log an API error with all fields', () => {
|
||||||
|
const event = new ApiErrorEvent(
|
||||||
|
'test-model',
|
||||||
|
'UNAVAILABLE. {"error":{"code":503,"message":"The model is overloaded. Please try again later.","status":"UNAVAILABLE"}}',
|
||||||
|
100,
|
||||||
|
{
|
||||||
|
prompt_id: 'prompt-id-1',
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'Hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generate_content_config: {
|
||||||
|
temperature: 1,
|
||||||
|
topP: 2,
|
||||||
|
topK: 3,
|
||||||
|
responseMimeType: 'text/plain',
|
||||||
|
candidateCount: 1,
|
||||||
|
seed: 678,
|
||||||
|
frequencyPenalty: 10,
|
||||||
|
maxOutputTokens: 8000,
|
||||||
|
presencePenalty: 6,
|
||||||
|
stopSequences: ['stop', 'please stop'],
|
||||||
|
systemInstruction: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'be nice' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
address: 'foo.com',
|
||||||
|
port: 8080,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
|
'ApiError',
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
|
||||||
|
logApiError(mockConfig, event);
|
||||||
|
|
||||||
|
expect(mockLogger.emit).toHaveBeenCalledWith({
|
||||||
|
body: 'API error for test-model. Error: UNAVAILABLE. {"error":{"code":503,"message":"The model is overloaded. Please try again later.","status":"UNAVAILABLE"}}. Duration: 100ms.',
|
||||||
|
attributes: expect.objectContaining({
|
||||||
|
'event.name': EVENT_API_ERROR,
|
||||||
|
prompt_id: 'prompt-id-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLogger.emit).toHaveBeenCalledWith({
|
||||||
|
body: 'GenAI operation error details from test-model. Error: UNAVAILABLE. {"error":{"code":503,"message":"The model is overloaded. Please try again later.","status":"UNAVAILABLE"}}. Duration: 100ms.',
|
||||||
|
attributes: expect.objectContaining({
|
||||||
|
'event.name': 'gen_ai.client.inference.operation.details',
|
||||||
|
'gen_ai.request.model': 'test-model',
|
||||||
|
'gen_ai.request.temperature': 1,
|
||||||
|
'gen_ai.request.top_p': 2,
|
||||||
|
'gen_ai.request.top_k': 3,
|
||||||
|
'gen_ai.input.messages':
|
||||||
|
'[{"role":"user","parts":[{"type":"text","content":"Hello"}]}]',
|
||||||
|
'gen_ai.operation.name': 'generate_content',
|
||||||
|
'gen_ai.output.type': 'text',
|
||||||
|
'gen_ai.request.choice.count': 1,
|
||||||
|
'gen_ai.request.seed': 678,
|
||||||
|
'gen_ai.request.frequency_penalty': 10,
|
||||||
|
'gen_ai.request.presence_penalty': 6,
|
||||||
|
'gen_ai.request.max_tokens': 8000,
|
||||||
|
'server.address': 'foo.com',
|
||||||
|
'server.port': 8080,
|
||||||
|
'gen_ai.request.stop_sequences': ['stop', 'please stop'],
|
||||||
|
'gen_ai.system_instructions': '[{"type":"text","content":"be nice"}]',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockMetrics.recordApiErrorMetrics).toHaveBeenCalledWith(
|
||||||
|
mockConfig,
|
||||||
|
100,
|
||||||
|
{
|
||||||
|
model: 'test-model',
|
||||||
|
status_code: 503,
|
||||||
|
error_type: 'ApiError',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockMetrics.recordApiResponseMetrics).toHaveBeenCalledWith(
|
||||||
|
mockConfig,
|
||||||
|
100,
|
||||||
|
{
|
||||||
|
model: 'test-model',
|
||||||
|
status_code: 503,
|
||||||
|
genAiAttributes: {
|
||||||
|
'gen_ai.operation.name': 'generate_content',
|
||||||
|
'gen_ai.provider.name': 'gcp.vertex_ai',
|
||||||
|
'gen_ai.request.model': 'test-model',
|
||||||
|
'gen_ai.response.model': 'test-model',
|
||||||
|
'error.type': 'ApiError',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockUiEvent.addEvent).toHaveBeenCalledWith({
|
||||||
|
...event,
|
||||||
|
'event.name': EVENT_API_ERROR,
|
||||||
|
'event.timestamp': '2025-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('logApiRequest', () => {
|
describe('logApiRequest', () => {
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
getSessionId: () => 'test-session-id',
|
getSessionId: () => 'test-session-id',
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export function logUserPrompt(config: Config, event: UserPromptEvent): void {
|
|||||||
if (!isTelemetrySdkInitialized()) return;
|
if (!isTelemetrySdkInitialized()) return;
|
||||||
|
|
||||||
const logger = logs.getLogger(SERVICE_NAME);
|
const logger = logs.getLogger(SERVICE_NAME);
|
||||||
|
|
||||||
const logRecord: LogRecord = {
|
const logRecord: LogRecord = {
|
||||||
body: event.toLogBody(),
|
body: event.toLogBody(),
|
||||||
attributes: event.toOpenTelemetryAttributes(config),
|
attributes: event.toOpenTelemetryAttributes(config),
|
||||||
@@ -219,11 +220,9 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
|
|||||||
if (!isTelemetrySdkInitialized()) return;
|
if (!isTelemetrySdkInitialized()) return;
|
||||||
|
|
||||||
const logger = logs.getLogger(SERVICE_NAME);
|
const logger = logs.getLogger(SERVICE_NAME);
|
||||||
const logRecord: LogRecord = {
|
logger.emit(event.toLogRecord(config));
|
||||||
body: event.toLogBody(),
|
logger.emit(event.toSemanticLogRecord(config));
|
||||||
attributes: event.toOpenTelemetryAttributes(config),
|
|
||||||
};
|
|
||||||
logger.emit(logRecord);
|
|
||||||
recordApiErrorMetrics(config, event.duration_ms, {
|
recordApiErrorMetrics(config, event.duration_ms, {
|
||||||
model: event.model,
|
model: event.model,
|
||||||
status_code: event.status_code,
|
status_code: event.status_code,
|
||||||
@@ -231,12 +230,11 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Record GenAI operation duration for errors
|
// Record GenAI operation duration for errors
|
||||||
const conventionAttributes = getConventionAttributes(event);
|
|
||||||
recordApiResponseMetrics(config, event.duration_ms, {
|
recordApiResponseMetrics(config, event.duration_ms, {
|
||||||
model: event.model,
|
model: event.model,
|
||||||
status_code: event.status_code,
|
status_code: event.status_code,
|
||||||
genAiAttributes: {
|
genAiAttributes: {
|
||||||
...conventionAttributes,
|
...getConventionAttributes(event),
|
||||||
'error.type': event.error_type || 'unknown',
|
'error.type': event.error_type || 'unknown',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -253,11 +251,8 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void {
|
|||||||
if (!isTelemetrySdkInitialized()) return;
|
if (!isTelemetrySdkInitialized()) return;
|
||||||
|
|
||||||
const logger = logs.getLogger(SERVICE_NAME);
|
const logger = logs.getLogger(SERVICE_NAME);
|
||||||
const logRecord: LogRecord = {
|
logger.emit(event.toLogRecord(config));
|
||||||
body: event.toLogBody(),
|
logger.emit(event.toSemanticLogRecord(config));
|
||||||
attributes: event.toOpenTelemetryAttributes(config),
|
|
||||||
};
|
|
||||||
logger.emit(logRecord);
|
|
||||||
|
|
||||||
const conventionAttributes = getConventionAttributes(event);
|
const conventionAttributes = getConventionAttributes(event);
|
||||||
|
|
||||||
@@ -268,11 +263,11 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tokenUsageData = [
|
const tokenUsageData = [
|
||||||
{ count: event.input_token_count, type: 'input' as const },
|
{ count: event.usage.input_token_count, type: 'input' as const },
|
||||||
{ count: event.output_token_count, type: 'output' as const },
|
{ count: event.usage.output_token_count, type: 'output' as const },
|
||||||
{ count: event.cached_content_token_count, type: 'cache' as const },
|
{ count: event.usage.cached_content_token_count, type: 'cache' as const },
|
||||||
{ count: event.thoughts_token_count, type: 'thought' as const },
|
{ count: event.usage.thoughts_token_count, type: 'thought' as const },
|
||||||
{ count: event.tool_token_count, type: 'tool' as const },
|
{ count: event.usage.tool_token_count, type: 'tool' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { count, type } of tokenUsageData) {
|
for (const { count, type } of tokenUsageData) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import type { Attributes, Meter, Counter, Histogram } from '@opentelemetry/api';
|
import type { Attributes, Meter, Counter, Histogram } from '@opentelemetry/api';
|
||||||
import { diag, metrics, ValueType } from '@opentelemetry/api';
|
import { diag, metrics, ValueType } from '@opentelemetry/api';
|
||||||
import { SERVICE_NAME } from './constants.js';
|
import { SERVICE_NAME } from './constants.js';
|
||||||
import { EVENT_CHAT_COMPRESSION } from './types.js';
|
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import type {
|
import type {
|
||||||
ModelRoutingEvent,
|
ModelRoutingEvent,
|
||||||
@@ -17,6 +16,7 @@ import type {
|
|||||||
import { AuthType } from '../core/contentGenerator.js';
|
import { AuthType } from '../core/contentGenerator.js';
|
||||||
import { getCommonAttributes } from './telemetryAttributes.js';
|
import { getCommonAttributes } from './telemetryAttributes.js';
|
||||||
|
|
||||||
|
const EVENT_CHAT_COMPRESSION = 'gemini_cli.chat_compression';
|
||||||
const TOOL_CALL_COUNT = 'gemini_cli.tool.call.count';
|
const TOOL_CALL_COUNT = 'gemini_cli.tool.call.count';
|
||||||
const TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency';
|
const TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency';
|
||||||
const API_REQUEST_COUNT = 'gemini_cli.api.request.count';
|
const API_REQUEST_COUNT = 'gemini_cli.api.request.count';
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
toChatMessage,
|
||||||
|
toInputMessages,
|
||||||
|
toSystemInstruction,
|
||||||
|
toOutputMessages,
|
||||||
|
toFinishReasons,
|
||||||
|
OTelFinishReason,
|
||||||
|
toOutputType,
|
||||||
|
OTelOutputType,
|
||||||
|
} from './semantic.js';
|
||||||
|
import {
|
||||||
|
Language,
|
||||||
|
type Content,
|
||||||
|
Outcome,
|
||||||
|
type Candidate,
|
||||||
|
FinishReason,
|
||||||
|
} from '@google/genai';
|
||||||
|
|
||||||
|
describe('toChatMessage', () => {
|
||||||
|
it('should correctly handle text parts', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'Hello' }],
|
||||||
|
};
|
||||||
|
expect(toChatMessage(content)).toEqual({
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
content: 'Hello',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle function call parts', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
name: 'test-function',
|
||||||
|
args: {
|
||||||
|
arg1: 'test-value',
|
||||||
|
},
|
||||||
|
id: '12345',
|
||||||
|
},
|
||||||
|
// include field not specified in semantic specification that could be present
|
||||||
|
thoughtSignature: '1234',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(toChatMessage(content)).toEqual({
|
||||||
|
role: 'system',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'tool_call',
|
||||||
|
name: 'test-function',
|
||||||
|
arguments: '{"arg1":"test-value"}',
|
||||||
|
id: '12345',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle function response parts', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: 'test-function',
|
||||||
|
response: {
|
||||||
|
result: 'success',
|
||||||
|
},
|
||||||
|
id: '12345',
|
||||||
|
},
|
||||||
|
// include field not specified in semantic specification that could be present
|
||||||
|
fileData: {
|
||||||
|
displayName: 'greatfile',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(toChatMessage(content)).toEqual({
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'tool_call_response',
|
||||||
|
response: '{"result":"success"}',
|
||||||
|
id: '12345',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle reasoning parts with text', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'system',
|
||||||
|
parts: [{ text: 'Hmm', thought: true }],
|
||||||
|
};
|
||||||
|
expect(toChatMessage(content)).toEqual({
|
||||||
|
role: 'system',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'reasoning',
|
||||||
|
content: 'Hmm',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle reasoning parts without text', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'system',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
thought: true,
|
||||||
|
// include field not specified in semantic specification that could be present
|
||||||
|
inlineData: {
|
||||||
|
displayName: 'wowdata',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(toChatMessage(content)).toEqual({
|
||||||
|
role: 'system',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'reasoning',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle text parts that are not reasoning parts', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'what a nice day', thought: false }],
|
||||||
|
};
|
||||||
|
expect(toChatMessage(content)).toEqual({
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
content: 'what a nice day',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle "generic" parts', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
executableCode: {
|
||||||
|
code: 'print("foo")',
|
||||||
|
language: Language.PYTHON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
codeExecutionResult: {
|
||||||
|
outcome: Outcome.OUTCOME_OK,
|
||||||
|
output: 'foo',
|
||||||
|
},
|
||||||
|
// include field not specified in semantic specification that could be present
|
||||||
|
videoMetadata: {
|
||||||
|
fps: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(toChatMessage(content)).toEqual({
|
||||||
|
role: 'system',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'executableCode',
|
||||||
|
code: 'print("foo")',
|
||||||
|
language: 'PYTHON',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'codeExecutionResult',
|
||||||
|
outcome: 'OUTCOME_OK',
|
||||||
|
output: 'foo',
|
||||||
|
videoMetadata: {
|
||||||
|
fps: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle unknown parts', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
fileData: {
|
||||||
|
displayName: 'superfile',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(toChatMessage(content)).toEqual({
|
||||||
|
role: 'system',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'unknown',
|
||||||
|
fileData: {
|
||||||
|
displayName: 'superfile',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toSystemInstruction', () => {
|
||||||
|
it('should correctly handle a string', () => {
|
||||||
|
const content = 'Hello';
|
||||||
|
expect(toSystemInstruction(content)).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
content: 'Hello',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle a Content object with a text part', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'Hello' }],
|
||||||
|
};
|
||||||
|
expect(toSystemInstruction(content)).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
content: 'Hello',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle a Content object with multiple parts', () => {
|
||||||
|
const content: Content = {
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'Hello' }, { text: 'Hmm', thought: true }],
|
||||||
|
};
|
||||||
|
expect(toSystemInstruction(content)).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
content: 'Hello',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'reasoning',
|
||||||
|
content: 'Hmm',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toInputMessages', () => {
|
||||||
|
it('should correctly convert an array of Content objects', () => {
|
||||||
|
const contents: Content[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'Hello' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'Hi there!' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(toInputMessages(contents)).toEqual([
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
content: 'Hello',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
content: 'Hi there!',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toOutputMessages', () => {
|
||||||
|
it('should correctly convert an array of Candidate objects', () => {
|
||||||
|
const candidates: Candidate[] = [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: FinishReason.STOP,
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'This is the first candidate.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finishReason: FinishReason.MAX_TOKENS,
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'This is the second candidate.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(toOutputMessages(candidates)).toEqual([
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
finish_reason: 'stop',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
content: 'This is the first candidate.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
finish_reason: 'length',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
content: 'This is the second candidate.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toFinishReasons', () => {
|
||||||
|
it('should return an empty array if candidates is undefined', () => {
|
||||||
|
expect(toFinishReasons(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty array if candidates is an empty array', () => {
|
||||||
|
expect(toFinishReasons([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly convert a single candidate', () => {
|
||||||
|
const candidates: Candidate[] = [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: FinishReason.STOP,
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'This is the first candidate.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(toFinishReasons(candidates)).toEqual([OTelFinishReason.STOP]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly convert multiple candidates', () => {
|
||||||
|
const candidates: Candidate[] = [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: FinishReason.STOP,
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'This is the first candidate.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
finishReason: FinishReason.MAX_TOKENS,
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'This is the second candidate.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
finishReason: FinishReason.SAFETY,
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'This is the third candidate.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(toFinishReasons(candidates)).toEqual([
|
||||||
|
OTelFinishReason.STOP,
|
||||||
|
OTelFinishReason.LENGTH,
|
||||||
|
OTelFinishReason.CONTENT_FILTER,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toOutputType', () => {
|
||||||
|
it('should return TEXT for text/plain', () => {
|
||||||
|
expect(toOutputType('text/plain')).toBe(OTelOutputType.TEXT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return JSON for application/json', () => {
|
||||||
|
expect(toOutputType('application/json')).toBe(OTelOutputType.JSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the custom mime type for other strings', () => {
|
||||||
|
expect(toOutputType('application/vnd.custom-type')).toBe(
|
||||||
|
'application/vnd.custom-type',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for undefined input', () => {
|
||||||
|
expect(toOutputType(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This file contains functions and types for converting Gemini API request/response
|
||||||
|
* formats to the OpenTelemetry semantic conventions for generative AI.
|
||||||
|
*
|
||||||
|
* @see https://github.com/open-telemetry/semantic-conventions/blob/8b4f210f43136e57c1f6f47292eb6d38e3bf30bb/docs/gen-ai/gen-ai-events.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FinishReason } from '@google/genai';
|
||||||
|
import type {
|
||||||
|
Candidate,
|
||||||
|
Content,
|
||||||
|
ContentUnion,
|
||||||
|
Part,
|
||||||
|
PartUnion,
|
||||||
|
} from '@google/genai';
|
||||||
|
|
||||||
|
export function toInputMessages(contents: Content[]): InputMessages {
|
||||||
|
const messages: ChatMessage[] = [];
|
||||||
|
for (const content of contents) {
|
||||||
|
messages.push(toChatMessage(content));
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPart(value: unknown): value is Part {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
!('parts' in value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPart(part: PartUnion): Part {
|
||||||
|
if (typeof part === 'string') {
|
||||||
|
return { text: part };
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toContent(content: ContentUnion): Content | undefined {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
// 1. It's a string
|
||||||
|
return {
|
||||||
|
parts: [toPart(content)],
|
||||||
|
};
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
// 2. It's an array of parts (PartUnion[])
|
||||||
|
return {
|
||||||
|
parts: content.map(toPart),
|
||||||
|
};
|
||||||
|
} else if ('parts' in content) {
|
||||||
|
// 3. It's a Content object
|
||||||
|
return content;
|
||||||
|
} else if (isPart(content)) {
|
||||||
|
// 4. It's a single Part object (asserted with type guard)
|
||||||
|
return {
|
||||||
|
parts: [content],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 5. Handle any other unexpected case
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSystemInstruction(
|
||||||
|
systemInstruction?: ContentUnion,
|
||||||
|
): SystemInstruction | undefined {
|
||||||
|
const parts: AnyPart[] = [];
|
||||||
|
if (systemInstruction) {
|
||||||
|
const content = toContent(systemInstruction);
|
||||||
|
if (content && content.parts) {
|
||||||
|
for (const part of content.parts) {
|
||||||
|
parts.push(toOTelPart(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOutputMessages(candidates?: Candidate[]): OutputMessages {
|
||||||
|
const messages: OutputMessage[] = [];
|
||||||
|
if (candidates) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
messages.push({
|
||||||
|
finish_reason: toOTelFinishReason(candidate.finishReason),
|
||||||
|
...toChatMessage(candidate.content),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFinishReasons(candidates?: Candidate[]): OTelFinishReason[] {
|
||||||
|
const reasons: OTelFinishReason[] = [];
|
||||||
|
if (candidates) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
reasons.push(toOTelFinishReason(candidate.finishReason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOutputType(requested_mime?: string): string | undefined {
|
||||||
|
switch (requested_mime) {
|
||||||
|
// explictly support the known good values of responseMimeType
|
||||||
|
case 'text/plain':
|
||||||
|
return OTelOutputType.TEXT;
|
||||||
|
case 'application/json':
|
||||||
|
return OTelOutputType.JSON;
|
||||||
|
default:
|
||||||
|
// if none of the well-known values applies, a custom value may be used
|
||||||
|
return requested_mime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toChatMessage(content?: Content): ChatMessage {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
role: undefined,
|
||||||
|
parts: [],
|
||||||
|
};
|
||||||
|
if (content && content.parts) {
|
||||||
|
message.role = toOTelRole(content.role);
|
||||||
|
for (const part of content.parts) {
|
||||||
|
message.parts.push(toOTelPart(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOTelPart(part: Part): AnyPart {
|
||||||
|
if (part.thought) {
|
||||||
|
if (part.text) {
|
||||||
|
return new ReasoningPart(part.text);
|
||||||
|
} else {
|
||||||
|
return new ReasoningPart('');
|
||||||
|
}
|
||||||
|
} else if (part.text) {
|
||||||
|
return new TextPart(part.text);
|
||||||
|
} else if (part.functionCall) {
|
||||||
|
return new ToolCallRequestPart(
|
||||||
|
part.functionCall.name,
|
||||||
|
part.functionCall.id,
|
||||||
|
JSON.stringify(part.functionCall.args),
|
||||||
|
);
|
||||||
|
} else if (part.functionResponse) {
|
||||||
|
return new ToolCallResponsePart(
|
||||||
|
JSON.stringify(part.functionResponse.response),
|
||||||
|
part.functionResponse.id,
|
||||||
|
);
|
||||||
|
} else if (part.executableCode) {
|
||||||
|
const { executableCode, ...unexpectedData } = part;
|
||||||
|
return new GenericPart('executableCode', {
|
||||||
|
code: executableCode.code,
|
||||||
|
language: executableCode.language,
|
||||||
|
...unexpectedData,
|
||||||
|
});
|
||||||
|
} else if (part.codeExecutionResult) {
|
||||||
|
const { codeExecutionResult, ...unexpectedData } = part;
|
||||||
|
return new GenericPart('codeExecutionResult', {
|
||||||
|
outcome: codeExecutionResult.outcome,
|
||||||
|
output: codeExecutionResult.output,
|
||||||
|
...unexpectedData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Assuming the above cases capture all the expected parts
|
||||||
|
// but adding a fallthrough just in case.
|
||||||
|
return new GenericPart('unknown', { ...part });
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OTelRole {
|
||||||
|
SYSTEM = 'system',
|
||||||
|
USER = 'user',
|
||||||
|
ASSISTANT = 'assistant',
|
||||||
|
TOOL = 'tool',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOTelRole(role?: string): OTelRole {
|
||||||
|
switch (role?.toLowerCase()) {
|
||||||
|
case 'system':
|
||||||
|
return OTelRole.SYSTEM;
|
||||||
|
// Our APIs seem to frequently use 'model'
|
||||||
|
case 'model':
|
||||||
|
return OTelRole.SYSTEM;
|
||||||
|
case 'user':
|
||||||
|
return OTelRole.USER;
|
||||||
|
case 'assistant':
|
||||||
|
return OTelRole.ASSISTANT;
|
||||||
|
case 'tool':
|
||||||
|
return OTelRole.TOOL;
|
||||||
|
default:
|
||||||
|
return OTelRole.SYSTEM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InputMessages = ChatMessage[];
|
||||||
|
|
||||||
|
export enum OTelOutputType {
|
||||||
|
IMAGE = 'image',
|
||||||
|
JSON = 'json',
|
||||||
|
SPEECH = 'speech',
|
||||||
|
TEXT = 'text',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OTelFinishReason {
|
||||||
|
STOP = 'stop',
|
||||||
|
LENGTH = 'length',
|
||||||
|
CONTENT_FILTER = 'content_filter',
|
||||||
|
TOOL_CALL = 'tool_call',
|
||||||
|
ERROR = 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOTelFinishReason(finishReason?: string): OTelFinishReason {
|
||||||
|
switch (finishReason) {
|
||||||
|
// we have significantly more finish reasons than the spec
|
||||||
|
case FinishReason.FINISH_REASON_UNSPECIFIED:
|
||||||
|
return OTelFinishReason.STOP;
|
||||||
|
case FinishReason.STOP:
|
||||||
|
return OTelFinishReason.STOP;
|
||||||
|
case FinishReason.MAX_TOKENS:
|
||||||
|
return OTelFinishReason.LENGTH;
|
||||||
|
case FinishReason.SAFETY:
|
||||||
|
return OTelFinishReason.CONTENT_FILTER;
|
||||||
|
case FinishReason.RECITATION:
|
||||||
|
return OTelFinishReason.CONTENT_FILTER;
|
||||||
|
case FinishReason.LANGUAGE:
|
||||||
|
return OTelFinishReason.CONTENT_FILTER;
|
||||||
|
case FinishReason.OTHER:
|
||||||
|
return OTelFinishReason.STOP;
|
||||||
|
case FinishReason.BLOCKLIST:
|
||||||
|
return OTelFinishReason.CONTENT_FILTER;
|
||||||
|
case FinishReason.PROHIBITED_CONTENT:
|
||||||
|
return OTelFinishReason.CONTENT_FILTER;
|
||||||
|
case FinishReason.SPII:
|
||||||
|
return OTelFinishReason.CONTENT_FILTER;
|
||||||
|
case FinishReason.MALFORMED_FUNCTION_CALL:
|
||||||
|
return OTelFinishReason.ERROR;
|
||||||
|
case FinishReason.IMAGE_SAFETY:
|
||||||
|
return OTelFinishReason.CONTENT_FILTER;
|
||||||
|
case FinishReason.UNEXPECTED_TOOL_CALL:
|
||||||
|
return OTelFinishReason.ERROR;
|
||||||
|
default:
|
||||||
|
return OTelFinishReason.STOP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutputMessage extends ChatMessage {
|
||||||
|
finish_reason: FinishReason | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutputMessages = OutputMessage[];
|
||||||
|
|
||||||
|
export type AnyPart =
|
||||||
|
| TextPart
|
||||||
|
| ToolCallRequestPart
|
||||||
|
| ToolCallResponsePart
|
||||||
|
| ReasoningPart
|
||||||
|
| GenericPart;
|
||||||
|
|
||||||
|
export type SystemInstruction = AnyPart[];
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: string | undefined;
|
||||||
|
parts: AnyPart[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextPart {
|
||||||
|
readonly type = 'text';
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
constructor(content: string) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToolCallRequestPart {
|
||||||
|
readonly type = 'tool_call';
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
arguments?: string;
|
||||||
|
|
||||||
|
constructor(name?: string, id?: string, args?: string) {
|
||||||
|
this.name = name;
|
||||||
|
this.id = id;
|
||||||
|
this.arguments = args;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToolCallResponsePart {
|
||||||
|
readonly type = 'tool_call_response';
|
||||||
|
response?: string;
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
constructor(response?: string, id?: string) {
|
||||||
|
this.response = response;
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReasoningPart {
|
||||||
|
readonly type = 'reasoning';
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
constructor(content: string) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenericPart {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
|
||||||
|
constructor(type: string, data: { [key: string]: unknown }) {
|
||||||
|
this.type = type;
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,19 +4,24 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
import type {
|
||||||
|
Candidate,
|
||||||
|
Content,
|
||||||
|
GenerateContentConfig,
|
||||||
|
GenerateContentResponseUsageMetadata,
|
||||||
|
} from '@google/genai';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import type { ApprovalMode } from '../config/config.js';
|
import type { ApprovalMode } from '../config/config.js';
|
||||||
import type { CompletedToolCall } from '../core/coreToolScheduler.js';
|
import type { CompletedToolCall } from '../core/coreToolScheduler.js';
|
||||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||||
import type { FileDiff } from '../tools/tools.js';
|
import type { FileDiff } from '../tools/tools.js';
|
||||||
import { AuthType } from '../core/contentGenerator.js';
|
import { AuthType } from '../core/contentGenerator.js';
|
||||||
import type { LogAttributes } from '@opentelemetry/api-logs';
|
import type { LogAttributes, LogRecord } from '@opentelemetry/api-logs';
|
||||||
import {
|
import {
|
||||||
getDecisionFromOutcome,
|
getDecisionFromOutcome,
|
||||||
ToolCallDecision,
|
ToolCallDecision,
|
||||||
} from './tool-call-decision.js';
|
} from './tool-call-decision.js';
|
||||||
import type { FileOperation } from './metrics.js';
|
import { getConventionAttributes, type FileOperation } from './metrics.js';
|
||||||
export { ToolCallDecision };
|
export { ToolCallDecision };
|
||||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||||
import type { OutputFormat } from '../output/types.js';
|
import type { OutputFormat } from '../output/types.js';
|
||||||
@@ -25,6 +30,13 @@ import type { AgentTerminateMode } from '../agents/types.js';
|
|||||||
import { getCommonAttributes } from './telemetryAttributes.js';
|
import { getCommonAttributes } from './telemetryAttributes.js';
|
||||||
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
|
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
|
||||||
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
|
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
|
||||||
|
import {
|
||||||
|
toInputMessages,
|
||||||
|
toOutputMessages,
|
||||||
|
toFinishReasons,
|
||||||
|
toOutputType,
|
||||||
|
toSystemInstruction,
|
||||||
|
} from './semantic.js';
|
||||||
|
|
||||||
export interface BaseTelemetryEvent {
|
export interface BaseTelemetryEvent {
|
||||||
'event.name': string;
|
'event.name': string;
|
||||||
@@ -358,18 +370,18 @@ export class ApiErrorEvent implements BaseTelemetryEvent {
|
|||||||
'event.name': 'api_error';
|
'event.name': 'api_error';
|
||||||
'event.timestamp': string;
|
'event.timestamp': string;
|
||||||
model: string;
|
model: string;
|
||||||
|
prompt: GenAIPromptDetails;
|
||||||
error: string;
|
error: string;
|
||||||
error_type?: string;
|
error_type?: string;
|
||||||
status_code?: number | string;
|
status_code?: number | string;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
prompt_id: string;
|
|
||||||
auth_type?: string;
|
auth_type?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
model: string,
|
model: string,
|
||||||
error: string,
|
error: string,
|
||||||
duration_ms: number,
|
duration_ms: number,
|
||||||
prompt_id: string,
|
prompt_details: GenAIPromptDetails,
|
||||||
auth_type?: string,
|
auth_type?: string,
|
||||||
error_type?: string,
|
error_type?: string,
|
||||||
status_code?: number | string,
|
status_code?: number | string,
|
||||||
@@ -381,11 +393,11 @@ export class ApiErrorEvent implements BaseTelemetryEvent {
|
|||||||
this.error_type = error_type;
|
this.error_type = error_type;
|
||||||
this.status_code = status_code;
|
this.status_code = status_code;
|
||||||
this.duration_ms = duration_ms;
|
this.duration_ms = duration_ms;
|
||||||
this.prompt_id = prompt_id;
|
this.prompt = prompt_details;
|
||||||
this.auth_type = auth_type;
|
this.auth_type = auth_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
toLogRecord(config: Config): LogRecord {
|
||||||
const attributes: LogAttributes = {
|
const attributes: LogAttributes = {
|
||||||
...getCommonAttributes(config),
|
...getCommonAttributes(config),
|
||||||
'event.name': EVENT_API_ERROR,
|
'event.name': EVENT_API_ERROR,
|
||||||
@@ -397,7 +409,7 @@ export class ApiErrorEvent implements BaseTelemetryEvent {
|
|||||||
error: this.error,
|
error: this.error,
|
||||||
status_code: this.status_code,
|
status_code: this.status_code,
|
||||||
duration_ms: this.duration_ms,
|
duration_ms: this.duration_ms,
|
||||||
prompt_id: this.prompt_id,
|
prompt_id: this.prompt.prompt_id,
|
||||||
auth_type: this.auth_type,
|
auth_type: this.auth_type,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -407,69 +419,151 @@ export class ApiErrorEvent implements BaseTelemetryEvent {
|
|||||||
if (typeof this.status_code === 'number') {
|
if (typeof this.status_code === 'number') {
|
||||||
attributes[SemanticAttributes.HTTP_STATUS_CODE] = this.status_code;
|
attributes[SemanticAttributes.HTTP_STATUS_CODE] = this.status_code;
|
||||||
}
|
}
|
||||||
return attributes;
|
const logRecord: LogRecord = {
|
||||||
|
body: `API error for ${this.model}. Error: ${this.error}. Duration: ${this.duration_ms}ms.`,
|
||||||
|
attributes,
|
||||||
|
};
|
||||||
|
return logRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
toLogBody(): string {
|
toSemanticLogRecord(config: Config): LogRecord {
|
||||||
return `API error for ${this.model}. Error: ${this.error}. Duration: ${this.duration_ms}ms.`;
|
const attributes: LogAttributes = {
|
||||||
|
...getCommonAttributes(config),
|
||||||
|
'event.name': EVENT_GEN_AI_OPERATION_DETAILS,
|
||||||
|
'event.timestamp': this['event.timestamp'],
|
||||||
|
...toGenerateContentConfigAttributes(this.prompt.generate_content_config),
|
||||||
|
...getConventionAttributes(this),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.prompt.server) {
|
||||||
|
attributes['server.address'] = this.prompt.server.address;
|
||||||
|
attributes['server.port'] = this.prompt.server.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.getTelemetryLogPromptsEnabled() && this.prompt.contents) {
|
||||||
|
attributes['gen_ai.input.messages'] = JSON.stringify(
|
||||||
|
toInputMessages(this.prompt.contents),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logRecord: LogRecord = {
|
||||||
|
body: `GenAI operation error details from ${this.model}. Error: ${this.error}. Duration: ${this.duration_ms}ms.`,
|
||||||
|
attributes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return logRecord;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EVENT_API_RESPONSE = 'gemini_cli.api_response';
|
export interface ServerDetails {
|
||||||
export class ApiResponseEvent implements BaseTelemetryEvent {
|
address: string;
|
||||||
'event.name': 'api_response';
|
port: number;
|
||||||
'event.timestamp': string;
|
}
|
||||||
model: string;
|
|
||||||
status_code?: number | string;
|
export interface GenAIPromptDetails {
|
||||||
duration_ms: number;
|
prompt_id: string;
|
||||||
|
contents: Content[];
|
||||||
|
generate_content_config?: GenerateContentConfig;
|
||||||
|
server?: ServerDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenAIResponseDetails {
|
||||||
|
response_id?: string;
|
||||||
|
candidates?: Candidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenAIUsageDetails {
|
||||||
input_token_count: number;
|
input_token_count: number;
|
||||||
output_token_count: number;
|
output_token_count: number;
|
||||||
cached_content_token_count: number;
|
cached_content_token_count: number;
|
||||||
thoughts_token_count: number;
|
thoughts_token_count: number;
|
||||||
tool_token_count: number;
|
tool_token_count: number;
|
||||||
total_token_count: number;
|
total_token_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EVENT_API_RESPONSE = 'gemini_cli.api_response';
|
||||||
|
export const EVENT_GEN_AI_OPERATION_DETAILS =
|
||||||
|
'gen_ai.client.inference.operation.details';
|
||||||
|
|
||||||
|
function toGenerateContentConfigAttributes(
|
||||||
|
config?: GenerateContentConfig,
|
||||||
|
): LogAttributes {
|
||||||
|
if (!config) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'gen_ai.request.temperature': config.temperature,
|
||||||
|
'gen_ai.request.top_p': config.topP,
|
||||||
|
'gen_ai.request.top_k': config.topK,
|
||||||
|
'gen_ai.request.choice.count': config.candidateCount,
|
||||||
|
'gen_ai.request.seed': config.seed,
|
||||||
|
'gen_ai.request.frequency_penalty': config.frequencyPenalty,
|
||||||
|
'gen_ai.request.presence_penalty': config.presencePenalty,
|
||||||
|
'gen_ai.request.max_tokens': config.maxOutputTokens,
|
||||||
|
'gen_ai.output.type': toOutputType(config.responseMimeType),
|
||||||
|
'gen_ai.request.stop_sequences': config.stopSequences,
|
||||||
|
'gen_ai.system_instructions': JSON.stringify(
|
||||||
|
toSystemInstruction(config.systemInstruction),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiResponseEvent implements BaseTelemetryEvent {
|
||||||
|
'event.name': 'api_response';
|
||||||
|
'event.timestamp': string;
|
||||||
|
status_code?: number | string;
|
||||||
|
duration_ms: number;
|
||||||
response_text?: string;
|
response_text?: string;
|
||||||
prompt_id: string;
|
|
||||||
auth_type?: string;
|
auth_type?: string;
|
||||||
|
|
||||||
|
model: string;
|
||||||
|
prompt: GenAIPromptDetails;
|
||||||
|
response: GenAIResponseDetails;
|
||||||
|
usage: GenAIUsageDetails;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
model: string,
|
model: string,
|
||||||
duration_ms: number,
|
duration_ms: number,
|
||||||
prompt_id: string,
|
prompt_details: GenAIPromptDetails,
|
||||||
|
response_details: GenAIResponseDetails,
|
||||||
auth_type?: string,
|
auth_type?: string,
|
||||||
usage_data?: GenerateContentResponseUsageMetadata,
|
usage_data?: GenerateContentResponseUsageMetadata,
|
||||||
response_text?: string,
|
response_text?: string,
|
||||||
) {
|
) {
|
||||||
this['event.name'] = 'api_response';
|
this['event.name'] = 'api_response';
|
||||||
this['event.timestamp'] = new Date().toISOString();
|
this['event.timestamp'] = new Date().toISOString();
|
||||||
this.model = model;
|
|
||||||
this.duration_ms = duration_ms;
|
this.duration_ms = duration_ms;
|
||||||
this.status_code = 200;
|
this.status_code = 200;
|
||||||
this.input_token_count = usage_data?.promptTokenCount ?? 0;
|
|
||||||
this.output_token_count = usage_data?.candidatesTokenCount ?? 0;
|
|
||||||
this.cached_content_token_count = usage_data?.cachedContentTokenCount ?? 0;
|
|
||||||
this.thoughts_token_count = usage_data?.thoughtsTokenCount ?? 0;
|
|
||||||
this.tool_token_count = usage_data?.toolUsePromptTokenCount ?? 0;
|
|
||||||
this.total_token_count = usage_data?.totalTokenCount ?? 0;
|
|
||||||
this.response_text = response_text;
|
this.response_text = response_text;
|
||||||
this.prompt_id = prompt_id;
|
|
||||||
this.auth_type = auth_type;
|
this.auth_type = auth_type;
|
||||||
|
|
||||||
|
this.model = model;
|
||||||
|
this.prompt = prompt_details;
|
||||||
|
this.response = response_details;
|
||||||
|
this.usage = {
|
||||||
|
input_token_count: usage_data?.promptTokenCount ?? 0,
|
||||||
|
output_token_count: usage_data?.candidatesTokenCount ?? 0,
|
||||||
|
cached_content_token_count: usage_data?.cachedContentTokenCount ?? 0,
|
||||||
|
thoughts_token_count: usage_data?.thoughtsTokenCount ?? 0,
|
||||||
|
tool_token_count: usage_data?.toolUsePromptTokenCount ?? 0,
|
||||||
|
total_token_count: usage_data?.totalTokenCount ?? 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
toLogRecord(config: Config): LogRecord {
|
||||||
const attributes: LogAttributes = {
|
const attributes: LogAttributes = {
|
||||||
...getCommonAttributes(config),
|
...getCommonAttributes(config),
|
||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
'event.timestamp': this['event.timestamp'],
|
'event.timestamp': this['event.timestamp'],
|
||||||
model: this.model,
|
model: this.model,
|
||||||
duration_ms: this.duration_ms,
|
duration_ms: this.duration_ms,
|
||||||
input_token_count: this.input_token_count,
|
input_token_count: this.usage.input_token_count,
|
||||||
output_token_count: this.output_token_count,
|
output_token_count: this.usage.output_token_count,
|
||||||
cached_content_token_count: this.cached_content_token_count,
|
cached_content_token_count: this.usage.cached_content_token_count,
|
||||||
thoughts_token_count: this.thoughts_token_count,
|
thoughts_token_count: this.usage.thoughts_token_count,
|
||||||
tool_token_count: this.tool_token_count,
|
tool_token_count: this.usage.tool_token_count,
|
||||||
total_token_count: this.total_token_count,
|
total_token_count: this.usage.total_token_count,
|
||||||
prompt_id: this.prompt_id,
|
prompt_id: this.prompt.prompt_id,
|
||||||
auth_type: this.auth_type,
|
auth_type: this.auth_type,
|
||||||
status_code: this.status_code,
|
status_code: this.status_code,
|
||||||
};
|
};
|
||||||
@@ -481,11 +575,51 @@ export class ApiResponseEvent implements BaseTelemetryEvent {
|
|||||||
attributes[SemanticAttributes.HTTP_STATUS_CODE] = this.status_code;
|
attributes[SemanticAttributes.HTTP_STATUS_CODE] = this.status_code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return attributes;
|
const logRecord: LogRecord = {
|
||||||
|
body: `API response from ${this.model}. Status: ${this.status_code || 'N/A'}. Duration: ${this.duration_ms}ms.`,
|
||||||
|
attributes,
|
||||||
|
};
|
||||||
|
return logRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
toLogBody(): string {
|
toSemanticLogRecord(config: Config): LogRecord {
|
||||||
return `API response from ${this.model}. Status: ${this.status_code || 'N/A'}. Duration: ${this.duration_ms}ms.`;
|
const attributes: LogAttributes = {
|
||||||
|
...getCommonAttributes(config),
|
||||||
|
'event.name': EVENT_GEN_AI_OPERATION_DETAILS,
|
||||||
|
'event.timestamp': this['event.timestamp'],
|
||||||
|
'gen_ai.response.id': this.response.response_id,
|
||||||
|
'gen_ai.response.finish_reasons': toFinishReasons(
|
||||||
|
this.response.candidates,
|
||||||
|
),
|
||||||
|
'gen_ai.output.messages': JSON.stringify(
|
||||||
|
toOutputMessages(this.response.candidates),
|
||||||
|
),
|
||||||
|
...toGenerateContentConfigAttributes(this.prompt.generate_content_config),
|
||||||
|
...getConventionAttributes(this),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.prompt.server) {
|
||||||
|
attributes['server.address'] = this.prompt.server.address;
|
||||||
|
attributes['server.port'] = this.prompt.server.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.getTelemetryLogPromptsEnabled() && this.prompt.contents) {
|
||||||
|
attributes['gen_ai.input.messages'] = JSON.stringify(
|
||||||
|
toInputMessages(this.prompt.contents),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.usage) {
|
||||||
|
attributes['gen_ai.usage.input_tokens'] = this.usage.input_token_count;
|
||||||
|
attributes['gen_ai.usage.output_tokens'] = this.usage.output_token_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logRecord: LogRecord = {
|
||||||
|
body: `GenAI operation details from ${this.model}. Status: ${this.status_code || 'N/A'}. Duration: ${this.duration_ms}ms.`,
|
||||||
|
attributes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return logRecord;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,12 +129,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 500,
|
duration_ms: 500,
|
||||||
input_token_count: 10,
|
usage: {
|
||||||
output_token_count: 20,
|
input_token_count: 10,
|
||||||
total_token_count: 30,
|
output_token_count: 20,
|
||||||
cached_content_token_count: 5,
|
total_token_count: 30,
|
||||||
thoughts_token_count: 2,
|
cached_content_token_count: 5,
|
||||||
tool_token_count: 3,
|
thoughts_token_count: 2,
|
||||||
|
tool_token_count: 3,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
||||||
|
|
||||||
service.addEvent(event);
|
service.addEvent(event);
|
||||||
@@ -151,12 +153,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 500,
|
duration_ms: 500,
|
||||||
input_token_count: 10,
|
usage: {
|
||||||
output_token_count: 20,
|
input_token_count: 10,
|
||||||
total_token_count: 30,
|
output_token_count: 20,
|
||||||
cached_content_token_count: 5,
|
total_token_count: 30,
|
||||||
thoughts_token_count: 2,
|
cached_content_token_count: 5,
|
||||||
tool_token_count: 3,
|
thoughts_token_count: 2,
|
||||||
|
tool_token_count: 3,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
||||||
|
|
||||||
service.addEvent(event);
|
service.addEvent(event);
|
||||||
@@ -185,12 +189,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 500,
|
duration_ms: 500,
|
||||||
input_token_count: 10,
|
usage: {
|
||||||
output_token_count: 20,
|
input_token_count: 10,
|
||||||
total_token_count: 30,
|
output_token_count: 20,
|
||||||
cached_content_token_count: 5,
|
total_token_count: 30,
|
||||||
thoughts_token_count: 2,
|
cached_content_token_count: 5,
|
||||||
tool_token_count: 3,
|
thoughts_token_count: 2,
|
||||||
|
tool_token_count: 3,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & {
|
} as ApiResponseEvent & {
|
||||||
'event.name': typeof EVENT_API_RESPONSE;
|
'event.name': typeof EVENT_API_RESPONSE;
|
||||||
};
|
};
|
||||||
@@ -198,12 +204,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 600,
|
duration_ms: 600,
|
||||||
input_token_count: 15,
|
usage: {
|
||||||
output_token_count: 25,
|
input_token_count: 15,
|
||||||
total_token_count: 40,
|
output_token_count: 25,
|
||||||
cached_content_token_count: 10,
|
total_token_count: 40,
|
||||||
thoughts_token_count: 4,
|
cached_content_token_count: 10,
|
||||||
tool_token_count: 6,
|
thoughts_token_count: 4,
|
||||||
|
tool_token_count: 6,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & {
|
} as ApiResponseEvent & {
|
||||||
'event.name': typeof EVENT_API_RESPONSE;
|
'event.name': typeof EVENT_API_RESPONSE;
|
||||||
};
|
};
|
||||||
@@ -235,12 +243,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 500,
|
duration_ms: 500,
|
||||||
input_token_count: 10,
|
usage: {
|
||||||
output_token_count: 20,
|
input_token_count: 10,
|
||||||
total_token_count: 30,
|
output_token_count: 20,
|
||||||
cached_content_token_count: 5,
|
total_token_count: 30,
|
||||||
thoughts_token_count: 2,
|
cached_content_token_count: 5,
|
||||||
tool_token_count: 3,
|
thoughts_token_count: 2,
|
||||||
|
tool_token_count: 3,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & {
|
} as ApiResponseEvent & {
|
||||||
'event.name': typeof EVENT_API_RESPONSE;
|
'event.name': typeof EVENT_API_RESPONSE;
|
||||||
};
|
};
|
||||||
@@ -248,12 +258,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-flash',
|
model: 'gemini-2.5-flash',
|
||||||
duration_ms: 1000,
|
duration_ms: 1000,
|
||||||
input_token_count: 100,
|
usage: {
|
||||||
output_token_count: 200,
|
input_token_count: 100,
|
||||||
total_token_count: 300,
|
output_token_count: 200,
|
||||||
cached_content_token_count: 50,
|
total_token_count: 300,
|
||||||
thoughts_token_count: 20,
|
cached_content_token_count: 50,
|
||||||
tool_token_count: 30,
|
thoughts_token_count: 20,
|
||||||
|
tool_token_count: 30,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & {
|
} as ApiResponseEvent & {
|
||||||
'event.name': typeof EVENT_API_RESPONSE;
|
'event.name': typeof EVENT_API_RESPONSE;
|
||||||
};
|
};
|
||||||
@@ -304,12 +316,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 500,
|
duration_ms: 500,
|
||||||
input_token_count: 10,
|
usage: {
|
||||||
output_token_count: 20,
|
input_token_count: 10,
|
||||||
total_token_count: 30,
|
output_token_count: 20,
|
||||||
cached_content_token_count: 5,
|
total_token_count: 30,
|
||||||
thoughts_token_count: 2,
|
cached_content_token_count: 5,
|
||||||
tool_token_count: 3,
|
thoughts_token_count: 2,
|
||||||
|
tool_token_count: 3,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & {
|
} as ApiResponseEvent & {
|
||||||
'event.name': typeof EVENT_API_RESPONSE;
|
'event.name': typeof EVENT_API_RESPONSE;
|
||||||
};
|
};
|
||||||
@@ -534,12 +548,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 500,
|
duration_ms: 500,
|
||||||
input_token_count: 100,
|
usage: {
|
||||||
output_token_count: 200,
|
input_token_count: 100,
|
||||||
total_token_count: 300,
|
output_token_count: 200,
|
||||||
cached_content_token_count: 50,
|
total_token_count: 300,
|
||||||
thoughts_token_count: 20,
|
cached_content_token_count: 50,
|
||||||
tool_token_count: 30,
|
thoughts_token_count: 20,
|
||||||
|
tool_token_count: 30,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
||||||
|
|
||||||
service.addEvent(event);
|
service.addEvent(event);
|
||||||
@@ -559,12 +575,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 500,
|
duration_ms: 500,
|
||||||
input_token_count: 100,
|
usage: {
|
||||||
output_token_count: 200,
|
input_token_count: 100,
|
||||||
total_token_count: 300,
|
output_token_count: 200,
|
||||||
cached_content_token_count: 50,
|
total_token_count: 300,
|
||||||
thoughts_token_count: 20,
|
cached_content_token_count: 50,
|
||||||
tool_token_count: 30,
|
thoughts_token_count: 20,
|
||||||
|
tool_token_count: 30,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
||||||
|
|
||||||
service.addEvent(event);
|
service.addEvent(event);
|
||||||
@@ -584,12 +602,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 500,
|
duration_ms: 500,
|
||||||
input_token_count: 100,
|
usage: {
|
||||||
output_token_count: 200,
|
input_token_count: 100,
|
||||||
total_token_count: 300,
|
output_token_count: 200,
|
||||||
cached_content_token_count: 50,
|
total_token_count: 300,
|
||||||
thoughts_token_count: 20,
|
cached_content_token_count: 50,
|
||||||
tool_token_count: 30,
|
thoughts_token_count: 20,
|
||||||
|
tool_token_count: 30,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
||||||
|
|
||||||
service.addEvent(event);
|
service.addEvent(event);
|
||||||
@@ -616,12 +636,14 @@ describe('UiTelemetryService', () => {
|
|||||||
'event.name': EVENT_API_RESPONSE,
|
'event.name': EVENT_API_RESPONSE,
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
duration_ms: 500,
|
duration_ms: 500,
|
||||||
input_token_count: 100,
|
usage: {
|
||||||
output_token_count: 200,
|
input_token_count: 100,
|
||||||
total_token_count: 300,
|
output_token_count: 200,
|
||||||
cached_content_token_count: 50,
|
total_token_count: 300,
|
||||||
thoughts_token_count: 20,
|
cached_content_token_count: 50,
|
||||||
tool_token_count: 30,
|
thoughts_token_count: 20,
|
||||||
|
tool_token_count: 30,
|
||||||
|
},
|
||||||
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
|
||||||
|
|
||||||
service.addEvent(event);
|
service.addEvent(event);
|
||||||
|
|||||||
@@ -165,12 +165,12 @@ export class UiTelemetryService extends EventEmitter {
|
|||||||
modelMetrics.api.totalRequests++;
|
modelMetrics.api.totalRequests++;
|
||||||
modelMetrics.api.totalLatencyMs += event.duration_ms;
|
modelMetrics.api.totalLatencyMs += event.duration_ms;
|
||||||
|
|
||||||
modelMetrics.tokens.prompt += event.input_token_count;
|
modelMetrics.tokens.prompt += event.usage.input_token_count;
|
||||||
modelMetrics.tokens.candidates += event.output_token_count;
|
modelMetrics.tokens.candidates += event.usage.output_token_count;
|
||||||
modelMetrics.tokens.total += event.total_token_count;
|
modelMetrics.tokens.total += event.usage.total_token_count;
|
||||||
modelMetrics.tokens.cached += event.cached_content_token_count;
|
modelMetrics.tokens.cached += event.usage.cached_content_token_count;
|
||||||
modelMetrics.tokens.thoughts += event.thoughts_token_count;
|
modelMetrics.tokens.thoughts += event.usage.thoughts_token_count;
|
||||||
modelMetrics.tokens.tool += event.tool_token_count;
|
modelMetrics.tokens.tool += event.usage.tool_token_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private processApiError(event: ApiErrorEvent) {
|
private processApiError(event: ApiErrorEvent) {
|
||||||
|
|||||||
Reference in New Issue
Block a user