mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python (fix:gemini): make Gemini honor declarative outputSchema, not just JSON mode (#5893)
* fix(gemini): preserve schema response_format * fix(gemini): satisfy pyright strict in response schema extraction Cast Any-narrowed mappings to Mapping[str, Any] in the structured-output schema helpers so pyright strict no longer reports partially-unknown member, argument, and variable types. Pass response_format["format"] straight into the recursive extractor, which already guards non-mapping inputs. No behavior change. * fix(gemini): use Sequence[object] cast to satisfy both mypy and pyright The Sequence[Any] cast pyright strict needs to know the loop element type is reported as a redundant-cast by mypy, which already narrows the isinstance branch to Sequence[Any]. Cast to Sequence[object] instead: pyright gets a fully known element type and mypy no longer sees an identical-type cast. No behavior change. --------- Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
bf4ad48cf2
commit
d5335fbeae
@@ -109,8 +109,8 @@ class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to
|
||||
or ``types.Tool`` objects returned by ``get_code_interpreter_tool``, ``get_web_search_tool``,
|
||||
``get_mcp_tool``, ``get_file_search_tool``, or ``get_maps_grounding_tool``.
|
||||
tool_choice: How the model picks a tool. One of ``'auto'``, ``'none'``, or ``'required'``.
|
||||
response_format: Pydantic model type for structured JSON output. The response text is
|
||||
parsed into the model and exposed via ``ChatResponse.value``.
|
||||
response_format: Pydantic model type or JSON schema mapping for structured JSON output.
|
||||
The response text is parsed and exposed via ``ChatResponse.value``.
|
||||
instructions: Extra system-level instructions prepended to the system message.
|
||||
|
||||
Not supported, and passing these raises a type error:
|
||||
@@ -255,6 +255,29 @@ _OPTION_CONSUMED_KEYS: frozenset[str] = frozenset({
|
||||
|
||||
_OPTION_EXCLUDE_KEYS: frozenset[str] = _OPTION_EXPLICIT_KEYS | _OPTION_CONSUMED_KEYS
|
||||
|
||||
_JSON_SCHEMA_TYPES: frozenset[str] = frozenset({
|
||||
"array",
|
||||
"boolean",
|
||||
"integer",
|
||||
"null",
|
||||
"number",
|
||||
"object",
|
||||
"string",
|
||||
})
|
||||
|
||||
_JSON_SCHEMA_KEYWORDS: frozenset[str] = frozenset({
|
||||
"$defs",
|
||||
"additionalProperties",
|
||||
"allOf",
|
||||
"anyOf",
|
||||
"enum",
|
||||
"items",
|
||||
"oneOf",
|
||||
"properties",
|
||||
"required",
|
||||
"type",
|
||||
})
|
||||
|
||||
_FINISH_REASON_MAP: dict[str, FinishReasonLiteral] = {
|
||||
"STOP": "stop",
|
||||
"MAX_TOKENS": "length",
|
||||
@@ -747,9 +770,13 @@ class RawGeminiChatClient(
|
||||
continue
|
||||
kwargs[_OPTION_TRANSLATIONS.get(key, key)] = value
|
||||
|
||||
if options.get("response_format") or options.get("response_schema"):
|
||||
response_format = options.get("response_format")
|
||||
response_schema = options.get("response_schema")
|
||||
if response_format is not None or response_schema is not None:
|
||||
kwargs["response_mime_type"] = "application/json"
|
||||
if schema := options.get("response_schema"):
|
||||
if response_schema is not None:
|
||||
kwargs["response_schema"] = response_schema
|
||||
elif (schema := self._extract_response_schema(response_format)) is not None:
|
||||
kwargs["response_schema"] = schema
|
||||
if tools := self._prepare_tools(options):
|
||||
kwargs["tools"] = tools
|
||||
@@ -762,6 +789,48 @@ class RawGeminiChatClient(
|
||||
|
||||
return types.GenerateContentConfig(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_schema(response_format: Any) -> dict[str, Any] | None:
|
||||
"""Extract a Gemini response schema from supported mapping response_format shapes."""
|
||||
if not isinstance(response_format, Mapping):
|
||||
return None
|
||||
mapping = cast("Mapping[str, Any]", response_format)
|
||||
|
||||
if (nested := RawGeminiChatClient._extract_response_schema(mapping.get("format"))) is not None:
|
||||
return nested
|
||||
|
||||
json_schema = mapping.get("json_schema")
|
||||
if isinstance(json_schema, Mapping):
|
||||
schema = cast("Mapping[str, Any]", json_schema).get("schema")
|
||||
if isinstance(schema, Mapping):
|
||||
return dict(cast("Mapping[str, Any]", schema))
|
||||
|
||||
schema = mapping.get("schema")
|
||||
if isinstance(schema, Mapping):
|
||||
return dict(cast("Mapping[str, Any]", schema))
|
||||
|
||||
if RawGeminiChatClient._is_json_schema_mapping(mapping):
|
||||
return dict(mapping)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_json_schema_mapping(value: Mapping[str, Any]) -> bool:
|
||||
"""Return True when a mapping appears to be a JSON Schema rather than a response-format envelope."""
|
||||
if not any(keyword in value for keyword in _JSON_SCHEMA_KEYWORDS):
|
||||
return False
|
||||
|
||||
schema_type = value.get("type")
|
||||
if schema_type is None:
|
||||
return True
|
||||
if isinstance(schema_type, str):
|
||||
return schema_type in _JSON_SCHEMA_TYPES
|
||||
if isinstance(schema_type, Sequence) and not isinstance(schema_type, (str, bytes)):
|
||||
entries = cast("Sequence[object]", schema_type)
|
||||
return all(isinstance(item, str) and item in _JSON_SCHEMA_TYPES for item in entries)
|
||||
|
||||
return False
|
||||
|
||||
def _prepare_tools(self, options: Mapping[str, Any]) -> list[types.Tool] | None:
|
||||
"""Translate the framework tool list into Gemini API tool objects.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user