From 1efebe257ad00bf259b7fe90f1237b271f40ba57 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 4 Jun 2026 18:07:06 +0200 Subject: [PATCH] fix(google): skip unreadable tool schemas --- .../google/realtime-voice-provider.test.ts | 82 ++++++++ extensions/google/realtime-voice-provider.ts | 21 +- extensions/google/tool-schema.ts | 45 ++++ extensions/google/transport-stream.test.ts | 198 ++++++++++++++++++ extensions/google/transport-stream.ts | 89 ++++++-- .../providers/google-shared.convert.test.ts | 171 ++++++++++++++- src/llm/providers/google-shared.ts | 110 ++++++++-- 7 files changed, 680 insertions(+), 36 deletions(-) create mode 100644 extensions/google/tool-schema.ts diff --git a/extensions/google/realtime-voice-provider.test.ts b/extensions/google/realtime-voice-provider.test.ts index 9968b2f586dc..d0646aedb02a 100644 --- a/extensions/google/realtime-voice-provider.test.ts +++ b/extensions/google/realtime-voice-provider.test.ts @@ -301,6 +301,88 @@ describe("buildGoogleRealtimeVoiceProvider", () => { expect(declarations[1]?.behavior).toBe("NON_BLOCKING"); }); + it("skips unreadable realtime tool declarations while preserving healthy tools", async () => { + const provider = buildGoogleRealtimeVoiceProvider(); + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + const cyclicSchema: Record = { type: "object" }; + cyclicSchema.properties = { self: cyclicSchema }; + const sharedProperty = { type: "string" }; + + const bridge = provider.createBridge({ + providerConfig: { + apiKey: "gemini-key", + model: "gemini-live-2.5-flash-preview", + }, + tools: [ + { + type: "function", + get name(): string { + throw new Error("tool name unavailable"); + }, + description: "Broken", + parameters: {}, + }, + { + type: "function", + name: "revoked", + description: "Broken", + parameters: proxy, + }, + { + type: "function", + name: "cyclic", + description: "Broken", + parameters: cyclicSchema, + }, + { + type: "function", + name: "lookup", + description: "Lookup", + parameters: { + type: "object", + properties: { + first: sharedProperty, + second: sharedProperty, + }, + required: ["first", "second"], + }, + }, + ] as never, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + await bridge.connect(); + + const declarations = + ( + lastConnectParams().config as { + tools?: Array<{ + functionDeclarations?: Array<{ + description?: string; + name?: string; + parametersJsonSchema?: unknown; + }>; + }>; + } + ).tools?.[0]?.functionDeclarations ?? []; + expect(declarations).toEqual([ + { + name: "lookup", + description: "Lookup", + parametersJsonSchema: { + type: "object", + properties: { + first: { type: "string" }, + second: { type: "string" }, + }, + required: ["first", "second"], + }, + }, + ]); + }); + it("omits zero temperature for native audio responses", async () => { const provider = buildGoogleRealtimeVoiceProvider(); const bridge = provider.createBridge({ diff --git a/extensions/google/realtime-voice-provider.ts b/extensions/google/realtime-voice-provider.ts index d2ca9c166d6a..29e26c9dc988 100644 --- a/extensions/google/realtime-voice-provider.ts +++ b/extensions/google/realtime-voice-provider.ts @@ -47,6 +47,7 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createGoogleGenAI } from "./google-genai-runtime.js"; +import { materializeGoogleToolSchema } from "./tool-schema.js"; const GOOGLE_REALTIME_DEFAULT_MODEL = "gemini-2.5-flash-native-audio-preview-12-2025"; const GOOGLE_REALTIME_DEFAULT_VOICE = "Kore"; @@ -338,17 +339,27 @@ function buildRealtimeInputConfig( } function buildFunctionDeclarations(tools: RealtimeVoiceTool[] | undefined): FunctionDeclaration[] { - return (tools ?? []).map((tool) => { + return (tools ?? []).flatMap((tool) => { + const declaration = buildFunctionDeclaration(tool); + return declaration ? [declaration] : []; + }); +} + +function buildFunctionDeclaration(tool: RealtimeVoiceTool): FunctionDeclaration | undefined { + try { + const name = tool.name; const declaration: FunctionDeclaration = { - name: tool.name, + name, description: tool.description, - parametersJsonSchema: tool.parameters, + parametersJsonSchema: materializeGoogleToolSchema(tool.parameters), }; - if (tool.name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) { + if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) { declaration.behavior = "NON_BLOCKING" as Behavior; } return declaration; - }); + } catch { + return undefined; + } } function buildGoogleLiveConnectConfig(config: GoogleRealtimeLiveConfig): LiveConnectConfig { diff --git a/extensions/google/tool-schema.ts b/extensions/google/tool-schema.ts new file mode 100644 index 000000000000..9a61eb74a654 --- /dev/null +++ b/extensions/google/tool-schema.ts @@ -0,0 +1,45 @@ +const MAX_GOOGLE_TOOL_SCHEMA_DEPTH = 64; +const MAX_GOOGLE_TOOL_SCHEMA_NODES = 10_000; + +export function materializeGoogleToolSchema(schema: unknown): unknown { + const state = { nodes: 0 }; + return materializeGoogleToolSchemaValue(schema, state, 0, new Set()); +} + +function materializeGoogleToolSchemaValue( + schema: unknown, + state: { nodes: number }, + depth: number, + stack: Set, +): unknown { + if (depth > MAX_GOOGLE_TOOL_SCHEMA_DEPTH) { + throw new Error("Google tool schema exceeds maximum supported depth"); + } + if (++state.nodes > MAX_GOOGLE_TOOL_SCHEMA_NODES) { + throw new Error("Google tool schema exceeds maximum supported size"); + } + if (typeof schema !== "object" || schema === null) { + return schema; + } + if (stack.has(schema)) { + throw new Error("Google tool schema contains a cycle"); + } + stack.add(schema); + try { + if (Array.isArray(schema)) { + return schema.map((item) => materializeGoogleToolSchemaValue(item, state, depth + 1, stack)); + } + const result: Record = {}; + for (const [key, value] of Object.entries(schema)) { + Object.defineProperty(result, key, { + value: materializeGoogleToolSchemaValue(value, state, depth + 1, stack), + enumerable: true, + configurable: true, + writable: true, + }); + } + return result; + } finally { + stack.delete(schema); + } +} diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index 7f569044f77a..c5a702b6d189 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -1867,6 +1867,204 @@ describe("google transport stream", () => { expect(params.toolConfig).toBeUndefined(); }); + it("skips unreadable Google tools while preserving healthy declarations", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + const cyclicSchema: Record = { type: "object" }; + cyclicSchema.properties = { self: cyclicSchema }; + const sharedProperty = { type: "string" }; + + const params = buildGoogleGenerativeAiParams(buildGeminiModel(), { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [ + { + get name(): string { + throw new Error("tool name unavailable"); + }, + description: "Broken", + parameters: {}, + }, + { + name: "revoked", + description: "Broken", + parameters: proxy, + }, + { + name: "cyclic", + description: "Broken", + parameters: cyclicSchema, + }, + { + name: "lookup", + description: "Lookup", + parameters: { + type: "object", + properties: { + first: sharedProperty, + second: sharedProperty, + }, + required: ["first", "second"], + }, + }, + ], + } as never); + + expect(params.tools).toEqual([ + { + functionDeclarations: [ + { + name: "lookup", + description: "Lookup", + parametersJsonSchema: { + type: "object", + properties: { + first: { type: "string" }, + second: { type: "string" }, + }, + required: ["first", "second"], + }, + }, + ], + }, + ]); + }); + + it("preserves reserved Google tool schema property names", () => { + const parameters = JSON.parse( + '{"type":"object","properties":{"__proto__":{"type":"string"}},"required":["__proto__"]}', + ) as Record; + + const params = buildGoogleGenerativeAiParams(buildGeminiModel(), { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [ + { + name: "lookup", + description: "Lookup", + parameters, + }, + ], + } as never); + + const schema = params.tools?.[0]?.functionDeclarations?.[0]?.parametersJsonSchema as + | { properties?: Record; required?: unknown } + | undefined; + const properties = schema?.properties; + expect(properties).toBeDefined(); + expect(Object.hasOwn(properties as Record, "__proto__")).toBe(true); + expect(Reflect.get(properties as Record, "__proto__")).toEqual({ + type: "string", + }); + expect(schema?.required).toEqual(["__proto__"]); + }); + + it("treats null Google tool choice as no explicit choice", () => { + const params = buildGoogleGenerativeAiParams( + buildGeminiModel(), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [ + { + name: "lookup", + description: "Lookup", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + }, + }, + }, + ], + } as never, + { toolChoice: null } as never, + ); + + expect(params.tools).toBeDefined(); + expect(params.toolConfig).toBeUndefined(); + }); + + it("omits optional tools when every Google tool declaration is skipped", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + + const params = buildGoogleGenerativeAiParams( + buildGeminiModel(), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [ + { + name: "revoked", + description: "Broken", + parameters: proxy, + }, + ], + } as never, + { toolChoice: "auto" }, + ); + + expect(params.tools).toBeUndefined(); + expect(params.toolConfig).toBeUndefined(); + }); + + it("throws when forced Google tool choice has no valid declarations", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + + expect(() => + buildGoogleGenerativeAiParams( + buildGeminiModel(), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [ + { + name: "revoked", + description: "Broken", + parameters: proxy, + }, + ], + } as never, + { toolChoice: "any" }, + ), + ).toThrow("Google tool choice requires at least one valid tool declaration"); + }); + + it("throws when pinned function tool choice is skipped", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + + expect(() => + buildGoogleGenerativeAiParams( + buildGeminiModel(), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [ + { + name: "revoked", + description: "Broken", + parameters: proxy, + }, + { + name: "lookup", + description: "Lookup", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, + ], + } as never, + { + toolChoice: { + type: "function", + function: { name: "revoked" }, + }, + }, + ), + ).toThrow("Google tool choice references an unavailable tool: revoked"); + }); + it("uses a non-empty text placeholder for empty user text", () => { const params = buildGoogleGenerativeAiParams(buildGeminiModel(), { messages: [ diff --git a/extensions/google/transport-stream.ts b/extensions/google/transport-stream.ts index c2e1816bf3d8..5329782d17bb 100644 --- a/extensions/google/transport-stream.ts +++ b/extensions/google/transport-stream.ts @@ -37,6 +37,7 @@ import { type GoogleThinkingInputLevel, type GoogleThinkingLevel, } from "./thinking-api.js"; +import { materializeGoogleToolSchema } from "./tool-schema.js"; import { isGoogleVertexCredentialsMarker, resolveGoogleVertexAuthorizedUserHeaders, @@ -290,7 +291,8 @@ function mapToolChoice( return undefined; } if (typeof choice === "object" && choice.type === "function") { - return { mode: "ANY", allowedFunctionNames: [choice.function.name] }; + const name = choice.function.name; + return { mode: "ANY", allowedFunctionNames: [name] }; } switch (choice) { case "none": @@ -303,6 +305,41 @@ function mapToolChoice( } } +function getPinnedFunctionToolName( + choice: GoogleTransportOptions["toolChoice"], +): string | undefined { + return choice !== null && typeof choice === "object" && choice.type === "function" + ? choice.function.name + : undefined; +} + +function requiresGoogleToolChoice(choice: GoogleTransportOptions["toolChoice"]): boolean { + return ( + choice === "any" || choice === "required" || getPinnedFunctionToolName(choice) !== undefined + ); +} + +function collectFunctionDeclarationNames( + tools: GoogleGenerateContentRequest["tools"], +): Set { + const names = new Set(); + for (const tool of tools ?? []) { + const declarations = tool.functionDeclarations; + if (!Array.isArray(declarations)) { + continue; + } + for (const declaration of declarations) { + if (declaration && typeof declaration === "object" && "name" in declaration) { + const name = (declaration as { name?: unknown }).name; + if (typeof name === "string") { + names.add(name); + } + } + } + } + return names; +} + function mapStopReasonString(reason: string): "stop" | "length" | "error" { switch (reason) { case "STOP": @@ -680,17 +717,34 @@ function convertGoogleTools(tools: NonNullable) { if (tools.length === 0) { return undefined; } + const functionDeclarations = tools.flatMap((tool) => { + const declaration = buildGoogleFunctionDeclaration(tool); + return declaration ? [declaration] : []; + }); + if (functionDeclarations.length === 0) { + return undefined; + } return [ { - functionDeclarations: tools.map((tool) => ({ - name: tool.name, - description: tool.description, - parametersJsonSchema: tool.parameters, - })), + functionDeclarations, }, ]; } +function buildGoogleFunctionDeclaration( + tool: NonNullable[number], +): Record | undefined { + try { + return { + name: tool.name, + description: tool.description, + parametersJsonSchema: materializeGoogleToolSchema(tool.parameters), + }; + } catch { + return undefined; + } +} + export function buildGoogleGenerativeAiParams( model: GoogleTransportModel, context: Context, @@ -732,12 +786,23 @@ export function buildGoogleGenerativeAiParams( }; } if (!cachedContent && context.tools?.length) { - params.tools = convertGoogleTools(context.tools); - const toolChoice = mapToolChoice(options?.toolChoice); - if (toolChoice) { - params.toolConfig = { - functionCallingConfig: toolChoice, - }; + const tools = convertGoogleTools(context.tools); + if (!tools && requiresGoogleToolChoice(options?.toolChoice)) { + throw new Error("Google tool choice requires at least one valid tool declaration"); + } + if (tools) { + const declarationNames = collectFunctionDeclarationNames(tools); + const pinnedFunctionName = getPinnedFunctionToolName(options?.toolChoice); + if (pinnedFunctionName && !declarationNames.has(pinnedFunctionName)) { + throw new Error(`Google tool choice references an unavailable tool: ${pinnedFunctionName}`); + } + params.tools = tools; + const toolChoice = mapToolChoice(options?.toolChoice); + if (toolChoice) { + params.toolConfig = { + functionCallingConfig: toolChoice, + }; + } } } return params; diff --git a/src/llm/providers/google-shared.convert.test.ts b/src/llm/providers/google-shared.convert.test.ts index 1e35a38be218..206970aa10a5 100644 --- a/src/llm/providers/google-shared.convert.test.ts +++ b/src/llm/providers/google-shared.convert.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { Context, Tool } from "../types.js"; -import { convertMessages, convertTools } from "./google-shared.js"; +import { + buildGoogleGenerateContentParams, + convertMessages, + convertTools, +} from "./google-shared.js"; import { asRecord, expectConvertedRoles, @@ -148,6 +152,171 @@ describe("google-shared convertTools", () => { expect(config.required).toEqual(["retries"]); expect(params.required).toEqual(["config"]); }); + + it("preserves reserved JSON Schema property names", () => { + const parameters = JSON.parse( + '{"type":"object","properties":{"__proto__":{"type":"string"}},"required":["__proto__"]}', + ) as Record; + + const converted = convertTools([ + { + name: "lookup", + description: "Lookup", + parameters, + }, + ] as unknown as Tool[]); + + const params = getFirstToolParameters( + converted as Parameters[0], + ); + const properties = asRecord(params.properties); + expect(Object.hasOwn(properties, "__proto__")).toBe(true); + expect(Reflect.get(properties, "__proto__")).toEqual({ type: "string" }); + expect(params.required).toEqual(["__proto__"]); + }); + + it("skips unreadable tool schemas while preserving healthy declarations", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + const cyclicSchema: Record = { type: "object" }; + cyclicSchema.properties = { self: cyclicSchema }; + const sharedProperty = { type: "string" }; + + const converted = convertTools([ + { + get name(): string { + throw new Error("tool name unavailable"); + }, + description: "Broken", + parameters: {}, + } as Tool, + { + name: "revoked", + description: "Broken", + parameters: proxy, + } as unknown as Tool, + { + name: "cyclic", + description: "Broken", + parameters: cyclicSchema, + } as unknown as Tool, + { + name: "lookup", + description: "Lookup", + parameters: { + type: "object", + properties: { + first: sharedProperty, + second: sharedProperty, + }, + required: ["first", "second"], + }, + }, + ]); + + const declarations = converted?.[0]?.functionDeclarations ?? []; + expect(declarations).toEqual([ + { + name: "lookup", + description: "Lookup", + parametersJsonSchema: { + type: "object", + properties: { + first: { type: "string" }, + second: { type: "string" }, + }, + required: ["first", "second"], + }, + }, + ]); + }); + + it("keeps OpenAPI parameter cleanup while skipping unreadable schemas", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + + const converted = convertTools( + [ + { + name: "revoked", + description: "Broken", + parameters: proxy, + } as unknown as Tool, + { + name: "lookup", + description: "Lookup", + parameters: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + query: { type: "string" }, + }, + }, + } as unknown as Tool, + ], + true, + ); + + expect(converted?.[0]?.functionDeclarations).toEqual([ + { + name: "lookup", + description: "Lookup", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + }, + }, + }, + ]); + }); +}); + +describe("google-shared buildGoogleGenerateContentParams", () => { + it("omits optional tools when every Google tool declaration is skipped", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + + const params = buildGoogleGenerateContentParams( + makeModel("gemini-2.5-pro"), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [ + { + name: "revoked", + description: "Broken", + parameters: proxy, + } as unknown as Tool, + ], + }, + { toolChoice: "auto" }, + ); + + expect(params.config?.tools).toBeUndefined(); + expect(params.config?.toolConfig).toBeUndefined(); + }); + + it("throws when forced Google tool choice has no valid declarations", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + + expect(() => + buildGoogleGenerateContentParams( + makeModel("gemini-2.5-pro"), + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [ + { + name: "revoked", + description: "Broken", + parameters: proxy, + } as unknown as Tool, + ], + }, + { toolChoice: "any" }, + ), + ).toThrow("Google tool choice requires at least one valid tool declaration"); + }); }); describe("google-shared convertMessages", () => { diff --git a/src/llm/providers/google-shared.ts b/src/llm/providers/google-shared.ts index d2f633365500..0af5e382c3e5 100644 --- a/src/llm/providers/google-shared.ts +++ b/src/llm/providers/google-shared.ts @@ -337,23 +337,88 @@ const JSON_SCHEMA_META_DECLARATIONS = new Set([ "$defs", "definitions", // pre-draft-2019-09 equivalent of $defs ]); +const MAX_GOOGLE_TOOL_SCHEMA_DEPTH = 64; +const MAX_GOOGLE_TOOL_SCHEMA_NODES = 10_000; /** * Strip meta-declarations from a schema obj */ function sanitizeForOpenApi(schema: unknown): unknown { - if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { + return materializeGoogleToolSchema(schema, { stripMetaDeclarations: true }); +} + +function materializeGoogleToolSchema( + schema: unknown, + options: { stripMetaDeclarations: boolean }, +): unknown { + const state = { nodes: 0 }; + return materializeGoogleToolSchemaValue(schema, options, state, 0, new Set()); +} + +function materializeGoogleToolSchemaValue( + schema: unknown, + options: { stripMetaDeclarations: boolean }, + state: { nodes: number }, + depth: number, + stack: Set, +): unknown { + if (depth > MAX_GOOGLE_TOOL_SCHEMA_DEPTH) { + throw new Error("Google tool schema exceeds maximum supported depth"); + } + if (++state.nodes > MAX_GOOGLE_TOOL_SCHEMA_NODES) { + throw new Error("Google tool schema exceeds maximum supported size"); + } + if (typeof schema !== "object" || schema === null) { return schema; } - - const result: Record = {}; - for (const [key, value] of Object.entries(schema)) { - if (JSON_SCHEMA_META_DECLARATIONS.has(key)) { - continue; - } - result[key] = sanitizeForOpenApi(value); + if (stack.has(schema)) { + throw new Error("Google tool schema contains a cycle"); + } + + stack.add(schema); + try { + if (Array.isArray(schema)) { + return schema.map((item) => + materializeGoogleToolSchemaValue(item, options, state, depth + 1, stack), + ); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(schema)) { + if (options.stripMetaDeclarations && JSON_SCHEMA_META_DECLARATIONS.has(key)) { + continue; + } + Object.defineProperty(result, key, { + value: materializeGoogleToolSchemaValue(value, options, state, depth + 1, stack), + enumerable: true, + configurable: true, + writable: true, + }); + } + return result; + } finally { + stack.delete(schema); + } +} + +function buildGoogleFunctionDeclaration( + tool: Tool, + useParameters: boolean, +): Record | undefined { + try { + const name = tool.name; + const description = tool.description; + const parameters = useParameters + ? sanitizeForOpenApi(tool.parameters) + : materializeGoogleToolSchema(tool.parameters, { stripMetaDeclarations: false }); + return { + name, + description, + ...(useParameters ? { parameters } : { parametersJsonSchema: parameters }), + }; + } catch { + return undefined; } - return result; } /** @@ -371,15 +436,16 @@ export function convertTools( if (tools.length === 0) { return undefined; } + const functionDeclarations = tools.flatMap((tool) => { + const declaration = buildGoogleFunctionDeclaration(tool, useParameters); + return declaration ? [declaration] : []; + }); + if (functionDeclarations.length === 0) { + return undefined; + } return [ { - functionDeclarations: tools.map((tool) => ({ - name: tool.name, - description: tool.description, - ...(useParameters - ? { parameters: sanitizeForOpenApi(tool.parameters as unknown) } - : { parametersJsonSchema: tool.parameters }), - })), + functionDeclarations, }, ]; } @@ -400,6 +466,10 @@ export function mapToolChoice(choice: string): FunctionCallingConfigMode { } } +function requiresGoogleToolChoice(choice: GoogleProviderOptions["toolChoice"]): boolean { + return choice === "any"; +} + export function createGoogleAssistantOutput( model: Model, api: Api = model.api, @@ -485,13 +555,17 @@ export function buildGoogleGenerateContentParams( generationConfig.stopSequences = options.stop; } + const tools = context.tools && context.tools.length > 0 ? convertTools(context.tools) : undefined; + if (!tools && context.tools?.length && requiresGoogleToolChoice(options.toolChoice)) { + throw new Error("Google tool choice requires at least one valid tool declaration"); + } const config: GenerateContentConfig = { ...(Object.keys(generationConfig).length > 0 && generationConfig), ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), - ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), + ...(tools && { tools }), }; - if (context.tools && context.tools.length > 0 && options.toolChoice) { + if (tools && options.toolChoice) { config.toolConfig = { functionCallingConfig: { mode: mapToolChoice(options.toolChoice),