Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from semantic_kernel.connectors.ai.google.shared_utils import (
FUNCTION_CHOICE_TYPE_TO_GOOGLE_FUNCTION_CALLING_MODE,
GEMINI_FUNCTION_NAME_SEPARATOR,
sanitize_schema_for_google_ai,
)
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
Expand Down Expand Up @@ -147,16 +148,23 @@ def format_tool_message(message: ChatMessageContent) -> list[Part]:

def kernel_function_metadata_to_google_ai_function_call_format(metadata: KernelFunctionMetadata) -> dict[str, Any]:
"""Convert the kernel function metadata to function calling format."""
parameters: dict[str, Any] | None = None
if metadata.parameters:
properties = {}
for param in metadata.parameters:
if param.name is None:
continue
prop_schema = sanitize_schema_for_google_ai(param.schema_data) if param.schema_data else param.schema_data
properties[param.name] = prop_schema
parameters = {
"type": "object",
"properties": properties,
"required": [p.name for p in metadata.parameters if p.is_required and p.name is not None],
}
return {
"name": metadata.custom_fully_qualified_name(GEMINI_FUNCTION_NAME_SEPARATOR),
"description": metadata.description or "",
"parameters": {
"type": "object",
"properties": {param.name: param.schema_data for param in metadata.parameters},
"required": [p.name for p in metadata.parameters if p.is_required],
}
if metadata.parameters
else None,
"parameters": parameters,
}


Expand Down
60 changes: 60 additions & 0 deletions python/semantic_kernel/connectors/ai/google/shared_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.

import logging
from copy import deepcopy
from typing import Any

from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceType
from semantic_kernel.const import DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR
Expand Down Expand Up @@ -51,6 +53,64 @@ def format_gemini_function_name_to_kernel_function_fully_qualified_name(gemini_f
return gemini_function_name


def sanitize_schema_for_google_ai(schema: dict[str, Any] | None) -> dict[str, Any] | None:
"""Sanitize a JSON schema dict so it is compatible with Google AI / Vertex AI.

The Google AI protobuf ``Schema`` does not support ``anyOf``, ``oneOf``, or
``allOf``. It also does not accept ``type`` as an array (e.g.
``["string", "null"]``). This helper recursively rewrites those constructs
into the subset that Google AI understands, using ``nullable`` where
appropriate.
"""
if schema is None:
return None

schema = deepcopy(schema)
return _sanitize_node(schema)


def _sanitize_node(node: dict[str, Any]) -> dict[str, Any]:
"""Recursively sanitize a single schema node."""
# --- handle ``type`` given as a list (e.g. ["string", "null"]) ---
type_val = node.get("type")
if isinstance(type_val, list):
non_null = [t for t in type_val if t != "null"]
if len(type_val) != len(non_null):
node["nullable"] = True
node["type"] = non_null[0] if non_null else "string"

# --- handle ``anyOf`` / ``oneOf`` / ``allOf`` ---
for key in ("anyOf", "oneOf", "allOf"):
variants = node.get(key)
if not variants:
continue
non_null = [v for v in variants if v.get("type") != "null"]
has_null = len(variants) != len(non_null)
chosen = _sanitize_node(non_null[0]) if non_null else {"type": "string"}
# Preserve description from the outer node
desc = node.get("description")
node.clear()
node.update(chosen)
if has_null:
node["nullable"] = True
if desc and "description" not in node:
node["description"] = desc
break # only process the first matching key

# --- recurse into nested structures ---
props = node.get("properties")
if isinstance(props, dict):
for prop_name, prop_schema in props.items():
if isinstance(prop_schema, dict):
props[prop_name] = _sanitize_node(prop_schema)

items = node.get("items")
if isinstance(items, dict):
node["items"] = _sanitize_node(items)

return node


def collapse_function_call_results_in_chat_history(chat_history: ChatHistory):
"""The Gemini API expects the results of parallel function calls to be contained in a single message to be returned.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from semantic_kernel.connectors.ai.google.shared_utils import (
FUNCTION_CHOICE_TYPE_TO_GOOGLE_FUNCTION_CALLING_MODE,
GEMINI_FUNCTION_NAME_SEPARATOR,
sanitize_schema_for_google_ai,
)
from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_prompt_execution_settings import (
VertexAIChatPromptExecutionSettings,
Expand Down Expand Up @@ -137,13 +138,20 @@ def format_tool_message(message: ChatMessageContent) -> list[Part]:

def kernel_function_metadata_to_vertex_ai_function_call_format(metadata: KernelFunctionMetadata) -> FunctionDeclaration:
"""Convert the kernel function metadata to function calling format."""
properties: dict[str, Any] = {}
if metadata.parameters:
for param in metadata.parameters:
if param.name is None:
continue
prop_schema = sanitize_schema_for_google_ai(param.schema_data) if param.schema_data else param.schema_data
properties[param.name] = prop_schema
return FunctionDeclaration(
name=metadata.custom_fully_qualified_name(GEMINI_FUNCTION_NAME_SEPARATOR),
description=metadata.description or "",
parameters={
"type": "object",
"properties": {param.name: param.schema_data for param in metadata.parameters},
"required": [p.name for p in metadata.parameters if p.is_required],
"properties": properties,
"required": [p.name for p in metadata.parameters if p.is_required and p.name is not None],
},
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
finish_reason_from_google_ai_to_semantic_kernel,
format_assistant_message,
format_user_message,
kernel_function_metadata_to_google_ai_function_call_format,
)
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
Expand All @@ -16,6 +17,8 @@
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.contents.utils.finish_reason import FinishReason as SemanticKernelFinishReason
from semantic_kernel.exceptions.service_exceptions import ServiceInvalidRequestError
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata


def test_finish_reason_from_google_ai_to_semantic_kernel():
Expand Down Expand Up @@ -157,3 +160,42 @@ def test_format_assistant_message_without_thought_signature() -> None:
assert formatted[0].function_call.name == "test_function"
assert formatted[0].function_call.args == {"arg1": "value1"}
assert not getattr(formatted[0], "thought_signature", None)


def test_google_ai_function_call_format_sanitizes_anyof_schema() -> None:
"""Integration test: anyOf in param schema_data is sanitized in the output dict."""
metadata = KernelFunctionMetadata(
name="test_func",
description="A test function",
is_prompt=False,
parameters=[
KernelParameterMetadata(
name="messages",
description="The user messages",
is_required=True,
schema_data={
"anyOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}},
],
"description": "The user messages",
},
),
],
)
result = kernel_function_metadata_to_google_ai_function_call_format(metadata)
param_schema = result["parameters"]["properties"]["messages"]
assert "anyOf" not in param_schema
assert param_schema["type"] == "string"


def test_google_ai_function_call_format_empty_parameters() -> None:
"""Integration test: metadata with no parameters produces parameters=None."""
metadata = KernelFunctionMetadata(
name="no_params_func",
description="No parameters",
is_prompt=False,
parameters=[],
)
result = kernel_function_metadata_to_google_ai_function_call_format(metadata)
assert result["parameters"] is None
172 changes: 172 additions & 0 deletions python/tests/unit/connectors/ai/google/test_shared_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
collapse_function_call_results_in_chat_history,
filter_system_message,
format_gemini_function_name_to_kernel_function_fully_qualified_name,
sanitize_schema_for_google_ai,
)
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
Expand Down Expand Up @@ -94,3 +95,174 @@ def test_collapse_function_call_results_in_chat_history() -> None:
collapse_function_call_results_in_chat_history(chat_history)
assert len(chat_history.messages) == 7
assert len(chat_history.messages[1].items) == 2


# --- sanitize_schema_for_google_ai tests ---


def test_sanitize_schema_none():
"""Test that None input returns None."""
assert sanitize_schema_for_google_ai(None) is None


def test_sanitize_schema_simple_passthrough():
"""Test that a simple schema passes through unchanged."""
schema = {"type": "string", "description": "A name"}
result = sanitize_schema_for_google_ai(schema)
assert result == {"type": "string", "description": "A name"}


def test_sanitize_schema_type_as_list_with_null():
"""type: ["string", "null"] should become type: "string" + nullable: true."""
schema = {"type": ["string", "null"], "description": "Optional field"}
result = sanitize_schema_for_google_ai(schema)
assert result == {"type": "string", "nullable": True, "description": "Optional field"}


def test_sanitize_schema_type_as_list_without_null():
"""type: ["string", "integer"] should pick the first type."""
schema = {"type": ["string", "integer"]}
result = sanitize_schema_for_google_ai(schema)
assert result == {"type": "string"}


def test_sanitize_schema_anyof_with_null():
"""AnyOf with null variant should become the non-null type + nullable."""
schema = {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Optional param",
}
result = sanitize_schema_for_google_ai(schema)
assert result == {"type": "string", "nullable": True, "description": "Optional param"}


def test_sanitize_schema_anyof_without_null():
"""AnyOf without null should pick the first variant."""
schema = {
"anyOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}},
],
}
result = sanitize_schema_for_google_ai(schema)
assert result == {"type": "string"}


def test_sanitize_schema_oneof():
"""OneOf should be handled the same as anyOf."""
schema = {
"oneOf": [{"type": "integer"}, {"type": "null"}],
}
result = sanitize_schema_for_google_ai(schema)
assert result == {"type": "integer", "nullable": True}


def test_sanitize_schema_nested_properties():
"""AnyOf inside nested properties should be sanitized recursively."""
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"anyOf": [{"type": "number"}, {"type": "null"}]},
},
}
result = sanitize_schema_for_google_ai(schema)
assert result == {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"type": "number", "nullable": True},
},
}


def test_sanitize_schema_nested_items():
"""AnyOf inside array items should be sanitized recursively."""
schema = {
"type": "array",
"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
}
result = sanitize_schema_for_google_ai(schema)
assert result == {
"type": "array",
"items": {"type": "string"},
}


def test_sanitize_schema_does_not_mutate_original():
"""The original schema dict should not be modified."""
schema = {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "test",
}
original = {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "test"}
sanitize_schema_for_google_ai(schema)
assert schema == original


def test_sanitize_schema_agent_messages_param():
"""Reproducer for issue #12442: str | list[str] parameter schema."""
schema = {
"anyOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}},
],
"description": "The user messages for the agent.",
}
result = sanitize_schema_for_google_ai(schema)
assert "anyOf" not in result
assert result["type"] == "string"
assert result["description"] == "The user messages for the agent."


def test_sanitize_schema_allof():
"""AllOf should be handled like anyOf/oneOf, picking the first variant."""
schema = {
"allOf": [
{"type": "object", "properties": {"name": {"type": "string"}}},
{"type": "object", "properties": {"age": {"type": "integer"}}},
],
}
result = sanitize_schema_for_google_ai(schema)
assert "allOf" not in result
assert result["type"] == "object"
assert "name" in result["properties"]


def test_sanitize_schema_allof_with_null():
"""AllOf with a null variant should produce nullable: true."""
schema = {
"allOf": [{"type": "string"}, {"type": "null"}],
}
result = sanitize_schema_for_google_ai(schema)
assert "allOf" not in result
assert result["type"] == "string"
assert result["nullable"] is True


def test_sanitize_schema_all_null_type_list():
"""type: ["null"] should fall back to type: "string" + nullable: true."""
schema = {"type": ["null"]}
result = sanitize_schema_for_google_ai(schema)
assert result == {"type": "string", "nullable": True}


def test_sanitize_schema_all_null_anyof():
"""AnyOf where all variants are null should fall back to type: "string"."""
schema = {"anyOf": [{"type": "null"}]}
result = sanitize_schema_for_google_ai(schema)
assert result == {"type": "string", "nullable": True}


def test_sanitize_schema_chosen_variant_keeps_own_description():
"""When the chosen anyOf variant has its own description, do not overwrite it."""
schema = {
"anyOf": [
{"type": "string", "description": "inner desc"},
{"type": "null"},
],
"description": "outer desc",
}
result = sanitize_schema_for_google_ai(schema)
assert result["description"] == "inner desc"
assert result["nullable"] is True
Loading
Loading