UNPKG

@ai-sdk/google

Version:
832 lines (767 loc) • 27.1 kB
import { UnsupportedFunctionalityError, type JSONValue, type LanguageModelV4Prompt, type LanguageModelV4ToolResultOutput, type SharedV4Warning, } from '@ai-sdk/provider'; import { stringifyWithBigIntAndCycles, convertToBase64, getTopLevelMediaType, isFullMediaType, isUrlSupported, resolveFullMediaType, resolveProviderReference, secureJsonParse, } from '@ai-sdk/provider-utils'; import { googleVideoMetadataSchema, type GoogleContent, type GoogleContentPart, type GoogleFunctionResponsePart, type GooglePrompt, } from './google-prompt'; import { codeExecutionInputSchema, codeExecutionOutputSchema, } from './tool/code-execution'; /** * Sentinel value Google documents for replaying functionCall parts whose * original thoughtSignature is not available to the client. * * See https://ai.google.dev/gemini-api/docs/thought-signatures. */ export const SKIP_THOUGHT_SIGNATURE_VALIDATOR = 'skip_thought_signature_validator'; const dataUrlRegex = /^data:([^;,]+);base64,(.+)$/s; function parseBase64DataUrl( value: string, ): { mediaType: string; data: string } | undefined { const match = dataUrlRegex.exec(value); if (match == null) { return undefined; } return { mediaType: match[1], data: match[2], }; } function convertUrlToolResultPart( url: string, ): GoogleFunctionResponsePart | undefined { const parsedDataUrl = parseBase64DataUrl(url); if (parsedDataUrl == null) { return undefined; } return { inlineData: { mimeType: parsedDataUrl.mediaType, data: parsedDataUrl.data, }, }; } function containsJSONSchemaReference( value: JSONValue | undefined, seen: WeakSet<object> = new WeakSet(), ): boolean { if (value === null || typeof value !== 'object') { return false; } if (seen.has(value)) { return false; } seen.add(value); if (Array.isArray(value)) { return value.some(item => containsJSONSchemaReference(item, seen)); } return Object.entries(value).some( ([key, nestedValue]) => key === '$ref' || containsJSONSchemaReference(nestedValue, seen), ); } function serializeFunctionResponseContent( value: JSONValue, ): JSONValue | string { // Google reserves { $ref: displayName } in structured function responses for // multimodal parts. This conflicts with JSON Schema $ref, so serialize the // result to preserve it without triggering Google's reference handling. if (containsJSONSchemaReference(value)) { return stringifyWithBigIntAndCycles(value); } // Keep Google's structured response for ordinary JSON, but normalize values // that would otherwise fail when the final request body is serialized. return containsNonJsonValue(value) ? secureJsonParse(stringifyWithBigIntAndCycles(value)) : value; } function containsNonJsonValue( value: unknown, ancestors = new Set<object>(), ): boolean { if (typeof value === 'bigint') { return true; } if (typeof value !== 'object' || value === null) { return false; } if (typeof (value as { toJSON?: unknown }).toJSON === 'function') { return true; } if (ancestors.has(value)) { return true; } ancestors.add(value); try { return Object.values(value).some(item => containsNonJsonValue(item, ancestors), ); } finally { ancestors.delete(value); } } /* * Appends tool result content parts to the message using the functionResponse * format with support for multimodal parts (e.g. inline images/files alongside * text). This format is supported by Gemini 3+ models. */ function appendToolResultParts( parts: GoogleContentPart[], toolName: string, outputValue: Extract< LanguageModelV4ToolResultOutput, { type: 'content' } >['value'], toolCallId?: string, includeFunctionCallIds = true, supportedUrls: Record<string, RegExp[]> = {}, ): void { const functionResponseParts: GoogleFunctionResponsePart[] = []; const responseTextParts: string[] = []; for (const contentPart of outputValue) { switch (contentPart.type) { case 'text': { responseTextParts.push(contentPart.text); break; } case 'file': { if (contentPart.data.type === 'data') { functionResponseParts.push({ inlineData: { mimeType: resolveFullMediaType({ part: contentPart }), data: convertToBase64(contentPart.data.data), }, }); } else if (contentPart.data.type === 'url') { const url = contentPart.data.url.toString(); const convertedUrlPart = convertUrlToolResultPart(url); const supportedUrl = contentPart.data.url.protocol === 'gs:' && contentPart.data.originalUrl != null ? contentPart.data.originalUrl : url; if (convertedUrlPart != null) { functionResponseParts.push(convertedUrlPart); } else if ( isFullMediaType(contentPart.mediaType) && isUrlSupported({ url: supportedUrl, mediaType: contentPart.mediaType, supportedUrls, }) ) { functionResponseParts.push({ fileData: { mimeType: contentPart.mediaType, fileUri: supportedUrl, }, }); } else { responseTextParts.push(JSON.stringify(contentPart)); } } else { responseTextParts.push(JSON.stringify(contentPart)); } break; } default: { responseTextParts.push(JSON.stringify(contentPart)); break; } } } parts.push({ functionResponse: { ...(includeFunctionCallIds && toolCallId != null ? { id: toolCallId } : {}), name: toolName, response: { name: toolName, content: responseTextParts.length > 0 ? responseTextParts.join('\n') : 'Tool executed successfully.', }, ...(functionResponseParts.length > 0 ? { parts: functionResponseParts } : {}), }, }); } /* * Appends tool result content parts using a legacy format for pre-Gemini 3 * models that do not support multimodal parts within functionResponse. Instead, * non-text content like images is sent as separate top-level inlineData parts. */ function appendLegacyToolResultParts( parts: GoogleContentPart[], toolName: string, outputValue: Extract< LanguageModelV4ToolResultOutput, { type: 'content' } >['value'], toolCallId?: string, includeFunctionCallIds = true, ): void { for (const contentPart of outputValue) { switch (contentPart.type) { case 'text': parts.push({ functionResponse: { ...(includeFunctionCallIds && toolCallId != null ? { id: toolCallId } : {}), name: toolName, response: { name: toolName, content: contentPart.text, }, }, }); break; case 'file': { if (contentPart.data.type === 'data') { const topLevelMediaType = getTopLevelMediaType(contentPart.mediaType); parts.push( { inlineData: { mimeType: resolveFullMediaType({ part: contentPart }), data: convertToBase64(contentPart.data.data), }, }, { text: `Tool executed successfully and returned this ` + `${topLevelMediaType === 'image' ? 'image' : 'file'} ` + `as a response`, }, ); } else { parts.push({ text: JSON.stringify(contentPart) }); } break; } default: parts.push({ text: JSON.stringify(contentPart) }); break; } } } export function convertToGoogleMessages( prompt: LanguageModelV4Prompt, options?: { isGemmaModel?: boolean; isGemini3Model?: boolean; onWarning?: (warning: SharedV4Warning) => void; /** * Names to look up under `providerOptions` when reading per-part metadata * (e.g. thought signatures). Tried in order; first match wins. For the * Vertex provider this is `['googleVertex', 'vertex']` (new key first, * legacy key as fallback) and for the Google provider it is `['google']`. */ providerOptionsNames?: readonly string[]; supportsFunctionResponseParts?: boolean; includeFunctionCallIds?: boolean; supportedFunctionResponseUrls?: Record<string, RegExp[]>; }, ): GooglePrompt { const systemInstructionParts: Array<{ text: string }> = []; const contents: Array<GoogleContent> = []; let systemMessagesAllowed = true; const isGemmaModel = options?.isGemmaModel ?? false; const isGemini3Model = options?.isGemini3Model ?? false; const onWarning = options?.onWarning; const providerOptionsNames = options?.providerOptionsNames ?? ['google']; const isVertexLike = !providerOptionsNames.includes('google'); const supportsFunctionResponseParts = options?.supportsFunctionResponseParts ?? true; const includeFunctionCallIds = options?.includeFunctionCallIds ?? true; const supportedFunctionResponseUrls = options?.supportedFunctionResponseUrls ?? {}; let sentinelInjected = false; const missingSignatureToolNames: string[] = []; const injectSkipSignature = (toolName: string) => { missingSignatureToolNames.push(toolName); sentinelInjected = true; return SKIP_THOUGHT_SIGNATURE_VALIDATOR; }; const readProviderOpts = (part: { providerOptions?: Record<string, unknown> | undefined; }): Record<string, unknown> | undefined => { for (const name of providerOptionsNames) { const v = part.providerOptions?.[name]; if (v != null) return v as Record<string, unknown>; } // Cross-namespace fallback (gateway interop): Vertex providers may receive // metadata under `google`, and the Google provider may receive metadata // under `googleVertex`/`vertex`. if (isVertexLike) { return part.providerOptions?.google as | Record<string, unknown> | undefined; } return (part.providerOptions?.googleVertex ?? part.providerOptions?.vertex) as Record<string, unknown> | undefined; }; for (const { role, content } of prompt) { switch (role) { case 'system': { if (!systemMessagesAllowed) { throw new UnsupportedFunctionalityError({ functionality: 'system messages are only supported at the beginning of the conversation', }); } systemInstructionParts.push({ text: content }); break; } case 'user': { systemMessagesAllowed = false; const parts: GoogleContentPart[] = []; for (const part of content) { switch (part.type) { case 'text': { parts.push({ text: part.text }); break; } case 'file': { const rawVideoMetadata = readProviderOpts(part)?.videoMetadata; const videoMetadata = rawVideoMetadata == null ? undefined : googleVideoMetadataSchema.parse(rawVideoMetadata); switch (part.data.type) { case 'url': { parts.push({ fileData: { mimeType: resolveFullMediaType({ part }), fileUri: part.data.url.protocol === 'gs:' && part.data.originalUrl != null ? part.data.originalUrl : part.data.url.toString(), }, ...(videoMetadata != null ? { videoMetadata } : {}), }); break; } case 'reference': { if (isVertexLike) { throw new UnsupportedFunctionalityError({ functionality: 'file parts with provider references', }); } parts.push({ fileData: { mimeType: resolveFullMediaType({ part }), fileUri: resolveProviderReference({ reference: part.data.reference, provider: 'google', }), }, ...(videoMetadata != null ? { videoMetadata } : {}), }); break; } case 'text': { parts.push({ inlineData: { mimeType: isFullMediaType(part.mediaType) ? part.mediaType : 'text/plain', data: convertToBase64( new TextEncoder().encode(part.data.text), ), }, ...(videoMetadata != null ? { videoMetadata } : {}), }); break; } case 'data': { parts.push({ inlineData: { mimeType: resolveFullMediaType({ part }), data: convertToBase64(part.data.data), }, ...(videoMetadata != null ? { videoMetadata } : {}), }); break; } } break; } } } contents.push({ role: 'user', parts }); break; } case 'assistant': { systemMessagesAllowed = false; let modelResponseHasSignedFunctionCall = false; contents.push({ role: 'model', parts: content .map(part => { const providerOpts = readProviderOpts(part); const thoughtSignature = providerOpts?.thoughtSignature != null ? String(providerOpts.thoughtSignature) : undefined; switch (part.type) { case 'text': { return part.text.length === 0 && thoughtSignature == null ? undefined : { text: part.text, thoughtSignature, }; } case 'reasoning': { return part.text.length === 0 && thoughtSignature == null ? undefined : { text: part.text, thought: true, thoughtSignature, }; } case 'reasoning-file': { switch (part.data.type) { case 'url': { throw new UnsupportedFunctionalityError({ functionality: 'File data URLs in assistant messages are not supported', }); } case 'data': { return { inlineData: { mimeType: part.mediaType, data: convertToBase64(part.data.data), }, thought: true, thoughtSignature, }; } } break; } case 'file': { switch (part.data.type) { case 'url': { throw new UnsupportedFunctionalityError({ functionality: 'File data URLs in assistant messages are not supported', }); } case 'reference': { if (isVertexLike) { throw new UnsupportedFunctionalityError({ functionality: 'file parts with provider references', }); } return { fileData: { mimeType: part.mediaType, fileUri: resolveProviderReference({ reference: part.data.reference, provider: 'google', }), }, ...(providerOpts?.thought === true ? { thought: true } : {}), thoughtSignature, }; } case 'text': { return { inlineData: { mimeType: isFullMediaType(part.mediaType) ? part.mediaType : 'text/plain', data: convertToBase64( new TextEncoder().encode(part.data.text), ), }, ...(providerOpts?.thought === true ? { thought: true } : {}), thoughtSignature, }; } case 'data': { return { inlineData: { mimeType: part.mediaType, data: convertToBase64(part.data.data), }, ...(providerOpts?.thought === true ? { thought: true } : {}), thoughtSignature, }; } } break; } case 'tool-call': { if ( part.providerExecuted === true && part.toolName === 'code_execution' ) { return { executableCode: codeExecutionInputSchema.parse( typeof part.input === 'string' ? secureJsonParse(part.input) : part.input, ), }; } const serverToolCallId = providerOpts?.serverToolCallId != null ? String(providerOpts.serverToolCallId) : undefined; const serverToolType = providerOpts?.serverToolType != null ? String(providerOpts.serverToolType) : undefined; const isServerToolCall = serverToolCallId != null && serverToolType != null; const shouldSkipMissingSignatureMitigation = // Gemini 3 returns a single signature for a parallel // function-call response on the first standard function // call. Subsequent standard function calls in the same // model response legitimately have no signature. !isServerToolCall && thoughtSignature == null && modelResponseHasSignedFunctionCall; const effectiveThoughtSignature = thoughtSignature ?? (isGemini3Model && !shouldSkipMissingSignatureMitigation ? injectSkipSignature(part.toolName) : undefined); if (!isServerToolCall && thoughtSignature != null) { modelResponseHasSignedFunctionCall = true; } if (isServerToolCall) { return { toolCall: { toolType: serverToolType, args: typeof part.input === 'string' ? secureJsonParse(part.input) : part.input, id: serverToolCallId, }, thoughtSignature: effectiveThoughtSignature, }; } return { functionCall: { ...(includeFunctionCallIds && part.toolCallId != null ? { id: part.toolCallId } : {}), name: part.toolName, args: parseToolCallInput(part.input), }, thoughtSignature: effectiveThoughtSignature, }; } case 'tool-result': { if ( part.toolName === 'code_execution' && part.output.type === 'json' ) { return { codeExecutionResult: codeExecutionOutputSchema.parse( part.output.value, ), }; } const serverToolCallId = providerOpts?.serverToolCallId != null ? String(providerOpts.serverToolCallId) : undefined; const serverToolType = providerOpts?.serverToolType != null ? String(providerOpts.serverToolType) : undefined; if (serverToolCallId && serverToolType) { return { toolResponse: { toolType: serverToolType, response: part.output.type === 'json' ? part.output.value : {}, id: serverToolCallId, }, thoughtSignature, }; } return undefined; } } }) .filter(part => part !== undefined), }); break; } case 'tool': { systemMessagesAllowed = false; const parts: GoogleContentPart[] = []; for (const part of content) { if (part.type === 'tool-approval-response') { continue; } const partProviderOpts = readProviderOpts(part); const serverToolCallId = partProviderOpts?.serverToolCallId != null ? String(partProviderOpts.serverToolCallId) : undefined; const serverToolType = partProviderOpts?.serverToolType != null ? String(partProviderOpts.serverToolType) : undefined; if (serverToolCallId && serverToolType) { const serverThoughtSignature = partProviderOpts?.thoughtSignature != null ? String(partProviderOpts.thoughtSignature) : undefined; if (contents.length > 0) { const lastContent = contents[contents.length - 1]; if (lastContent.role === 'model') { lastContent.parts.push({ toolResponse: { toolType: serverToolType, response: part.output.type === 'json' ? part.output.value : {}, id: serverToolCallId, }, thoughtSignature: serverThoughtSignature, }); continue; } } } const output = part.output; if (output.type === 'content') { if (supportsFunctionResponseParts) { appendToolResultParts( parts, part.toolName, output.value, part.toolCallId, includeFunctionCallIds, supportedFunctionResponseUrls, ); } else { appendLegacyToolResultParts( parts, part.toolName, output.value, part.toolCallId, includeFunctionCallIds, ); } } else { const isError = output.type === 'error-text' || output.type === 'error-json' || output.type === 'execution-denied'; parts.push({ functionResponse: { ...(includeFunctionCallIds && part.toolCallId != null ? { id: part.toolCallId } : {}), name: part.toolName, response: { name: part.toolName, ...(isError ? { error: output.type === 'execution-denied' ? (output.reason ?? 'Tool call execution denied.') : serializeFunctionResponseContent(output.value), } : { content: serializeFunctionResponseContent(output.value), }), }, }, }); } } contents.push({ role: 'user', parts, }); break; } } } if ( isGemmaModel && systemInstructionParts.length > 0 && contents.length > 0 && contents[0].role === 'user' ) { const systemText = systemInstructionParts .map(part => part.text) .join('\n\n'); contents[0].parts.unshift({ text: systemText + '\n\n' }); } if (sentinelInjected && onWarning != null) { const uniqueToolNames = Array.from(new Set(missingSignatureToolNames)); onWarning({ type: 'other', message: `Replayed ${missingSignatureToolNames.length} \`functionCall\` part(s) ` + `for a Gemini 3 model without a \`thoughtSignature\` ` + `(tools: ${uniqueToolNames.map(name => `\`${name}\``).join(', ')}). ` + `Injected the documented \`skip_thought_signature_validator\` sentinel ` + `to keep the request from failing with HTTP 400. ` + `The likely cause is application code that drops ` + '`providerOptions.google.thoughtSignature` when persisting or ' + 'serializing assistant tool-call messages. ' + 'See https://ai.google.dev/gemini-api/docs/thought-signatures.', }); } return { systemInstruction: systemInstructionParts.length > 0 && !isGemmaModel ? { parts: systemInstructionParts } : undefined, contents, }; } function parseToolCallInput(input: unknown): unknown { if (typeof input !== 'string') { return input; } try { return secureJsonParse(input); } catch { return input; } }