Download packages/core/src/hooks/hookTranslator.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/hooks/hookTranslator.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/hooks/hookTranslator.ts
-
curl -L -o hookTranslator.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/hooks/hookTranslator.ts
15.4 kB
| /** | |
| * @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(); | |