@ai-sdk/google
Version:
2,165 lines • 75.5 kB
text/typescript
import type {
LanguageModelV4,
LanguageModelV4CallOptions,
LanguageModelV4Content,
LanguageModelV4FunctionTool,
LanguageModelV4FinishReason,
LanguageModelV4GenerateResult,
LanguageModelV4Source,
LanguageModelV4StreamPart,
LanguageModelV4StreamResult,
JSONObject,
SharedV4ProviderMetadata,
SharedV4Warning,
} from '@ai-sdk/provider';
import {
combineHeaders,
convertJsonResponseToolStream,
createToolNameMapping,
createEventSourceResponseHandler,
createJsonResponseHandler,
createProviderStreamError,
generateId,
isCustomReasoning,
lazySchema,
mapReasoningToProviderBudget,
mapReasoningToProviderEffort,
postJsonToApi,
resolve,
resolveProviderOptions,
serializeModelOptions,
WORKFLOW_SERIALIZE,
WORKFLOW_DESERIALIZE,
zodSchema,
type FetchFunction,
type InferSchema,
type Resolvable,
} from '@ai-sdk/provider-utils';
import { z } from 'zod/v4';
import {
continueGoogleStream,
type GoogleStreamPart,
} from './continue-google-stream';
import {
addGoogleUsageMetadata,
convertGoogleUsage,
type GoogleUsageMetadata,
} from './convert-google-usage';
import { convertToGoogleMessages } from './convert-to-google-messages';
import { downloadToolResultFiles } from './download-tool-result-files';
import { getModelPath } from './get-model-path';
import {
getGoogleErrorSchema,
googleFailedResponseHandler,
mapGoogleErrorStatusToHttpStatusCode,
} from './google-error';
import { sanitizeResponseJsonSchema } from './sanitize-response-json-schema';
import {
googleLanguageModelOptions,
type GoogleLanguageModelOptions,
type GoogleModelId,
} from './google-language-model-options';
import { getGoogleLanguageModelEntry } from './google-model-catalog';
import type { GoogleProviderMetadata } from './google-prompt';
import { prepareTools } from './google-prepare-tools';
import {
GoogleJSONAccumulator,
type PartialArg,
} from './google-json-accumulator';
import { mapGoogleFinishReason } from './map-google-finish-reason';
const configurableSafetySettingCategories = [
'HARM_CATEGORY_HATE_SPEECH',
'HARM_CATEGORY_DANGEROUS_CONTENT',
'HARM_CATEGORY_HARASSMENT',
'HARM_CATEGORY_SEXUALLY_EXPLICIT',
] as const;
const googleCloudStorageFunctionResponseUrls = {
'image/png': [/^gs:\/\/.*$/],
'image/jpeg': [/^gs:\/\/.*$/],
'image/webp': [/^gs:\/\/.*$/],
'application/pdf': [/^gs:\/\/.*$/],
'text/plain': [/^gs:\/\/.*$/],
} satisfies Record<string, RegExp[]>;
export type GoogleLanguageModelConfig = {
provider: string;
baseURL: string;
headers?: Resolvable<Record<string, string | undefined>>;
fetch?: FetchFunction;
generateId: () => string;
/**
* The supported URLs for the model.
*/
supportedUrls?: () => LanguageModelV4['supportedUrls'];
/**
* Settings for downloading remote files in tool results before conversion.
*/
downloadToolResultFiles?: {
maxBytes: number;
supportsGoogleCloudStorageUrls?: boolean;
/** Transport for untrusted file URLs, without provider authentication. */
fetch?: FetchFunction;
};
};
export class GoogleLanguageModel implements LanguageModelV4 {
readonly specificationVersion = 'v4';
readonly modelId: GoogleModelId;
private readonly config: GoogleLanguageModelConfig;
private readonly generateId: () => string;
static [WORKFLOW_SERIALIZE](model: GoogleLanguageModel) {
return serializeModelOptions({
modelId: model.modelId,
config: model.config,
});
}
static [WORKFLOW_DESERIALIZE](options: {
modelId: string;
config: GoogleLanguageModelConfig;
}) {
return new GoogleLanguageModel(options.modelId, options.config);
}
constructor(modelId: GoogleModelId, config: GoogleLanguageModelConfig) {
this.modelId = modelId;
this.config = config;
this.generateId = config.generateId ?? generateId;
}
get provider(): string {
return this.config.provider;
}
get supportedUrls() {
return this.config.supportedUrls?.() ?? {};
}
static async prepareRequest({
modelId,
config,
options: {
prompt,
maxOutputTokens,
temperature,
topP,
topK,
frequencyPenalty,
presencePenalty,
stopSequences,
responseFormat,
seed,
tools,
toolChoice,
reasoning,
providerOptions,
abortSignal,
},
isStreaming = false,
}: {
modelId: GoogleModelId;
config: GoogleLanguageModelConfig;
options: LanguageModelV4CallOptions;
isStreaming?: boolean;
}) {
const warnings: SharedV4Warning[] = [];
// Names to look up in providerOptions and to write into providerMetadata.
// For the Vertex provider we read both the new `googleVertex` key and the
// legacy `vertex` key (new takes precedence) and write under both for
// backward compatibility. For other Google providers we use just `google`.
const providerOptionsNames: readonly string[] = config.provider.includes(
'vertex',
)
? (['googleVertex', 'vertex'] as const)
: (['google'] as const);
// The first present namespace wins as a whole. A Vertex provider also
// falls back to the `google` key (e.g. via the AI Gateway).
const { options: googleOptions } =
await resolveProviderOptions<GoogleLanguageModelOptions>({
providerOptions,
namespaces: [...providerOptionsNames, 'google'],
resolution: 'first',
schema: googleLanguageModelOptions,
});
// Add warning if Vertex rag tools are used with a non-Vertex Google provider
const isVertexProvider = config.provider.startsWith('google.vertex.');
if (
tools?.some(
tool =>
tool.type === 'provider' && tool.id === 'google.vertex_rag_store',
) &&
!isVertexProvider
) {
warnings.push({
type: 'other',
message:
"The 'vertex_rag_store' tool is only supported with the Google Vertex provider " +
'and might not be supported or could behave unexpectedly with the current Google provider ' +
`(${config.provider}).`,
});
}
if (googleOptions?.streamFunctionCallArguments && !isVertexProvider) {
warnings.push({
type: 'other',
message:
"'streamFunctionCallArguments' is only supported on the Vertex AI API " +
'and will be ignored with the current Google provider ' +
`(${config.provider}). See https://docs.cloud.google.com/vertex-ai/generative-ai/docs/multimodal/function-calling#streaming-fc`,
});
}
if (googleOptions?.serviceTier && isVertexProvider) {
warnings.push({
type: 'other',
message:
"'serviceTier' is a Gemini API option and is not supported on Vertex AI. " +
"Use 'sharedRequestType' (and optionally 'requestType') instead. See " +
'https://docs.cloud.google.com/vertex-ai/generative-ai/docs/priority-paygo',
});
}
if (
(googleOptions?.sharedRequestType || googleOptions?.requestType) &&
!isVertexProvider
) {
warnings.push({
type: 'other',
message:
"'sharedRequestType' and 'requestType' are Vertex AI options and " +
`are ignored with the current Google provider (${config.provider}).`,
});
}
const vertexPaygoHeaders: Record<string, string> | undefined =
isVertexProvider &&
(googleOptions?.sharedRequestType || googleOptions?.requestType)
? {
...(googleOptions.sharedRequestType && {
'X-Vertex-AI-LLM-Shared-Request-Type':
googleOptions.sharedRequestType,
}),
...(googleOptions.requestType && {
'X-Vertex-AI-LLM-Request-Type': googleOptions.requestType,
}),
}
: undefined;
const bodyServiceTier = isVertexProvider
? undefined
: googleOptions?.serviceTier;
// personGeneration, prominentPeople and imageOutputOptions are only
// supported by the Vertex AI API, the Gemini API rejects them.
let imageConfig = googleOptions?.imageConfig;
if (imageConfig != null && !isVertexProvider) {
const {
personGeneration,
prominentPeople,
imageOutputOptions,
...geminiApiImageConfig
} = imageConfig;
const droppedImageConfigFields = Object.entries({
personGeneration,
prominentPeople,
imageOutputOptions,
})
.filter(([, value]) => value != null)
.map(([key]) => `'imageConfig.${key}'`);
if (droppedImageConfigFields.length > 0) {
warnings.push({
type: 'other',
message:
`${droppedImageConfigFields.join(', ')} ` +
`${droppedImageConfigFields.length === 1 ? 'is a Vertex AI option and is' : 'are Vertex AI options and are'} ` +
`ignored with the current Google provider (${config.provider}).`,
});
imageConfig = geminiApiImageConfig;
}
}
const entry = getGoogleLanguageModelEntry(modelId);
const isGemmaModel =
entry.requestConfig.language.systemInstruction === 'user';
const isGemini25DeveloperApiModel =
!isVertexProvider &&
entry.capabilities.language.sampling.penalties !== true;
if (isGemini25DeveloperApiModel && frequencyPenalty != null) {
warnings.push({
type: 'unsupported',
feature: 'frequencyPenalty',
});
}
if (isGemini25DeveloperApiModel && presencePenalty != null) {
warnings.push({
type: 'unsupported',
feature: 'presencePenalty',
});
}
const usesGemini3Features =
entry.capabilities.language.tools.functionResponseParts;
const supportedFunctionResponseUrls =
usesGemini3Features &&
config.downloadToolResultFiles?.supportsGoogleCloudStorageUrls
? googleCloudStorageFunctionResponseUrls
: undefined;
const promptWithDownloadedToolResultFiles = config.downloadToolResultFiles
? await downloadToolResultFiles(prompt, {
abortSignal,
maxBytes: config.downloadToolResultFiles.maxBytes,
fetch: config.downloadToolResultFiles.fetch,
supportedUrls: supportedFunctionResponseUrls,
})
: prompt;
const { contents, systemInstruction } = convertToGoogleMessages(
promptWithDownloadedToolResultFiles,
{
isGemmaModel,
isGemini3Model: usesGemini3Features,
onWarning: warning => warnings.push(warning),
providerOptionsNames,
supportsFunctionResponseParts: usesGemini3Features,
includeFunctionCallIds: !isVertexProvider,
supportedFunctionResponseUrls,
},
);
const {
tools: googleTools,
toolConfig: googleToolConfig,
toolWarnings,
} = prepareTools({
tools,
toolChoice,
modelId,
isVertexProvider,
});
const toolNameMapping = createToolNameMapping({
tools,
providerToolNames: {
'google.code_execution': 'code_execution',
},
});
const resolvedThinking =
googleOptions?.thinkingConfig?.thinkingBudget != null ||
googleOptions?.thinkingConfig?.thinkingLevel != null
? undefined
: resolveThinkingConfig({ reasoning, modelId, warnings });
const thinkingConfig =
googleOptions?.thinkingConfig || resolvedThinking
? { ...resolvedThinking, ...googleOptions?.thinkingConfig }
: undefined;
const streamFunctionCallArguments =
isStreaming && isVertexProvider
? (googleOptions?.streamFunctionCallArguments ?? false)
: undefined;
const safetyThreshold = googleOptions?.threshold;
const safetySettings =
googleOptions?.safetySettings ??
(safetyThreshold != null
? configurableSafetySettingCategories.map(category => ({
category,
threshold: safetyThreshold,
}))
: undefined);
const toolConfig =
googleToolConfig ||
streamFunctionCallArguments ||
googleOptions?.retrievalConfig
? {
...googleToolConfig,
...(streamFunctionCallArguments && {
functionCallingConfig: {
...googleToolConfig?.functionCallingConfig,
streamFunctionCallArguments: true as const,
},
}),
...(googleOptions?.retrievalConfig && {
retrievalConfig: googleOptions.retrievalConfig,
}),
}
: undefined;
return {
args: {
generationConfig: {
// standardized settings:
maxOutputTokens,
temperature,
topK,
topP,
frequencyPenalty: isGemini25DeveloperApiModel
? undefined
: frequencyPenalty,
presencePenalty: isGemini25DeveloperApiModel
? undefined
: presencePenalty,
stopSequences,
seed,
// response format:
responseMimeType:
responseFormat?.type === 'json' ? 'application/json' : undefined,
responseJsonSchema:
responseFormat?.type === 'json' &&
responseFormat.schema != null &&
// Google does not support all JSON Schema features in
// responseJsonSchema, so this is needed as an escape hatch:
// TODO convert into provider option
(googleOptions?.structuredOutputs ?? true)
? sanitizeResponseJsonSchema(responseFormat.schema)
: undefined,
...(googleOptions?.audioTimestamp && {
audioTimestamp: googleOptions.audioTimestamp,
}),
// provider options:
responseModalities: googleOptions?.responseModalities,
thinkingConfig,
...(googleOptions?.mediaResolution && {
mediaResolution: googleOptions.mediaResolution,
}),
...(imageConfig && { imageConfig }),
},
contents,
systemInstruction: isGemmaModel ? undefined : systemInstruction,
safetySettings,
tools: googleTools,
toolConfig,
cachedContent: googleOptions?.cachedContent,
labels: googleOptions?.labels,
serviceTier: bodyServiceTier,
...(googleOptions?.continuationToken != null && {
continuationToken: googleOptions.continuationToken,
}),
},
warnings: [...warnings, ...toolWarnings],
providerOptionsNames,
extraHeaders: vertexPaygoHeaders,
toolNameMapping,
};
}
private async getArgs(
options: LanguageModelV4CallOptions,
{ isStreaming = false }: { isStreaming?: boolean } = {},
) {
const { responseFormat, toolChoice } = options;
// Enforce the caller's tool choice before the JSON response fallback can
// replace it with a forced call to the synthetic response tool.
const tools =
toolChoice?.type === 'none'
? options.tools?.filter(tool => tool.type === 'function')
: options.tools;
const entry = getGoogleLanguageModelEntry(this.modelId);
const { structuredOutput } = entry.capabilities.language;
let jsonResponseTool: LanguageModelV4FunctionTool | undefined;
// Gemini models before Gemini 3 reject JSON response formatting together
// with function tools. Gemini 3 supports the combination only with automatic
// tool choice (AUTO). Forced tool choice (ANY) requires a JSON response tool.
if (
responseFormat?.type === 'json' &&
responseFormat.schema != null &&
tools?.some(tool => tool.type === 'function') &&
entry.requestConfig.language.jsonResponseTool &&
(!structuredOutput.withFunctionTools ||
toolChoice?.type === 'required' ||
toolChoice?.type === 'tool')
) {
let name = 'json';
for (let suffix = 1; tools.some(tool => tool.name === name); suffix++) {
name = `json_${suffix}`;
}
jsonResponseTool = {
type: 'function',
name,
description:
responseFormat.description ?? 'Respond with a JSON object.',
inputSchema: responseFormat.schema,
};
}
const prepared = await GoogleLanguageModel.prepareRequest({
modelId: this.modelId,
config: this.config,
options:
jsonResponseTool == null
? options
: {
...options,
responseFormat: undefined,
tools: [...(tools ?? []), jsonResponseTool],
toolChoice:
toolChoice?.type === 'tool'
? toolChoice
: toolChoice?.type === 'none'
? { type: 'tool', toolName: jsonResponseTool.name }
: { type: 'required' },
},
isStreaming,
});
return { ...prepared, jsonResponseToolName: jsonResponseTool?.name };
}
static convertGenerateContentResponse({
config,
response,
responseHeaders,
warnings,
providerOptionsNames,
toolNameMapping,
jsonResponseToolName,
initialPromptTokenCount,
groundingMetadataPerRequest,
}: {
config: GoogleLanguageModelConfig;
response: InferSchema<typeof responseSchema>;
responseHeaders?: Record<string, string>;
warnings: SharedV4Warning[];
providerOptionsNames: readonly string[];
toolNameMapping?: ReturnType<typeof createToolNameMapping>;
jsonResponseToolName?: string;
initialPromptTokenCount?: number | null;
groundingMetadataPerRequest?: Array<GroundingMetadataSchema | null>;
}): LanguageModelV4GenerateResult {
const wrapProviderMetadata = (payload: Record<string, unknown>) =>
Object.fromEntries(
providerOptionsNames.map(name => [name, payload]),
) as SharedV4ProviderMetadata;
const candidate = response.candidates?.[0];
const promptBlockReason = response.promptFeedback?.blockReason;
const confirmedPromptBlockReason = isConfirmedPromptBlockReason(
promptBlockReason,
)
? promptBlockReason
: undefined;
const isPromptBlocked =
candidate?.finishReason == null && confirmedPromptBlockReason != null;
const rawFinishReason =
candidate?.finishReason ?? confirmedPromptBlockReason;
const content: Array<LanguageModelV4Content> = [];
// map ordered parts to content:
const parts = candidate?.content?.parts ?? [];
const usageMetadata = response.usageMetadata;
// Associates code execution results with their preceding call.
let lastCodeExecutionToolCallId: string | undefined;
// Associates a server-side tool response with its preceding call (tool combination).
let lastServerToolCallId: string | undefined;
// Build content array from all parts
for (const part of parts) {
if ('executableCode' in part && part.executableCode?.code) {
const toolCallId = config.generateId();
lastCodeExecutionToolCallId = toolCallId;
content.push({
type: 'tool-call',
toolCallId,
toolName:
toolNameMapping?.toCustomToolName('code_execution') ??
'code_execution',
input: JSON.stringify(part.executableCode),
providerExecuted: true,
});
} else if ('codeExecutionResult' in part && part.codeExecutionResult) {
content.push({
type: 'tool-result',
// Results correspond to the most recent executable code part.
toolCallId: lastCodeExecutionToolCallId!,
toolName:
toolNameMapping?.toCustomToolName('code_execution') ??
'code_execution',
result: {
outcome: part.codeExecutionResult.outcome,
output: part.codeExecutionResult.output ?? '',
},
});
} else if ('text' in part && part.text != null) {
if (jsonResponseToolName != null && part.thought !== true) continue;
const thoughtSignatureMetadata = part.thoughtSignature
? wrapProviderMetadata({
thoughtSignature: part.thoughtSignature,
})
: undefined;
if (part.text.length === 0) {
if (thoughtSignatureMetadata != null && content.length > 0) {
const lastContent = content[content.length - 1];
lastContent.providerMetadata = thoughtSignatureMetadata;
}
} else {
content.push({
type: part.thought === true ? 'reasoning' : 'text',
text: part.text,
providerMetadata: thoughtSignatureMetadata,
});
}
} else if ('functionCall' in part && part.functionCall.name != null) {
if (part.functionCall.name === jsonResponseToolName) {
content.push({
type: 'text',
text:
typeof part.functionCall.args === 'string'
? part.functionCall.args
: JSON.stringify(part.functionCall.args ?? {}),
...(part.thoughtSignature
? {
providerMetadata: wrapProviderMetadata({
thoughtSignature: part.thoughtSignature,
}),
}
: {}),
});
continue;
}
content.push({
type: 'tool-call' as const,
toolCallId: part.functionCall.id || config.generateId(),
toolName: part.functionCall.name,
input: JSON.stringify(part.functionCall.args ?? {}),
providerMetadata: part.thoughtSignature
? wrapProviderMetadata({
thoughtSignature: part.thoughtSignature,
})
: undefined,
});
} else if ('inlineData' in part) {
const hasThought = part.thought === true;
const hasThoughtSignature = !!part.thoughtSignature;
content.push({
type: hasThought ? 'reasoning-file' : 'file',
data: { type: 'data', data: part.inlineData.data },
mediaType: part.inlineData.mimeType,
providerMetadata: hasThoughtSignature
? wrapProviderMetadata({
thoughtSignature: part.thoughtSignature,
})
: undefined,
});
} else if ('toolCall' in part && part.toolCall) {
const toolCallId = part.toolCall.id || config.generateId();
lastServerToolCallId = toolCallId;
content.push({
type: 'tool-call',
toolCallId,
toolName: `server:${part.toolCall.toolType}`,
input: JSON.stringify(part.toolCall.args ?? {}),
providerExecuted: true,
dynamic: true,
providerMetadata: part.thoughtSignature
? wrapProviderMetadata({
thoughtSignature: part.thoughtSignature,
serverToolCallId: toolCallId,
serverToolType: part.toolCall.toolType,
})
: wrapProviderMetadata({
serverToolCallId: toolCallId,
serverToolType: part.toolCall.toolType,
}),
});
} else if ('toolResponse' in part && part.toolResponse) {
const responseToolCallId =
lastServerToolCallId || part.toolResponse.id || config.generateId();
content.push({
type: 'tool-result',
toolCallId: responseToolCallId,
toolName: `server:${part.toolResponse.toolType}`,
result: (part.toolResponse.response ?? {}) as JSONObject,
providerMetadata: part.thoughtSignature
? wrapProviderMetadata({
thoughtSignature: part.thoughtSignature,
serverToolCallId: responseToolCallId,
serverToolType: part.toolResponse.toolType,
})
: wrapProviderMetadata({
serverToolCallId: responseToolCallId,
serverToolType: part.toolResponse.toolType,
}),
});
lastServerToolCallId = undefined;
}
}
const sources =
extractSources({
groundingMetadata: candidate?.groundingMetadata,
generateId: config.generateId,
}) ?? [];
for (const source of sources) {
content.push(source);
}
return {
content,
finishReason: {
unified: isPromptBlocked
? 'content-filter'
: mapGoogleFinishReason({
finishReason: rawFinishReason,
// Only count client-executed tool calls for finish reason determination.
hasToolCalls: content.some(
part => part.type === 'tool-call' && !part.providerExecuted,
),
}),
raw: rawFinishReason,
},
usage: convertGoogleUsage(usageMetadata),
warnings,
providerMetadata: wrapProviderMetadata({
promptFeedback: response.promptFeedback ?? null,
groundingMetadata: candidate?.groundingMetadata ?? null,
...(groundingMetadataPerRequest != null
? { groundingMetadataPerRequest }
: {}),
urlContextMetadata: candidate?.urlContextMetadata ?? null,
safetyRatings: candidate?.safetyRatings ?? null,
usageMetadata: usageMetadata ?? null,
finishMessage: candidate?.finishMessage ?? null,
serviceTier:
usageMetadata?.serviceTier ??
responseHeaders?.['x-gemini-service-tier'] ??
null,
...(candidate?.continuationToken != null
? { continuationToken: candidate.continuationToken }
: {}),
...(initialPromptTokenCount != null ? { initialPromptTokenCount } : {}),
} satisfies GoogleProviderMetadata),
response: {
// TODO timestamp, model id
id: response.responseId ?? undefined,
},
};
}
async doGenerate(
options: LanguageModelV4CallOptions,
): Promise<LanguageModelV4GenerateResult> {
const {
args,
warnings,
providerOptionsNames,
extraHeaders,
toolNameMapping,
jsonResponseToolName,
} = await this.getArgs(options);
const generateContent = async (body: typeof args) =>
postJsonToApi({
url: `${this.config.baseURL}/${getModelPath(
this.modelId,
)}:generateContent`,
headers: combineHeaders(
await resolve(this.config.headers),
options.headers,
extraHeaders,
),
body,
failedResponseHandler: googleFailedResponseHandler,
successfulResponseHandler: createJsonResponseHandler(responseSchema),
abortSignal: options.abortSignal,
fetch: this.config.fetch,
});
let {
responseHeaders,
value: response,
rawValue: rawResponse,
} = await generateContent(args);
// Gemini 4 long decode: a request that reaches the per-request cap while
// `maxOutputTokens` budget remains finishes with `CONTINUATION`. Resend
// the same request with the continuation token (the server tracks the
// cumulative tokens) and combine the content of all requests.
//
// Usage is summed across requests (matching billing). Each request re-sends
// the prompt plus all output so far as input, so the first request's
// prompt token count is kept separately for context window checks.
const content: LanguageModelV4Content[] = [];
const groundingMetadataPerRequest: Array<GroundingMetadataSchema | null> =
[];
let usageMetadata: GoogleUsageMetadata | undefined;
const firstPromptTokenCount = response.usageMetadata?.promptTokenCount;
let isContinued = false;
while (true) {
const candidate = response.candidates?.[0];
groundingMetadataPerRequest.push(candidate?.groundingMetadata ?? null);
const continuationToken =
candidate?.finishReason === 'CONTINUATION'
? candidate.continuationToken
: undefined;
usageMetadata = addGoogleUsageMetadata(
usageMetadata,
response.usageMetadata ?? undefined,
);
if (continuationToken == null) {
break;
}
content.push(
...GoogleLanguageModel.convertGenerateContentResponse({
config: this.config,
response,
warnings,
providerOptionsNames,
toolNameMapping,
jsonResponseToolName,
}).content,
);
isContinued = true;
({
responseHeaders,
value: response,
rawValue: rawResponse,
} = await generateContent({ ...args, continuationToken }));
}
const lastResult = GoogleLanguageModel.convertGenerateContentResponse({
config: this.config,
response: { ...response, usageMetadata },
responseHeaders,
warnings,
providerOptionsNames,
toolNameMapping,
jsonResponseToolName,
initialPromptTokenCount: isContinued ? firstPromptTokenCount : undefined,
groundingMetadataPerRequest: isContinued
? groundingMetadataPerRequest
: undefined,
});
const result = {
...lastResult,
content: [...content, ...lastResult.content],
};
return {
...result,
request: { body: args },
response: {
...result.response,
headers: responseHeaders,
body: rawResponse,
},
};
}
async doStream(
options: LanguageModelV4CallOptions,
): Promise<LanguageModelV4StreamResult> {
const {
args,
warnings,
providerOptionsNames,
extraHeaders,
toolNameMapping,
jsonResponseToolName,
} = await this.getArgs(options, { isStreaming: true });
const wrapProviderMetadata = (payload: Record<string, unknown>) =>
Object.fromEntries(
providerOptionsNames.map(name => [name, payload]),
) as SharedV4ProviderMetadata;
const streamGenerateContent = async (body: typeof args) =>
postJsonToApi({
url: `${this.config.baseURL}/${getModelPath(
this.modelId,
)}:streamGenerateContent?alt=sse`,
headers: combineHeaders(
await resolve(this.config.headers),
options.headers,
extraHeaders,
),
body,
failedResponseHandler: googleFailedResponseHandler,
successfulResponseHandler:
createEventSourceResponseHandler(chunkSchema),
abortSignal: options.abortSignal,
fetch: this.config.fetch,
});
const { responseHeaders, value: firstResponse } =
await streamGenerateContent(args);
const groundingMetadataPerRequest: Array<GroundingMetadataSchema | null> =
[];
let requestGroundingMetadata: GroundingMetadataSchema | null = null;
// Gemini 4 long decode: when a stream reaches the per-request cap while
// `maxOutputTokens` budget remains, its last chunk finishes with
// `CONTINUATION`. Resend the same request with the continuation token and
// append the new stream, which contains only newly generated content.
const response = continueGoogleStream({
stream: firstResponse,
getContinuationToken: chunk => {
const candidate = chunk.candidates?.[0];
if (candidate?.groundingMetadata != null) {
requestGroundingMetadata = candidate.groundingMetadata;
}
return candidate?.finishReason === 'CONTINUATION'
? (candidate.continuationToken ?? undefined)
: undefined;
},
continueStream: async continuationToken => {
const { value } = await streamGenerateContent({
...args,
continuationToken,
});
groundingMetadataPerRequest.push(requestGroundingMetadata);
requestGroundingMetadata = null;
return value;
},
});
// A clean end without the provider's completion signal is interrupted.
let finishReason: LanguageModelV4FinishReason = {
unified: 'interrupted',
raw: undefined,
};
let usage: GoogleUsageMetadata | undefined = undefined;
// Usage of the completed requests of an automatically continued response.
let continuedUsage: GoogleUsageMetadata | undefined = undefined;
// Prompt token count of the first request, for context window checks.
let initialPromptTokenCount: number | null | undefined = undefined;
let promptFeedback: PromptFeedbackSchema | null = null;
let lastGroundingMetadata: GroundingMetadataSchema | null = null;
let lastUrlContextMetadata: UrlContextMetadataSchema | null = null;
let lastSafetyRatings: SafetyRatingSchema[] | null = null;
let lastFinishMessage: string | null = null;
let lastContinuationToken: string | null = null;
let confirmedPromptBlockReason: string | undefined;
const generateId = this.config.generateId;
let hasToolCalls = false;
let hasEmittedResponseMetadata = false;
// Track active blocks to group consecutive parts of same type
let currentTextBlockId: string | null = null;
let currentReasoningBlockId: string | null = null;
let blockCounter = 0;
// Track emitted sources to prevent duplicates
const emittedSourceKeys = new Set<string>();
// Associates code execution results with their preceding call.
let lastCodeExecutionToolCallId: string | undefined;
// Associates a server-side tool response with its preceding call (tool combination).
let lastServerToolCallId: string | undefined;
const activeStreamingToolCalls: Array<{
toolCallId: string;
toolName: string;
accumulator: GoogleJSONAccumulator;
providerMetadata?: SharedV4ProviderMetadata;
}> = [];
const finishActiveStreamingToolCall = (
controller: TransformStreamDefaultController<LanguageModelV4StreamPart>,
) => {
const active = activeStreamingToolCalls.pop();
if (active == null) {
return;
}
// Finalized partial-argument calls also separate surrounding blocks.
if (currentTextBlockId !== null) {
controller.enqueue({ type: 'text-end', id: currentTextBlockId });
currentTextBlockId = null;
}
if (currentReasoningBlockId !== null) {
controller.enqueue({
type: 'reasoning-end',
id: currentReasoningBlockId,
});
currentReasoningBlockId = null;
}
const { finalJSON, closingDelta } = active.accumulator.finalize();
if (closingDelta.length > 0) {
controller.enqueue({
type: 'tool-input-delta',
id: active.toolCallId,
delta: closingDelta,
providerMetadata: active.providerMetadata,
});
}
controller.enqueue({
type: 'tool-input-end',
id: active.toolCallId,
providerMetadata: active.providerMetadata,
});
controller.enqueue({
type: 'tool-call',
toolCallId: active.toolCallId,
toolName: active.toolName,
input: finalJSON,
providerMetadata: active.providerMetadata,
});
hasToolCalls = true;
};
const result = {
stream: response.pipeThrough(
new TransformStream<
GoogleStreamPart<ChunkSchema>,
LanguageModelV4StreamPart
>({
start(controller) {
controller.enqueue({ type: 'stream-start', warnings });
},
transform(chunk, controller) {
if ('type' in chunk) {
// Reset in stream order, after the prior response's buffered
// CONTINUATION chunk, rather than when the request starts.
finishReason = { unified: 'interrupted', raw: undefined };
return;
}
if (options.includeRawChunks) {
controller.enqueue({ type: 'raw', rawValue: chunk.rawValue });
}
if (!chunk.success) {
if (finishReason.unified === 'interrupted') {
finishReason = { unified: 'error', raw: undefined };
}
controller.enqueue({ type: 'error', error: chunk.error });
return;
}
const value = chunk.value;
if (value.error != null) {
finishReason = { unified: 'error', raw: undefined };
const error = value.error;
const statusCode =
error.code ??
mapGoogleErrorStatusToHttpStatusCode(error.status);
controller.enqueue({
type: 'error',
error: createProviderStreamError({
message: error.message,
type: error.status,
code: error.code ?? undefined,
statusCode,
isRetryable:
statusCode != null &&
(statusCode === 408 ||
statusCode === 409 ||
statusCode === 429 ||
statusCode >= 500),
data: error,
}),
});
return;
}
if (!hasEmittedResponseMetadata && value.responseId != null) {
hasEmittedResponseMetadata = true;
controller.enqueue({
type: 'response-metadata',
id: value.responseId,
});
}
const usageMetadata = value.usageMetadata;
if (usageMetadata != null) {
usage = usageMetadata;
}
if (
value.promptFeedback != null &&
confirmedPromptBlockReason == null
) {
promptFeedback = value.promptFeedback;
if (
isConfirmedPromptBlockReason(value.promptFeedback.blockReason)
) {
confirmedPromptBlockReason = value.promptFeedback.blockReason;
finishReason = {
unified: 'content-filter',
raw: confirmedPromptBlockReason,
};
}
}
const candidate = value.candidates?.[0];
if (candidate != null) {
if (candidate.groundingMetadata != null) {
lastGroundingMetadata = candidate.groundingMetadata;
}
if (candidate.urlContextMetadata != null) {
lastUrlContextMetadata = candidate.urlContextMetadata;
}
if (candidate.safetyRatings != null) {
lastSafetyRatings = candidate.safetyRatings;
}
if (candidate.finishMessage != null) {
lastFinishMessage = candidate.finishMessage;
}
if (candidate.continuationToken != null) {
lastContinuationToken = candidate.continuationToken;
}
}
// A confirmed prompt block is terminal for generated content, but
// later chunks can still contribute usage and provider metadata.
if (confirmedPromptBlockReason != null || candidate == null) {
return;
}
const content = candidate.content;
const sources = extractSources({
groundingMetadata: candidate.groundingMetadata,
generateId,
emittedSourceKeys,
});
if (sources != null) {
for (const source of sources) {
controller.enqueue(source);
}
}
// Process tool call's parts before determining finishReason to ensure hasToolCalls is properly set
if (content != null) {
// Process all parts in a single loop to preserve original order
const parts = content.parts ?? [];
for (const part of parts) {
if ('executableCode' in part && part.executableCode?.code) {
const toolCallId = generateId();
lastCodeExecutionToolCallId = toolCallId;
controller.enqueue({
type: 'tool-call',
toolCallId,
toolName:
toolNameMapping.toCustomToolName('code_execution'),
input: JSON.stringify(part.executableCode),
providerExecuted: true,
});
} else if (
'codeExecutionResult' in part &&
part.codeExecutionResult
) {
// Results correspond to the most recent executable code part.
const toolCallId = lastCodeExecutionToolCallId;
if (toolCallId) {
controller.enqueue({
type: 'tool-result',
toolCallId,
toolName:
toolNameMapping.toCustomToolName('code_execution'),
result: {
outcome: part.codeExecutionResult.outcome,
output: part.codeExecutionResult.output ?? '',
},
});
}
} else if ('text' in part && part.text != null) {
const thoughtSignatureMetadata = part.thoughtSignature
? wrapProviderMetadata({
thoughtSignature: part.thoughtSignature,
})
: undefined;
if (part.text.length === 0) {
if (thoughtSignatureMetadata != null) {
if (currentTextBlockId !== null) {
controller.enqueue({
type: 'text-delta',
id: currentTextBlockId,
delta: '',
providerMetadata: thoughtSignatureMetadata,
});
} else {
if (currentReasoningBlockId !== null) {
controller.enqueue({
type: 'reasoning-end',
id: currentReasoningBlockId,
});
currentReasoningBlockId = null;
}
// Keep the signature in position without merging later content.
const id = String(blockCounter++);
const type =
part.thought === true ? 'reasoning' : 'text';
controller.enqueue({
type: `${type}-start`,
id,
providerMetadata: thoughtSignatureMetadata,
});
controller.enqueue({
type: `${type}-delta`,
id,
delta: '',
providerMetadata: thoughtSignatureMetadata,
});
controller.enqueue({ type: `${type}-end`, id });
}
}
} else if (part.thought === true) {
// End any active text block before starting reasoning
if (currentTextBlockId !== null) {
controller.enqueue({
type: 'text-end',
id: currentTextBlockId,
});
currentTextBlockId = null;
}
// Start new reasoning block if not already active
if (currentReasoningBlockId === null) {
currentReasoningBlockId = String(blockCounter++);
controller.enqueue({
type: 'reasoning-start',
id: currentReasoningBlockId,
providerMetadata: thoughtSignatureMetadata,
});
}
controller.enqueue({
type: 'reasoning-delta',
id: currentReasoningBlockId,
delta: part.text,
providerMetadata: thoughtSignatureMetadata,
});
} else {
if (currentReasoningBlockId !== null) {
controller.enqueue({
type: 'reasoning-end',
id: currentReasoningBlockId,
});
currentReasoningBlockId = null;
}
if (currentTextBlockId === null) {
currentTextBlockId = String(blockCounter++);
controller.enqueue({
type: 'text-start',
id: currentTextBlockId,
providerMetadata: thoughtSignatureMetadata,
});
}
controller.enqueue({
type: 'text-delta',
id: currentTextBlockId,
delta: part.text,
providerMetadata: thoughtSignatureMetadata,
});
}
} else if ('inlineData' in part) {
// End any active text or reasoning block before starting file output.
// Relevant for multimodal output models.
if (currentTextBlockId !== null) {
controller.enqueue({
type: 'text-end',
id: currentTextBlockId,
});
currentTextBlockId = null;
}
if (currentReasoningBlockId !== null) {
controller.enqueue({
type: 'reasoning-end',
id: currentReasoningBlockId,
});
currentReasoningBlockId = null;
}
const hasThought = part.thought === true;
const hasThoughtSignature = !!part.thoughtSignature;
const fileMeta = hasThoughtSignature
? wrapProviderMetadata({
thoughtSignature: part.thoughtSignature,
})
: undefined;
controller.enqueue({
type: hasThought ? 'reasoning-file' : 'file',
mediaType: part.inlineData.mimeType,
data: { type: 'data', data: part.inlineData.data },
providerMetadata: fileMeta,
});
} else if ('toolCall' in part && part.toolCall) {
const toolCallId = part.toolCall.id || generateId();
lastServerToolCallId = toolCallId;
const serverMeta = wrapProviderMetadata({
...(part.thoughtSignature
? { thoughtSignature: part.thoughtSignature }
: {}),
serverToolCallId: toolCallId,
serverToolType: part.toolCall.toolType,
});
controller.enqueue({
type: 'tool-call',
toolCallId,
toolName: `server:${part.toolCall.toolType}`,
input: JSON.stringify(part.toolCall.args ?? {}),
providerExecuted: true,
dynamic: true,
providerMetadata: serverMeta,
});
} else if ('toolResponse' in part && part.toolResponse) {
const responseToolCallId =
lastServerToolCallId ||
part.toolResponse.id ||
generateId();
const serverMeta = wrapProviderMetadata({
...(part.thoughtSignature
? { thoughtSignature: part.thoughtSignature }
: {}),
serverToolCallId: responseToolCallId,
serverToolType: part.toolResponse.toolType,
});
controller.enqueue({
type: 'tool-result',
toolCallId: responseToolCallId,
toolName: `server:${part.toolResponse.toolType}`,
result: (part.toolResponse.response ?? {}) as JSONObject,
providerMetadata: serverMeta,
});
lastServerToolCallId = undefined;
} else if ('functionCall' in part) {
const providerMeta = part.thoughtSignature
? wrapProviderMetadata({
thoughtSignature: part.thoughtSignature,
})
: undefined;
const isStreamingChunk =
part.functionCall.partialArgs != null ||
(part.functionCall.name != null &&
part.functionCall.willContinue === true);
const isTerminalChunk =
part.functionCall.name == null &&
part.functionCall.args == null &&
part.functionCall.partialArgs == null &&
part.functionCall.willContinue == null;
const isCompleteCall =
part.functionCall.name != null &&
part.functionCall.args != null &&
part.functionCall.partialArgs == null;
// Single-chunk no-args call: `{ name: 'X' }` with no `args`,
// `partialArgs`, or `willContinue`. Carries `thoughtSignature`.
const isNoArgsCompleteCall =
part.functionCall.name != null &&
part.functionCall.args == null &&
part.functionCall.partialArgs == null &&
part.functionCall.willContinue !== true;
if (
!isStreamingChunk &&
(isCompleteCall || isNoArgsCompleteCall)
) {
if (currentTextBlockId !== null) {
controller.enqueue({
type: 'text-end',
id: currentTextBlockId,
});
currentTextBlockId = null;
}
if (currentReasoningBlockId !== null) {
controller.enqueue({
type: 'reasoning-end',
id: currentReasoningBlockId,
});
currentReasoningBlockId = null;
}
}
if (isStreamingChunk) {
if (part.functionCall.name != null) {
const toolCallId = part.functionCall.id || generateId();
const accumulator = new GoogleJSONAccumulator();
activeStreamingToolCalls.push({
toolCallId,
toolName: part.functionCall.name,
accumulator,
providerMetadata: providerMeta,
});
controller.enqueue({
type: 'tool-input-start',
id: toolCallId,
toolName: part.functionCall.name,
providerMetadata: providerMeta,
});
if (part.functionCall.partialArgs != null) {
const partialArgs = part.functionCall
.partialArgs as PartialArg[];
const { textDelta } =
accumulator.processPartialArgs(partialArgs);
if (textDelta.length > 0) {
controller.enqueue({
type: 'tool-input-delta',
id: toolCallId,
delta: textDelta,
providerMetadata: providerMeta,
});
}
if (
part.functionCall.willContinue !== true &&
partialArgs.every(arg => arg.willContinue !== true)
) {
finishActiveStreamingToolCall(controller);
}
}
} else if (
part.functionCall.partialArgs != null &&
activeStreamingToolCalls.length > 0
) {
const active =
activeStreamingToolCalls[
activeStreamingToolCalls.length - 1
];
const partialArgs = part.functionCall
.partialArgs as PartialArg[];
const { textDelta } =
active.accumulator.processPartialArgs(partialArgs);
if (textDelta.length > 0) {
controller.enqueue({
type: 'tool-input-delta',
id: active.toolCallId,
delta: textDelta,
providerMetadata: providerMeta,
});
}
if (
part.functionCall.willContinue !== true &&
partialArgs.every(arg => arg.willContinue !== true)
) {
finishActiveStreamingToolCall(controller);
}
}
} else if (
isTerminalChunk &&
activeStreamingToolCalls.length > 0
) {
finishActiveStreamingToolCall(controller);
} else if (isCompleteCall) {
const toolCallId = part.functionCall.id || generateId();
const toolName = part.functionCall.name!;
const args =
typeof part.functionCall.args === 'string'
? part.functionCall.args
: JSON.stringify(part.functionCall.args ?? {});
controller.enqueue({
type: 'tool-input-start',
id: toolCallId,
toolName,
providerMetadata: providerMeta,
});
controller.enqueue({
type: 'tool-input-delta',
id: toolCallId,
delta: args,
providerMetadata: providerMeta,
});
controller.enqueue({
type: 'tool-input-end',
id: toolCallId,
providerMetadata: providerMeta,
});
controller.enqueue({
type: 'tool-call',
toolCallId,
toolName,
input: args,
providerMetadata: providerMeta,
});
hasToolCalls = true;
} else if (isNoArgsCompleteCall) {
const toolCallId = part.functionCall.id || generateId();
const toolName = part.functionCall.name!;
controller.enqueue({
type: 'tool-input-start',
id: toolCallId,
toolName,
providerMetadata: providerMeta,
});
controller.enqueue({
type: 'tool-input-end',
id: toolCallId,
providerMetadata: providerMeta,
});
controller.enqueue({
type: 'tool-call',
toolCallId,
toolName,
input: '{}',
providerMetadata: providerMeta,
});
hasToolCalls = true;
}
}
}
}
if (candidate.finishReason === 'CONTINUATION') {
// Usage metadata is per request. The next request reports its
// own usage, so bank the usage of this one.
if (continuedUsage == null) {
initialPromptTokenCount = usage?.promptTokenCount;
}
continuedUsage = addGoogleUsageMetadata(continuedUsage, usage);
usage = undefined;
}
if (candidate.finishReason != null) {
finishReason = {
unified: mapGoogleFinishReason({
finishReason: candidate.finishReason,
hasToolCalls,
}),
raw: candidate.finishReason,
};
}
},
flush(controller) {
usage = addGoogleUsageMetadata(continuedUsage, usage);
if (currentTextBlockId !== null) {
controller.enqueue({
type: 'text-end',
id: currentTextBlockId,
});
}
if (currentReasoningBlockId !== null) {
controller.enqueue({
type: 'reasoning-end',
id: currentReasoningBlockId,
});
}
controller.enqueue({
type: 'finish',
finishReason,
usage: convertGoogleUsage(usage),
providerMetadata: wrapProviderMetadata({
promptFeedback,
groundingMetadata: lastGroundingMetadata,
...(groundingMetadataPerRequest.length > 0
? {
groundingMetadataPerRequest: [
...groundingMetadataPerRequest,
requestGroundingMetadata,
],
}
: {}),
urlContextMetadata: lastUrlContextMetadata,
safetyRatings: lastSafetyRatings,
usageMetadata: usage ?? null,
finishMessage: lastFinishMessage,
serviceTier:
usage?.serviceTier ??
responseHeaders?.['x-gemini-service-tier'] ??
null,
// Only surface the token when the response stopped early
// (budget used up, or continuing failed), not for the
// checkpoint tokens of a stream that finished normally.
...(lastContinuationToken != null &&
(finishReason.raw === 'MAX_TOKENS' ||
finishReason.raw === 'CONTINUATION')
? { continuationToken: lastContinuationToken }
: {}),
...(initialPromptTokenCount != null
? { initialPromptTokenCount }
: {}),
} satisfies GoogleProviderMetadata),
});
},
}),
),
response: { headers: responseHeaders },
request: { body: args },
};
return {
...result,
stream:
jsonResponseToolName == null
? result.stream
: result.stream.pipeThrough(
convertJsonResponseToolStream(jsonResponseToolName),
),
};
}
}
type GoogleThinkingConfig = NonNullable<
InferSchema<typeof googleLanguageModelOptions>['thinkingConfig']
>;
function resolveThinkingConfig({
reasoning,
modelId,
warnings,
}: {
reasoning: LanguageModelV4CallOptions['reasoning'];
modelId: string;
warnings: SharedV4Warning[];
}): Omit<GoogleThinkingConfig, 'includeThoughts'> | undefined {
if (!isCustomReasoning(reasoning)) {
return undefined;
}
if (
getGoogleLanguageModelEntry(modelId).requestConfig.language
.thinkingFormat === 'level'
) {
return resolveGemini3ThinkingConfig({ reasoning, modelId, warnings });
}
return resolveGemini25ThinkingConfig({ reasoning, modelId, warnings });
}
function resolveGemini3ThinkingConfig({
reasoning,
modelId,
warnings,
}: {
reasoning: Exclude<
LanguageModelV4CallOptions['reasoning'],
'provider-default' | undefined
>;
modelId: string;
warnings: SharedV4Warning[];
}): Pick<GoogleThinkingConfig, 'thinkingLevel'> | undefined {
const thinkingLevels =
getGoogleLanguageModelEntry(modelId).capabilities.language.reasoning
.efforts;
// It's not possible to fully disable thinking with Gemini 3, so reasoning
// 'none' maps to the lowest supported thinking level.
const thinkingLevel = mapReasoningToProviderEffort<
NonNullable<GoogleThinkingConfig['thinkingLevel']>
>({
reasoning,
effortMap: Object.fromEntries(
(thinkingLevels ?? []).map(level => [level, level]),
),
warnings,
});
if (thinkingLevel == null) {
return undefined;
}
return { thinkingLevel };
}
function resolveGemini25ThinkingConfig({
reasoning,
modelId,
warnings,
}: {
reasoning: Exclude<
LanguageModelV4CallOptions['reasoning'],
'provider-default' | undefined
>;
modelId: string;
warnings: SharedV4Warning[];
}): Pick<GoogleThinkingConfig, 'thinkingBudget'> {
const entry = getGoogleLanguageModelEntry(modelId);
const minThinkingBudget = entry.capabilities.language.reasoning.minBudget;
if (reasoning === 'none' && minThinkingBudget === 0) {
return { thinkingBudget: 0 };
}
const thinkingBudget = mapReasoningToProviderBudget({
reasoning,
maxOutputTokens: entry.requestConfig.language.reasoningMaxOutputTokens,
maxReasoningBudget: entry.capabilities.language.reasoning.maxBudget,
minReasoningBudget: minThinkingBudget,
warnings,
});
return { thinkingBudget };
}
function extractSources({
groundingMetadata,
generateId,
emittedSourceKeys,
}: {
groundingMetadata: GroundingMetadataSchema | undefined | null;
generateId: () => string;
emittedSourceKeys?: Set<string>;
}): undefined | LanguageModelV4Source[] {
if (!groundingMetadata?.groundingChunks) {
return undefined;
}
const sources: LanguageModelV4Source[] = [];
const addSource = (source: LanguageModelV4Source, key: string) => {
if (emittedSourceKeys?.has(key)) {
return;
}
emittedSourceKeys?.add(key);
sources.push(source);
};
for (const chunk of groundingMetadata.groundingChunks) {
if (chunk.web != null) {
// Handle web chunks as URL sources
addSource(
{
type: 'source',
sourceType: 'url',
id: generateId(),
url: chunk.web.uri,
title: chunk.web.title ?? undefined,
},
JSON.stringify(['url', chunk.web.uri]),
);
} else if (chunk.image != null) {
// Handle image chunks as image sources
addSource(
{
type: 'source',
sourceType: 'url',
id: generateId(),
// Google requires attribution to the source URI, not the actual image URI.
// TODO: add another type in v7 to allow both the image and source URL to be included separately
url: chunk.image.sourceUri,
title: chunk.image.title ?? undefined,
},
JSON.stringify(['url', chunk.image.sourceUri]),
);
} else if (chunk.retrievedContext != null) {
// Handle retrievedContext chunks from RAG operations
const uri = chunk.retrievedContext.uri;
const fileSearchStore = chunk.retrievedContext.fileSearchStore;
if (uri && (uri.startsWith('http://') || uri.startsWith('https://'))) {
// Old format: Google Search with HTTP/HTTPS URL
addSource(
{
type: 'source',
sourceType: 'url',
id: generateId(),
url: uri,
title: chunk.retrievedContext.title ?? undefined,
},
JSON.stringify(['url', uri]),
);
} else if (uri) {
// Old format: Document with file path (gs://, etc.)
const title = chunk.retrievedContext.title ?? 'Unknown Document';
let mediaType = 'application/octet-stream';
let filename: string | undefined = undefined;
if (uri.endsWith('.pdf')) {
mediaType = 'application/pdf';
filename = uri.split('/').pop();
} else if (uri.endsWith('.txt')) {
mediaType = 'text/plain';
filename = uri.split('/').pop();
} else if (uri.endsWith('.docx')) {
mediaType =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
filename = uri.split('/').pop();
} else if (uri.endsWith('.doc')) {
mediaType = 'application/msword';
filename = uri.split('/').pop();
} else if (uri.match(/\.(md|markdown)$/)) {
mediaType = 'text/markdown';
filename = uri.split('/').pop();
} else {
filename = uri.split('/').pop();
}
addSource(
{
type: 'source',
sourceType: 'document',
id: generateId(),
mediaType,
title,
filename,
},
JSON.stringify(['document', uri]),
);
} else if (fileSearchStore) {
// New format: File Search with fileSearchStore (no uri)
const title = chunk.retrievedContext.title ?? 'Unknown Document';
addSource(
{
type: 'source',
sourceType: 'document',
id: generateId(),
mediaType: 'application/octet-stream',
title,
filename: fileSearchStore.split('/').pop(),
},
JSON.stringify(['file-search', fileSearchStore, title]),
);
}
} else if (chunk.maps != null) {
if (chunk.maps.uri) {
addSource(
{
type: 'source',
sourceType: 'url',
id: generateId(),
url: chunk.maps.uri,
title: chunk.maps.title ?? undefined,
},
JSON.stringify(['url', chunk.maps.uri]),
);
}
}
}
return sources.length > 0 ? sources : undefined;
}
export const getGroundingMetadataSchema = () =>
z.object({
webSearchQueries: z.array(z.string()).nullish(),
imageSearchQueries: z.array(z.string()).nullish(),
retrievalQueries: z.array(z.string()).nullish(),
searchEntryPoint: z.object({ renderedContent: z.string() }).nullish(),
groundingChunks: z
.array(
z.object({
web: z
.object({ uri: z.string(), title: z.string().nullish() })
.nullish(),
image: z
.object({
sourceUri: z.string(),
imageUri: z.string(),
title: z.string().nullish(),
domain: z.string().nullish(),
})
.nullish(),
retrievedContext: z
.object({
uri: z.string().nullish(),
title: z.string().nullish(),
text: z.string().nullish(),
fileSearchStore: z.string().nullish(),
customMetadata: z
.array(
z.object({
key: z.string(),
stringValue: z.string().nullish(),
numericValue: z.number().nullish(),
stringListValue: z
.object({ values: z.array(z.string()).nullish() })
.nullish(),
}),
)
.nullish(),
})
.nullish(),
maps: z
.object({
uri: z.string().nullish(),
title: z.string().nullish(),
text: z.string().nullish(),
placeId: z.string().nullish(),
})
.nullish(),
}),
)
.nullish(),
groundingSupports: z
.array(
z.object({
segment: z
.object({
startIndex: z.number().nullish(),
endIndex: z.number().nullish(),
text: z.string().nullish(),
})
.nullish(),
segment_text: z.string().nullish(),
groundingChunkIndices: z.array(z.number()).nullish(),
supportChunkIndices: z.array(z.number()).nullish(),
confidenceScores: z.array(z.number()).nullish(),
confidenceScore: z.array(z.number()).nullish(),
}),
)
.nullish(),
retrievalMetadata: z
.union([
z.object({
webDynamicRetrievalScore: z.number(),
}),
z.object({}),
])
.nullish(),
});
const partialArgSchema = () =>
z.object({
jsonPath: z.string(),
stringValue: z.string().nullish(),
numberValue: z.number().nullish(),
boolValue: z.boolean().nullish(),
nullValue: z.unknown().nullish(),
willContinue: z.boolean().nullish(),
});
const getContentSchema = () =>
z.object({
parts: z
.array(
z.union([
// note: order matters since text can be fully empty
z.object({
functionCall: z.object({
id: z.string().nullish(),
name: z.string().nullish(),
args: z.unknown().nullish(),
partialArgs: z.array(partialArgSchema()).nullish(),
willContinue: z.boolean().nullish(),
}),
thoughtSignature: z.string().nullish(),
}),
z.object({
inlineData: z.object({
mimeType: z.string(),
data: z.string(),
}),
thought: z.boolean().nullish(),
thoughtSignature: z.string().nullish(),
}),
z.object({
toolCall: z.object({
toolType: z.string(),
args: z.unknown().nullish(),
id: z.string(),
}),
thoughtSignature: z.string().nullish(),
}),
z.object({
toolResponse: z.object({
toolType: z.string(),
response: z.unknown().nullish(),
id: z.string(),
}),
thoughtSignature: z.string().nullish(),
}),
z.object({
executableCode: z
.object({
language: z.string(),
code: z.string(),
})
.nullish(),
codeExecutionResult: z
.object({
outcome: z.string(),
output: z.string().nullish(),
})
.nullish(),
text: z.string().nullish(),
thought: z.boolean().nullish(),
thoughtSignature: z.string().nullish(),
}),
]),
)
.nullish(),
});
// https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/configure-safety-filters
const getSafetyRatingSchema = () =>
z.object({
category: z.string().nullish(),
probability: z.string().nullish(),
probabilityScore: z.number().nullish(),
severity: z.string().nullish(),
severityScore: z.number().nullish(),
blocked: z.boolean().nullish(),
});
const tokenDetailsSchema = () =>
z
.array(
z
.object({
modality: z.string(),
tokenCount: z.number(),
})
.loose(),
)
.nullish();
const usageSchema = () =>
z
.object({
cachedContentTokenCount: z.number().nullish(),
thoughtsTokenCount: z.number().nullish(),
promptTokenCount: z.number().nullish(),
candidatesTokenCount: z.number().nullish(),
toolUsePromptTokenCount: z.number().nullish(),
totalTokenCount: z.number().nullish(),
// https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/GenerateContentResponse#TrafficType
trafficType: z.string().nullish(),
serviceTier: z.string().nullish(),
// https://ai.google.dev/api/generate-content#Modality
promptTokensDetails: tokenDetailsSchema(),
cacheTokensDetails: tokenDetailsSchema(),
candidatesTokensDetails: tokenDetailsSchema(),
toolUsePromptTokensDetails: tokenDetailsSchema(),
})
.loose();
// https://ai.google.dev/api/generate-content#UrlRetrievalMetadata
export const getUrlContextMetadataSchema = () =>
z.object({
urlMetadata: z
.array(
z.object({
retrievedUrl: z.string(),
urlRetrievalStatus: z.string(),
}),
)
.nullish(),
});
export const responseSchema = lazySchema(() =>
zodSchema(
z.object({
responseId: z.string().nullish(),
candidates: z
.array(
z.object({
content: getContentSchema().nullish().or(z.object({}).strict()),
finishReason: z.string().nullish(),
finishMessage: z.string().nullish(),
continuationToken: z.string().nullish(),
safetyRatings: z.array(getSafetyRatingSchema()).nullish(),
groundingMetadata: getGroundingMetadataSchema().nullish(),
urlContextMetadata: getUrlContextMetadataSchema().nullish(),
}),
)
.nullish(),
usageMetadata: usageSchema().nullish(),
promptFeedback: z
.object({
blockReason: z.string().nullish(),
safetyRatings: z.array(getSafetyRatingSchema()).nullish(),
})
.nullish(),
}),
),
);
type CandidateSchema = NonNullable<
InferSchema<typeof responseSchema>['candidates']
>[number];
export type GroundingMetadataSchema = NonNullable<
CandidateSchema['groundingMetadata']
>;
export type UrlContextMetadataSchema = NonNullable<
CandidateSchema['urlContextMetadata']
>;
export type SafetyRatingSchema = NonNullable<
CandidateSchema['safetyRatings']
>[number];
export type PromptFeedbackSchema = NonNullable<
InferSchema<typeof responseSchema>['promptFeedback']
>;
export type UsageMetadataSchema = NonNullable<
InferSchema<typeof responseSchema>['usageMetadata']
>;
// limited version of the schema, focussed on what is needed for the implementation
// this approach limits breakages when the API changes and increases efficiency
const chunkSchema = lazySchema(() =>
zodSchema(
z.object({
error: getGoogleErrorSchema().nullish(),
responseId: z.string().nullish(),
candidates: z
.array(
z.object({
content: getContentSchema().nullish(),
finishReason: z.string().nullish(),
finishMessage: z.string().nullish(),
continuationToken: z.string().nullish(),
safetyRatings: z.array(getSafetyRatingSchema()).nullish(),
groundingMetadata: getGroundingMetadataSchema().nullish(),
urlContextMetadata: getUrlContextMetadataSchema().nullish(),
}),
)
.nullish(),
usageMetadata: usageSchema().nullish(),
promptFeedback: z
.object({
blockReason: z.string().nullish(),
safetyRatings: z.array(getSafetyRatingSchema()).nullish(),
})
.nullish(),
}),
),
);
type ChunkSchema = InferSchema<typeof chunkSchema>;
function isConfirmedPromptBlockReason(
blockReason: string | null | undefined,
): blockReason is string {
return (
blockReason != null &&
blockReason !== '' &&
blockReason !== 'BLOCK_REASON_UNSPECIFIED' &&
blockReason !== 'BLOCKED_REASON_UNSPECIFIED'
);
}