diff --git a/docs/ui/vercel-ai.md b/docs/ui/vercel-ai.md index 7d94aadbb9..f0da2110d3 100644 --- a/docs/ui/vercel-ai.md +++ b/docs/ui/vercel-ai.md @@ -1,6 +1,7 @@ # Vercel AI Data Stream Protocol -Pydantic AI natively supports the [Vercel AI Data Stream Protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol) to receive agent run input from, and stream events to, a [Vercel AI Elements](https://ai-sdk.dev/elements) frontend. +Pydantic AI natively supports the [Vercel AI Data Stream Protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol) to receive agent run input from, and stream events to, a frontend using [AI SDK UI](https://ai-sdk.dev/docs/ai-sdk-ui/overview) hooks like [`useChat`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat). You can optionally use [AI Elements](https://ai-sdk.dev/elements) for pre-built UI components. + ## Usage @@ -36,7 +37,7 @@ async def chat(request: Request) -> Response: If you're using a web framework not based on Starlette (e.g. Django or Flask) or need fine-grained control over the input or output, you can create a `VercelAIAdapter` instance and directly use its methods, which can be chained to accomplish the same thing as the `VercelAIAdapter.dispatch_request()` class method shown above: 1. The [`VercelAIAdapter.build_run_input()`][pydantic_ai.ui.vercel_ai.VercelAIAdapter.build_run_input] class method takes the request body as bytes and returns a Vercel AI [`RequestData`][pydantic_ai.ui.vercel_ai.request_types.RequestData] run input object, which you can then pass to the [`VercelAIAdapter()`][pydantic_ai.ui.vercel_ai.VercelAIAdapter] constructor along with the agent. - - You can also use the [`VercelAIAdapter.from_request()`][pydantic_ai.ui.UIAdapter.from_request] class method to build an adapter directly from a Starlette/FastAPI request. + - You can also use the [`VercelAIAdapter.from_request()`][pydantic_ai.ui.vercel_ai.VercelAIAdapter.from_request] class method to build an adapter directly from a Starlette/FastAPI request. 2. The [`VercelAIAdapter.run_stream()`][pydantic_ai.ui.UIAdapter.run_stream] method runs the agent and returns a stream of Vercel AI events. It supports the same optional arguments as [`Agent.run_stream_events()`](../agents.md#running-agents) and an optional `on_complete` callback function that receives the completed [`AgentRunResult`][pydantic_ai.agent.AgentRunResult] and can optionally yield additional Vercel AI events. - You can also use [`VercelAIAdapter.run_stream_native()`][pydantic_ai.ui.UIAdapter.run_stream_native] to run the agent and return a stream of Pydantic AI events instead, which can then be transformed into Vercel AI events using [`VercelAIAdapter.transform_stream()`][pydantic_ai.ui.UIAdapter.transform_stream]. 3. The [`VercelAIAdapter.encode_stream()`][pydantic_ai.ui.UIAdapter.encode_stream] method encodes the stream of Vercel AI events as SSE (HTTP Server-Sent Events) strings, which you can then return as a streaming response. @@ -81,3 +82,27 @@ async def chat(request: Request) -> Response: sse_event_stream = adapter.encode_stream(event_stream) return StreamingResponse(sse_event_stream, media_type=accept) ``` + +## Tool Approval + +!!! note + Tool approval requires AI SDK UI v6 or later on the frontend. + +Pydantic AI supports human-in-the-loop tool approval workflows with AI SDK UI, allowing users to approve or deny tool executions before they run. See the [deferred tool calls documentation](../deferred-tools.md) for details on setting up tools that require approval. + +To enable tool approval streaming, pass `tool_approval=True` when creating the adapter: + +```py {test="skip" lint="skip"} +@app.post('/chat') +async def chat(request: Request) -> Response: + adapter = await VercelAIAdapter.from_request(request, agent=agent, tool_approval=True) + return adapter.streaming_response(adapter.run_stream()) +``` + +When `tool_approval=True`, the adapter will: + +1. Emit `tool-approval-request` chunks when tools with `requires_approval=True` are called +2. Automatically extract approval responses from follow-up requests +3. Emit `tool-output-denied` chunks for rejected tools + +On the frontend, AI SDK UI's [`useChat`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) hook handles the approval flow. You can use the [`Confirmation`](https://ai-sdk.dev/elements/components/confirmation) component from AI Elements for a pre-built approval UI, or build your own using the hook's `addToolResult` function. diff --git a/pydantic_ai_slim/pydantic_ai/_json_schema.py b/pydantic_ai_slim/pydantic_ai/_json_schema.py index a0360dc7fa..1fc0680dd2 100644 --- a/pydantic_ai_slim/pydantic_ai/_json_schema.py +++ b/pydantic_ai_slim/pydantic_ai/_json_schema.py @@ -45,7 +45,7 @@ def __init__( self.prefer_inlined_defs = prefer_inlined_defs self.simplify_nullable_unions = simplify_nullable_unions - self.defs: dict[str, JsonSchema] = self.schema.get('$defs', {}) + self.defs: dict[str, JsonSchema] = deepcopy(self.schema.get('$defs', {})) self.refs_stack: list[str] = [] self.recursive_refs = set[str]() diff --git a/pydantic_ai_slim/pydantic_ai/profiles/google.py b/pydantic_ai_slim/pydantic_ai/profiles/google.py index 6708fc41c3..76be665e39 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/google.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/google.py @@ -44,6 +44,17 @@ def transform(self, schema: JsonSchema) -> JsonSchema: if (const := schema.pop('const', None)) is not None: # Gemini doesn't support const, but it does support enum with a single value schema['enum'] = [const] + # If type is not present, infer it from the const value for Gemini API compatibility + if 'type' not in schema: + if isinstance(const, str): + schema['type'] = 'string' + elif isinstance(const, bool): + # bool must be checked before int since bool is a subclass of int in Python + schema['type'] = 'boolean' + elif isinstance(const, int): + schema['type'] = 'integer' + elif isinstance(const, float): + schema['type'] = 'number' schema.pop('discriminator', None) schema.pop('examples', None) diff --git a/pydantic_ai_slim/pydantic_ai/ui/_adapter.py b/pydantic_ai_slim/pydantic_ai/ui/_adapter.py index 4ac609a07e..93310c34f3 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/_adapter.py +++ b/pydantic_ai_slim/pydantic_ai/ui/_adapter.py @@ -125,6 +125,12 @@ class UIAdapter(ABC, Generic[RunInputT, MessageT, EventT, AgentDepsT, OutputData accept: str | None = None """The `Accept` header value of the request, used to determine how to encode the protocol-specific events for the streaming response.""" + tool_approval: bool = False + """Whether to enable tool approval streaming for human-in-the-loop workflows.""" + + deferred_tool_results: DeferredToolResults | None = None + """Deferred tool results extracted from the request, used for tool approval workflows.""" + @classmethod async def from_request( cls, request: Request, *, agent: AbstractAgent[AgentDepsT, OutputDataT] @@ -237,6 +243,10 @@ def run_stream_native( toolsets: Optional additional toolsets for this run. builtin_tools: Optional additional builtin tools to use for this run. """ + # Use instance field as fallback if not explicitly passed + if deferred_tool_results is None: + deferred_tool_results = self.deferred_tool_results + message_history = [*(message_history or []), *self.messages] toolset = self.toolset diff --git a/pydantic_ai_slim/pydantic_ai/ui/_event_stream.py b/pydantic_ai_slim/pydantic_ai/ui/_event_stream.py index 391cf06f2f..13b862147e 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/_event_stream.py +++ b/pydantic_ai_slim/pydantic_ai/ui/_event_stream.py @@ -70,6 +70,9 @@ class UIEventStream(ABC, Generic[RunInputT, EventT, AgentDepsT, OutputDataT]): accept: str | None = None """The `Accept` header value of the request, used to determine how to encode the protocol-specific events for the streaming response.""" + tool_approval: bool = False + """Whether tool approval streaming is enabled for human-in-the-loop workflows.""" + message_id: str = field(default_factory=lambda: str(uuid4())) """The message ID to use for the next event.""" diff --git a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py index fa82b9255b..c63efdf26d 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py +++ b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py @@ -35,7 +35,7 @@ VideoUrl, ) from ...output import OutputDataT -from ...tools import AgentDepsT +from ...tools import AgentDepsT, DeferredToolApprovalResult, DeferredToolResults from .. import MessagesBuilder, UIAdapter, UIEventStream from ._event_stream import VercelAIEventStream from .request_types import ( @@ -51,6 +51,7 @@ SourceUrlUIPart, StepStartUIPart, TextUIPart, + ToolApprovalResponded, ToolInputAvailablePart, ToolOutputAvailablePart, ToolOutputErrorPart, @@ -61,7 +62,9 @@ from .response_types import BaseChunk if TYPE_CHECKING: - pass + from starlette.requests import Request + + from ...agent import AbstractAgent __all__ = ['VercelAIAdapter'] @@ -78,9 +81,51 @@ def build_run_input(cls, body: bytes) -> RequestData: """Build a Vercel AI run input object from the request body.""" return request_data_ta.validate_json(body) + @classmethod + async def from_request( + cls, + request: Request, + *, + agent: AbstractAgent[AgentDepsT, OutputDataT], + tool_approval: bool = False, + ) -> VercelAIAdapter[AgentDepsT, OutputDataT]: + """Create a Vercel AI adapter from a request. + + Args: + request: The incoming Starlette/FastAPI request. + agent: The Pydantic AI agent to run. + tool_approval: Whether to enable tool approval streaming for human-in-the-loop workflows. + """ + run_input = cls.build_run_input(await request.body()) + + # Extract deferred tool results from messages when tool_approval is enabled + deferred_tool_results: DeferredToolResults | None = None + if tool_approval: + approvals: dict[str, bool | DeferredToolApprovalResult] = {} + for msg in run_input.messages: + if msg.role != 'assistant': + continue + for part in msg.parts: + if not isinstance(part, ToolUIPart | DynamicToolUIPart): + continue + approval = part.approval + if not isinstance(approval, ToolApprovalResponded): + continue + approvals[part.tool_call_id] = approval.approved + if approvals: + deferred_tool_results = DeferredToolResults(approvals=approvals) + + return cls( + agent=agent, + run_input=run_input, + accept=request.headers.get('accept'), + tool_approval=tool_approval, + deferred_tool_results=deferred_tool_results, + ) + def build_event_stream(self) -> UIEventStream[RequestData, BaseChunk, AgentDepsT, OutputDataT]: """Build a Vercel AI event stream transformer.""" - return VercelAIEventStream(self.run_input, accept=self.accept) + return VercelAIEventStream(self.run_input, accept=self.accept, tool_approval=self.tool_approval) @cached_property def messages(self) -> list[ModelMessage]: diff --git a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py index ca94c5c186..bb854e29c6 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py +++ b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py @@ -4,7 +4,9 @@ from collections.abc import AsyncIterator, Mapping from dataclasses import dataclass +from functools import cached_property from typing import Any +from uuid import uuid4 from pydantic_core import to_json @@ -13,6 +15,7 @@ BuiltinToolCallPart, BuiltinToolReturnPart, FilePart, + FinishReason as PydanticFinishReason, FunctionToolResultEvent, RetryPromptPart, TextPart, @@ -23,15 +26,22 @@ ToolCallPartDelta, ) from ...output import OutputDataT -from ...tools import AgentDepsT +from ...run import AgentRunResultEvent +from ...tools import AgentDepsT, DeferredToolRequests from .. import UIEventStream -from .request_types import RequestData +from .request_types import ( + DynamicToolUIPart, + RequestData, + ToolApprovalResponded, + ToolUIPart, +) from .response_types import ( BaseChunk, DoneChunk, ErrorChunk, FileChunk, FinishChunk, + FinishReason, FinishStepChunk, ReasoningDeltaChunk, ReasoningEndChunk, @@ -41,13 +51,24 @@ TextDeltaChunk, TextEndChunk, TextStartChunk, + ToolApprovalRequestChunk, ToolInputAvailableChunk, ToolInputDeltaChunk, ToolInputStartChunk, ToolOutputAvailableChunk, + ToolOutputDeniedChunk, ToolOutputErrorChunk, ) +# Map Pydantic AI finish reasons to Vercel AI format +_FINISH_REASON_MAP: dict[PydanticFinishReason, FinishReason] = { + 'stop': 'stop', + 'length': 'length', + 'content_filter': 'content-filter', + 'tool_call': 'tool-calls', + 'error': 'error', +} + __all__ = ['VercelAIEventStream'] # See https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol @@ -64,6 +85,21 @@ class VercelAIEventStream(UIEventStream[RequestData, BaseChunk, AgentDepsT, Outp """UI event stream transformer for the Vercel AI protocol.""" _step_started: bool = False + _finish_reason: FinishReason = None + + @cached_property + def _denied_tool_ids(self) -> set[str]: + """Get the set of tool_call_ids that were denied by the user.""" + denied_ids: set[str] = set() + for msg in self.run_input.messages: + if msg.role != 'assistant': + continue + for part in msg.parts: + if not isinstance(part, ToolUIPart | DynamicToolUIPart): + continue + if isinstance(part.approval, ToolApprovalResponded) and not part.approval.approved: + denied_ids.add(part.tool_call_id) + return denied_ids @property def response_headers(self) -> Mapping[str, str] | None: @@ -85,10 +121,25 @@ async def before_response(self) -> AsyncIterator[BaseChunk]: async def after_stream(self) -> AsyncIterator[BaseChunk]: yield FinishStepChunk() - yield FinishChunk() + yield FinishChunk(finish_reason=self._finish_reason) yield DoneChunk() + async def handle_run_result(self, event: AgentRunResultEvent) -> AsyncIterator[BaseChunk]: + pydantic_reason = event.result.response.finish_reason + if pydantic_reason: + self._finish_reason = _FINISH_REASON_MAP.get(pydantic_reason, 'unknown') + + # Emit tool approval requests for deferred approvals (only when tool_approval is enabled) + output = event.result.output + if self.tool_approval and isinstance(output, DeferredToolRequests): + for tool_call in output.approvals: + yield ToolApprovalRequestChunk( + approval_id=str(uuid4()), + tool_call_id=tool_call.tool_call_id, + ) + async def on_error(self, error: Exception) -> AsyncIterator[BaseChunk]: + self._finish_reason = 'error' yield ErrorChunk(error_text=str(error)) async def handle_text_start(self, part: TextPart, follows_text: bool = False) -> AsyncIterator[BaseChunk]: @@ -182,10 +233,15 @@ async def handle_file(self, part: FilePart) -> AsyncIterator[BaseChunk]: async def handle_function_tool_result(self, event: FunctionToolResultEvent) -> AsyncIterator[BaseChunk]: part = event.result - if isinstance(part, RetryPromptPart): - yield ToolOutputErrorChunk(tool_call_id=part.tool_call_id, error_text=part.model_response()) + tool_call_id = part.tool_call_id + + # Check if this tool was denied by the user (only when tool_approval is enabled) + if self.tool_approval and tool_call_id in self._denied_tool_ids: + yield ToolOutputDeniedChunk(tool_call_id=tool_call_id) + elif isinstance(part, RetryPromptPart): + yield ToolOutputErrorChunk(tool_call_id=tool_call_id, error_text=part.model_response()) else: - yield ToolOutputAvailableChunk(tool_call_id=part.tool_call_id, output=self._tool_return_output(part)) + yield ToolOutputAvailableChunk(tool_call_id=tool_call_id, output=self._tool_return_output(part)) # ToolCallResultEvent.content may hold user parts (e.g. text, images) that Vercel AI does not currently have events for diff --git a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/request_types.py b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/request_types.py index 1fe9a593af..3203bb6162 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/request_types.py +++ b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/request_types.py @@ -1,7 +1,9 @@ """Vercel AI request types (UI messages). Converted to Python from: -https://github.com/vercel/ai/blob/ai%405.0.59/packages/ai/src/ui/ui-messages.ts +https://github.com/vercel/ai/blob/ai%406.0.0-beta.159/packages/ai/src/ui/ui-messages.ts + +Tool approval types (`ToolApprovalRequested`, `ToolApprovalResponded`) require AI SDK v6 or later. """ from abc import ABC @@ -110,6 +112,30 @@ class DataUIPart(BaseUIPart): data: Any +class ToolApprovalRequested(CamelBaseModel): + """Tool approval in requested state (awaiting user response).""" + + id: str + """The approval request ID.""" + + +class ToolApprovalResponded(CamelBaseModel): + """Tool approval in responded state (user has approved or denied).""" + + id: str + """The approval request ID.""" + + approved: bool + """Whether the user approved the tool call.""" + + reason: str | None = None + """Optional reason for the approval or denial.""" + + +ToolApproval = ToolApprovalRequested | ToolApprovalResponded +"""Union of tool approval states.""" + + # Tool part states as separate models class ToolInputStreamingPart(BaseUIPart): """Tool part in input-streaming state.""" @@ -119,6 +145,7 @@ class ToolInputStreamingPart(BaseUIPart): state: Literal['input-streaming'] = 'input-streaming' input: Any | None = None provider_executed: bool | None = None + approval: ToolApproval | None = None class ToolInputAvailablePart(BaseUIPart): @@ -130,6 +157,7 @@ class ToolInputAvailablePart(BaseUIPart): input: Any | None = None provider_executed: bool | None = None call_provider_metadata: ProviderMetadata | None = None + approval: ToolApproval | None = None class ToolOutputAvailablePart(BaseUIPart): @@ -143,6 +171,7 @@ class ToolOutputAvailablePart(BaseUIPart): provider_executed: bool | None = None call_provider_metadata: ProviderMetadata | None = None preliminary: bool | None = None + approval: ToolApproval | None = None class ToolOutputErrorPart(BaseUIPart): @@ -156,6 +185,7 @@ class ToolOutputErrorPart(BaseUIPart): error_text: str provider_executed: bool | None = None call_provider_metadata: ProviderMetadata | None = None + approval: ToolApproval | None = None ToolUIPart = ToolInputStreamingPart | ToolInputAvailablePart | ToolOutputAvailablePart | ToolOutputErrorPart @@ -171,6 +201,7 @@ class DynamicToolInputStreamingPart(BaseUIPart): tool_call_id: str state: Literal['input-streaming'] = 'input-streaming' input: Any | None = None + approval: ToolApproval | None = None class DynamicToolInputAvailablePart(BaseUIPart): @@ -182,6 +213,7 @@ class DynamicToolInputAvailablePart(BaseUIPart): state: Literal['input-available'] = 'input-available' input: Any call_provider_metadata: ProviderMetadata | None = None + approval: ToolApproval | None = None class DynamicToolOutputAvailablePart(BaseUIPart): @@ -195,6 +227,7 @@ class DynamicToolOutputAvailablePart(BaseUIPart): output: Any call_provider_metadata: ProviderMetadata | None = None preliminary: bool | None = None + approval: ToolApproval | None = None class DynamicToolOutputErrorPart(BaseUIPart): @@ -207,6 +240,7 @@ class DynamicToolOutputErrorPart(BaseUIPart): input: Any error_text: str call_provider_metadata: ProviderMetadata | None = None + approval: ToolApproval | None = None DynamicToolUIPart = ( diff --git a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/response_types.py b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/response_types.py index 1255503107..0757eb4848 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/response_types.py +++ b/pydantic_ai_slim/pydantic_ai/ui/vercel_ai/response_types.py @@ -1,7 +1,9 @@ """Vercel AI response types (SSE chunks). Converted to Python from: -https://github.com/vercel/ai/blob/ai%405.0.59/packages/ai/src/ui-message-stream/ui-message-chunks.ts +https://github.com/vercel/ai/blob/ai%406.0.0-beta.159/packages/ai/src/ui-message-stream/ui-message-chunks.ts + +Tool approval types (`ToolApprovalRequestChunk`, `ToolOutputDeniedChunk`) require AI SDK UI v6 or later. """ from abc import ABC @@ -16,6 +18,9 @@ ProviderMetadata = dict[str, dict[str, JSONValue]] """Provider metadata.""" +FinishReason = Literal['stop', 'length', 'content-filter', 'tool-calls', 'error', 'other', 'unknown'] | None +"""Reason why the model finished generating.""" + class BaseChunk(CamelBaseModel, ABC): """Abstract base class for response SSE events.""" @@ -145,6 +150,27 @@ class ToolOutputErrorChunk(BaseChunk): dynamic: bool | None = None +class ToolApprovalRequestChunk(BaseChunk): + """Tool approval request chunk for human-in-the-loop approval. + + Requires AI SDK UI v6 or later. + """ + + type: Literal['tool-approval-request'] = 'tool-approval-request' + approval_id: str + tool_call_id: str + + +class ToolOutputDeniedChunk(BaseChunk): + """Tool output denied chunk when user denies tool execution. + + Requires AI SDK UI v6 or later. + """ + + type: Literal['tool-output-denied'] = 'tool-output-denied' + tool_call_id: str + + class SourceUrlChunk(BaseChunk): """Source URL chunk.""" @@ -178,7 +204,9 @@ class DataChunk(BaseChunk): """Data chunk with dynamic type.""" type: Annotated[str, Field(pattern=r'^data-')] + id: str | None = None data: Any + transient: bool | None = None class StartStepChunk(BaseChunk): @@ -205,6 +233,7 @@ class FinishChunk(BaseChunk): """Finish chunk.""" type: Literal['finish'] = 'finish' + finish_reason: FinishReason = None message_metadata: Any | None = None diff --git a/tests/profiles/test_google.py b/tests/profiles/test_google.py new file mode 100644 index 0000000000..de7ec4aa7a --- /dev/null +++ b/tests/profiles/test_google.py @@ -0,0 +1,208 @@ +"""Tests for Google JSON schema transformer. + +The GoogleJsonSchemaTransformer transforms JSON schemas for compatibility with Gemini API: +- Converts `const` to `enum` with inferred `type` field +- Removes unsupported fields like $schema, title, discriminator, examples +- Handles format fields by moving them to description +""" + +from __future__ import annotations as _annotations + +from typing import Literal + +from inline_snapshot import snapshot +from pydantic import BaseModel + +from pydantic_ai.profiles.google import GoogleJsonSchemaTransformer, google_model_profile + +# ============================================================================= +# Transformer Tests - const to enum conversion with type inference +# ============================================================================= + + +def test_const_string_infers_type(): + """When converting const to enum, type should be inferred for string values.""" + schema = {'const': 'hello'} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert transformed == snapshot({'enum': ['hello'], 'type': 'string'}) + + +def test_const_integer_infers_type(): + """When converting const to enum, type should be inferred for integer values.""" + schema = {'const': 42} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert transformed == snapshot({'enum': [42], 'type': 'integer'}) + + +def test_const_float_infers_type(): + """When converting const to enum, type should be inferred for float values.""" + schema = {'const': 3.14} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert transformed == snapshot({'enum': [3.14], 'type': 'number'}) + + +def test_const_boolean_infers_type(): + """When converting const to enum, type should be inferred for boolean values.""" + schema = {'const': True} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert transformed == snapshot({'enum': [True], 'type': 'boolean'}) + + +def test_const_false_boolean_infers_type(): + """When converting const to enum, type should be inferred for False boolean.""" + schema = {'const': False} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert transformed == snapshot({'enum': [False], 'type': 'boolean'}) + + +def test_const_preserves_existing_type(): + """When const has an existing type field, it should be preserved.""" + schema = {'const': 'hello', 'type': 'string'} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert transformed == snapshot({'enum': ['hello'], 'type': 'string'}) + + +def test_const_array_does_not_infer_type(): + """When const is an array, type cannot be inferred and should not be added.""" + schema = {'const': [1, 2, 3]} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert transformed == snapshot({'enum': [[1, 2, 3]]}) + + +def test_const_in_nested_object(): + """const should be properly converted in nested object properties.""" + + class TaggedModel(BaseModel): + tag: Literal['hello'] + value: str + + schema = TaggedModel.model_json_schema() + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + # The tag property should have both enum and type + assert transformed['properties']['tag'] == snapshot({'enum': ['hello'], 'type': 'string'}) + + +# ============================================================================= +# Transformer Tests - field removal +# ============================================================================= + + +def test_removes_schema_field(): + """$schema field should be removed.""" + schema = {'$schema': 'http://json-schema.org/draft-07/schema#', 'type': 'string'} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert '$schema' not in transformed + assert transformed == snapshot({'type': 'string'}) + + +def test_removes_title_field(): + """title field should be removed.""" + schema = {'title': 'MyString', 'type': 'string'} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert 'title' not in transformed + assert transformed == snapshot({'type': 'string'}) + + +def test_removes_discriminator_field(): + """discriminator field should be removed.""" + schema = {'discriminator': {'propertyName': 'type'}, 'type': 'object'} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert 'discriminator' not in transformed + assert transformed == snapshot({'type': 'object'}) + + +def test_removes_examples_field(): + """examples field should be removed.""" + schema = {'examples': ['foo', 'bar'], 'type': 'string'} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert 'examples' not in transformed + assert transformed == snapshot({'type': 'string'}) + + +def test_removes_exclusive_min_max(): + """exclusiveMinimum and exclusiveMaximum should be removed.""" + schema = {'type': 'integer', 'exclusiveMinimum': 0, 'exclusiveMaximum': 100} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert 'exclusiveMinimum' not in transformed + assert 'exclusiveMaximum' not in transformed + assert transformed == snapshot({'type': 'integer'}) + + +# ============================================================================= +# Transformer Tests - format handling +# ============================================================================= + + +def test_format_moved_to_description(): + """format should be moved to description for string types.""" + schema = {'type': 'string', 'format': 'date-time'} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert 'format' not in transformed + assert transformed == snapshot({'type': 'string', 'description': 'Format: date-time'}) + + +def test_format_appended_to_existing_description(): + """format should be appended to existing description.""" + schema = {'type': 'string', 'format': 'email', 'description': 'User email address'} + transformer = GoogleJsonSchemaTransformer(schema) + transformed = transformer.walk() + + assert 'format' not in transformed + assert transformed == snapshot({'type': 'string', 'description': 'User email address (format: email)'}) + + +# ============================================================================= +# Model Profile Tests +# ============================================================================= + + +def test_model_profile_gemini_2(): + """Gemini 2.x models should have proper profile settings.""" + profile = google_model_profile('gemini-2.0-flash') + assert profile is not None + assert profile.json_schema_transformer == GoogleJsonSchemaTransformer + assert profile.supports_json_schema_output is True + + +def test_model_profile_gemini_3(): + """Gemini 3.x models should support native output with builtin tools.""" + profile = google_model_profile('gemini-3.0-pro') + assert profile is not None + assert profile.google_supports_native_output_with_builtin_tools is True # type: ignore + + +def test_model_profile_image_model(): + """Image models should have limited capabilities.""" + profile = google_model_profile('gemini-2.0-flash-image') + assert profile is not None + assert profile.supports_image_output is True + assert profile.supports_json_schema_output is False + assert profile.supports_tools is False diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index da2e141ebc..5a91655840 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -2,6 +2,7 @@ from __future__ import annotations as _annotations +from copy import deepcopy from typing import Any from pydantic_ai._json_schema import JsonSchemaTransformer @@ -49,3 +50,40 @@ def transform(self, schema: dict[str, Any]) -> dict[str, Any]: # Should keep anyOf since it's not nullable assert 'anyOf' in result3 assert len(result3['anyOf']) == 2 + + +def test_schema_defs_not_modified(): + """Test that the original schema $defs are not modified during transformation.""" + + # Create a concrete subclass for testing + class TestTransformer(JsonSchemaTransformer): + def transform(self, schema: dict[str, Any]) -> dict[str, Any]: + return schema + + # Create a schema with $defs that should not be modified + original_schema = { + 'type': 'object', + 'properties': {'value': {'$ref': '#/$defs/TestUnion'}}, + '$defs': { + 'TestUnion': { + 'anyOf': [ + {'type': 'string'}, + {'type': 'number'}, + ], + 'title': 'TestUnion', + } + }, + } + + # Keep a deepcopy to compare against later + original_schema_copy = deepcopy(original_schema) + + # Transform the schema + transformer = TestTransformer(original_schema) + result = transformer.walk() + + # Verify the original schema was not modified + assert original_schema == original_schema_copy + + # Verify the result is correct + assert result == original_schema_copy diff --git a/tests/test_vercel_ai.py b/tests/test_vercel_ai.py index 12a4ea3eaa..69d52cfa4b 100644 --- a/tests/test_vercel_ai.py +++ b/tests/test_vercel_ai.py @@ -3,6 +3,7 @@ import json from collections.abc import AsyncIterator, MutableMapping from typing import Any, cast +from uuid import UUID import pytest from inline_snapshot import snapshot @@ -1039,7 +1040,7 @@ def client_response\ {'type': 'text-delta', 'delta': ' bodies safely?', 'id': IsStr()}, {'type': 'text-end', 'id': IsStr()}, {'type': 'finish-step'}, - {'type': 'finish'}, + {'type': 'finish', 'finishReason': 'stop'}, '[DONE]', ] ) @@ -1488,7 +1489,7 @@ async def stream_function( {'type': 'tool-input-available', 'toolCallId': IsStr(), 'toolName': 'unknown_tool', 'input': {}}, {'type': 'error', 'errorText': 'Exceeded maximum retries (1) for output validation'}, {'type': 'finish-step'}, - {'type': 'finish'}, + {'type': 'finish', 'finishReason': 'error'}, '[DONE]', ] ) @@ -1531,7 +1532,7 @@ async def tool(query: str) -> str: }, {'type': 'error', 'errorText': 'Unknown tool'}, {'type': 'finish-step'}, - {'type': 'finish'}, + {'type': 'finish', 'finishReason': 'error'}, '[DONE]', ] ) @@ -1572,7 +1573,7 @@ def raise_error(run_result: AgentRunResult[Any]) -> None: {'type': 'text-end', 'id': IsStr()}, {'type': 'error', 'errorText': 'Faulty on_complete'}, {'type': 'finish-step'}, - {'type': 'finish'}, + {'type': 'finish', 'finishReason': 'error'}, '[DONE]', ] ) @@ -1619,6 +1620,391 @@ async def on_complete(run_result: AgentRunResult[Any]) -> AsyncIterator[BaseChun ) +async def test_data_chunk_with_id_and_transient(): + """Test DataChunk supports optional id and transient fields for AI SDK compatibility.""" + agent = Agent(model=TestModel()) + + request = SubmitMessage( + id='foo', + messages=[ + UIMessage( + id='bar', + role='user', + parts=[TextUIPart(text='Hello')], + ), + ], + ) + + async def on_complete(run_result: AgentRunResult[Any]) -> AsyncIterator[BaseChunk]: + # Yield a data chunk with id for reconciliation + yield DataChunk(type='data-task', id='task-123', data={'status': 'complete'}) + # Yield a transient data chunk (not persisted to history) + yield DataChunk(type='data-progress', data={'percent': 100}, transient=True) + + adapter = VercelAIAdapter(agent, request) + events = [ + '[DONE]' if '[DONE]' in event else json.loads(event.removeprefix('data: ')) + async for event in adapter.encode_stream(adapter.run_stream(on_complete=on_complete)) + ] + + # Verify the data chunks are present in the events with correct fields + assert {'type': 'data-task', 'id': 'task-123', 'data': {'status': 'complete'}} in events + assert {'type': 'data-progress', 'data': {'percent': 100}, 'transient': True} in events + + +async def test_tool_approval_request_emission(): + """Test that ToolApprovalRequestChunk is emitted when tools require approval.""" + from pydantic_ai.tools import DeferredToolRequests + + async def stream_function( + messages: list[ModelMessage], agent_info: AgentInfo + ) -> AsyncIterator[DeltaToolCalls | str]: + yield { + 0: DeltaToolCall( + name='delete_file', + json_args='{"path": "test.txt"}', + tool_call_id='delete_1', + ) + } + + agent: Agent[None, str | DeferredToolRequests] = Agent( + model=FunctionModel(stream_function=stream_function), output_type=[str, DeferredToolRequests] + ) + + @agent.tool_plain(requires_approval=True) + def delete_file(path: str) -> str: + return f'Deleted {path}' # pragma: no cover + + request = SubmitMessage( + id='foo', + messages=[ + UIMessage( + id='bar', + role='user', + parts=[TextUIPart(text='Delete test.txt')], + ), + ], + ) + + adapter = VercelAIAdapter(agent, request, tool_approval=True) + events: list[str | dict[str, Any]] = [ + '[DONE]' if '[DONE]' in event else json.loads(event.removeprefix('data: ')) + async for event in adapter.encode_stream(adapter.run_stream()) + ] + + # Verify tool-approval-request chunk is emitted with UUID approval_id + approval_event: dict[str, Any] | None = next( + (e for e in events if isinstance(e, dict) and e.get('type') == 'tool-approval-request'), + None, + ) + assert approval_event is not None + assert approval_event['toolCallId'] == 'delete_1' + # Validate approval_id is a valid UUID + approval_id = approval_event.get('approvalId') + assert approval_id is not None + UUID(approval_id) # Raises ValueError if not a valid UUID + + +async def test_tool_approval_false_does_not_emit_approval_chunks(): + """Test that ToolApprovalRequestChunk is NOT emitted when tool_approval=False.""" + from pydantic_ai.tools import DeferredToolRequests + + async def stream_function( + messages: list[ModelMessage], agent_info: AgentInfo + ) -> AsyncIterator[DeltaToolCalls | str]: + yield { + 0: DeltaToolCall( + name='delete_file', + json_args='{"path": "test.txt"}', + tool_call_id='delete_1', + ) + } + + agent: Agent[None, str | DeferredToolRequests] = Agent( + model=FunctionModel(stream_function=stream_function), output_type=[str, DeferredToolRequests] + ) + + @agent.tool_plain(requires_approval=True) + def delete_file(path: str) -> str: + return f'Deleted {path}' # pragma: no cover + + request = SubmitMessage( + id='foo', + messages=[ + UIMessage( + id='bar', + role='user', + parts=[TextUIPart(text='Delete test.txt')], + ), + ], + ) + + adapter = VercelAIAdapter(agent, request, tool_approval=False) + events: list[str | dict[str, Any]] = [ + '[DONE]' if '[DONE]' in event else json.loads(event.removeprefix('data: ')) + async for event in adapter.encode_stream(adapter.run_stream()) + ] + + # Verify tool-approval-request chunk is NOT emitted when tool_approval=False + approval_events = [e for e in events if isinstance(e, dict) and e.get('type') == 'tool-approval-request'] + assert len(approval_events) == 0 + + +@pytest.mark.skipif(not starlette_import_successful, reason='Starlette is not installed') +async def test_tool_output_denied_chunk_emission(): + """Test that ToolOutputDeniedChunk is emitted when a tool call is denied. + + This test verifies the full public interface: from_request() extracts approval + data from messages, and the adapter emits tool-output-denied chunks for denied tools. + """ + from unittest.mock import AsyncMock + + from starlette.requests import Request + + from pydantic_ai.tools import DeferredToolRequests + from pydantic_ai.ui.vercel_ai.request_types import ( + DynamicToolInputAvailablePart, + ToolApprovalResponded, + ) + + async def stream_function( + messages: list[ModelMessage], agent_info: AgentInfo + ) -> AsyncIterator[DeltaToolCalls | str]: + # Model acknowledges the denial + yield 'The file deletion was cancelled as requested.' + + agent = Agent(model=FunctionModel(stream_function=stream_function), output_type=[str, DeferredToolRequests]) + + @agent.tool_plain(requires_approval=True) + def delete_file(path: str) -> str: + return f'Deleted {path}' # pragma: no cover + + # Simulate a follow-up request where the user denied the tool + request = SubmitMessage( + id='foo', + messages=[ + UIMessage( + id='user-1', + role='user', + parts=[TextUIPart(text='Delete test.txt')], + ), + UIMessage( + id='assistant-1', + role='assistant', + parts=[ + TextUIPart(text='I will delete the file for you.'), + DynamicToolInputAvailablePart( + tool_name='delete_file', + tool_call_id='delete_approved', + input={'path': 'approved.txt'}, + approval=ToolApprovalResponded(id='approval-456', approved=True), + ), + DynamicToolInputAvailablePart( + tool_name='delete_file', + tool_call_id='delete_1', + input={'path': 'test.txt'}, + approval=ToolApprovalResponded( + id='approval-123', + approved=False, + reason='User cancelled the deletion', + ), + ), + ], + ), + ], + ) + + def mock_header_get(key: str) -> str | None: + return None + + request_body = request.model_dump_json().encode() + mock_request = AsyncMock(spec=Request) + mock_request.body = AsyncMock(return_value=request_body) + mock_request.headers.get = mock_header_get + + adapter = await VercelAIAdapter[None, str | DeferredToolRequests].from_request( + mock_request, agent=agent, tool_approval=True + ) + events: list[str | dict[str, Any]] = [ + '[DONE]' if '[DONE]' in event else json.loads(event.removeprefix('data: ')) + async for event in adapter.encode_stream(adapter.run_stream()) + ] + + # Verify tool-output-denied chunk is emitted + denied_event: dict[str, Any] | None = next( + (e for e in events if isinstance(e, dict) and e.get('type') == 'tool-output-denied'), + None, + ) + assert denied_event is not None + assert denied_event['toolCallId'] == 'delete_1' + + +@pytest.mark.skipif(not starlette_import_successful, reason='Starlette is not installed') +async def test_tool_approval_extraction_with_edge_cases(): + """Test that approval extraction correctly skips non-tool parts and non-responded approvals.""" + from unittest.mock import AsyncMock + + from starlette.requests import Request + + from pydantic_ai.tools import DeferredToolRequests + from pydantic_ai.ui.vercel_ai.request_types import ( + DynamicToolInputAvailablePart, + ToolApprovalRequested, + ToolApprovalResponded, + ) + + agent = Agent(TestModel(), output_type=[str, DeferredToolRequests]) + + @agent.tool_plain(requires_approval=True) + def some_tool(x: str) -> str: + return x # pragma: no cover + + request = SubmitMessage( + id='foo', + messages=[ + UIMessage(id='user-1', role='user', parts=[TextUIPart(text='Test')]), + UIMessage( + id='assistant-1', + role='assistant', + parts=[ + TextUIPart(text='Here is my response.'), + DynamicToolInputAvailablePart( + tool_name='some_tool', + tool_call_id='pending_tool', + input={'x': 'pending'}, + approval=ToolApprovalRequested(id='pending-approval'), + ), + DynamicToolInputAvailablePart( + tool_name='some_tool', + tool_call_id='no_approval_tool', + input={'x': 'no_approval'}, + approval=None, + ), + DynamicToolInputAvailablePart( + tool_name='some_tool', + tool_call_id='approved_tool', + input={'x': 'approved'}, + approval=ToolApprovalResponded(id='approved-id', approved=True), + ), + ], + ), + ], + ) + + def mock_header_get(key: str) -> str | None: + return None + + request_body = request.model_dump_json().encode() + mock_request = AsyncMock(spec=Request) + mock_request.body = AsyncMock(return_value=request_body) + mock_request.headers.get = mock_header_get + + adapter = await VercelAIAdapter[None, str | DeferredToolRequests].from_request( + mock_request, agent=agent, tool_approval=True + ) + + # Verify that only the responded approval was extracted + assert adapter.deferred_tool_results is not None + assert adapter.deferred_tool_results.approvals == {'approved_tool': True} + + +@pytest.mark.skipif(not starlette_import_successful, reason='Starlette is not installed') +async def test_tool_approval_no_approvals_extracted(): + """Test that deferred_tool_results is None when no approvals are responded.""" + from unittest.mock import AsyncMock + + from starlette.requests import Request + + from pydantic_ai.tools import DeferredToolRequests + from pydantic_ai.ui.vercel_ai.request_types import ( + DynamicToolInputAvailablePart, + ToolApprovalRequested, + ) + + agent = Agent(TestModel(), output_type=[str, DeferredToolRequests]) + + @agent.tool_plain(requires_approval=True) + def some_tool(x: str) -> str: + return x # pragma: no cover + + request = SubmitMessage( + id='foo', + messages=[ + UIMessage(id='user-1', role='user', parts=[TextUIPart(text='Test')]), + UIMessage( + id='assistant-1', + role='assistant', + parts=[ + DynamicToolInputAvailablePart( + tool_name='some_tool', + tool_call_id='pending_tool', + input={'x': 'pending'}, + approval=ToolApprovalRequested(id='pending-approval'), + ), + ], + ), + ], + ) + + def mock_header_get(key: str) -> str | None: + return None + + request_body = request.model_dump_json().encode() + mock_request = AsyncMock(spec=Request) + mock_request.body = AsyncMock(return_value=request_body) + mock_request.headers.get = mock_header_get + + adapter = await VercelAIAdapter[None, str | DeferredToolRequests].from_request( + mock_request, agent=agent, tool_approval=True + ) + + assert adapter.deferred_tool_results is None + + +@pytest.mark.skipif(not starlette_import_successful, reason='Starlette is not installed') +async def test_run_stream_with_explicit_deferred_tool_results(): + """Test that run_stream accepts explicit deferred_tool_results parameter.""" + from unittest.mock import AsyncMock + + from starlette.requests import Request + + from pydantic_ai.tools import DeferredToolResults + + async def stream_function( + messages: list[ModelMessage], agent_info: AgentInfo + ) -> AsyncIterator[DeltaToolCalls | str]: + yield 'Done' + + agent = Agent(model=FunctionModel(stream_function=stream_function)) + + request = SubmitMessage( + id='foo', + messages=[ + UIMessage(id='user-1', role='user', parts=[TextUIPart(text='Test')]), + ], + ) + + def mock_header_get(key: str) -> str | None: + return None + + request_body = request.model_dump_json().encode() + mock_request = AsyncMock(spec=Request) + mock_request.body = AsyncMock(return_value=request_body) + mock_request.headers.get = mock_header_get + + adapter = await VercelAIAdapter[None, str].from_request(mock_request, agent=agent) + + # Pass deferred_tool_results explicitly (even though it's empty, it covers the else branch) + explicit_results = DeferredToolResults() + events: list[str | dict[str, Any]] = [ + '[DONE]' if '[DONE]' in event else json.loads(event.removeprefix('data: ')) + async for event in adapter.encode_stream(adapter.run_stream(deferred_tool_results=explicit_results)) + ] + + # Verify stream completed successfully + assert events[-1] == '[DONE]' + + @pytest.mark.skipif(not starlette_import_successful, reason='Starlette is not installed') async def test_adapter_dispatch_request(): agent = Agent(model=TestModel()) @@ -2057,6 +2443,7 @@ async def test_adapter_dump_messages_with_tools(): 'output': '{"results":["result1","result2"]}', 'call_provider_metadata': None, 'preliminary': None, + 'approval': None, }, ], }, @@ -2119,6 +2506,7 @@ async def test_adapter_dump_messages_with_builtin_tools(): 'provider_executed': True, 'call_provider_metadata': {'pydantic_ai': {'provider_name': 'openai'}}, 'preliminary': None, + 'approval': None, } ], }, @@ -2165,6 +2553,7 @@ async def test_adapter_dump_messages_with_builtin_tool_without_return(): 'input': '{"query":"orphan query"}', 'provider_executed': True, 'call_provider_metadata': {'pydantic_ai': {'provider_name': 'openai'}}, + 'approval': None, } ], }, @@ -2331,6 +2720,7 @@ async def test_adapter_dump_messages_with_retry(): Fix the errors and try again.\ """, 'call_provider_metadata': None, + 'approval': None, } ], }, @@ -2462,6 +2852,7 @@ async def test_adapter_dump_messages_text_with_interruption(): 'provider_executed': True, 'call_provider_metadata': {'pydantic_ai': {'provider_name': 'test'}}, 'preliminary': None, + 'approval': None, }, { 'type': 'text', @@ -2580,6 +2971,7 @@ async def test_adapter_dump_messages_tool_call_without_return(): 'state': 'input-available', 'input': '{"city":"New York"}', 'call_provider_metadata': None, + 'approval': None, } ], } @@ -2614,6 +3006,7 @@ async def test_adapter_dump_messages_assistant_starts_with_tool(): 'state': 'input-available', 'input': '{}', 'call_provider_metadata': None, + 'approval': None, }, { 'type': 'text', diff --git a/uv.lock b/uv.lock index ba4012112b..75bc6b08fb 100644 --- a/uv.lock +++ b/uv.lock @@ -1984,16 +1984,16 @@ wheels = [ [[package]] name = "google-auth" -version = "2.38.0" +version = "2.45.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, { name = "pyasn1-modules" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/eb/d504ba1daf190af6b204a9d4714d457462b486043744901a6eeea711f913/google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4", size = 270866, upload-time = "2025-01-23T01:05:29.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/00/3c794502a8b892c404b2dea5b3650eb21bfc7069612fbfd15c7f17c1cb0d/google_auth-2.45.0.tar.gz", hash = "sha256:90d3f41b6b72ea72dd9811e765699ee491ab24139f34ebf1ca2b9cc0c38708f3", size = 320708, upload-time = "2025-12-15T22:58:42.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/47/603554949a37bca5b7f894d51896a9c534b9eab808e2520a748e081669d0/google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a", size = 210770, upload-time = "2025-01-23T01:05:26.572Z" }, + { url = "https://files.pythonhosted.org/packages/c6/97/451d55e05487a5cd6279a01a7e34921858b16f7dc8aa38a2c684743cd2b3/google_auth-2.45.0-py2.py3-none-any.whl", hash = "sha256:82344e86dc00410ef5382d99be677c6043d72e502b625aa4f4afa0bdacca0f36", size = 233312, upload-time = "2025-12-15T22:58:40.777Z" }, ] [package.optional-dependencies] @@ -2003,7 +2003,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.55.0" +version = "1.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2017,9 +2017,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/7c/19b59750592702305ae211905985ec8ab56f34270af4a159fba5f0214846/google_genai-1.55.0.tar.gz", hash = "sha256:ae9f1318fedb05c7c1b671a4148724751201e8908a87568364a309804064d986", size = 477615, upload-time = "2025-12-11T02:49:28.624Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/ad/d3ac5a102135bd3f1e4b1475ca65d2bd4bcc22eb2e9348ac40fe3fadb1d6/google_genai-1.56.0.tar.gz", hash = "sha256:0491af33c375f099777ae207d9621f044e27091fafad4c50e617eba32165e82f", size = 340451, upload-time = "2025-12-17T12:35:05.412Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/86/a5a8e32b2d40b30b5fb20e7b8113fafd1e38befa4d1801abd5ce6991065a/google_genai-1.55.0-py3-none-any.whl", hash = "sha256:98c422762b5ff6e16b8d9a1e4938e8e0ad910392a5422e47f5301498d7f373a1", size = 703389, upload-time = "2025-12-11T02:49:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/84/93/94bc7a89ef4e7ed3666add55cd859d1483a22737251df659bf1aa46e9405/google_genai-1.56.0-py3-none-any.whl", hash = "sha256:9e6b11e0c105ead229368cb5849a480e4d0185519f8d9f538d61ecfcf193b052", size = 426563, upload-time = "2025-12-17T12:35:03.717Z" }, ] [[package]]