Files
gemini-cli/packages/core/src/hooks/hookTranslator.ts
T

476 lines
15 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Content,
GenerateContentResponse,
GenerateContentParameters,
Part,
ToolConfig,
FinishReason,
FunctionCallingConfig,
} from '@google/genai';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { getResponseText } from '../utils/partUtils.js';
/**
* Decoupled LLM request format - stable across Gemini CLI versions
*/
export interface LLMRequest {
model: string;
messages: Array<{
role: 'user' | 'model' | 'system';
content: string | Array<{ type: string; [key: string]: unknown }>;
}>;
config?: {
temperature?: number;
maxOutputTokens?: number;
topP?: number;
topK?: number;
stopSequences?: string[];
candidateCount?: number;
presencePenalty?: number;
frequencyPenalty?: number;
[key: string]: unknown;
};
toolConfig?: HookToolConfig;
}
/**
* Decoupled LLM response format - stable across Gemini CLI versions
*/
export interface LLMResponse {
text?: string;
candidates: Array<{
content: {
role: 'model';
parts: string[];
};
finishReason?: 'STOP' | 'MAX_TOKENS' | 'SAFETY' | 'RECITATION' | 'OTHER';
index?: number;
safetyRatings?: Array<{
category: string;
probability: string;
blocked?: boolean;
}>;
}>;
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
}
/**
* Decoupled tool configuration - stable across Gemini CLI versions
*/
export interface HookToolConfig {
mode?: 'AUTO' | 'ANY' | 'NONE';
allowedFunctionNames?: string[];
}
/**
* Base class for hook translators - handles version-specific translation logic
*/
export abstract class HookTranslator {
abstract toHookLLMRequest(sdkRequest: GenerateContentParameters): LLMRequest;
abstract fromHookLLMRequest(
hookRequest: LLMRequest,
baseRequest?: GenerateContentParameters,
): GenerateContentParameters;
abstract toHookLLMResponse(sdkResponse: GenerateContentResponse): LLMResponse;
abstract fromHookLLMResponse(
hookResponse: LLMResponse,
): GenerateContentResponse;
abstract toHookToolConfig(sdkToolConfig: ToolConfig): HookToolConfig;
abstract fromHookToolConfig(hookToolConfig: HookToolConfig): ToolConfig;
}
/**
* Type guard to check if a value has a text property
*/
function hasTextProperty(value: unknown): value is { text: string } {
return (
typeof value === 'object' &&
value !== null &&
'text' in value &&
typeof (value as { text: unknown }).text === 'string'
);
}
/**
* Type guard to check if a value is a Content object (i.e. has role and parts
* properties). Narrows to Content so callers can access `parts` as Part[].
*/
function isContentWithParts(content: unknown): content is Content {
return (
typeof content === 'object' &&
content !== null &&
'role' in content &&
'parts' in content
);
}
/**
* Helper to safely extract generation config from SDK request
* The SDK uses a config field that contains generation parameters
*/
function extractGenerationConfig(request: GenerateContentParameters):
| {
temperature?: number;
maxOutputTokens?: number;
topP?: number;
topK?: number;
}
| undefined {
// Access the config field which contains generation settings
// Use type assertion after checking the field exists
if (request.config && typeof request.config === 'object') {
const config = request.config as {
temperature?: number;
maxOutputTokens?: number;
topP?: number;
topK?: number;
};
return {
temperature: config.temperature,
maxOutputTokens: config.maxOutputTokens,
topP: config.topP,
topK: config.topK,
};
}
return undefined;
}
/**
* Hook translator for GenAI SDK v1.x
* Handles translation between GenAI SDK types and stable Hook API types
*/
export class HookTranslatorGenAIv1 extends HookTranslator {
/**
* Convert genai SDK GenerateContentParameters to stable LLMRequest
*
* Note: This implementation intentionally extracts only text content from parts.
* Non-text parts (images, function calls, etc.) are filtered out in v1 to provide
* a simplified, stable interface for hooks. This allows hooks to focus on text
* manipulation without needing to handle complex multimodal content.
* Future versions may expose additional content types if needed.
*/
toHookLLMRequest(sdkRequest: GenerateContentParameters): LLMRequest {
const messages: LLMRequest['messages'] = [];
// Convert contents to messages format (simplified)
if (sdkRequest.contents) {
const contents = Array.isArray(sdkRequest.contents)
? sdkRequest.contents
: [sdkRequest.contents];
for (const content of contents) {
if (typeof content === 'string') {
messages.push({
role: 'user',
content,
});
} else if (isContentWithParts(content)) {
const role =
content.role === 'model'
? ('model' as const)
: content.role === 'system'
? ('system' as const)
: ('user' as const);
const parts = Array.isArray(content.parts)
? content.parts
: [content.parts];
// Extract only text parts - intentionally filtering out non-text content
const textContent = parts
.filter(hasTextProperty)
.map((part) => part.text)
.join('');
// Only add message if there's text content
if (textContent) {
messages.push({
role,
content: textContent,
});
}
}
}
}
// Safely extract generation config using proper type access
const config = extractGenerationConfig(sdkRequest);
return {
model: sdkRequest.model || DEFAULT_GEMINI_FLASH_MODEL,
messages,
config: {
temperature: config?.temperature,
maxOutputTokens: config?.maxOutputTokens,
topP: config?.topP,
topK: config?.topK,
},
};
}
/**
* Convert stable LLMRequest to genai SDK GenerateContentParameters
*/
fromHookLLMRequest(
hookRequest: LLMRequest,
baseRequest?: GenerateContentParameters,
): GenerateContentParameters {
// Convert hook messages back to SDK Content format.
//
// When both hookRequest.messages and baseRequest.contents are present, we
// merge the hook's text edits back into the original contents in place,
// preserving non-text parts (functionCall, functionResponse, inlineData,
// thought, etc.) that toHookLLMRequest filtered out for the simplified
// hook API. Without this merge, a BeforeModel hook that modifies text
// would destroy tool call/response history and cause the model to loop
// (see https://github.com/google-gemini/gemini-cli/issues/25558).
//
// If the hook returned a partial request without messages (e.g. only
// overriding `model`), fall back to the base request's contents so the
// conversation is preserved.
let contents: GenerateContentParameters['contents'];
if (!hookRequest.messages) {
contents = baseRequest?.contents ?? [];
} else if (baseRequest?.contents) {
// Merge hook messages back into base contents, preserving non-text parts.
const baseContents = Array.isArray(baseRequest.contents)
? baseRequest.contents
: [baseRequest.contents];
// The merged result is uniformly Content[] — ContentListUnion does not
// allow mixing strings (PartUnion) and Content objects in the same
// array, so any string entries from baseContents are normalized to
// Content here.
const merged: Content[] = [];
let messageIndex = 0;
const messageToContent = (
message: LLMRequest['messages'][number],
): Content => ({
role: message.role === 'model' ? 'model' : message.role,
parts: [
{
text:
typeof message.content === 'string'
? message.content
: String(message.content),
},
],
});
for (const content of baseContents) {
// Normalize each baseContents entry into a Content object so the
// merged array is homogeneous.
if (typeof content === 'string') {
// String entries always contributed one message to the hook view.
if (messageIndex < hookRequest.messages.length) {
merged.push(messageToContent(hookRequest.messages[messageIndex++]));
} else {
merged.push({ role: 'user', parts: [{ text: content }] });
}
continue;
}
if (!isContentWithParts(content)) {
// Bare Part object (PartUnion expansion: Content | Part | string).
// toHookLLMRequest does not emit a message for these, so preserve
// them as a single-part Content with a default role.
merged.push({ role: 'user', parts: [content] });
continue;
}
const parts: Part[] = content.parts ?? [];
const hasText = parts.some(hasTextProperty);
const baseContent: Content = { ...content, parts };
if (!hasText) {
// toHookLLMRequest skipped this entry — preserve it untouched so
// tool-call/response history is not lost.
merged.push(baseContent);
continue;
}
// This entry contributed a message — merge the hook's text back in
// and keep any non-text parts in their original order.
if (messageIndex < hookRequest.messages.length) {
const message = hookRequest.messages[messageIndex++];
const newText =
typeof message.content === 'string'
? message.content
: String(message.content);
const nonTextParts = parts.filter(
(p): p is Part => !hasTextProperty(p),
);
merged.push({
...baseContent,
role: message.role === 'model' ? 'model' : message.role,
parts: [{ text: newText }, ...nonTextParts],
});
} else {
merged.push(baseContent);
}
}
// Append any remaining hook messages beyond baseContents (the hook may
// have added new turns).
while (messageIndex < hookRequest.messages.length) {
merged.push(messageToContent(hookRequest.messages[messageIndex++]));
}
contents = merged;
} else {
// No baseRequest contents to merge against — fall back to text-only.
contents = hookRequest.messages.map((message) => ({
role: message.role === 'model' ? 'model' : message.role,
parts: [
{
text:
typeof message.content === 'string'
? message.content
: String(message.content),
},
],
}));
}
// Build the result with proper typing.
// Use nullish coalescing so a hook that only sets `model` still works --
// fall back to the base request's model rather than overwriting with undefined.
const result: GenerateContentParameters = {
...baseRequest,
model: hookRequest.model ?? baseRequest?.model ?? '',
contents,
};
// Add generation config if it exists in the hook request
if (hookRequest.config) {
const baseConfig = baseRequest
? extractGenerationConfig(baseRequest)
: undefined;
result.config = {
...baseConfig,
temperature: hookRequest.config.temperature,
maxOutputTokens: hookRequest.config.maxOutputTokens,
topP: hookRequest.config.topP,
topK: hookRequest.config.topK,
} as GenerateContentParameters['config'];
}
return result;
}
/**
* Convert genai SDK GenerateContentResponse to stable LLMResponse
*/
toHookLLMResponse(sdkResponse: GenerateContentResponse): LLMResponse {
return {
text: getResponseText(sdkResponse) ?? undefined,
candidates: (sdkResponse.candidates || []).map((candidate) => {
// Extract text parts from the candidate
const textParts =
candidate.content?.parts
?.filter(hasTextProperty)
.map((part) => part.text) || [];
return {
content: {
role: 'model' as const,
parts: textParts,
},
finishReason:
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
candidate.finishReason as LLMResponse['candidates'][0]['finishReason'],
index: candidate.index,
safetyRatings: candidate.safetyRatings?.map((rating) => ({
category: String(rating.category || ''),
probability: String(rating.probability || ''),
})),
};
}),
usageMetadata: sdkResponse.usageMetadata
? {
promptTokenCount: sdkResponse.usageMetadata.promptTokenCount,
candidatesTokenCount:
sdkResponse.usageMetadata.candidatesTokenCount,
totalTokenCount: sdkResponse.usageMetadata.totalTokenCount,
}
: undefined,
};
}
/**
* Convert stable LLMResponse to genai SDK GenerateContentResponse
*/
fromHookLLMResponse(hookResponse: LLMResponse): GenerateContentResponse {
// Build response object with proper structure
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const response: GenerateContentResponse = {
text: hookResponse.text,
candidates: hookResponse.candidates.map((candidate) => ({
content: {
role: 'model',
parts: candidate.content.parts.map((part) => ({
text: part,
})),
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
finishReason: candidate.finishReason as FinishReason,
index: candidate.index,
safetyRatings: candidate.safetyRatings,
})),
usageMetadata: hookResponse.usageMetadata,
} as GenerateContentResponse;
return response;
}
/**
* Convert genai SDK ToolConfig to stable HookToolConfig
*/
toHookToolConfig(sdkToolConfig: ToolConfig): HookToolConfig {
return {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
mode: sdkToolConfig.functionCallingConfig?.mode as HookToolConfig['mode'],
allowedFunctionNames:
sdkToolConfig.functionCallingConfig?.allowedFunctionNames,
};
}
/**
* Convert stable HookToolConfig to genai SDK ToolConfig
*/
fromHookToolConfig(hookToolConfig: HookToolConfig): ToolConfig {
const functionCallingConfig: FunctionCallingConfig | undefined =
hookToolConfig.mode || hookToolConfig.allowedFunctionNames
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
({
mode: hookToolConfig.mode,
allowedFunctionNames: hookToolConfig.allowedFunctionNames,
} as FunctionCallingConfig)
: undefined;
return {
functionCallingConfig,
};
}
}
/**
* Default translator instance for current GenAI SDK version
*/
export const defaultHookTranslator = new HookTranslatorGenAIv1();