From efd34f8ffde966e1c109826f0e4bf9fc05a74ef1 Mon Sep 17 00:00:00 2001 From: Elena Kolevska Date: Tue, 12 Aug 2025 23:33:18 +0100 Subject: [PATCH 01/15] 1.16.0-rc1 Signed-off-by: Elena Kolevska --- README.md | 2 +- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 2 +- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 2 +- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 15 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 73fae5b93..05720eb98 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pip3 install dapr-ext-fastapi ```sh # Install Dapr client sdk -pip3 install dapr-dev +pip3 install dapr # Install Dapr gRPC AppCallback service extension pip3 install dapr-ext-grpc-dev diff --git a/dapr/version/version.py b/dapr/version/version.py index 112a2520f..95693e397 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index 4c2215b51..36548818d 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi-dev>=1.15.0.dev +dapr-ext-fastapi>=1.16.0rc1 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index 7f7a666d2..76d2a673f 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow-dev>=1.15.0.dev \ No newline at end of file +dapr-ext-workflow>=1.16.0rc1 \ No newline at end of file diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index ee0ce7078..1481c0c94 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc-dev >= 1.15.0.dev -dapr-dev >= 1.15.0.dev +dapr-ext-grpc >= 1.16.0rc1 +dapr >= 1.16.0rc1 diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index cd15885bd..042e1b7fa 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc-dev >= 1.15.0.dev -dapr-dev >= 1.15.0.dev +dapr-ext-grpc >= 1.16.0rc1 +dapr >= 1.16.0rc1 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index e220036d6..857632631 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow-dev>=1.15.0.dev -dapr-dev>=1.15.0.dev +dapr-ext-workflow>=1.16.0rc1 +dapr>=1.16.0rc1 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index 112a2520f..95693e397 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index 560a795f8..4d1c4d61a 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr-dev >= 1.15.0.dev + dapr >= 1.16.0rc1 uvicorn >= 0.11.6 fastapi >= 0.60.1 diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index 112a2520f..95693e397 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index caf84a2ec..c998af5d7 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr-dev >= 1.15.0.dev + dapr >= 1.16.0rc1 cloudevents >= 1.0.0 [options.packages.find] diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index 112a2520f..95693e397 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index 3776ec899..df21ebc28 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr-dev >= 1.15.0.dev + dapr >= 1.16.0rc1 durabletask-dapr >= 0.2.0a7 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index 112a2520f..95693e397 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.15.0.dev' +__version__ = '1.16.0rc1' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index 64d15941d..d3019d271 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr-dev >= 1.15.0.dev + dapr >= 1.16.0rc1 From d62dd5ee3066c00a955c1c998592d7393b7230f2 Mon Sep 17 00:00:00 2001 From: Filinto Duran <1373693+filintod@users.noreply.github.com> Date: Sun, 7 Sep 2025 06:49:33 -0500 Subject: [PATCH 02/15] [Conversation API - Alpha2] Add new tool calling capability (#822) (#832) * initial * fixes after proto change upstream * minor name changes and cleanup unused function * refactors, updates to readme, linting * feedback * feedback, updates * fix import in examples * cleanup, import, lint, more conversation helpers * clarify README, minor test import changes, copyright * feedback DRY test_conversation file * lint * move conversation classes in _response module to conversation module. Some example README refactor/lint * minor readme change * Update daprdocs/content/en/python-sdk-docs/python-client.md * lint * updates to fix issue with tool calling helper when dealing with classes instead of dataclasses, and also with serializatin output of the tool back to the LLM * coalesce conv helper tests, fix typing lint * make indent line method doc more dev friendly * tackle some feedback, still missing unit tests * add unit test to convert_value_to_struct * more unit tests per feedback * make async version of unit test conversation * add some information how to run markdown tests with a different runtime * ran tox -e ruff, even though tox -e flake8 was fine * add tests to increase coverage in conversation and conversation_helpers that codecov pointed out * add more information on execute registered tools, also added more tests for them to validate * fix test failing on py 1.13. Merge two unit test files per feedback * Linter * fix typing issue with UnionType in py3.9 --------- Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com> Signed-off-by: Elena Kolevska Co-authored-by: Albert Callarisa Co-authored-by: Elena Kolevska Co-authored-by: Elena Kolevska --- README.md | 9 + dapr/aio/clients/grpc/client.py | 121 +- dapr/clients/_constants.py | 25 + dapr/clients/base.py | 4 - dapr/clients/grpc/_conversation_helpers.py | 1045 ++++++++ dapr/clients/grpc/_helpers.py | 135 +- dapr/clients/grpc/_request.py | 18 +- dapr/clients/grpc/_response.py | 20 +- dapr/clients/grpc/client.py | 124 +- dapr/clients/grpc/conversation.py | 662 +++++ dapr/clients/http/client.py | 2 +- dapr/conf/global_settings.py | 8 + .../en/python-sdk-docs/python-client.md | 2 +- dev-requirements.txt | 6 + examples/conversation/.env.example | 20 + examples/conversation/README.md | 591 ++++- examples/conversation/TOOL-CALL-QUICKSTART.md | 165 ++ ...conversation.py => conversation_alpha1.py} | 5 +- examples/conversation/conversation_alpha2.py | 39 + .../real_llm_providers_example.py | 1265 ++++++++++ ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py | 2 +- tests/clients/fake_dapr_server.py | 82 + tests/clients/test_conversation.py | 1227 ++++++++++ tests/clients/test_conversation_helpers.py | 2153 +++++++++++++++++ tests/clients/test_dapr_grpc_client.py | 315 ++- tests/clients/test_dapr_grpc_client_async.py | 298 ++- tests/clients/test_dapr_grpc_helpers.py | 189 ++ tox.ini | 19 + 28 files changed, 8456 insertions(+), 95 deletions(-) create mode 100644 dapr/clients/_constants.py create mode 100644 dapr/clients/grpc/_conversation_helpers.py create mode 100644 dapr/clients/grpc/conversation.py create mode 100644 examples/conversation/.env.example create mode 100644 examples/conversation/TOOL-CALL-QUICKSTART.md rename examples/conversation/{conversation.py => conversation_alpha1.py} (91%) create mode 100644 examples/conversation/conversation_alpha2.py create mode 100644 examples/conversation/real_llm_providers_example.py create mode 100644 tests/clients/test_conversation.py create mode 100644 tests/clients/test_conversation_helpers.py create mode 100644 tests/clients/test_dapr_grpc_helpers.py diff --git a/README.md b/README.md index 05720eb98..c5cdda814 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,15 @@ tox -e type tox -e examples ``` +[Dapr Mechanical Markdown](https://github.com/dapr/mechanical-markdown) is used to test the examples. + +If you need to run the examples against a pre-released version of the runtime, you can use the following command: +- Get your daprd runtime binary from [here](https://github.com/dapr/dapr/releases) for your platform. +- Copy the binary to your dapr home folder at $HOME/.dapr/bin/daprd. +Or using dapr cli directly: `dapr init --runtime-version ` +- Now you can run the example with `tox -e examples`. + + ## Documentation Documentation is generated using Sphinx. Extensions used are mainly Napoleon (To process the Google Comment Style) and Autodocs (For automatically generating documentation). The `.rst` files are generated using Sphinx-Apidocs. diff --git a/dapr/aio/clients/grpc/client.py b/dapr/aio/clients/grpc/client.py index be7c7c109..995b82680 100644 --- a/dapr/aio/clients/grpc/client.py +++ b/dapr/aio/clients/grpc/client.py @@ -63,6 +63,8 @@ to_bytes, validateNotNone, validateNotBlankString, + convert_dict_to_grpc_dict_of_any, + convert_value_to_struct, ) from dapr.aio.clients.grpc._request import ( EncryptRequestIterator, @@ -76,13 +78,12 @@ InvokeMethodRequest, BindingRequest, TransactionalStateOperation, - ConversationInput, ) +from dapr.clients.grpc import conversation + from dapr.clients.grpc._jobs import Job from dapr.clients.grpc._response import ( BindingResponse, - ConversationResponse, - ConversationResult, DaprResponse, GetSecretResponse, GetBulkSecretResponse, @@ -1722,21 +1723,21 @@ async def purge_workflow(self, instance_id: str, workflow_component: str) -> Dap async def converse_alpha1( self, name: str, - inputs: List[ConversationInput], + inputs: List[conversation.ConversationInput], *, context_id: Optional[str] = None, parameters: Optional[Dict[str, GrpcAny]] = None, metadata: Optional[Dict[str, str]] = None, scrub_pii: Optional[bool] = None, temperature: Optional[float] = None, - ) -> ConversationResponse: + ) -> conversation.ConversationResponseAlpha1: """Invoke an LLM using the conversation API (Alpha). Args: name: Name of the LLM component to invoke inputs: List of conversation inputs context_id: Optional ID for continuing an existing chat - parameters: Optional custom parameters for the request + parameters: Optional custom parameters for the request (raw Python values or GrpcAny objects) metadata: Optional metadata for the component scrub_pii: Optional flag to scrub PII from inputs and outputs temperature: Optional temperature setting for the LLM to optimize for creativity or predictability @@ -1752,11 +1753,14 @@ async def converse_alpha1( for inp in inputs ] + # Convert raw Python parameters to GrpcAny objects + converted_parameters = convert_dict_to_grpc_dict_of_any(parameters) + request = api_v1.ConversationRequest( name=name, inputs=inputs_pb, contextID=context_id, - parameters=parameters or {}, + parameters=converted_parameters, metadata=metadata or {}, scrubPII=scrub_pii, temperature=temperature, @@ -1766,11 +1770,110 @@ async def converse_alpha1( response = await self._stub.ConverseAlpha1(request) outputs = [ - ConversationResult(result=output.result, parameters=output.parameters) + conversation.ConversationResultAlpha1( + result=output.result, parameters=output.parameters + ) for output in response.outputs ] - return ConversationResponse(context_id=response.contextID, outputs=outputs) + return conversation.ConversationResponseAlpha1( + context_id=response.contextID, outputs=outputs + ) + + except grpc.aio.AioRpcError as err: + raise DaprGrpcError(err) from err + + async def converse_alpha2( + self, + name: str, + inputs: List[conversation.ConversationInputAlpha2], + *, + context_id: Optional[str] = None, + parameters: Optional[Dict[str, Union[GrpcAny, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + scrub_pii: Optional[bool] = None, + temperature: Optional[float] = None, + tools: Optional[List[conversation.ConversationTools]] = None, + tool_choice: Optional[str] = None, + ) -> conversation.ConversationResponseAlpha2: + """Invoke an LLM using the conversation API (Alpha2) with tool calling support. + + Args: + name: Name of the LLM component to invoke + inputs: List of Alpha2 conversation inputs with sophisticated message types + context_id: Optional ID for continuing an existing chat + parameters: Optional custom parameters for the request (raw Python values or GrpcAny objects) + metadata: Optional metadata for the component + scrub_pii: Optional flag to scrub PII from inputs and outputs + temperature: Optional temperature setting for the LLM to optimize for creativity or predictability + tools: Optional list of tools available for the LLM to call + tool_choice: Optional control over which tools can be called ('none', 'auto', 'required', or specific tool name) + + Returns: + ConversationResponseAlpha2 containing the conversation results with choices and tool calls + + Raises: + DaprGrpcError: If the Dapr runtime returns an error + """ + + # Convert inputs to proto format + inputs_pb = [] + for inp in inputs: + proto_input = api_v1.ConversationInputAlpha2() + if inp.scrub_pii is not None: + proto_input.scrub_pii = inp.scrub_pii + + for message in inp.messages: + proto_input.messages.append(message.to_proto()) + + inputs_pb.append(proto_input) + + # Convert tools to proto format + tools_pb = [] + if tools: + for tool in tools: + proto_tool = api_v1.ConversationTools() + if tool.function: + proto_tool.function.name = tool.function.name + if tool.function.description: + proto_tool.function.description = tool.function.description + if tool.function.parameters: + proto_tool.function.parameters.CopyFrom( + convert_value_to_struct(tool.function.parameters) + ) + tools_pb.append(proto_tool) + + # Convert raw Python parameters to GrpcAny objects + converted_parameters = convert_dict_to_grpc_dict_of_any(parameters) + + # Build the request + request = api_v1.ConversationRequestAlpha2( + name=name, + inputs=inputs_pb, + parameters=converted_parameters, + metadata=metadata or {}, + tools=tools_pb, + ) + + if context_id is not None: + request.context_id = context_id + if scrub_pii is not None: + request.scrub_pii = scrub_pii + if temperature is not None: + request.temperature = temperature + if tool_choice is not None: + request.tool_choice = tool_choice + + try: + response, call = await self.retry_policy.run_rpc_async( + self._stub.ConverseAlpha2, request + ) + + outputs = conversation._get_outputs_from_grpc_response(response) + + return conversation.ConversationResponseAlpha2( + context_id=response.context_id, outputs=outputs + ) except grpc.aio.AioRpcError as err: raise DaprGrpcError(err) from err diff --git a/dapr/clients/_constants.py b/dapr/clients/_constants.py new file mode 100644 index 000000000..31f6c4db2 --- /dev/null +++ b/dapr/clients/_constants.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +""" +Internal constants for the Dapr clients package. + +This module contains shared constants that can be imported by various +client modules without creating circular dependencies. +""" + +# Encoding and content type constants +DEFAULT_ENCODING = 'utf-8' +DEFAULT_JSON_CONTENT_TYPE = f'application/json; charset={DEFAULT_ENCODING}' diff --git a/dapr/clients/base.py b/dapr/clients/base.py index dccb96240..d2b972245 100644 --- a/dapr/clients/base.py +++ b/dapr/clients/base.py @@ -17,10 +17,6 @@ from typing import Optional -DEFAULT_ENCODING = 'utf-8' -DEFAULT_JSON_CONTENT_TYPE = f'application/json; charset={DEFAULT_ENCODING}' - - class DaprActorClientBase(ABC): """A base class that represents Dapr Actor Client.""" diff --git a/dapr/clients/grpc/_conversation_helpers.py b/dapr/clients/grpc/_conversation_helpers.py new file mode 100644 index 000000000..37bb81c18 --- /dev/null +++ b/dapr/clients/grpc/_conversation_helpers.py @@ -0,0 +1,1045 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import inspect +import random +import string +from dataclasses import fields, is_dataclass +from enum import Enum +from typing import ( + Any, + Callable, + Dict, + List, + Mapping, + Optional, + Sequence, + Union, + Literal, + get_args, + get_origin, + get_type_hints, + cast, +) + +from dapr.conf import settings + +import types + +# Make mypy happy. Runtime handle: real class on 3.10+, else None. +# TODO: Python 3.9 is about to be end-of-life, so we can drop this at some point next year (2026) +UnionType: Any = getattr(types, 'UnionType', None) + +# duplicated from conversation to avoid circular import +Params = Union[Mapping[str, Any], Sequence[Any], None] + +""" +Tool Calling Helpers for Dapr Conversation API. + +This module provides function-to-JSON-schema helpers that automatically +convert typed Python functions to tools for the Conversation API. + +These makes it easy to create tools for the Conversation API without +having to manually define the JSON schema for each tool. +""" + + +def _python_type_to_json_schema(python_type: Any, field_name: str = '') -> Dict[str, Any]: + """Convert a Python type hint to JSON schema format. + + Args: + python_type: The Python type to convert + field_name: The name of the field (for better error messages) + + Returns: + Dict representing the JSON schema for this type + + Examples: + >>> _python_type_to_json_schema(str) + {"type": "string"} + >>> _python_type_to_json_schema(Optional[int]) + {"type": "integer"} + >>> _python_type_to_json_schema(List[str]) + {"type": "array", "items": {"type": "string"}} + """ + # Handle None type + if python_type is type(None): + return {'type': 'null'} + + # Get the origin type for generic types (List, Dict, Union, etc.) + origin = get_origin(python_type) + args = get_args(python_type) + + # Handle Union types (including Optional which is Union[T, None]) + if origin is Union: + # Check if this is Optional[T] (Union[T, None]) + non_none_args = [arg for arg in args if arg is not type(None)] + if len(non_none_args) == 1 and type(None) in args: + # This is Optional[T], convert T + return _python_type_to_json_schema(non_none_args[0], field_name) + else: + # This is a true Union, use anyOf + return {'anyOf': [_python_type_to_json_schema(arg, field_name) for arg in args]} + + # Handle Literal types -> map to enum + if origin is Literal: + # Normalize literal values (convert Enum members to their value) + literal_values: List[Any] = [] + for val in args: + try: + from enum import Enum as _Enum + + if isinstance(val, _Enum): + literal_values.append(val.value) + else: + literal_values.append(val) + except Exception: + literal_values.append(val) + + # Determine JSON Schema primitive types for provided literals + def _json_primitive_type(v: Any) -> str: + if v is None: + return 'null' + if isinstance(v, bool): + return 'boolean' + if isinstance(v, int) and not isinstance(v, bool): + return 'integer' + if isinstance(v, float): + return 'number' + if isinstance(v, (bytes, bytearray)): + return 'string' + if isinstance(v, str): + return 'string' + # Fallback: let enum carry through without explicit type + return 'string' + + types = {_json_primitive_type(v) for v in literal_values} + schema: Dict[str, Any] = {'enum': literal_values} + # If all non-null literals share same type, include it + non_null_types = {t for t in types if t != 'null'} + if len(non_null_types) == 1 and (len(types) == 1 or len(types) == 2 and 'null' in types): + only_type = next(iter(non_null_types)) if non_null_types else 'null' + if only_type == 'string' and any( + isinstance(v, (bytes, bytearray)) for v in literal_values + ): + schema['type'] = 'string' + # Note: bytes literals represented as raw bytes are unusual; keeping enum as-is. + else: + schema['type'] = only_type + elif types == {'null'}: + schema['type'] = 'null' + return schema + + # Handle List types + if origin is list or python_type is list: + if args: + return { + 'type': 'array', + 'items': _python_type_to_json_schema(args[0], f'{field_name}[]'), + } + else: + return {'type': 'array'} + + # Handle Dict types + if origin is dict or python_type is dict: + schema = {'type': 'object'} + if args and len(args) == 2: + # Dict[str, ValueType] - add additionalProperties + key_type, value_type = args + if key_type is str: + schema['additionalProperties'] = _python_type_to_json_schema( + value_type, f'{field_name}.*' + ) + return schema + + # Handle basic types + if python_type is str: + return {'type': 'string'} + elif python_type is int: + return {'type': 'integer'} + elif python_type is float: + return {'type': 'number'} + elif python_type is bool: + return {'type': 'boolean'} + elif python_type is bytes: + return {'type': 'string', 'format': 'byte'} + + # Handle Enum types + if inspect.isclass(python_type) and issubclass(python_type, Enum): + try: + members = list(python_type) + except Exception: + members = [] + count = len(members) + # If enum is small enough, include full enum list (current behavior) + if count <= settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS: + return {'type': 'string', 'enum': [item.value for item in members]} + # Large enum handling + if settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR == 'error': + raise ValueError( + f"Enum '{getattr(python_type, '__name__', str(python_type))}' has {count} members, " + f"exceeding DAPR_CONVERSATION_MAX_ENUM_ITEMS={settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS}. " + f"Either reduce the enum size or set DAPR_CONVERSATION_LARGE_ENUM_BEHAVIOR=string to allow compact schema." + ) + # Default behavior: compact schema as a string with helpful context and a few examples + example_values = [item.value for item in members[:5]] if members else [] + desc = ( + f"{getattr(python_type, '__name__', 'Enum')} (enum with {count} values). " + f"Provide a valid value. Schema compacted to avoid oversized enum listing." + ) + schema = {'type': 'string', 'description': desc} + if example_values: + schema['examples'] = example_values + return schema + + # Handle Pydantic models (if available) + if hasattr(python_type, 'model_json_schema'): + try: + return python_type.model_json_schema() + except Exception: + pass + elif hasattr(python_type, 'schema'): + try: + return python_type.schema() + except Exception: + pass + + # Handle dataclasses + if is_dataclass(python_type): + from dataclasses import MISSING + + dataclass_schema: Dict[str, Any] = {'type': 'object', 'properties': {}, 'required': []} + + for field in fields(python_type): + field_schema = _python_type_to_json_schema(field.type, field.name) + dataclass_schema['properties'][field.name] = field_schema + + # Check if field has no default (required) - use MISSING for dataclasses + if field.default is MISSING: + dataclass_schema['required'].append(field.name) + + return dataclass_schema + + # Handle plain classes (non-dataclass) using __init__ signature and annotations + if inspect.isclass(python_type) and python_type is not Any: + try: + # Gather type hints from __init__ if available; fall back to class annotations + init = getattr(python_type, '__init__', None) + init_hints = get_type_hints(init) if init else {} + class_hints = get_type_hints(python_type) + except Exception: + init_hints = {} + class_hints = {} + + # Build properties from __init__ parameters (excluding self) + properties: Dict[str, Any] = {} + required: List[str] = [] + + try: + sig = inspect.signature(python_type) + except Exception: + sig = None # type: ignore + + check_slots = True + if sig is not None: + check_slots = False + for pname, param in sig.parameters.items(): + if pname == 'self': + continue + # Determine type for this parameter + ptype = init_hints.get(pname) or class_hints.get(pname) or Any + properties[pname] = _python_type_to_json_schema(ptype, pname) + # Required if no default provided and not VAR_KEYWORD/POSITIONAL + if param.default is inspect._empty and param.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + required.append(pname) + else: + check_slots = True + if check_slots: + # Fall back to __slots__ if present + slots = getattr(python_type, '__slots__', None) + if isinstance(slots, (list, tuple)): + for pname in slots: + ptype = class_hints.get(pname, Any) + properties[pname] = _python_type_to_json_schema(ptype, pname) + if not (get_origin(ptype) is Union and type(None) in get_args(ptype)): + required.append(pname) + else: # use class_hints + for pname, ptype in class_hints.items(): + properties[pname] = _python_type_to_json_schema(ptype, pname) + if not (get_origin(ptype) is Union and type(None) in get_args(ptype)): + required.append(pname) + + # If we found nothing, return a generic object + if not properties: + return {'type': 'object'} + + schema = {'type': 'object', 'properties': properties} + if required: + schema['required'] = required + return schema + + # Fallback for unknown/unsupported types + raise TypeError( + f"Unsupported type in JSON schema conversion for field '{field_name}': {python_type}. " + f'Please use supported typing annotations (e.g., str, int, float, bool, bytes, List[T], Dict[str, V], Union, Optional, Literal, Enum, dataclass, or plain classes).' + f'You can report this issue for future support of this type. You can always create the json schema manually.' + ) + + +def _extract_docstring_args(func) -> Dict[str, str]: + """Extract parameter descriptions from function docstring. + + Supports Google-style, NumPy-style, and Sphinx-style docstrings. + + Args: + func: The function to analyze + + Returns: + Dict mapping parameter names to their descriptions + + Raises: + ValueError: If docstring contains parameter info but doesn't match supported formats + """ + docstring = inspect.getdoc(func) + if not docstring: + return {} + + param_descriptions = {} + lines = docstring.split('\n') + + # First, try to extract Sphinx-style parameters (:param name: description) + param_descriptions.update(_extract_sphinx_params(lines)) + + # If no Sphinx-style params found, try Google/NumPy style + if not param_descriptions: + param_descriptions.update(_extract_google_numpy_params(lines)) + + # If still no parameters found, check if docstring might have parameter info + # in an unsupported format + if not param_descriptions and _has_potential_param_info(lines): + func_name = getattr(func, '__name__', 'unknown') + import warnings + + warnings.warn( + f"Function '{func_name}' has a docstring that appears to contain parameter " + f"information, but it doesn't match any supported format (Google, NumPy, or Sphinx style). " + f'Consider reformatting the docstring to use one of the supported styles for ' + f'automatic parameter extraction or create the tool manually.', + UserWarning, + stacklevel=2, + ) + + return param_descriptions + + +def _has_potential_param_info(lines: List[str]) -> bool: + """Check if docstring lines might contain parameter information in unsupported format. + + This is a heuristic to detect when a docstring might have parameter info + but doesn't match our supported formats (Google, NumPy, Sphinx). + """ + text = ' '.join(line.strip().lower() for line in lines) + + # Look for specific parameter documentation patterns that suggest + # an attempt to document parameters in an unsupported format + import re + + # Look for informal parameter descriptions like: + # "The filename parameter should be..." or "parameter_name is used for..." + has_param_descriptions = bool( + re.search(r'the\s+\w+\s+parameter\s+(should|is|controls|specifies)', text) + ) + + # Look for patterns where parameters are mentioned with descriptions + # "filename parameter", "mode parameter", etc. + has_param_mentions = bool( + re.search(r'\w+\s+parameter\s+(should|is|controls|specifies|contains)', text) + ) + + # Look for informal patterns like "takes param1 which is", "param2 is an integer" + has_informal_param_descriptions = bool( + re.search(r'takes\s+\w+\s+which\s+(is|are)', text) + or re.search(r'\w+\s+(is|are)\s+(a|an)\s+\w+\s+(input|argument)', text) + ) + + # Look for multiple parameter mentions suggesting documentation attempt + param_count = text.count(' parameter ') + has_multiple_param_mentions = param_count >= 2 + + # Exclude common phrases that don't indicate parameter documentation attempts + exclude_phrases = [ + 'no parameters', + 'without parameters', + 'parameters documented', + 'parameter information', + 'parameter extraction', + 'function parameter', + 'optional parameter', + 'required parameter', # These are often in general descriptions + ] + has_excluded_phrases = any(phrase in text for phrase in exclude_phrases) + + return ( + has_param_descriptions + or has_param_mentions + or has_informal_param_descriptions + or has_multiple_param_mentions + ) and not has_excluded_phrases + + +def _extract_sphinx_params(lines: List[str]) -> Dict[str, str]: + """Extract parameters from Sphinx-style docstring. + + Looks for patterns like: + :param name: description + :parameter name: description + """ + import re + + param_descriptions = {} + + for original_line in lines: + line = original_line.strip() + + # Match Sphinx-style parameter documentation + # Patterns: :param name: description or :parameter name: description + param_match = re.match(r':param(?:eter)?\s+(\w+)\s*:\s*(.*)', line) + if param_match: + param_name = param_match.group(1) + description = param_match.group(2).strip() + param_descriptions[param_name] = description + continue + + # Handle multi-line descriptions for Sphinx style + # If line is indented and we have existing params, it might be a continuation + if ( + original_line.startswith(' ') or original_line.startswith('\t') + ) and param_descriptions: + # Check if this could be a continuation of the last parameter + last_param = list(param_descriptions.keys())[-1] + # Don't treat section headers or other directive-like content as continuations + # Also don't treat content that looks like parameter definitions from other styles + if ( + param_descriptions[last_param] + and not any( + line.startswith(prefix) for prefix in [':param', ':type', ':return', ':raises'] + ) + and not line.lower().endswith(':') + and not line.lower() in ('args', 'arguments', 'parameters', 'params') + and ':' not in line + ): # Avoid treating "param1: description" as continuation + param_descriptions[last_param] += ' ' + line.strip() + + return param_descriptions + + +def _extract_google_numpy_params(lines: List[str]) -> Dict[str, str]: + """Extract parameters from Google/NumPy-style docstring.""" + param_descriptions = {} + in_args_section = False + current_param = None + + for i, original_line in enumerate(lines): + line = original_line.strip() + + # Detect Args/Parameters section + if line.lower() in ('args:', 'arguments:', 'parameters:', 'params:'): + in_args_section = True + continue + + # Handle NumPy style section headers with dashes + if line.lower() in ('parameters', 'arguments') and in_args_section is False: + in_args_section = True + continue + + # Skip NumPy-style separator lines (dashes) but also check if this signals section end + if in_args_section and line and all(c in '-=' for c in line): + # Check if next line starts a new section + next_line_idx = i + 1 + if next_line_idx < len(lines): + next_line = lines[next_line_idx].strip().lower() + if next_line in ( + 'returns', + 'return', + 'yields', + 'yield', + 'raises', + 'raise', + 'notes', + 'note', + 'examples', + 'example', + ): + in_args_section = False + continue + + # Exit args section on new section + if in_args_section and (line.endswith(':') and not line.startswith(' ')): + in_args_section = False + continue + + # Also exit on direct section headers without separators + if in_args_section and line.lower() in ( + 'returns', + 'return', + 'yields', + 'yield', + 'raises', + 'raise', + 'notes', + 'note', + 'examples', + 'example', + ): + in_args_section = False + continue + + if in_args_section and line: + # Look for parameter definitions (contains colon) + if ':' in line: + parts = line.split(':', 1) + if len(parts) == 2: + param_name = parts[0].strip() + description = parts[1].strip() + + # Handle type annotations like "param_name (type): description" + if '(' in param_name and ')' in param_name: + param_name = param_name.split('(')[0].strip() + # Handle NumPy style "param_name : type" format where description is on next line + if ' ' in param_name: + param_name = param_name.split()[0] + + # Check if this looks like a real description vs just a type annotation + # For NumPy style: "param : type" vs Google style: "param: description" + # Type annotations are usually single words like "str", "int", "float" + # Descriptions have multiple words or punctuation + if description: + if description.replace(' ', '').isalnum() and len(description.split()) == 1: + # Likely just a type annotation (single alphanumeric word), wait for real description + param_descriptions[param_name] = '' + else: + # Contains multiple words or punctuation, likely a real description + param_descriptions[param_name] = description + else: + param_descriptions[param_name] = '' + current_param = param_name + elif ( + current_param + and (original_line.startswith(' ') or original_line.startswith('\t')) + and in_args_section + ): + # Indented continuation line for current parameter (only if still in args section) + if not param_descriptions[current_param]: + # First description line for this parameter (for cases where description is on next line) + param_descriptions[current_param] = line + else: + # Additional description lines + param_descriptions[current_param] += ' ' + line + + return param_descriptions + + +def extract_docstring_summary(func) -> Optional[str]: + """Extract only the summary from a function's docstring. + + Args: + func: The function to extract the summary from + + Returns: + The summary portion of the docstring, or None if no docstring exists + """ + docstring = inspect.getdoc(func) + if not docstring: + return None + + lines = docstring.strip().split('\n') + if not lines: + return None + + # Extract all lines before the first section header + summary_lines = [] + + for line in lines: + line = line.strip() + + # Skip empty lines + if not line: + continue + + # Check if this line starts a Google/NumPy-style section + google_numpy_headers = ( + 'args:', + 'arguments:', + 'parameters:', + 'params:', + 'returns:', + 'return:', + 'yields:', + 'yield:', + 'raises:', + 'raise:', + 'note:', + 'notes:', + 'example:', + 'examples:', + 'see also:', + 'references:', + 'attributes:', + ) + if line.lower().endswith(':') and line.lower() in google_numpy_headers: + break + + # Check if this line starts a Sphinx-style section + # Look for patterns like :param name:, :returns:, :raises:, etc. + import re + + sphinx_pattern = r'^:(?:param|parameter|type|returns?|return|yields?|yield|raises?|raise|note|notes|example|examples|see|seealso|references?|attributes?)(?:\s+\w+)?:' + if re.match(sphinx_pattern, line.lower()): + break + + summary_lines.append(line) + + return ' '.join(summary_lines) if summary_lines else None + + +def function_to_json_schema( + func, name: Optional[str] = None, description: Optional[str] = None +) -> Dict[str, Any]: + """Convert a Python function to a JSON schema for tool calling. + All parameters without default values are set as required. + + Args: + func: The Python function to convert + name: Override the function name (defaults to func.__name__) + description: Override the function description (defaults to first line of docstring) + + Returns: + Complete JSON schema with properties and required fields + + Examples: + >>> def get_weather(location: str, unit: str = "fahrenheit") -> str: + ... '''Get weather for a location. + ... + ... Args: + ... location: The city name + ... unit: Temperature unit (celsius or fahrenheit) + ... ''' + ... pass + >>> schema = function_to_json_schema(get_weather) + >>> schema["properties"]["location"]["type"] + 'string' + """ + # Get function signature and type hints + sig = inspect.signature(func) + type_hints = get_type_hints(func) + + # Extract parameter descriptions from docstring + param_descriptions = _extract_docstring_args(func) + + # Build JSON schema + schema: Dict[str, Any] = {'type': 'object', 'properties': {}, 'required': []} + + for param_name, param in sig.parameters.items(): + # Skip *args and **kwargs + if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): + continue + + # Get type hint + param_type = type_hints.get(param_name, str) + + # Convert to JSON schema + param_schema = _python_type_to_json_schema(param_type, param_name) + + # Add description if available + if param_name in param_descriptions: + param_schema['description'] = param_descriptions[param_name] + + schema['properties'][param_name] = param_schema + + # Check if parameter is required (no default value) + if param.default is param.empty: + schema['required'].append(param_name) + + return schema + + +def _generate_unique_tool_call_id(): + """Generate a unique ID for a tool call. Mainly used if the LLM provider is not able to generate one itself.""" + return ''.join(random.choices(string.ascii_letters + string.digits, k=9)) + + +def stringify_tool_output(value: Any) -> str: + """Convert arbitrary tool return values into a serializable string. + + Rules: + - If value is already a string, return as-is. + - For bytes/bytearray, return a base64-encoded string with 'base64:' prefix (not JSON). + - Otherwise, attempt to JSON-serialize the value and return the JSON string. + Uses a conservative default encoder that supports only: + * Enum -> enum.value (fallback to name) + * dataclass -> asdict + If JSON serialization still fails, fallback to str(value). If that fails, return ''. + """ + import json as _json + import base64 as _b64 + from dataclasses import asdict as _asdict + + if isinstance(value, str): + return value + + # bytes/bytearray -> base64 string (raw, not JSON-quoted) + if isinstance(value, (bytes, bytearray)): + try: + return 'base64:' + _b64.b64encode(bytes(value)).decode('ascii') + except Exception: + try: + return str(value) + except Exception: + return '' + + def _default(o: Any): + # Enum handling + try: + from enum import Enum as _Enum + + if isinstance(o, _Enum): + try: + return o.value + except Exception: + return getattr(o, 'name', str(o)) + except Exception: + pass + + # dataclass handling + try: + if is_dataclass(o): + # mypy: asdict expects a DataclassInstance; after the runtime guard, this cast is safe + return _asdict(cast(Any, o)) + except Exception: + pass + + # Plain Python objects with __dict__: return a dict filtered for non-callable attributes + try: + d = getattr(o, '__dict__', None) + if isinstance(d, dict): + return {k: v for k, v in d.items() if not callable(v)} + except Exception: + pass + + # Fallback: cause JSON to fail for unsupported types + raise TypeError(f'Object of type {type(o).__name__} is not JSON serializable') + + try: + return _json.dumps(value, default=_default, ensure_ascii=False) + except Exception: + try: + # Last resort: convert to string + return str(value) + except Exception: + return '' + + +# --- Tool Function Executor Backend + +# --- Errors ---- + + +class ToolError(RuntimeError): + ... + + +class ToolNotFoundError(ToolError): + ... + + +class ToolExecutionError(ToolError): + ... + + +class ToolArgumentError(ToolError): + ... + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int,)): + return bool(value) + if isinstance(value, str): + v = value.strip().lower() + if v in {'true', '1', 'yes', 'y', 'on'}: + return True + if v in {'false', '0', 'no', 'n', 'off'}: + return False + raise ValueError(f'Cannot coerce to bool: {value!r}') + + +def _coerce_scalar(value: Any, expected_type: Any) -> Any: + # Basic scalar coercions + if expected_type is str: + return value if isinstance(value, str) else str(value) + if expected_type is int: + if isinstance(value, bool): # avoid True->1 surprises + return int(value) + if isinstance(value, int): + return value + if isinstance(value, (float,)) and value.is_integer(): + return int(value) + if isinstance(value, str): + return int(value.strip()) + raise ValueError + if expected_type is float: + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + return float(value.strip()) + raise ValueError + if expected_type is bool: + return _coerce_bool(value) + return value + + +def _coerce_enum(value: Any, enum_type: Any) -> Any: + # Accept enum instance, name, or value + if isinstance(value, enum_type): + return value + try: + # match by value + for member in enum_type: + if member.value == value: + return member + if isinstance(value, str): + name = value.strip() + try: + return enum_type[name] + except Exception: + # try case-insensitive + for member in enum_type: + if member.name.lower() == name.lower(): + return member + except Exception: + pass + raise ValueError(f'Cannot coerce {value!r} to {enum_type.__name__}') + + +def _coerce_literal(value: Any, lit_args: List[Any]) -> Any: + # Try exact match first + if value in lit_args: + return value + # Try string-to-number coercions if literal set is homogeneous numeric + try_coerced: List[Any] = [] + for target in lit_args: + try: + if isinstance(target, int) and not isinstance(target, bool) and isinstance(value, str): + try_coerced.append(int(value)) + elif isinstance(target, float) and isinstance(value, str): + try_coerced.append(float(value)) + else: + try_coerced.append(value) + except Exception: + try_coerced.append(value) + for coerced in try_coerced: + if coerced in lit_args: + return coerced + raise ValueError(f'{value!r} not in allowed literals {lit_args!r}') + + +def _is_union(t) -> bool: + origin = get_origin(t) + if origin is Union: + return True + return UnionType is not None and origin is UnionType + + +def _coerce_and_validate(value: Any, expected_type: Any) -> Any: + args = get_args(expected_type) + + if expected_type is Any: + raise TypeError('We cannot handle parameters with type Any') + + # Optional[T] -> Union[T, None] + if _is_union(expected_type): + # try each option + last_err: Optional[Exception] = None + for opt in args: + if opt is type(None): + if value is None: + return None + continue + try: + return _coerce_and_validate(value, opt) + except Exception as e: + last_err = e + continue + raise ValueError( + str(last_err) if last_err else f'Cannot coerce {value!r} to {expected_type}' + ) + + origin = get_origin(expected_type) + + # Literal + if origin is Literal: + return _coerce_literal(value, list(args)) + + # List[T] + if origin is list or expected_type is list: + item_type = args[0] if args else Any + if not isinstance(value, list): + raise ValueError(f'Expected list, got {type(value).__name__}') + return [_coerce_and_validate(v, item_type) for v in value] + + # Dict[K, V] + if origin is dict or expected_type is dict: + key_t = args[0] if len(args) > 0 else Any + val_t = args[1] if len(args) > 1 else Any + if not isinstance(value, dict): + raise ValueError(f'Expected dict, got {type(value).__name__}') + coerced: Dict[Any, Any] = {} + for k, v in value.items(): + ck = _coerce_and_validate(k, key_t) + cv = _coerce_and_validate(v, val_t) + coerced[ck] = cv + return coerced + + # Enums + if inspect.isclass(expected_type) and issubclass(expected_type, Enum): + return _coerce_enum(value, expected_type) + + # Dataclasses + if inspect.isclass(expected_type) and is_dataclass(expected_type): + if isinstance(value, expected_type) or value is None: + return value + raise ValueError( + f'Expected {expected_type.__name__} dataclass instance, got {type(value).__name__}' + ) + + # Plain classes (construct from dict using __init__ where possible) + if inspect.isclass(expected_type): + if isinstance(value, expected_type): + return value + if isinstance(value, dict): + try: + sig = inspect.signature(expected_type) + except Exception as e: + raise ValueError(f'Cannot inspect constructor for {expected_type.__name__}: {e}') + + # type hints from __init__ + try: + init_hints = get_type_hints(getattr(expected_type, '__init__', None)) + except Exception: + init_hints = {} + + kwargs: Dict[str, Any] = {} + missing: List[str] = [] + for pname, param in sig.parameters.items(): + if pname == 'self': + continue + if pname in value: + et = init_hints.get(pname, Any) + kwargs[pname] = _coerce_and_validate(value[pname], et) + else: + if param.default is inspect._empty and param.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + missing.append(pname) + if missing: + raise ValueError( + f"Missing required constructor arg(s) for {expected_type.__name__}: {', '.join(missing)}" + ) + try: + return expected_type(**kwargs) + except Exception as e: + raise ValueError(f'Failed constructing {expected_type.__name__} with {kwargs}: {e}') + # Not a dict or instance: fall through to isinstance check + + # Basic primitives + try: + return _coerce_scalar(value, expected_type) + except Exception: + # Fallback to isinstance check + if expected_type is Any or isinstance(value, expected_type): + return value + raise ValueError( + f"Expected {getattr(expected_type, '__name__', str(expected_type))}, got {type(value).__name__}" + ) + + +def bind_params_to_func(fn: Callable[..., Any], params: Params): + """Bind parameters to a function in the correct order. + + Args: + fn: The function to bind parameters to + params: The parameters to bind + + Returns: + The bound parameters + """ + sig = inspect.signature(fn) + if params is None: + bound = sig.bind() + bound.apply_defaults() + return bound + + if isinstance(params, Mapping): + bound = sig.bind_partial(**params) + # missing required parameters + missing = [ + p.name + for p in sig.parameters.values() + if p.default is inspect._empty + and p.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + and p.name not in bound.arguments + ] + if missing: + raise ToolArgumentError(f"Missing required parameter(s): {', '.join(missing)}") + # unexpected kwargs unless **kwargs present + if not any(p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()): + extra = set(params) - set(sig.parameters) + if extra: + raise ToolArgumentError(f"Unexpected parameter(s): {', '.join(sorted(extra))}") + elif isinstance(params, Sequence): + bound = sig.bind(*params) + else: + raise ToolArgumentError('params must be a mapping (kwargs), sequence (positional), or None') + + bound.apply_defaults() + + # Coerce and validate according to type hints + try: + type_hints = get_type_hints(fn) + except Exception: + type_hints = {} + for name, value in list(bound.arguments.items()): + if name in type_hints: + expected = type_hints[name] + try: + bound.arguments[name] = _coerce_and_validate(value, expected) + except Exception as e: + raise ToolArgumentError( + f"Invalid value for parameter '{name}': expected {getattr(get_origin(expected) or expected, '__name__', str(expected))}, got {type(value).__name__} ({value!r}). Details: {e}" + ) from e + + return bound diff --git a/dapr/clients/grpc/_helpers.py b/dapr/clients/grpc/_helpers.py index 7da35dc27..c68b0f56a 100644 --- a/dapr/clients/grpc/_helpers.py +++ b/dapr/clients/grpc/_helpers.py @@ -12,10 +12,22 @@ See the License for the specific language governing permissions and limitations under the License. """ -from typing import Dict, List, Union, Tuple, Optional from enum import Enum +from typing import Any, Dict, List, Optional, Union, Tuple + + from google.protobuf.any_pb2 import Any as GrpcAny from google.protobuf.message import Message as GrpcMessage +from google.protobuf.wrappers_pb2 import ( + BoolValue, + StringValue, + Int32Value, + Int64Value, + DoubleValue, + BytesValue, +) +from google.protobuf.struct_pb2 import Struct +from google.protobuf import json_format MetadataDict = Dict[str, List[Union[bytes, str]]] MetadataTuple = Tuple[Tuple[str, Union[bytes, str]], ...] @@ -105,3 +117,124 @@ def getWorkflowRuntimeStatus(inputString): return WorkflowRuntimeStatus[inputString].value except KeyError: return WorkflowRuntimeStatus.UNKNOWN + + +def convert_value_to_struct(value: Dict[str, Any]) -> Struct: + """Convert a raw Python value to a protobuf Struct message. + + This function converts Python values to a protobuf Struct, which is designed + to represent JSON-like dynamic data structures. + + Args: + value: Raw Python value (str, int, float, bool, None, dict, list, or already Struct) + + Returns: + Struct: The value converted to a protobuf Struct message + + Raises: + ValueError: If the value type is not supported or cannot be serialized + + Examples: + >>> convert_value_to_struct("hello") # -> Struct with string value + >>> convert_value_to_struct(42) # -> Struct with number value + >>> convert_value_to_struct(True) # -> Struct with bool value + >>> convert_value_to_struct({"key": "value"}) # -> Struct with nested structure + """ + # If it's already a Struct, return as-is (backward compatibility) + if isinstance(value, Struct): + return value + + # raise an error if the value is not a dictionary + if not isinstance(value, dict) and not isinstance(value, bytes): + raise ValueError(f'Value must be a dictionary, got {type(value)}') + + # Convert the value to a JSON-serializable format first + # Handle bytes by converting to base64 string for JSON compatibility + if isinstance(value, bytes): + import base64 + + json_value = base64.b64encode(value).decode('utf-8') + else: + json_value = value + + try: + # For dict values, use ParseDict directly + struct = Struct() + json_format.ParseDict(json_value, struct) + return struct + + except (TypeError, ValueError) as e: + raise ValueError( + f'Unsupported parameter type or value: {type(value)} = {repr(value)}. ' + f'Must be JSON-serializable. Error: {e}' + ) from e + + +def convert_value_to_grpc_any(value: Any) -> GrpcAny: + """Convert a raw Python value to a GrpcAny protobuf message. + This function automatically detects the type of the input value and wraps it + in the appropriate protobuf wrapper type before packing it into GrpcAny. + Args: + value: Raw Python value (str, int, float, bool, bytes, or already GrpcAny) + Returns: + GrpcAny: The value wrapped in a GrpcAny protobuf message + Raises: + ValueError: If the value type is not supported + Examples: + >>> convert_value_to_grpc_any("hello") # -> GrpcAny containing StringValue + >>> convert_value_to_grpc_any(42) # -> GrpcAny containing Int64Value + >>> convert_value_to_grpc_any(3.14) # -> GrpcAny containing DoubleValue + >>> convert_value_to_grpc_any(True) # -> GrpcAny containing BoolValue + """ + # If it's already a GrpcAny, return as-is (backward compatibility) + if isinstance(value, GrpcAny): + return value + + # Create the GrpcAny wrapper + any_pb = GrpcAny() + + # Convert based on Python type + if isinstance(value, bool): + # Note: bool check must come before int since bool is a subclass of int in Python + any_pb.Pack(BoolValue(value=value)) + elif isinstance(value, str): + any_pb.Pack(StringValue(value=value)) + elif isinstance(value, int): + # Use Int64Value to handle larger integers, but Int32Value for smaller ones + if -2147483648 <= value <= 2147483647: + any_pb.Pack(Int32Value(value=value)) + else: + any_pb.Pack(Int64Value(value=value)) + elif isinstance(value, float): + any_pb.Pack(DoubleValue(value=value)) + elif isinstance(value, bytes): + any_pb.Pack(BytesValue(value=value)) + else: + raise ValueError( + f'Unsupported parameter type: {type(value)}. ' + f'Supported types: str, int, float, bool, bytes, GrpcAny' + ) + + return any_pb + + +def convert_dict_to_grpc_dict_of_any(parameters: Optional[Dict[str, Any]]) -> Dict[str, GrpcAny]: + """Convert a dictionary of raw Python values to GrpcAny parameters. + This function takes a dictionary with raw Python values and converts each + value to the appropriate GrpcAny protobuf message for use in Dapr API calls. + Args: + parameters: Optional dictionary of parameter names to raw Python values + Returns: + Dictionary of parameter names to GrpcAny values + Examples: + >>> convert_dict_to_grpc_dict_of_any({"temperature": 0.7, "max_tokens": 1000, "stream": False}) + >>> # Returns: {"temperature": GrpcAny, "max_tokens": GrpcAny, "stream": GrpcAny} + """ + if not parameters: + return {} + + converted = {} + for key, value in parameters.items(): + converted[key] = convert_value_to_grpc_any(value) + + return converted diff --git a/dapr/clients/grpc/_request.py b/dapr/clients/grpc/_request.py index c914a9d52..0ac1ef2fc 100644 --- a/dapr/clients/grpc/_request.py +++ b/dapr/clients/grpc/_request.py @@ -15,23 +15,22 @@ import io from enum import Enum -from dataclasses import dataclass from typing import Dict, Optional, Union from google.protobuf.any_pb2 import Any as GrpcAny from google.protobuf.message import Message as GrpcMessage -from dapr.proto import api_v1, common_v1 -from dapr.clients.base import DEFAULT_JSON_CONTENT_TYPE -from dapr.clients.grpc._crypto import EncryptOptions, DecryptOptions +from dapr.clients._constants import DEFAULT_JSON_CONTENT_TYPE +from dapr.clients.grpc._crypto import DecryptOptions, EncryptOptions from dapr.clients.grpc._helpers import ( MetadataDict, MetadataTuple, - tuple_to_dict, to_bytes, to_str, + tuple_to_dict, unpack, ) +from dapr.proto import api_v1, common_v1 class DaprRequest: @@ -428,15 +427,6 @@ def __next__(self): return request_proto -@dataclass -class ConversationInput: - """A single input message for the conversation.""" - - content: str - role: Optional[str] = None - scrub_pii: Optional[bool] = None - - class JobEvent: """Represents a job event received from Dapr runtime. diff --git a/dapr/clients/grpc/_response.py b/dapr/clients/grpc/_response.py index 6d6ee92a2..fff511ff7 100644 --- a/dapr/clients/grpc/_response.py +++ b/dapr/clients/grpc/_response.py @@ -18,7 +18,6 @@ import contextlib import json import threading -from dataclasses import dataclass, field from datetime import datetime from enum import Enum from typing import ( @@ -40,7 +39,7 @@ from google.protobuf.any_pb2 import Any as GrpcAny from google.protobuf.message import Message as GrpcMessage -from dapr.clients.base import DEFAULT_JSON_CONTENT_TYPE +from dapr.clients._constants import DEFAULT_JSON_CONTENT_TYPE from dapr.clients.grpc._helpers import ( MetadataDict, MetadataTuple, @@ -57,6 +56,7 @@ if TYPE_CHECKING: from dapr.clients.grpc.client import DaprGrpcClient + TCryptoResponse = TypeVar( 'TCryptoResponse', bound=Union[api_v1.EncryptResponse, api_v1.DecryptResponse] ) @@ -1071,19 +1071,3 @@ class EncryptResponse(CryptoResponse[TCryptoResponse]): class DecryptResponse(CryptoResponse[TCryptoResponse]): ... - - -@dataclass -class ConversationResult: - """Result from a single conversation input.""" - - result: str - parameters: Dict[str, GrpcAny] = field(default_factory=dict) - - -@dataclass -class ConversationResponse: - """Response from the conversation API.""" - - context_id: Optional[str] - outputs: List[ConversationResult] diff --git a/dapr/clients/grpc/client.py b/dapr/clients/grpc/client.py index 0e4460166..e4ffb2646 100644 --- a/dapr/clients/grpc/client.py +++ b/dapr/clients/grpc/client.py @@ -23,6 +23,7 @@ from warnings import warn from typing import Callable, Dict, Optional, Text, Union, Sequence, List, Any + from typing_extensions import Self from datetime import datetime from google.protobuf.message import Message as GrpcMessage @@ -40,7 +41,6 @@ from dapr.clients.exceptions import DaprInternalError, DaprGrpcError from dapr.clients.grpc._state import StateOptions, StateItem -from dapr.clients.grpc._helpers import getWorkflowRuntimeStatus from dapr.clients.grpc._crypto import EncryptOptions, DecryptOptions from dapr.clients.grpc.subscription import Subscription, StreamInactiveError from dapr.clients.grpc.interceptors import DaprClientInterceptor, DaprClientTimeoutInterceptor @@ -53,10 +53,13 @@ from dapr.version import __version__ from dapr.clients.grpc._helpers import ( + getWorkflowRuntimeStatus, MetadataTuple, to_bytes, validateNotNone, validateNotBlankString, + convert_dict_to_grpc_dict_of_any, + convert_value_to_struct, ) from dapr.conf.helpers import GrpcEndpoint from dapr.clients.grpc._request import ( @@ -65,8 +68,8 @@ TransactionalStateOperation, EncryptRequestIterator, DecryptRequestIterator, - ConversationInput, ) +from dapr.clients.grpc import conversation from dapr.clients.grpc._jobs import Job from dapr.clients.grpc._response import ( BindingResponse, @@ -91,8 +94,6 @@ EncryptResponse, DecryptResponse, TopicEventResponse, - ConversationResponse, - ConversationResult, ) @@ -1725,21 +1726,21 @@ def purge_workflow(self, instance_id: str, workflow_component: str) -> DaprRespo def converse_alpha1( self, name: str, - inputs: List[ConversationInput], + inputs: List[conversation.ConversationInput], *, context_id: Optional[str] = None, - parameters: Optional[Dict[str, GrpcAny]] = None, + parameters: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, str]] = None, scrub_pii: Optional[bool] = None, temperature: Optional[float] = None, - ) -> ConversationResponse: + ) -> conversation.ConversationResponseAlpha1: """Invoke an LLM using the conversation API (Alpha). Args: name: Name of the LLM component to invoke inputs: List of conversation inputs context_id: Optional ID for continuing an existing chat - parameters: Optional custom parameters for the request + parameters: Optional custom parameters for the request (raw Python values or GrpcAny objects) metadata: Optional metadata for the component scrub_pii: Optional flag to scrub PII from inputs and outputs temperature: Optional temperature setting for the LLM to optimize for creativity or predictability @@ -1756,11 +1757,14 @@ def converse_alpha1( for inp in inputs ] + # Convert raw Python parameters to GrpcAny objects + converted_parameters = convert_dict_to_grpc_dict_of_any(parameters) + request = api_v1.ConversationRequest( name=name, inputs=inputs_pb, contextID=context_id, - parameters=parameters or {}, + parameters=converted_parameters, metadata=metadata or {}, scrubPII=scrub_pii, temperature=temperature, @@ -1770,11 +1774,109 @@ def converse_alpha1( response, call = self.retry_policy.run_rpc(self._stub.ConverseAlpha1.with_call, request) outputs = [ - ConversationResult(result=output.result, parameters=output.parameters) + conversation.ConversationResultAlpha1( + result=output.result, parameters=output.parameters + ) for output in response.outputs ] - return ConversationResponse(context_id=response.contextID, outputs=outputs) + return conversation.ConversationResponseAlpha1( + context_id=response.contextID, outputs=outputs + ) + except RpcError as err: + raise DaprGrpcError(err) from err + + def converse_alpha2( + self, + name: str, + inputs: List[conversation.ConversationInputAlpha2], + *, + context_id: Optional[str] = None, + parameters: Optional[Dict[str, Union[GrpcAny, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + scrub_pii: Optional[bool] = None, + temperature: Optional[float] = None, + tools: Optional[List[conversation.ConversationTools]] = None, + tool_choice: Optional[str] = None, + ) -> conversation.ConversationResponseAlpha2: + """Invoke an LLM using the conversation API (Alpha2) with tool calling support. + + Args: + name: Name of the LLM component to invoke + inputs: List of Alpha2 conversation inputs with sophisticated message types + context_id: Optional ID for continuing an existing chat + parameters: Optional custom parameters for the request (raw Python values or GrpcAny objects) + metadata: Optional metadata for the component + scrub_pii: Optional flag to scrub PII from inputs and outputs + temperature: Optional temperature setting for the LLM to optimize for creativity or predictability + tools: Optional list of tools available for the LLM to call + tool_choice: Optional control over which tools can be called ('none', 'auto', 'required', or specific tool name) + + Returns: + ConversationResponseAlpha2 containing the conversation results with choices and tool calls + + Raises: + DaprGrpcError: If the Dapr runtime returns an error + """ + + # Convert inputs to proto format + inputs_pb = [] + for inp in inputs: + proto_input = api_v1.ConversationInputAlpha2() + if inp.scrub_pii is not None: + proto_input.scrub_pii = inp.scrub_pii + + for message in inp.messages: + proto_input.messages.append(message.to_proto()) + + inputs_pb.append(proto_input) + + # Convert tools to proto format + tools_pb = [] + if tools: + for tool in tools: + proto_tool = api_v1.ConversationTools() + if tool.function: + proto_tool.function.name = tool.function.name + if tool.function.description: + proto_tool.function.description = tool.function.description + if tool.function.parameters: + # we only keep type, properties and required + proto_tool.function.parameters.CopyFrom( + convert_value_to_struct(tool.function.parameters) + ) + tools_pb.append(proto_tool) + + # Convert raw Python parameters to GrpcAny objects + converted_parameters = convert_dict_to_grpc_dict_of_any(parameters) + + # Build the request + request = api_v1.ConversationRequestAlpha2( + name=name, + inputs=inputs_pb, + parameters=converted_parameters, + metadata=metadata or {}, + tools=tools_pb, + ) + + if context_id is not None: + request.context_id = context_id + if scrub_pii is not None: + request.scrub_pii = scrub_pii + if temperature is not None: + request.temperature = temperature + if tool_choice is not None: + request.tool_choice = tool_choice + + try: + response, call = self.retry_policy.run_rpc(self._stub.ConverseAlpha2.with_call, request) + + # Convert response to our format + outputs = conversation._get_outputs_from_grpc_response(response) + + return conversation.ConversationResponseAlpha2( + context_id=response.context_id, outputs=outputs + ) except RpcError as err: raise DaprGrpcError(err) from err diff --git a/dapr/clients/grpc/conversation.py b/dapr/clients/grpc/conversation.py new file mode 100644 index 000000000..1da02dac2 --- /dev/null +++ b/dapr/clients/grpc/conversation.py @@ -0,0 +1,662 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +import asyncio +import inspect +import json +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Sequence, Union, cast + +from google.protobuf.any_pb2 import Any as GrpcAny + +from dapr.clients.grpc import _conversation_helpers as conv_helpers +from dapr.clients.grpc._conversation_helpers import _generate_unique_tool_call_id +from dapr.proto import api_v1 + +Params = Union[Mapping[str, Any], Sequence[Any], None] + +# ------------------------------------------------------------------------------------------------ +# Request Classes +# ------------------------------------------------------------------------------------------------ + + +@dataclass +class ConversationInput: + """A single input message for the conversation.""" + + content: str + role: Optional[str] = None + scrub_pii: Optional[bool] = None + + +@dataclass +class ConversationMessageContent: + """Content for conversation messages.""" + + text: str + + +def _indent_lines(title: str, text: str, indent: int) -> str: + """ + Indent lines of text. + Example: + >>> print("foo") + foo + >>> print(_indent_lines("Description", "This is a long\nmultiline\ntext block", 4)) + Description: This is a long + multiline + text block + """ + indent_after_first_line = indent + len(title) + 2 + lines = text.splitlines() if text is not None else [''] + first = lines[0] if lines else '' + rest = '' + for line in lines[1:]: + rest += f'\n{indent_after_first_line * " "}{line}' + return f'{indent * " "}{title}: {first}{rest}' + + +class HasNameAndContent: + """Mixin Protocol for name and content typing.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + + +class UserTracePrintMixin(HasNameAndContent): + """Mixin for trace_print for text based message content from user to LLM.""" + + def trace_print(self, indent: int = 0) -> None: + base = ' ' * indent + if self.name: + print(f'{base}name: {self.name}') + for i, c in enumerate(self.content): + print(_indent_lines(f'content[{i}]', c.text, indent)) + + +@dataclass +class ConversationMessageOfDeveloper(UserTracePrintMixin): + """Developer message content.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + + +@dataclass +class ConversationMessageOfSystem(UserTracePrintMixin): + """System message content.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + + +@dataclass +class ConversationMessageOfUser(UserTracePrintMixin): + """User message content.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + + +@dataclass +class ConversationToolCallsOfFunction: + """Function call details within a tool call.""" + + name: str + arguments: str + + +@dataclass +class ConversationToolCalls: + """Tool calls generated by the model.""" + + id: Optional[str] = None + function: Optional[ConversationToolCallsOfFunction] = None + + +@dataclass +class ConversationMessageOfAssistant: + """Assistant message content.""" + + name: Optional[str] = None + content: List[ConversationMessageContent] = field(default_factory=list) + tool_calls: List[ConversationToolCalls] = field(default_factory=list) + + def trace_print(self, indent: int = 0) -> None: + base = ' ' * indent + if self.name: + print(f'{base}name: {self.name}') + for i, c in enumerate(self.content): + print(_indent_lines(f'content[{i}]', c.text, indent)) + if self.tool_calls: + print(f'{base}tool_calls: {len(self.tool_calls)}') + for idx, tc in enumerate(self.tool_calls): + tc_id = tc.id or '' + fn = tc.function.name if tc.function else '' + args = tc.function.arguments if tc.function else '' + print(f'{base} [{idx}] id={tc_id} function={fn}({args})') + + +@dataclass +class ConversationMessageOfTool: + """Tool message content.""" + + tool_id: Optional[str] = None + name: str = '' + content: List[ConversationMessageContent] = field(default_factory=list) + + def trace_print(self, indent: int = 0) -> None: + base = ' ' * indent + if self.tool_id: + print(f'{base}tool_id: {self.tool_id}') + if self.name: + print(f'{base}name: {self.name}') + for i, c in enumerate(self.content): + lines = c.text.splitlines() if c.text is not None else [''] + first = lines[0] if lines else '' + print(f'{base}content[{i}]: {first}') + for extra in lines[1:]: + print(extra) + + +@dataclass +class ConversationMessage: + """Conversation message with different role types.""" + + of_developer: Optional[ConversationMessageOfDeveloper] = None + of_system: Optional[ConversationMessageOfSystem] = None + of_user: Optional[ConversationMessageOfUser] = None + of_assistant: Optional[ConversationMessageOfAssistant] = None + of_tool: Optional[ConversationMessageOfTool] = None + + def trace_print(self, indent: int = 0): + print() + """Print the conversation message with indentation and direction arrows.""" + if self.of_developer: + print(f'{" " * indent}client[devel] --------------> LLM[assistant]:') + self.of_developer.trace_print(indent + 2) + if self.of_system: + print(f'{" " * indent}client[system] --------------> LLM[assistant]:') + self.of_system.trace_print(indent + 2) + if self.of_user: + print(f'{" " * indent}client[user] --------------> LLM[assistant]:') + self.of_user.trace_print(indent + 2) + if self.of_assistant: + print(f'{" " * indent}client <------------- LLM[assistant]:') + self.of_assistant.trace_print(indent + 2) + if self.of_tool: + print(f'{" " * indent}client[tool] -------------> LLM[assistant]:') + self.of_tool.trace_print(indent + 2) + + def to_proto(self) -> api_v1.ConversationMessage: + """Convert a conversation message to proto format.""" + + def _convert_message_content_to_proto( + content_list: List[ConversationMessageContent], + ): + """Convert message content list to proto format.""" + if not content_list: + return [] + return [ + api_v1.ConversationMessageContent(text=content.text) for content in content_list + ] + + def _convert_tool_calls_to_proto(tool_calls: List[ConversationToolCalls]): + """Convert tool calls to proto format.""" + if not tool_calls: + return [] + proto_calls = [] + for call in tool_calls: + proto_call = api_v1.ConversationToolCalls() + if call.id: + proto_call.id = call.id + if call.function: + proto_call.function.name = call.function.name + proto_call.function.arguments = call.function.arguments + proto_calls.append(proto_call) + return proto_calls + + proto_message = api_v1.ConversationMessage() + + if self.of_developer: + proto_message.of_developer.name = self.of_developer.name or '' + proto_message.of_developer.content.extend( + _convert_message_content_to_proto(self.of_developer.content or []) + ) + elif self.of_system: + proto_message.of_system.name = self.of_system.name or '' + proto_message.of_system.content.extend( + _convert_message_content_to_proto(self.of_system.content or []) + ) + elif self.of_user: + proto_message.of_user.name = self.of_user.name or '' + proto_message.of_user.content.extend( + _convert_message_content_to_proto(self.of_user.content or []) + ) + elif self.of_assistant: + proto_message.of_assistant.name = self.of_assistant.name or '' + proto_message.of_assistant.content.extend( + _convert_message_content_to_proto(self.of_assistant.content or []) + ) + proto_message.of_assistant.tool_calls.extend( + _convert_tool_calls_to_proto(self.of_assistant.tool_calls or []) + ) + elif self.of_tool: + if self.of_tool.tool_id: + proto_message.of_tool.tool_id = self.of_tool.tool_id + proto_message.of_tool.name = self.of_tool.name + proto_message.of_tool.content.extend( + _convert_message_content_to_proto(self.of_tool.content or []) + ) + + return proto_message + + +@dataclass +class ConversationInputAlpha2: + """Alpha2 input message for conversation API.""" + + messages: List[ConversationMessage] + scrub_pii: Optional[bool] = None + + +@dataclass +class ConversationToolsFunction: + """Function definition for conversation tools.""" + + name: str + description: Optional[str] = None + parameters: Optional[Dict] = None + + def schema_as_dict(self) -> Dict: + """Return the function's schema as a dictionary. + + Returns: + Dict: The JSON schema for the function parameters. + """ + return self.parameters or {} + + @classmethod + def from_function(cls, func: Callable, register: bool = True) -> 'ConversationToolsFunction': + """Create a ConversationToolsFunction from a function. + + Args: + func: The function to extract the schema from. + register: Whether to register the function in the tool registry. + """ + c = cls( + name=func.__name__, + description=conv_helpers.extract_docstring_summary(func), + parameters=conv_helpers.function_to_json_schema(func), + ) + if register: + register_tool(c.name, ConversationTools(function=c, backend=FunctionBackend(func))) + return c + + +# ------------------------------------------------------------------------------------------------ +# Response Classes +# ------------------------------------------------------------------------------------------------ + + +@dataclass +class ConversationResultAlpha1: + """One of the outputs to a request to the conversation API.""" + + result: str + parameters: Dict[str, GrpcAny] = field(default_factory=dict) + + +@dataclass +class ConversationResultAlpha2Message: + """Message content in one conversation result choice.""" + + content: str + tool_calls: List[ConversationToolCalls] = field(default_factory=list) + + +@dataclass +class ConversationResultAlpha2Choices: + """Choice in one Alpha2 conversation result output.""" + + finish_reason: str + index: int + message: ConversationResultAlpha2Message + + +@dataclass +class ConversationResultAlpha2: + """One of the outputs in Alpha2 response from conversation input.""" + + choices: List[ConversationResultAlpha2Choices] = field(default_factory=list) + + +@dataclass +class ConversationResponseAlpha1: + """Response to a request from the conversation API.""" + + context_id: Optional[str] + outputs: List[ConversationResultAlpha1] + + +@dataclass +class ConversationResponseAlpha2: + """Alpha2 response to a request from the conversation API.""" + + context_id: Optional[str] + outputs: List[ConversationResultAlpha2] + + def to_assistant_messages(self) -> List[ConversationMessage]: + """Helper to convert to Assistant messages and makes it easy to use in multi-turn conversations.""" + + def convert_llm_response_to_conversation_input( + result_message: ConversationResultAlpha2Message, + ) -> ConversationMessage: + """Convert ConversationResultMessage (from LLM response) to ConversationMessage.""" + + # Convert content string to ConversationMessageContent list + content = [] + if result_message.content: + content = [ConversationMessageContent(text=(result_message.content))] + + # Convert tool_calls if present (they're already the right type) + tool_calls = result_message.tool_calls or [] + + # Create an assistant message (since LLM responses are always assistant messages) + return ConversationMessage( + of_assistant=ConversationMessageOfAssistant(content=content, tool_calls=tool_calls) + ) + + """Convert the outputs to a list of ConversationInput.""" + assistant_messages = [] + + for output in self.outputs or []: + for choice in output.choices or []: + # Convert and collect all assistant messages + assistant_message = convert_llm_response_to_conversation_input(choice.message) + assistant_messages.append(assistant_message) + + return assistant_messages + + +# ------------------------------------------------------------------------------------------------ +# Tool Helpers +# ------------------------------------------------------------------------------------------------ + + +class ToolBackend(Protocol): + """Interface for executors that knows how to execute a tool call.""" + + def invoke(self, spec: ConversationToolsFunction, params: Params) -> Any: + ... + + async def ainvoke( + self, spec: ConversationToolsFunction, params: Params, *, timeout: Union[float, None] = None + ) -> Any: + ... + + +@dataclass +class FunctionBackend: + """A backend that executes a local function.""" + + func: Callable[..., Any] = field(repr=False) + + def invoke(self, spec: ConversationToolsFunction, params: Params) -> Any: + bound = conv_helpers.bind_params_to_func(self.func, params) + if inspect.iscoroutinefunction(self.func): + raise conv_helpers.ToolExecutionError( + "This tool is async; use 'await tool.ainvoke(...)'." + ) + try: + return self.func(*bound.args, **bound.kwargs) + except Exception as e: + raise conv_helpers.ToolExecutionError(f'Tool raised: {e}') from e + + async def ainvoke( + self, spec: ConversationToolsFunction, params: Params, *, timeout: Union[float, None] = None + ) -> Any: + bound = conv_helpers.bind_params_to_func(self.func, params) + try: + if inspect.iscoroutinefunction(self.func): + coro = self.func(*bound.args, **bound.kwargs) + return await (asyncio.wait_for(coro, timeout) if timeout else coro) + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: self.func(*bound.args, **bound.kwargs)) + except asyncio.TimeoutError as err: + raise conv_helpers.ToolExecutionError(f'Timed out after {timeout} seconds') from err + except Exception as e: + raise conv_helpers.ToolExecutionError(f'Tool raised: {e}') from e + + +def tool( + func: Optional[Callable] = None, + *, + name: Optional[str] = None, + description: Optional[str] = None, + namespace: Optional[str] = None, + register: bool = True, +): + """ + Decorate a callable as a conversation tool. + + Security note: + - Register only trusted functions. Tool calls may be triggered from LLM outputs and receive + untrusted parameters. + - Use precise type annotations and docstrings for your function; we derive a JSON schema used by + the binder to coerce types and reject unexpected/invalid arguments. + - Add your own guardrails if the tool can perform side effects (filesystem, network, subprocess). + - You can set register=False and call register_tool later to control registration explicitly. + """ + + def _decorate(f: Callable): + ctf = ConversationToolsFunction.from_function(f, register=False) + + # Prefix name with namespace/module if not provided explicitly + ns = namespace or '' + if ns: + ns += '.' + ctf.name = name or f'{ns}{ctf.name}' + + if description: + ctf.description = description + + ct = ConversationTools(function=ctf, backend=FunctionBackend(f)) + + # Store the tool in the function for later retrieval (mypy-safe without setattr) + cast(Any, f).__dapr_conversation_tool__ = ct + + if register: + register_tool(ctf.name, ct) + + return f + + return _decorate if func is None else _decorate(func) + + +@dataclass +class ConversationTools: + """Tools available for conversation. + + Notes on safety and validation: + - Tools execute arbitrary Python callables. Register only trusted functions and be mindful of + side effects (filesystem, network, subprocesses). + - Parameters provided by an LLM are untrusted. The invocation path uses bind_params_to_func to + coerce types based on your function annotations and to reject unexpected/invalid arguments. + - Consider adding your own validation/guardrails in your tool implementation. + """ + + # currently only function is supported + function: ConversationToolsFunction + backend: Optional[ToolBackend] = None + + def invoke(self, params: Params = None) -> Any: + """Execute the tool with params (synchronous). + + params may be: + - Mapping[str, Any]: passed as keyword arguments + - Sequence[Any]: passed as positional arguments + - None: no arguments + Detailed validation and coercion are performed by the backend via bind_params_to_func. + """ + if not self.backend: + raise conv_helpers.ToolExecutionError('Tool backend not set') + return self.backend.invoke(self.function, params) + + async def ainvoke(self, params: Params = None, *, timeout: Union[float, None] = None) -> Any: + """Execute the tool asynchronously. See invoke() for parameter shape and safety notes.""" + if not self.backend: + raise conv_helpers.ToolExecutionError('Tool backend not set') + return await self.backend.ainvoke(self.function, params, timeout=timeout) + + +# registry of tools +_TOOL_REGISTRY: Dict[str, ConversationTools] = {} + + +def register_tool(name: str, t: ConversationTools): + if name in _TOOL_REGISTRY: + raise ValueError(f"Tool '{name}' already registered") + _TOOL_REGISTRY[name] = t + + +def unregister_tool(name: str): + """Unregister a tool. Good for cleanup and avoid collisions.""" + if name in _TOOL_REGISTRY: + del _TOOL_REGISTRY[name] + + +def get_registered_tools() -> List[ConversationTools]: + """Get a list of all registered tools. This can be pass as tools in the ConversationInput.""" + return list(_TOOL_REGISTRY.values()) + + +def _get_tool(name: str) -> ConversationTools: + try: + return _TOOL_REGISTRY[name] + except KeyError as err: + raise conv_helpers.ToolNotFoundError(f"Tool '{name}' is not registered") from err + + +def execute_registered_tool(name: str, params: Union[Params, str] = None) -> Any: + """Execute a registered tool. + + Security considerations: + - A registered tool typically executes user-defined code (or code imported from libraries). Only + register and execute tools you trust. Treat model-provided params as untrusted input. + - Prefer defining a JSON schema for your tool function parameters (ConversationToolsFunction + is created from your function’s signature and annotations). The internal binder performs + type coercion and rejects unexpected/invalid arguments. + - Add your own guardrails if the tool can perform side effects (filesystem, network, subprocess, etc.). + """ + if isinstance(params, str): + params = json.loads(params) + # Minimal upfront shape check; detailed validation happens in bind_params_to_func + if params is not None and not isinstance(params, (Mapping, Sequence)): + raise conv_helpers.ToolArgumentError( + 'params must be a mapping (kwargs), a sequence (args), or None' + ) + return _get_tool(name).invoke(params) + + +async def execute_registered_tool_async( + name: str, params: Union[Params, str] = None, *, timeout: Union[float, None] = None +) -> Any: + """Execute a registered tool asynchronously. + + Security considerations: + - Only execute trusted tools; treat model-provided params as untrusted input. + - Prefer well-typed function signatures and schemas for parameter validation. The binder will + coerce and validate, rejecting unexpected arguments. + - For async tools, consider timeouts and guardrails to limit side effects. + """ + if isinstance(params, str): + params = json.loads(params) + if params is not None and not isinstance(params, (Mapping, Sequence)): + raise conv_helpers.ToolArgumentError( + 'params must be a mapping (kwargs), a sequence (args), or None' + ) + return await _get_tool(name).ainvoke(params, timeout=timeout) + + +# ------------------------------------------------------------------------------------------------ +# Helpers to create messages for Alpha2 inputs +# ------------------------------------------------------------------------------------------------ + + +def create_user_message(text: str) -> ConversationMessage: + """Helper to create a user message for Alpha2.""" + return ConversationMessage( + of_user=ConversationMessageOfUser(content=[ConversationMessageContent(text=text)]) + ) + + +def create_system_message(text: str) -> ConversationMessage: + """Helper to create a system message for Alpha2.""" + return ConversationMessage( + of_system=ConversationMessageOfSystem(content=[ConversationMessageContent(text=text)]) + ) + + +def create_assistant_message(text: str) -> ConversationMessage: + """Helper to create an assistant message for Alpha2.""" + return ConversationMessage( + of_assistant=ConversationMessageOfAssistant(content=[ConversationMessageContent(text=text)]) + ) + + +def create_tool_message(tool_id: str, name: str, content: Any) -> ConversationMessage: + """Helper to create a tool message for Alpha2 responses (from client to LLM).""" + content = conv_helpers.stringify_tool_output(content) + return ConversationMessage( + of_tool=ConversationMessageOfTool( + tool_id=tool_id, name=name, content=[ConversationMessageContent(text=content)] + ) + ) + + +def _get_outputs_from_grpc_response( + response: api_v1.ConversationResponseAlpha2, +) -> List[ConversationResultAlpha2]: + """Helper to get outputs from a Converse gRPC response from dapr sidecar.""" + outputs: List[ConversationResultAlpha2] = [] + for output in response.outputs: + choices = [] + for choice in output.choices: + # Convert tool calls from response + tool_calls = [] + for tool_call in choice.message.tool_calls: + function_call = ConversationToolCallsOfFunction( + name=tool_call.function.name, arguments=tool_call.function.arguments + ) + if not tool_call.id: + tool_call.id = _generate_unique_tool_call_id() + tool_calls.append(ConversationToolCalls(id=tool_call.id, function=function_call)) + + result_message = ConversationResultAlpha2Message( + content=choice.message.content, tool_calls=tool_calls + ) + + choices.append( + ConversationResultAlpha2Choices( + finish_reason=choice.finish_reason, + index=choice.index, + message=result_message, + ) + ) + + outputs.append(ConversationResultAlpha2(choices=choices)) + return outputs diff --git a/dapr/clients/http/client.py b/dapr/clients/http/client.py index 5944e2782..86e9ab6f0 100644 --- a/dapr/clients/http/client.py +++ b/dapr/clients/http/client.py @@ -30,7 +30,7 @@ from dapr.serializers import Serializer from dapr.conf import settings -from dapr.clients.base import DEFAULT_JSON_CONTENT_TYPE +from dapr.clients._constants import DEFAULT_JSON_CONTENT_TYPE from dapr.clients.exceptions import DaprHttpError, DaprInternalError diff --git a/dapr/conf/global_settings.py b/dapr/conf/global_settings.py index 43bb51f6f..5a64e5d4c 100644 --- a/dapr/conf/global_settings.py +++ b/dapr/conf/global_settings.py @@ -33,3 +33,11 @@ DAPR_API_METHOD_INVOCATION_PROTOCOL = 'http' DAPR_HTTP_TIMEOUT_SECONDS = 60 + +# ----- Conversation API settings ------ + +# Configuration for handling large enums to avoid massive JSON schemas that can exceed LLM token limits +DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS = 100 +# What to do when an enum has more than DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS items. Convert to String message or raise an exception +# possible values: 'string' (default), 'error' +DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR = 'string' diff --git a/daprdocs/content/en/python-sdk-docs/python-client.md b/daprdocs/content/en/python-sdk-docs/python-client.md index b26899714..f03a6a74c 100644 --- a/daprdocs/content/en/python-sdk-docs/python-client.md +++ b/daprdocs/content/en/python-sdk-docs/python-client.md @@ -441,7 +441,7 @@ Since version 1.15 Dapr offers developers the capability to securely and reliabl ```python from dapr.clients import DaprClient -from dapr.clients.grpc._request import ConversationInput +from dapr.clients.grpc.conversation import ConversationInput with DaprClient() as d: inputs = [ diff --git a/dev-requirements.txt b/dev-requirements.txt index cec56fb2a..cbd719859 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -16,3 +16,9 @@ Flask>=1.1 ruff===0.2.2 # needed for dapr-ext-workflow durabletask-dapr >= 0.2.0a7 +# needed for .env file loading in examples +python-dotenv>=1.0.0 +# needed for enhanced schema generation from function features +pydantic>=2.0.0 +# needed for yaml file generation in examples +PyYAML>=6.0.2 diff --git a/examples/conversation/.env.example b/examples/conversation/.env.example new file mode 100644 index 000000000..97956ab05 --- /dev/null +++ b/examples/conversation/.env.example @@ -0,0 +1,20 @@ +# LLM Provider API Keys +# Add your API keys for the providers you want to test + +# OpenAI +OPENAI_API_KEY=your_openai_api_key_here + +# Anthropic +ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# Mistral AI +MISTRAL_API_KEY=your_mistral_api_key_here + +# DeepSeek +DEEPSEEK_API_KEY=your_deepseek_api_key_here + +# Google AI (Gemini/Vertex) +GOOGLE_API_KEY=your_google_api_key_here + +# Optional: Default component to use if not specified +DAPR_LLM_COMPONENT_DEFAULT=openai diff --git a/examples/conversation/README.md b/examples/conversation/README.md index c793dd4b5..1d7789b32 100644 --- a/examples/conversation/README.md +++ b/examples/conversation/README.md @@ -1,34 +1,579 @@ -# Example - Conversation API +# Dapr Python SDK - Conversation API Examples -## Step +This directory contains examples demonstrating how to use the Dapr Conversation API with the Python SDK, including real LLM provider integrations and advanced Alpha2 features. -### Prepare +## Real LLM Providers Support -- Dapr installed +The Conversation API supports real LLM providers including: -### Run Conversation Example +- **OpenAI** (GPT-4o-mini, GPT-4, etc.) +- **Anthropic** (Claude Sonnet 4, Claude Haiku, etc.) +- **Mistral** (Mistral Large, etc.) +- **DeepSeek** (DeepSeek V3, etc.) +- **Google AI** (Gemini 2.5 Flash, etc.) - +### Environment Setup -```bash -dapr run --app-id conversation \ - --log-level debug \ - --resources-path ./config \ - -- python3 conversation.py +1. **Install dependencies:** + ```bash + pip install python-dotenv # For .env file support + ``` + +2. **Run the simple conversation on the Alpha V1 version (dapr 1.15)** + + This is a basic example that uses the Conversation API to get a response from a bot. + It also uses the `echo` provider that just echoes back the message. + In the echo provider, a multi-input message is returned as a single output separated by newlines. + + + + ```bash + dapr run --app-id conversation-alpha1 \ + --log-level debug \ + --resources-path ./config \ + -- python3 conversation_alpha1.py + ``` + + + +3. **Run the simple conversation on the Alpha V2 version (dapr 1.16)** + + + ```bash + dapr run --app-id conversation-alpha2 \ + --log-level debug \ + --resources-path ./config \ + -- python3 conversation_alpha2.py + ``` + + + +4. **Run the comprehensive example with real LLM providers (This requires LLM API Keys)** + + You need to have at least one of the following LLM providers API keys: + + - OpenAI + - Anthropic + - Mistral + - Deepseek + - Google AI + + **Create .env file:** + + We use the python-dotenv package to load environment variables from a .env file, so we need to create one first. + If you don't have an .env file, you can copy the .env.example file and rename it to .env: + ```bash + cp .env.example .env + ``` + + **Add your API keys to .env:** + + Open the .env file and add your API keys for the providers you want to use. For example: + ```bash + OPENAI_API_KEY=your_openai_key_here + ANTHROPIC_API_KEY=your_anthropic_key_here + MISTRAL_API_KEY=your_mistral_key_here + DEEPSEEK_API_KEY=your_deepseek_key_here + GOOGLE_API_KEY=your_google_ai_key_here + ``` + Run the example: + + ```bash + python examples/conversation/real_llm_providers_example.py + ``` + + Depending on what API key you have, this will run and print the result of each test function in the example file. + + Before running the example, you need to start the Dapr sidecar with the component configurations as shown below in our run output. + Here we have a temporary directory with the component configurations with the API keys setup in the .env file. + + ```bash + dapr run --app-id test-app --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path + ``` + + For example if we have openai, anthropic, mistral, deepseek and google ai, we will have a temporary directory with the component configurations for each provider: + + The example will run and print the result of each test function in the example file. + ```bash + 🚀 Real LLM Providers Example for Dapr Conversation API Alpha2 + ============================================================ + 📁 Loaded environment from /Users/filinto/diagrid/python-sdk/examples/conversation/.env + + 🔍 Detecting available LLM providers... + + ✅ Found 5 configured provider(s) + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/openai.yaml + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/anthropic.yaml + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/mistral.yaml + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/deepseek.yaml + 📝 Created component: /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3/google.yaml + + ⚠️ IMPORTANT: Make sure Dapr sidecar is running with components from: + /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3 + + To start the sidecar with these components: + dapr run --app-id test-app --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path /var/folders/3t/b6jkjnv970l6dd1sp81b19hw0000gn/T/dapr-llm-components-9mcpb1a3 + + Press Enter when Dapr sidecar is running with the component configurations... + ``` + + At this point, you can press Enter to continue if you have the Dapr sidecar running with the component configurations. + +## Alpha2 API Features + +The Alpha2 API introduces sophisticated features: + +- **Advanced Message Types**: user, system, assistant, developer, tool messages +- **Automatic Parameter Conversion**: Raw Python values → GrpcAny +- **Tool Calling**: Function calling with JSON schema definition +- **Function-to-Schema**: Ultimate DevEx for tool creation +- **Multi-turn Conversations**: Context accumulation across turns +- **Async Support**: Full async/await implementation + +## Current Limitations + +- **Streaming**: Response streaming is not yet supported in Alpha2. All responses are returned as complete messages. + +## Tool Creation (Alpha2) + +Recommended order of approaches: +- Decorator-based definition (best ergonomics) +- Function-to-Schema (automatic schema from typed function) +- JSON schema variants (fallbacks for dynamic/manual cases) + +When using the Decorator or Function-to-Schema approach, you get the following benefits: + +- ✅ **Type Safety**: Full Python type hint support (str, int, List, Optional, Enum, etc.) +- ✅ **Auto-Documentation**: Docstring parsing for parameter descriptions +- ✅ **Ultimate DevEx**: Define functions, get tools automatically +- ✅ **90%+ less boilerplate** compared to manual schema creation +- ✅ **Automatic Tool Registration** this comes handy when you want to execute the tool when called by the LLM + + +### Decorator-based Tool Definition (Recommended) +```python +from dapr.clients.grpc import conversation + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get current weather for a location.""" + # Implementation or placeholder + return f"Weather in {location} (unit={unit})" + +# Tools registered via @conversation.tool can be retrieved with: +tools = conversation.get_registered_tools() +``` + +### Function-to-Schema (from_function) + +Automatically generate the tool schema from a typed Python function. + +```python +from enum import Enum +from dapr.clients.grpc import conversation + + +class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + + +def get_weather(location: str, unit: Units = Units.FAHRENHEIT) -> str: + """Get current weather for a location.""" + return f"Weather in {location}" + + +# Use the from_function class method for automatic schema generation +function = conversation.ConversationToolsFunction.from_function(get_weather) +weather_tool = conversation.ConversationTools(function=function) +``` + +### JSON Schema Variants (fallbacks) + +Use when you can't decorate or need to build tools dynamically. + +#### Complete JSON Schema (e.g., calculator) +```python +from dapr.clients.grpc import conversation + +function = conversation.ConversationToolsFunction( + name="calculate", + description="Perform calculations", + parameters={ + "type": "object", + "properties": { + "expression": {"type": "string", "description": "Math expression"} + }, + "required": ["expression"] + } +) +calc_tool = conversation.ConversationTools(function=function) +``` + +#### No Parameters +```python +from dapr.clients.grpc import conversation + +function = conversation.ConversationToolsFunction( + name="get_time", + description="Get current time", + parameters={"type": "object", "properties": {}, "required": []} +) +time_tool = conversation.ConversationTools(function=function) +``` + +#### Complex Schema with Arrays +```python +from dapr.clients.grpc import conversation + +function = conversation.ConversationToolsFunction( + name="search", + description="Search the web", + parameters={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "domains": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["query"] + } +) +search_tool = conversation.ConversationTools(function=function) +``` + +## Advanced Message Types (Alpha2) + +Alpha2 supports sophisticated message structures for complex conversations: + +### User Messages + +```python + +from dapr.clients.grpc._conversation import ConversationMessageContent, ConversationMessageOfDeveloper, + +ConversationMessage +ConversationMessageOfTool +ConversationMessageOfAssistant +ConversationMessageOfUser +ConversationMessageOfSystem + +user_message = ConversationMessage( + of_user=ConversationMessageOfUser( + content=[ConversationMessageContent(text="What's the weather in Paris?")] + ) +) +``` + +### System Messages +```python +system_message = ConversationMessage( + of_system=ConversationMessageOfSystem( + content=[ConversationMessageContent(text="You are a helpful AI assistant.")] + ) +) +``` + +### Developer Messages +```python +developer_message = ConversationMessage( + of_developer=ConversationMessageOfDeveloper( + name="developer", + content=[ConversationMessageContent(text="System configuration update.")] + ) +) +``` + +### Assistant Messages +```python +assistant_message = ConversationMessage( + of_assistant=ConversationMessageOfAssistant( + content=[ConversationMessageContent(text="I can help you with that!")], + tool_calls=[...] # Optional tool calls + ) +) ``` - +### Tool Messages (for tool responses) +```python +tool_message = ConversationMessage( + of_tool=ConversationMessageOfTool( + tool_id="call_123", + name="get_weather", + content=[ConversationMessageContent(text="Weather: 72°F, sunny")] + ) +) +``` + +### Convenience message helpers + +You can create the same messages more concisely using helpers from `conversation`: + +```python +from dapr.clients.grpc import conversation + +user = conversation.create_user_message("What's the weather in Paris?") +system = conversation.create_system_message("You are a helpful AI assistant.") +assistant = conversation.create_assistant_message("I can help you with that!") +tool_result = conversation.create_tool_message( + tool_id="call_123", name="get_weather", content="Weather: 72°F, sunny" +) +``` + +## Multi-turn Conversations + +Alpha2 excels at multi-turn conversations with proper context accumulation: + +```python +from dapr.clients.grpc import conversation + +conversation_history: list[conversation.ConversationMessage] = [ + conversation.create_user_message("What's the weather in SF?")] + +# Turn 1: User asks a question + +response1: conversation.ConversationResponseAlpha2 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=conversation_history)], + tools=conversation.get_registered_tools(), + tool_choice='auto', +) + +# Append assistant messages directly using the helper +for msg in response1.to_assistant_messages(): + conversation_history.append(msg) + # If tool calls were returned, you can execute and append a tool message + for tc in msg.of_assistant.tool_calls: + tool_output = conversation.execute_registered_tool(tc.function.name, tc.function.arguments) + conversation_history.append( + conversation.create_tool_message(tool_id=tc.id, name=tc.function.name, content=str(tool_output)) + ) + +# Turn 2 with accumulated context +conversation_history.append(conversation.create_user_message("Should I bring an umbrella?")) +response2 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=conversation_history)], + tools=conversation.get_registered_tools(), +) +``` + +### Trace print + +We have added a trace print method to the ConversationMessage class that will print the conversation history with the direction arrows and the content of the messages that is good for debugging. + +For example in the real_llm_providers_example.py file, we have the following code in a multi-turn conversation: + +```python + + for msg in conversation_history: + msg.trace_print(2) +``` + +That will print the conversation history with the following output (might vary depending on the LLM provider): + +``` +Full conversation history trace: + + client[user] --------------> LLM[assistant]: + content[0]: What's the weather like in San Francisco? Use one of the tools available. + + client <------------- LLM[assistant]: + content[0]: I'll check the current weather in San Francisco for you. + + client <------------- LLM[assistant]: + tool_calls: 1 + [0] id=toolu_01TJSATPrtE4uL9GcpJJDKEY function=get_weather({"location":"San Francisco"}) + + client[tool] -------------> LLM[assistant]: + tool_id: toolu_01TJSATPrtE4uL9GcpJJDKEY + name: get_weather + content[0]: The weather in San Francisco is sunny with a temperature of 72°F. + + client <------------- LLM[assistant]: + content[0]: The weather in San Francisco is currently sunny with a temperature of 72°F. It's a beautiful day there! + + client[user] --------------> LLM[assistant]: + content[0]: Should I bring an umbrella? Also, what about the weather in New York? + + client <------------- LLM[assistant]: + content[0]: Let me check the weather in New York for you to help answer both questions. + + client <------------- LLM[assistant]: + tool_calls: 1 + [0] id=toolu_01DqngeKSXhgqbn128NC4J1o function=get_weather({"location":"New York"}) + + client[tool] -------------> LLM[assistant]: + tool_id: toolu_01DqngeKSXhgqbn128NC4J1o + name: get_weather + content[0]: The weather in New York is sunny with a temperature of 72°F. + + client <------------- LLM[assistant]: + content[0]: Based on the weather information: + + **San Francisco**: Sunny, 72°F - No need for an umbrella there! + + **New York**: Also sunny, 72°F - No umbrella needed here either. + + Both cities are having beautiful, sunny weather today, so you shouldn't need an umbrella for either location. Perfect weather for being outdoors! +``` + + +### How context accumulation works + +- Context is not automatic. Each turn you must pass the entire `messages` history you want the LLM to see. +- Append assistant responses using `response.to_assistant_messages()` before the next turn. +- If the LLM makes tool calls, execute them locally and append a tool result message via `conversation.create_tool_message(...)`. +- Re-send available tools on every turn (e.g., `tools=conversation.get_registered_tools()`), especially when the provider requires tools to be present to call them. +- Keep history as a list of `ConversationMessage` objects; add new user/assistant/tool messages as the dialog progresses. +- Context Engineering is a key skill for multi-turn conversations and you will need to experiment with different approaches to get the best results as you cannot keep accumulating context forever. + +## Async Support -## Result +Full async/await support for non-blocking operations: +```python +from dapr.aio.clients import DaprClient as AsyncDaprClient + +async def async_conversation(): + async with AsyncDaprClient() as client: + user_message = create_user_message("Tell me a joke about async programming.") + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = await client.converse_alpha2( + name="openai", + inputs=[input_alpha2], + parameters={'temperature': 0.7} + ) + + return response.outputs[0].choices[0].message.content + +# Run async function +result = asyncio.run(async_conversation()) ``` - - '== APP == Result: What's Dapr?' - - '== APP == Result: Give a brief overview.' -``` \ No newline at end of file + +## Benefits + +- ✅ **Clean JSON schema definition** +- ✅ **Automatic type conversion** +- ✅ **Multiple input formats supported** +- ✅ **Direct Python dict to protobuf Struct conversion** +- ✅ **Clean, readable code** +- ✅ **Supports complex nested structures** +- ✅ **Real LLM provider integration** +- ✅ **Multi-turn conversation support** +- ✅ **Function-to-schema automation** +- ✅ **Full async/await support** + +## Dapr Component Configuration + +For real LLM providers, you need Dapr component configurations. The example real_llm_providers_example.py automatically creates these for each provider you have configured in the .env file: + +### OpenAI Component Example +```yaml +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: openai +spec: + type: conversation.openai + version: v1 + metadata: + - name: key + value: "your_openai_api_key" + - name: model + value: "gpt-4o-mini" +``` + +### Running with Dapr Sidecar +```bash +# The example creates temporary component configs and shows you the command: +dapr run --app-id test-app --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path /tmp/dapr-llm-components-xyz/ +``` + + +## Examples in This Directory + +- **`real_llm_providers_example.py`** - Comprehensive Alpha2 examples with real providers + - Real LLM provider setup (OpenAI, Anthropic, Mistral, DeepSeek, Google AI) + - Basic conversation testing (Alpha2) + - Multi-turn conversation testing + - Tool calling with real LLMs + - Parameter conversion demonstration + - Multi-turn tool calling with context accumulation + - Function-to-schema automatic tool generation + - Decorator-based tool definition + - Async conversation and tool calling support + - Backward compatibility with Alpha1 + +- **`conversation_alpha1.py`** - Basic conversation examples (Alpha1) + - Simple Alpha1 conversation flow +- **`conversation_alpha2.py`** - Basic conversation examples (Alpha2) + - Simple Alpha2 conversation flow + +- **Configuration files:** + - `.env.example` - Environment variables template + - `config/` directory - Provider-specific component configurations + + + +## Troubleshooting + +### Common Issues + +1. **No LLM providers configured** + - Ensure API keys are set in environment variables or `.env` file + - Check that component configurations are correctly formatted + +2. **Tool calls not working** + - Verify tool schema is properly formatted (use examples as reference) + - Check that `tool_choice` is set to `'auto'` or specific tool name + - Ensure LLM provider supports function calling + +3. **Multi-turn context issues** + - Use `to_assistant_message()` helper function + - Maintain conversation history across turns + - Include all previous messages in subsequent requests + +4. **Parameter conversion errors** + - Alpha2 automatically converts raw Python values to GrpcAny + - No need to manually create GrpcAny objects for parameters + - Supported types: int, float, bool, str, dict, list + +5. **Streaming not available** + - Response streaming is not yet supported in Alpha2 + - Set `stream: False` in parameters (this is the default) + - All responses are returned as complete, non-streaming messages + + +## Features Overview + +| Feature | Alpha1 | Alpha2 | +|---------------------------|--------|--------| +| Basic Conversations | ✅ | ✅ | +| Tool Calling | ❌ | ✅ | +| Multi-turn Context | ❌ | ✅ | +| Advanced Message Types | ❌ | ✅ | +| Parameter Auto-conversion | ❌ | ✅ | +| Function-to-Schema | ❌ | ✅ | +| Async Support | ✅ | ✅ | +| Real LLM Providers | ✅ | ✅ | +| Streaming | ❌ | ❌ | + +**Recommendation:** Use Alpha2 for new projects and consider migrating existing Alpha1 code to benefit from enhanced features and improved developer experience. \ No newline at end of file diff --git a/examples/conversation/TOOL-CALL-QUICKSTART.md b/examples/conversation/TOOL-CALL-QUICKSTART.md new file mode 100644 index 000000000..4d0ab88ee --- /dev/null +++ b/examples/conversation/TOOL-CALL-QUICKSTART.md @@ -0,0 +1,165 @@ +# Conversation API Tool Calling Quickstart (Alpha2) + +This guide shows the cleanest, most ergonomic way to use the Conversation API with tools and multi‑turn flows. + +## Recommended: Decorator‑based Tools + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get current weather for a location.""" + return f"Weather in {location} (unit={unit})" + + +user_msg = conversation.create_user_message("What's the weather in Paris?") +input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_msg]) + +with DaprClient() as client: + response = client.converse_alpha2( + name="openai", + inputs=[input_alpha2], + tools=conversation.get_registered_tools(), # tools registered by @conversation.tool + tool_choice='auto', + parameters={'temperature': 0.2, 'max_tokens': 200}, # raw values auto-converted + ) + + for msg in response.to_assistant_messages(): + if msg.of_assistant.tool_calls: + for tc in msg.of_assistant.tool_calls: + print(f"Tool call: {tc.function.name} args={tc.function.arguments}") + else: + print(msg.of_assistant.content[0].text) +``` + +## Minimal Multi‑turn Pattern with Tools + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + return f"Weather in {location} (unit={unit})" + + +history: list[conversation.ConversationMessage] = [] +history.append(conversation.create_user_message("What's the weather in San Francisco?")) + +with DaprClient() as client: + # Turn 1 + resp1 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=history)], + tools=conversation.get_registered_tools(), + tool_choice='auto', + parameters={'temperature': 0.2}, + ) + + # Append assistant messages; execute any tool calls and append tool results + for msg in resp1.to_assistant_messages(): + history.append(msg) + for tc in msg.of_assistant.tool_calls: + # Execute (we suggest validating inputs before execution in production) + tool_output = conversation.execute_registered_tool(tc.function.name, tc.function.arguments) + history.append( + conversation.create_tool_message(tool_id=tc.id, name=tc.function.name, content=str(tool_output)) + ) + + # Turn 2 (LLM sees tool result) + history.append(conversation.create_user_message("Should I bring an umbrella?")) + resp2 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=history)], + tools=conversation.get_registered_tools(), + parameters={'temperature': 0.2}, + ) + + for msg in resp2.to_assistant_messages(): + history.append(msg) + if not msg.of_assistant.tool_calls: + print(msg.of_assistant.content[0].text) +``` + + + +## Alternative: Function‑to‑Schema (from_function) + +```python +from enum import Enum +from dapr.clients.grpc import conversation + + +class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + + +def get_weather(location: str, unit: Units = Units.FAHRENHEIT) -> str: + return f"Weather in {location}" + + +fn = conversation.ConversationToolsFunction.from_function(get_weather) +weather_tool = conversation.ConversationTools(function=fn) +``` + +## JSON Schema Variants (fallbacks) + +```python +from dapr.clients.grpc import conversation + + +# Simple schema +fn = conversation.ConversationToolsFunction( + name='get_weather', + description='Get current weather', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string'}, + 'unit': {'type': 'string', 'enum': ['celsius', 'fahrenheit']}, + }, + 'required': ['location'], + }, +) +weather_tool = conversation.ConversationTools(function=fn) +``` + +## Async Variant + +```python +import asyncio +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients.grpc import conversation + + +@conversation.tool +def get_time() -> str: + return '2025-01-01T12:00:00Z' + + +async def main(): + async with AsyncDaprClient() as client: + msg = conversation.create_user_message('What time is it?') + inp = conversation.ConversationInputAlpha2(messages=[msg]) + resp = await client.converse_alpha2( + name='openai', inputs=[inp], tools=conversation.get_registered_tools() + ) + for m in resp.to_assistant_messages(): + if m.of_assistant.content: + print(m.of_assistant.content[0].text) + + +asyncio.run(main()) +``` + +## See also + +- `examples/conversation/real_llm_providers_example.py` — end‑to‑end multi‑turn and tool calling flows with real providers +- Main README in this folder for provider setup and additional examples + + diff --git a/examples/conversation/conversation.py b/examples/conversation/conversation_alpha1.py similarity index 91% rename from examples/conversation/conversation.py rename to examples/conversation/conversation_alpha1.py index 6b39e37c4..42ba94c9d 100644 --- a/examples/conversation/conversation.py +++ b/examples/conversation/conversation_alpha1.py @@ -11,7 +11,7 @@ # limitations under the License. # ------------------------------------------------------------ from dapr.clients import DaprClient -from dapr.clients.grpc._request import ConversationInput +from dapr.clients.grpc.conversation import ConversationInput with DaprClient() as d: inputs = [ @@ -29,5 +29,6 @@ name='echo', inputs=inputs, temperature=0.7, context_id='chat-123', metadata=metadata ) + print('Result: ', end='') for output in response.outputs: - print(f'Result: {output.result}') + print(output.result) diff --git a/examples/conversation/conversation_alpha2.py b/examples/conversation/conversation_alpha2.py new file mode 100644 index 000000000..b96f7f966 --- /dev/null +++ b/examples/conversation/conversation_alpha2.py @@ -0,0 +1,39 @@ +# ------------------------------------------------------------ +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ +from dapr.clients import DaprClient +from dapr.clients.grpc.conversation import ( + ConversationInputAlpha2, + create_user_message, +) + +with DaprClient() as d: + inputs = [ + ConversationInputAlpha2(messages=[create_user_message("What's Dapr?")], scrub_pii=True), + ConversationInputAlpha2( + messages=[create_user_message('Give a brief overview.')], scrub_pii=True + ), + ] + + metadata = { + 'model': 'foo', + 'key': 'authKey', + 'cacheTTL': '10m', + } + + response = d.converse_alpha2( + name='echo', inputs=inputs, temperature=0.7, context_id='chat-123', metadata=metadata + ) + + print('Result: ', end='') + for output in response.outputs: + print(output.choices[0].message.content) diff --git a/examples/conversation/real_llm_providers_example.py b/examples/conversation/real_llm_providers_example.py new file mode 100644 index 000000000..c103007e0 --- /dev/null +++ b/examples/conversation/real_llm_providers_example.py @@ -0,0 +1,1265 @@ +# ------------------------------------------------------------ +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +""" +Real LLM Providers Example for Dapr Conversation API (Alpha2) + +This example demonstrates how to use real LLM providers (OpenAI, Anthropic, etc.) +with the Dapr Conversation API Alpha2. It showcases the latest features including: +- Advanced message types (user, system, assistant, developer, tool) +- Automatic parameter conversion (raw Python values) +- Enhanced tool calling capabilities +- Multi-turn conversations +- Decorator-based tool definition +- Both sync and async implementations + +Prerequisites: +1. Set up API keys in .env file (copy from .env.example) +2. For manual mode: Start Dapr sidecar manually + +Usage: + # requires manual Dapr sidecar setup + python examples/conversation/real_llm_providers_example.py + + # Show help + python examples/conversation/real_llm_providers_example.py --help + +Environment Variables: + OPENAI_API_KEY: OpenAI API key + ANTHROPIC_API_KEY: Anthropic API key + MISTRAL_API_KEY: Mistral API key + DEEPSEEK_API_KEY: DeepSeek API key + GOOGLE_API_KEY: Google AI (Gemini) API key +""" + +import asyncio +import json +import os +import sys +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +import yaml + +# Add the parent directory to the path so we can import local dapr sdk +# uncomment if running from development version +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +# Load environment variables from .env file if available +try: + from dotenv import load_dotenv + + DOTENV_AVAILABLE = True +except ImportError: + DOTENV_AVAILABLE = False + print('⚠️ python-dotenv not installed. Install with: pip install python-dotenv') + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + + +def create_weather_tool() -> conversation.ConversationTools: + """Create a weather tool for testing Alpha2 tool calling using full JSON schema in parameters approach.""" + conversation.unregister_tool('get_weather') + function = conversation.ConversationToolsFunction( + name='get_weather', + description='Get the current weather for a location', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string', 'description': 'The city and state or country'}, + 'unit': { + 'type': 'string', + 'enum': ['celsius', 'fahrenheit'], + 'description': 'Temperature unit', + }, + }, + 'required': ['location'], + }, + ) + return conversation.ConversationTools(function=function) + + +def create_calculator_tool() -> conversation.ConversationTools: + """Create a calculator tool using full JSON schema in parameters approach.""" + conversation.unregister_tool('calculate') # cleanup + function = conversation.ConversationToolsFunction( + name='calculate', + description='Perform mathematical calculations', + parameters={ + 'type': 'object', + 'properties': { + 'expression': { + 'type': 'string', + 'description': "Mathematical expression to evaluate (e.g., '2+2', 'sqrt(16)')", + } + }, + 'required': ['expression'], + }, + ) + return conversation.ConversationTools(function=function) + + +def create_time_tool() -> conversation.ConversationTools: + """Create a simple tool with no parameters using full JSON schema in parameters approach.""" + conversation.unregister_tool('get_current_time') + function = conversation.ConversationToolsFunction( + name='get_current_time', + description='Get the current date and time', + parameters={'type': 'object', 'properties': {}, 'required': []}, + ) + return conversation.ConversationTools(function=function) + + +def create_search_tool() -> conversation.ConversationTools: + """Create a more complex tool with multiple parameter types and constraints using full JSON schema in parameters approach.""" + conversation.unregister_tool('web_search') + function = conversation.ConversationToolsFunction( + name='web_search', + description='Search the web for information', + parameters={ + 'type': 'object', + 'properties': { + 'query': {'type': 'string', 'description': 'Search query'}, + 'limit': { + 'type': 'integer', + 'description': 'Maximum number of results', + 'minimum': 1, + 'maximum': 10, + 'default': 5, + }, + 'include_images': { + 'type': 'boolean', + 'description': 'Whether to include image results', + 'default': False, + }, + 'domains': { + 'type': 'array', + 'items': {'type': 'string'}, + # 'description': 'Limit search to specific domains', + }, + }, + 'required': ['query'], + }, + ) + return conversation.ConversationTools(function=function) + + +def create_tool_from_typed_function_example() -> conversation.ConversationTools: + """Demonstrate creating tools from typed Python functions - Best DevEx for most cases. + + This shows the most advanced approach: define a typed function and automatically + generate the complete tool schema from type hints and docstrings. + """ + from typing import Optional, List + from enum import Enum + + conversation.unregister_tool('find_restaurants') + + # Define the tool behavior as a regular Python function with type hints + class PriceRange(Enum): + BUDGET = 'budget' + MODERATE = 'moderate' + EXPENSIVE = 'expensive' + + def find_restaurants( + location: str, + cuisine: str = 'any', + price_range: PriceRange = PriceRange.MODERATE, + max_results: int = 5, + dietary_restrictions: Optional[List[str]] = None, + ) -> str: + """Find restaurants in a specific location. + + Args: + location: The city or neighborhood to search + cuisine: Type of cuisine (italian, chinese, mexican, etc.) + price_range: Budget preference for dining + max_results: Maximum number of restaurant recommendations + dietary_restrictions: Special dietary needs (vegetarian, gluten-free, etc.) + """ + # This would contain actual implementation + return f'Found restaurants in {location} serving {cuisine} food' + + # Create the tool using the from_function class method + function = conversation.ConversationToolsFunction.from_function(find_restaurants) + + return conversation.ConversationTools(function=function) + + +def create_tool_from_tool_decorator_example() -> conversation.ConversationTools: + """Demonstrate creating tools from typed Python functions - Best DevEx for most cases. + + This shows the most advanced approach: define a typed function and automatically + generate the complete tool schema from type hints and docstrings. + """ + from typing import Optional, List + from enum import Enum + + conversation.unregister_tool('find_restaurants') + + # Define the tool behavior as a regular Python function with type hints + class PriceRange(Enum): + MODERATE = 'moderate' + EXPENSIVE = 'expensive' + + @conversation.tool + def find_restaurants( + location: str, + cuisine: str = 'any', + price_range: PriceRange = PriceRange.MODERATE, + max_results: int = 5, + dietary_restrictions: Optional[List[str]] = None, + ) -> str: + """Find restaurants in a specific location. + + Args: + location: The city or neighborhood to search + cuisine: Type of cuisine (italian, chinese, mexican, etc.) + price_range: Budget preference for dining + max_results: Maximum number of restaurant recommendations + dietary_restrictions: Special dietary needs (vegetarian, gluten-free, etc.) + """ + # This would contain actual implementation + return f'Found restaurants in {location} serving {cuisine} food' + + return conversation.ConversationTools(function=find_restaurants) + + +def execute_weather_tool(location: str, unit: str = 'fahrenheit') -> str: + """Simulate weather tool execution.""" + temp = '72°F' if unit == 'fahrenheit' else '22°C' + return f'The weather in {location} is sunny with a temperature of {temp}.' + + +def convert_llm_response_to_conversation_input( + result_message: conversation.ConversationResultAlpha2Message, +) -> conversation.ConversationMessage: + """Convert ConversationResultMessage (from LLM response) to ConversationMessage (for conversation input). + + This standalone utility function makes it easy to append LLM responses to conversation history + and reuse them as input for subsequent conversation turns in multi-turn scenarios. + + Args: + result_message: ConversationResultMessage from LLM response (choice.message) + + Returns: + ConversationMessage suitable for input to next conversation turn + + Example: + >>> import dapr.clients.grpc.conversation + >>> client = DaprClient() + >>> response = client.converse_alpha2(name="openai", inputs=[input_alpha2], tools=[tool]) + >>> choice = response.outputs[0].choices[0] + >>> + >>> # Convert LLM response to conversation message + >>> conversation_history = [] + >>> assistant_message = convert_llm_response_to_conversation_input(choice.message) + >>> conversation_history.append(assistant_message) + >>> + >>> # Use in next turn + >>> next_input = conversation.ConversationInputAlpha2(messages=conversation_history) + >>> next_response = client.converse_alpha2(name="openai", inputs=[next_input]) + """ + # Convert content string to ConversationMessageContent list + content = [] + if result_message.content: + content = [conversation.ConversationMessageContent(text=result_message.content)] + + # Convert tool_calls if present (they're already the right type) + tool_calls = result_message.tool_calls or [] + + # Create assistant message (since LLM responses are always assistant messages) + return conversation.ConversationMessage( + of_assistant=conversation.ConversationMessageOfAssistant( + content=content, tool_calls=tool_calls + ) + ) + + +class RealLLMProviderTester: + """Test real LLM providers with Dapr Conversation API Alpha2.""" + + def __init__(self): + self.available_providers = {} + self.component_configs = {} + self.components_dir = None + + def load_environment(self) -> None: + """Load environment variables from .env file if available.""" + if DOTENV_AVAILABLE: + env_file = Path(__file__).parent / '.env' + if env_file.exists(): + load_dotenv(env_file) + print(f'📁 Loaded environment from {env_file}') + else: + print(f'⚠️ No .env file found at {env_file}') + print(' Copy .env.example to .env and add your API keys') + else: + print('⚠️ python-dotenv not available, using system environment variables') + + def detect_available_providers(self) -> Dict[str, Dict[str, Any]]: + """Detect which LLM providers are available based on API keys.""" + providers = {} + + # OpenAI + if os.getenv('OPENAI_API_KEY'): + providers['openai'] = { + 'display_name': 'OpenAI GPT-5-mini', + 'component_type': 'conversation.openai', + 'api_key_env': 'OPENAI_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('OPENAI_API_KEY')}, + {'name': 'model', 'value': 'gpt-5-mini-2025-08-07'}, + ], + } + + # Anthropic + if os.getenv('ANTHROPIC_API_KEY'): + providers['anthropic'] = { + 'display_name': 'Anthropic Claude Sonnet 4', + 'component_type': 'conversation.anthropic', + 'api_key_env': 'ANTHROPIC_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('ANTHROPIC_API_KEY')}, + {'name': 'model', 'value': 'claude-sonnet-4-20250514'}, + ], + } + + # Mistral + if os.getenv('MISTRAL_API_KEY'): + providers['mistral'] = { + 'display_name': 'Mistral Large', + 'component_type': 'conversation.mistral', + 'api_key_env': 'MISTRAL_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('MISTRAL_API_KEY')}, + {'name': 'model', 'value': 'mistral-large-latest'}, + ], + } + + # DeepSeek + if os.getenv('DEEPSEEK_API_KEY'): + providers['deepseek'] = { + 'display_name': 'DeepSeek V3', + 'component_type': 'conversation.deepseek', + 'api_key_env': 'DEEPSEEK_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('DEEPSEEK_API_KEY')}, + {'name': 'model', 'value': 'deepseek-chat'}, + ], + } + + # Google AI (Gemini) + if os.getenv('GOOGLE_API_KEY'): + providers['google'] = { + 'display_name': 'Google Gemini 2.5 Flash', + 'component_type': 'conversation.googleai', + 'api_key_env': 'GOOGLE_API_KEY', + 'metadata': [ + {'name': 'key', 'value': os.getenv('GOOGLE_API_KEY')}, + {'name': 'model', 'value': 'gemini-2.5-flash'}, + ], + } + + return providers + + def create_component_configs(self, selected_providers: Optional[List[str]] = None) -> str: + """Create Dapr component configurations for available providers (those with API keys exposed).""" + # Create temporary directory for components + self.components_dir = tempfile.mkdtemp(prefix='dapr-llm-components-') + + # If no specific providers selected, use OpenAI as default (most reliable) + if not selected_providers: + selected_providers = ( + ['openai'] + if 'openai' in self.available_providers + else list(self.available_providers.keys())[:1] + ) + + for provider_id in selected_providers: + if provider_id not in self.available_providers: + continue + + config = self.available_providers[provider_id] + component_config = { + 'apiVersion': 'dapr.io/v1alpha1', + 'kind': 'Component', + 'metadata': {'name': provider_id}, + 'spec': { + 'type': config['component_type'], + 'version': 'v1', + 'metadata': config['metadata'], + }, + } + + # Write component file + component_file = Path(self.components_dir) / f'{provider_id}.yaml' + with open(component_file, 'w') as f: + yaml.dump(component_config, f, default_flow_style=False) + + print(f'📝 Created component: {component_file}') + + return self.components_dir + + def test_basic_conversation_alpha2(self, provider_id: str) -> None: + """Test basic Alpha2 conversation with a provider.""" + print( + f"\n💬 Testing Alpha2 basic conversation with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + # Create Alpha2 conversation input with sophisticated message structure + user_message = conversation.create_user_message( + "Hello! Please respond with exactly: 'Hello from Dapr Alpha2!'" + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + # Use new parameter conversion (raw Python values automatically converted) + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + temperature=1, + parameters={ + 'temperature': 0.7, + 'max_tokens': 100, + 'top_p': 0.9, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'✅ Alpha2 Response: {choice.message.content}') + print(f'📊 Finish reason: {choice.finish_reason}') + else: + print('❌ No Alpha2 response received') + + except Exception as e: + print(f'❌ Alpha2 basic conversation error: {e}') + + def test_multi_turn_conversation_alpha2(self, provider_id: str) -> None: + """Test multi-turn Alpha2 conversation with different message types.""" + print( + f"\n🔄 Testing Alpha2 multi-turn conversation with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + # Create a multi-turn conversation with system, user, and assistant messages + system_message = conversation.create_system_message( + 'You are a helpful AI assistant. Be concise.' + ) + user_message1 = conversation.create_user_message('What is 2+2?') + assistant_message = conversation.create_assistant_message('2+2 equals 4.') + user_message2 = conversation.create_user_message('What about 3+3?') + + input_alpha2 = conversation.ConversationInputAlpha2( + messages=[system_message, user_message1, assistant_message, user_message2] + ) + + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + temperature=1, + parameters={ + 'max_tokens': 150, + }, + ) + + if response.outputs and response.outputs[0].choices: + print( + f'✅ Multi-turn conversation processed {len(response.outputs[0].choices)} message(s)' + ) + for i, choice in enumerate(response.outputs[0].choices): + print(f' Response {i+1}: {choice.message.content[:100]}...') + else: + print('❌ No multi-turn response received') + + except Exception as e: + print(f'❌ Multi-turn conversation error: {e}') + + def test_tool_calling_alpha2(self, provider_id: str) -> None: + """Test Alpha2 tool calling with a provider.""" + print( + f"\n🔧 Testing Alpha2 tool calling with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + weather_tool = create_weather_tool() + user_message = conversation.create_user_message( + "What's the weather like in San Francisco?" + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=[weather_tool], + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'📊 Finish reason: {choice.finish_reason}') + + if choice.finish_reason == 'tool_calls' and choice.message.tool_calls: + print(f'🔧 Tool calls made: {len(choice.message.tool_calls)}') + for tool_call in choice.message.tool_calls: + print(f' Tool: {tool_call.function.name}') + print(f' Arguments: {tool_call.function.arguments}') + + # Execute the tool to show the workflow + try: + args = json.loads(tool_call.function.arguments) + weather_result = execute_weather_tool( + args.get('location', 'San Francisco'), + args.get('unit', 'fahrenheit'), + ) + print(f'🌤️ Tool executed: {weather_result}') + + # Demonstrate tool result message (for multi-turn tool workflows) + tool_result_message = conversation.create_tool_message( + tool_id=tool_call.id, + name=tool_call.function.name, + content=weather_result, + ) + print( + '✅ Alpha2 tool calling demonstration completed! Tool Result Message:' + ) + print(tool_result_message) + + except json.JSONDecodeError: + print('⚠️ Could not parse tool arguments') + else: + print(f'💬 Regular response: {choice.message.content}') + else: + print('❌ No tool calling response received') + + except Exception as e: + print(f'❌ Alpha2 tool calling error: {e}') + + def test_parameter_conversion(self, provider_id: str) -> None: + """Test the new parameter conversion feature.""" + print( + f"\n🔄 Testing parameter conversion with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + user_message = conversation.create_user_message( + 'Tell me about the different tool creation approaches available.' + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + # Demonstrate different tool creation approaches + weather_tool = create_weather_tool() # Simple properties approach + calc_tool = create_calculator_tool() # Full JSON schema approach + time_tool = create_time_tool() # No parameters approach + search_tool = create_search_tool() # Complex schema with arrays, etc. + + print( + f'✅ Created {len([weather_tool, calc_tool, time_tool, search_tool])} tools with different approaches!' + ) + + # Test various parameter types that are automatically converted + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=[weather_tool, calc_tool, time_tool, search_tool], + temperature=1, + parameters={ + # Raw Python values - automatically converted to GrpcAny + 'max_tokens': 200, # int + 'top_p': 1.0, # float + 'frequency_penalty': 0.0, # float + 'presence_penalty': 0.0, # float + 'stream': False, # bool + 'tool_choice': 'none', # string + 'model': 'gpt-4o-mini', # string (provider-specific) + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'✅ Parameter conversion successful!') + print(f'✅ Tool creation helpers working perfectly!') + print(f' Response: {choice.message.content[:100]}...') + else: + print('❌ Parameter conversion test failed') + + except Exception as e: + print(f'❌ Parameter conversion error: {e}') + + def test_multi_turn_tool_calling_alpha2(self, provider_id: str) -> None: + """Test multi-turn Alpha2 tool calling with proper context accumulation.""" + print( + f"\n🔄🔧 Testing multi-turn tool calling with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + weather_tool = create_weather_tool() + conversation_history = [] + + # Turn 1: User asks about weather (include tools) + print('\n--- Turn 1: Initial weather query ---') + user_message1 = conversation.create_user_message( + "What's the weather like in San Francisco? Use one of the tools available." + ) + conversation_history.append(user_message1) + + print(f'📝 Request 1 context: {len(conversation_history)} messages + tools') + input_alpha2_turn1 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response1 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn1], + tools=[weather_tool], # Tools included in turn 1 + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + # Check all outputs and choices for tool calls + tool_calls_found = [] + assistant_messages = [] + + for output_idx, output in enumerate(response1.outputs or []): + for choice_idx, choice in enumerate(output.choices or []): + print( + f'📋 Checking output {output_idx}, choice {choice_idx}: finish_reason={choice.finish_reason}, choice: {choice}' + ) + + # Convert and collect all assistant messages + assistant_message = convert_llm_response_to_conversation_input( + choice.message + ) + assistant_messages.append(assistant_message) + + # Check for tool calls in this choice + if choice.message.tool_calls: + # if not choice.message.tool_calls[0].id: + # choice.message.tool_calls[0].id = "1" + tool_calls_found.extend(choice.message.tool_calls) + print( + f'🔧 Found {len(choice.message.tool_calls)} tool call(s) in output {output_idx}, choice {choice_idx}' + ) + + # Use the first assistant message for conversation history (most providers return one) + if assistant_messages: + for assistant_message in assistant_messages: + conversation_history.append(assistant_message) + print( + f'✅ Added assistant message to history (from {len(assistant_messages)} total messages)' + ) + + if tool_calls_found: + # Use the first tool call for demonstration + tool_call = tool_calls_found[0] + print( + f'🔧 Processing tool call: {tool_call.function.name} (found {len(tool_calls_found)} total tool calls)' + ) + + # Execute the tool + args = json.loads(tool_call.function.arguments) + weather_result = execute_weather_tool( + args.get('location', 'San Francisco'), args.get('unit', 'fahrenheit') + ) + print(f'🌤️ Tool result: {weather_result}') + + # Add tool result to conversation history + tool_result_message = conversation.create_tool_message( + tool_id=tool_call.id, name=tool_call.function.name, content=weather_result + ) + conversation_history.append(tool_result_message) + + # Turn 2: LLM processes tool result (accumulate context + tools) + print('\n--- Turn 2: LLM processes tool result ---') + print(f'📝 Request 2 context: {len(conversation_history)} messages + tools') + input_alpha2_turn2 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response2 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn2], + tools=[weather_tool], # Tools carried forward to turn 2 + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response2.outputs and response2.outputs[0].choices: + choice2 = response2.outputs[0].choices[0] + print(f'🤖 LLM response with tool context: {choice2.message.content}') + + # Add LLM's response to accumulated history using utility + assistant_message2 = convert_llm_response_to_conversation_input( + choice2.message + ) + conversation_history.append(assistant_message2) + + # Turn 3: Follow-up question (full context + tools) + print('\n--- Turn 3: Follow-up question using accumulated context ---') + user_message2 = conversation.create_user_message( + 'Should I bring an umbrella? Also, what about the weather in New York?' + ) + conversation_history.append(user_message2) + + print(f'📝 Request 3 context: {len(conversation_history)} messages + tools') + print('📋 Accumulated context includes:') + print(' • Original user query about San Francisco') + print(" • Assistant's tool call intention") + print(' • Weather tool execution result') + print(" • Assistant's weather summary") + print(' • New user follow-up question') + + input_alpha2_turn3 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response3 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn3], + tools=[weather_tool], # Tools still available in turn 3 + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response3.outputs and response3.outputs[0].choices: + choice3 = response3.outputs[0].choices[0] + + if choice3.finish_reason == 'tool_calls' and choice3.message.tool_calls: + print( + f'🔧 Follow-up tool call: {choice3.message.tool_calls[0].function.name}' + ) + + # Execute second tool call + tool_call3 = choice3.message.tool_calls[0] + # if not tool_call3.id: + # tool_call3.id = "2" + args3 = json.loads(tool_call3.function.arguments) + weather_result3 = execute_weather_tool( + args3.get('location', 'New York'), + args3.get('unit', 'fahrenheit'), + ) + print(f'🌤️ Second tool result: {weather_result3}') + + # Could continue accumulating context for turn 4... + print( + '✅ Multi-turn tool calling with proper context accumulation successful!' + ) + print( + f'📊 Final context: {len(conversation_history)} messages + tools available for next turn' + ) + else: + print( + f'💬 Follow-up response using accumulated context: {choice3.message.content}' + ) + print( + '✅ Multi-turn conversation with proper context accumulation successful!' + ) + print(f'📊 Final context: {len(conversation_history)} messages') + else: + print( + '⚠️ No tool calls found in any output/choice - continuing with regular conversation flow' + ) + # Could continue with regular multi-turn conversation without tools + + if not assistant_messages: + print('❌ No assistant messages received in first turn') + + except Exception as e: + print(f'❌ Multi-turn tool calling error: {e}') + + def test_multi_turn_tool_calling_alpha2_tool_helpers(self, provider_id: str) -> None: + """Test multi-turn Alpha2 tool calling with proper context accumulation using higher level abstractions.""" + print( + f"\n🔄🔧 Testing multi-turn tool calling with {self.available_providers[provider_id]['display_name']}" + ) + + # using decorator + + @conversation.tool + def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get the current weather for a location.""" + # This is a mock implementation. Replace with actual weather API call. + temp = '72°F' if unit == 'fahrenheit' else '22°C' + return f'The weather in {location} is sunny with a temperature of {temp}.' + + try: + with DaprClient() as client: + conversation_history = [] # our context to pass to the LLM on each turn + + # Turn 1: User asks about weather (include tools) + print('\n--- Turn 1: Initial weather query ---') + user_message1 = conversation.create_user_message( + "What's the weather like in San Francisco? Use one of the tools available." + ) + conversation_history.append(user_message1) + + print(f'📝 Request 1 context: {len(conversation_history)} messages + tools') + input_alpha2_turn1 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response1 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn1], + tools=conversation.get_registered_tools(), # using registered tools (automatically registered by the decorator) + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + def append_response_to_history( + response: conversation.ConversationResponseAlpha2, skip_execution: bool = False + ): + """Helper to append response to history and execute tool calls.""" + for msg in response.to_assistant_messages(): + conversation_history.append(msg) + if not msg.of_assistant.tool_calls: + continue + for _tool_call in msg.of_assistant.tool_calls: + print(f'Executing tool call: {_tool_call.function.name}') + + # execute the tool called by the LLM + if not skip_execution: + output = conversation.execute_registered_tool( + _tool_call.function.name, _tool_call.function.arguments + ) + print(f'Tool output: {output}') + else: + output = 'tool execution skipped' + + # append a result to history + conversation_history.append( + conversation.create_tool_message( + tool_id=_tool_call.id, + name=_tool_call.function.name, + content=output, + ) + ) + + append_response_to_history(response1) + + # Turn 2: LLM processes tool result (accumulate context + tools) + print('\n--- Turn 2: LLM processes tool result ---') + print(f'📝 Request 2 context: {len(conversation_history)} messages + tools') + input_alpha2_turn2 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response2 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn2], + tools=conversation.get_registered_tools(), + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + # Turn 3: Follow-up question (full context + tools) + + append_response_to_history(response2) + + print('\n--- Turn 3: Follow-up question using accumulated context ---') + user_message2 = conversation.create_user_message( + 'Should I bring an umbrella? Also, what about the weather in New York?' + ) + conversation_history.append(user_message2) + + print(f'📝 Request 3 context: {len(conversation_history)} messages + tools') + print('📋 Accumulated context includes:') + + input_alpha2_turn3 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response3 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn3], + tools=conversation.get_registered_tools(), + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + append_response_to_history(response3) + + print(f'📝 Request 4 context: {len(conversation_history)} messages + tools') + print('📋 Expect response about the umbrella:') + + input_alpha2_turn4 = conversation.ConversationInputAlpha2( + messages=conversation_history + ) + + response4 = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2_turn4], + tools=conversation.get_registered_tools(), + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + append_response_to_history(response4, skip_execution=False) + + print('Full conversation history trace:') + print(' Tools available:') + for tool in conversation.get_registered_tools(): + print(f' - {tool.function.name}({tool.function.parameters["properties"]})') + for msg in conversation_history: + msg.trace_print(2) + + except Exception as e: + print(f'❌ Multi-turn tool calling error: {e}') + finally: + conversation.unregister_tool('get_weather') + + def test_function_to_schema_approach(self, provider_id: str) -> None: + """Test the best DevEx for most cases: function-to-JSON-schema automatic tool creation.""" + print( + f"\n🎯 Testing function-to-schema approach with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + # Create a tool using the typed function approach + restaurant_tool = create_tool_from_typed_function_example() + print(restaurant_tool) + + user_message = conversation.create_user_message( + 'I want to find Italian restaurants in San Francisco with a moderate price range.' + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=[restaurant_tool], + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'📊 Finish reason: {choice.finish_reason}') + + if choice.finish_reason == 'tool_calls' and choice.message.tool_calls: + print('🎯 Function-to-schema tool calling successful!') + for tool_call in choice.message.tool_calls: + print(f' Tool: {tool_call.function.name}') + print(f' Arguments: {tool_call.function.arguments}') + + # This demonstrates the complete workflow + print('✅ Auto-generated schema worked perfectly with real LLM!') + else: + print(f'💬 Response: {choice.message.content}') + else: + print('❌ No function-to-schema response received') + + except Exception as e: + print(f'❌ Function-to-schema approach error: {e}') + + def test_tool_decorated_function_to_schema_approach(self, provider_id: str) -> None: + """Test the best DevEx for most cases: function-to-JSON-schema automatic tool creation.""" + print( + f"\n🎯 Testing decorator tool function-to-schema approach with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + # Create a tool using the typed function approach + create_tool_from_tool_decorator_example() + + # we can get tools registered from different places in our repo + print(conversation.get_registered_tools()) + + user_message = conversation.create_user_message( + 'I want to find Italian restaurants in San Francisco with a moderate price range.' + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=conversation.get_registered_tools(), + tool_choice='auto', + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'📊 Finish reason: {choice.finish_reason}') + + if choice.finish_reason == 'tool_calls' and choice.message.tool_calls: + print('🎯 Function-to-schema tool calling successful!') + for tool_call in choice.message.tool_calls: + print(f' Tool: {tool_call.function.name}') + print(f' Arguments: {tool_call.function.arguments}') + + # This demonstrates the complete workflow + print('✅ Auto-generated schema worked perfectly with real LLM!') + else: + print(f'💬 Response: {choice.message.content}') + else: + print('❌ No function-to-schema response received') + + except Exception as e: + print(f'❌ Function-to-schema approach error: {e}') + + async def test_async_conversation_alpha2(self, provider_id: str) -> None: + """Test async Alpha2 conversation with a provider.""" + print( + f"\n⚡ Testing async Alpha2 conversation with {self.available_providers[provider_id]['display_name']}" + ) + + try: + async with AsyncDaprClient() as client: + user_message = conversation.create_user_message( + 'Tell me a very short joke about async programming.' + ) + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = await client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + print(f'✅ Async Alpha2 response: {choice.message.content}') + else: + print('❌ No async Alpha2 response received') + + except Exception as e: + print(f'❌ Async Alpha2 error: {e}') + + async def test_async_tool_calling_alpha2(self, provider_id: str) -> None: + """Test async Alpha2 tool calling with a provider.""" + print( + f"\n🔧⚡ Testing async Alpha2 tool calling with {self.available_providers[provider_id]['display_name']}" + ) + + try: + async with AsyncDaprClient() as client: + weather_tool = create_weather_tool() + user_message = conversation.create_user_message("What's the weather in Tokyo?") + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = await client.converse_alpha2( + name=provider_id, + inputs=[input_alpha2], + tools=[weather_tool], + temperature=1, + parameters={ + 'max_tokens': 500, + }, + ) + + if response.outputs and response.outputs[0].choices: + choice = response.outputs[0].choices[0] + if choice.finish_reason == 'tool_calls' and choice.message.tool_calls: + print('✅ Async tool calling successful!') + for tool_call in choice.message.tool_calls: + print(f' Tool: {tool_call.function.name}') + args = json.loads(tool_call.function.arguments) + weather_result = execute_weather_tool( + args.get('location', 'Tokyo'), args.get('unit', 'fahrenheit') + ) + print(f' Result: {weather_result}') + else: + print(f'💬 Async response: {choice.message.content}') + else: + print('❌ No async tool calling response received') + + except Exception as e: + print(f'❌ Async tool calling error: {e}') + + def run_comprehensive_test(self, provider_id: str) -> None: + """Run comprehensive Alpha2 tests for a provider.""" + provider_name = self.available_providers[provider_id]['display_name'] + print(f"\n{'='*60}") + print(f'🧪 Testing {provider_name} with Alpha2 API') + print(f"{'='*60}") + + # Alpha2 Sync tests + self.test_basic_conversation_alpha2(provider_id) + self.test_multi_turn_conversation_alpha2(provider_id) + self.test_tool_calling_alpha2(provider_id) + self.test_parameter_conversion(provider_id) + self.test_function_to_schema_approach(provider_id) + self.test_tool_decorated_function_to_schema_approach(provider_id) + self.test_multi_turn_tool_calling_alpha2(provider_id) + self.test_multi_turn_tool_calling_alpha2_tool_helpers(provider_id) + + # Alpha2 Async tests + asyncio.run(self.test_async_conversation_alpha2(provider_id)) + asyncio.run(self.test_async_tool_calling_alpha2(provider_id)) + + # Legacy Alpha1 test for comparison + self.test_basic_conversation_alpha1_legacy(provider_id) + + def test_basic_conversation_alpha1_legacy(self, provider_id: str) -> None: + """Test legacy Alpha1 conversation for comparison.""" + print( + f"\n📚 Testing legacy Alpha1 for comparison with {self.available_providers[provider_id]['display_name']}" + ) + + try: + with DaprClient() as client: + inputs = [ + conversation.ConversationInput( + content="Hello! Please respond with: 'Hello from Dapr Alpha1!'", role='user' + ) + ] + + response = client.converse_alpha1( + name=provider_id, + inputs=inputs, + temperature=1, + parameters={ + 'max_tokens': 100, + }, + ) + + if response.outputs: + result = response.outputs[0].result + print(f'✅ Alpha1 Response: {result}') + else: + print('❌ No Alpha1 response received') + + except Exception as e: + print(f'❌ Alpha1 legacy conversation error: {e}') + + def cleanup(self) -> None: + # Clean up temporary components directory + if self.components_dir and Path(self.components_dir).exists(): + import shutil + + shutil.rmtree(self.components_dir) + print(f'🧹 Cleaned up components directory: {self.components_dir}') + + +def main(): + """Main function to run the real LLM providers test with Alpha2 API.""" + print('🚀 Real LLM Providers Example for Dapr Conversation API Alpha2') + print('=' * 60) + + # Check for help flag + if '--help' in sys.argv or '-h' in sys.argv: + print(__doc__) + return + + tester = RealLLMProviderTester() + + try: + # Load environment variables + tester.load_environment() + + # Detect available providers + print('\n🔍 Detecting available LLM providers...') + tester.available_providers = tester.detect_available_providers() + + if not tester.available_providers: + print('\n❌ No LLM providers configured!') + print('Please set up API keys in .env file (copy from .env.example)') + print('Available providers: OpenAI, Anthropic, Mistral, DeepSeek, Google AI') + return + + print(f'\n✅ Found {len(tester.available_providers)} configured provider(s)') + + # Create component configurations for all available providers + selected_providers = list(tester.available_providers.keys()) + components_dir = tester.create_component_configs(selected_providers) + + # Manual sidecar setup + print('\n⚠️ IMPORTANT: Make sure Dapr sidecar is running with components from:') + print(f' {components_dir}') + print('\nTo start the sidecar with these components:') + print( + f' dapr run --app-id test-app --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path {components_dir}' + ) + + # Wait for user to confirm + input('\nPress Enter when Dapr sidecar is running with the component configurations...') + + # Test only the providers we created components for + for provider_id in selected_providers: + if provider_id in tester.available_providers: + tester.run_comprehensive_test(provider_id) + + print(f"\n{'='*60}") + print('🎉 All Alpha2 tests completed!') + print('✅ Real LLM provider integration with Alpha2 API is working correctly') + print('🔧 Features demonstrated:') + print(' • Alpha2 conversation API with sophisticated message types') + print(' • Automatic parameter conversion (raw Python values)') + print(' • Enhanced tool calling capabilities') + print(' • Multi-turn conversations') + print(' • Multi-turn tool calling with context expansion') + print(' • Function-to-schema automatic tool generation') + print(' • Function-to-schema using @tool decorator for automatic tool generation') + print(' • Both sync and async implementations') + print(' • Backward compatibility with Alpha1') + print(f"{'='*60}") + + except KeyboardInterrupt: + print('\n\n⏹️ Tests interrupted by user') + except Exception as e: + print(f'\n❌ Unexpected error: {e}') + import traceback + + traceback.print_exc() + finally: + tester.cleanup() + + +if __name__ == '__main__': + main() diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py b/ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py index c51df48ba..996267fdd 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/_servicer.py @@ -28,7 +28,7 @@ JobEventRequest, ) from dapr.proto.common.v1.common_pb2 import InvokeRequest -from dapr.clients.base import DEFAULT_JSON_CONTENT_TYPE +from dapr.clients._constants import DEFAULT_JSON_CONTENT_TYPE from dapr.clients.grpc._request import InvokeMethodRequest, BindingRequest, JobEvent from dapr.clients.grpc._response import InvokeMethodResponse, TopicEventResponse diff --git a/tests/clients/fake_dapr_server.py b/tests/clients/fake_dapr_server.py index d530b8389..a1cbeb4b7 100644 --- a/tests/clients/fake_dapr_server.py +++ b/tests/clients/fake_dapr_server.py @@ -34,6 +34,12 @@ EncryptResponse, DecryptRequest, DecryptResponse, + ConversationResultAlpha2, + ConversationResultChoices, + ConversationResultMessage, + ConversationResponseAlpha2, + ConversationToolCalls, + ConversationToolCallsOfFunction, ) from typing import Dict @@ -538,6 +544,82 @@ def ConverseAlpha1(self, request, context): return api_v1.ConversationResponse(contextID=request.contextID, outputs=outputs) + def ConverseAlpha2(self, request, context): + """Mock implementation of the ConverseAlpha2 endpoint.""" + self.check_for_exception(context) + + # Process inputs and create responses with choices structure + outputs = [] + for input_idx, input in enumerate(request.inputs): + choices = [] + + # Process each message in the input + for msg_idx, message in enumerate(input.messages): + response_content = '' + tool_calls = [] + + # Extract content based on message type + if message.HasField('of_user'): + if message.of_user.content: + response_content = f'Response to user: {message.of_user.content[0].text}' + elif message.HasField('of_system'): + if message.of_system.content: + response_content = ( + f'System acknowledged: {message.of_system.content[0].text}' + ) + elif message.HasField('of_assistant'): + if message.of_assistant.content: + response_content = ( + f'Assistant continued: {message.of_assistant.content[0].text}' + ) + elif message.HasField('of_developer'): + if message.of_developer.content: + response_content = ( + f'Developer note processed: {message.of_developer.content[0].text}' + ) + elif message.HasField('of_tool'): + if message.of_tool.content: + response_content = ( + f'Tool result processed: {message.of_tool.content[0].text}' + ) + + # Check if tools are available and simulate tool calling + if request.tools and response_content and 'weather' in response_content.lower(): + # Simulate a tool call for weather requests + for tool in request.tools: + if tool.function and 'weather' in tool.function.name.lower(): + tool_call = ConversationToolCalls( + id=f'call_{input_idx}_{msg_idx}', + function=ConversationToolCallsOfFunction( + name=tool.function.name, + arguments='{"location": "San Francisco", "unit": "celsius"}', + ), + ) + tool_calls.append(tool_call) + response_content = "I'll check the weather for you." + break + + # Create result message + result_message = ConversationResultMessage( + content=response_content, tool_calls=tool_calls + ) + + # Create choice + finish_reason = 'tool_calls' if tool_calls else 'stop' + choice = ConversationResultChoices( + finish_reason=finish_reason, index=msg_idx, message=result_message + ) + choices.append(choice) + + # Create result for this input + result = ConversationResultAlpha2(choices=choices) + outputs.append(result) + + return ConversationResponseAlpha2( + context_id=request.context_id if request.HasField('context_id') else None, + outputs=outputs, + ) + def ScheduleJobAlpha1(self, request, context): self.check_for_exception(context) diff --git a/tests/clients/test_conversation.py b/tests/clients/test_conversation.py new file mode 100644 index 000000000..8a6cc697e --- /dev/null +++ b/tests/clients/test_conversation.py @@ -0,0 +1,1227 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +import asyncio +import json +import unittest +import uuid + +from google.rpc import code_pb2, status_pb2 + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients import DaprClient +from dapr.clients.exceptions import DaprGrpcError +from dapr.clients.grpc import conversation +from dapr.clients.grpc._conversation_helpers import ( + ToolArgumentError, + ToolExecutionError, + ToolNotFoundError, +) +from dapr.clients.grpc.conversation import ( + ConversationInput, + ConversationInputAlpha2, + ConversationResponseAlpha2, + ConversationTools, + ConversationToolsFunction, + FunctionBackend, + create_assistant_message, + create_system_message, + create_tool_message, + create_user_message, + execute_registered_tool_async, + get_registered_tools, + register_tool, + unregister_tool, + ConversationResultAlpha2Message, + ConversationResultAlpha2Choices, + ConversationResultAlpha2, + ConversationMessage, + ConversationMessageOfAssistant, + ConversationToolCalls, + ConversationToolCallsOfFunction, + execute_registered_tool, +) +from dapr.clients.grpc.conversation import ( + tool as tool_decorator, +) +from dapr.conf import settings +from tests.clients.fake_dapr_server import FakeDaprSidecar + +""" +Comprehensive tests for Dapr conversation API functionality. + +This test suite covers: +- Basic conversation API (Alpha1) +- Advanced conversation API (Alpha2) with tool calling +- Multi-turn conversations +- Different message types (user, system, assistant, developer, tool) +- Error handling +- Both sync and async implementations +- Parameter conversion and validation +""" + + +def create_weather_tool(): + """Create a weather tool for testing.""" + return ConversationTools( + function=ConversationToolsFunction( + name='get_weather', + description='Get weather information for a location', + parameters={ + 'type': 'object', + 'properties': { + 'location': { + 'type': 'string', + 'description': 'The city and state, e.g. San Francisco, CA', + }, + 'unit': { + 'type': 'string', + 'enum': ['celsius', 'fahrenheit'], + 'description': 'Temperature unit', + }, + }, + 'required': ['location'], + }, + ) + ) + + +def create_calculate_tool(): + """Create a calculate tool for testing.""" + return ConversationTools( + function=ConversationToolsFunction( + name='calculate', + description='Perform mathematical calculations', + parameters={ + 'type': 'object', + 'properties': { + 'expression': { + 'type': 'string', + 'description': 'Mathematical expression to evaluate', + } + }, + 'required': ['expression'], + }, + ) + ) + + +class ConversationTestBase: + """Base class for conversation tests with common setup.""" + + grpc_port = 50011 + http_port = 3510 + scheme = '' + + @classmethod + def setUpClass(cls): + cls._fake_dapr_server = FakeDaprSidecar(grpc_port=cls.grpc_port, http_port=cls.http_port) + cls._fake_dapr_server.start() + # Configure health check to use fake server's HTTP port + settings.DAPR_HTTP_PORT = cls.http_port + settings.DAPR_HTTP_ENDPOINT = f'http://127.0.0.1:{cls.http_port}' + + @classmethod + def tearDownClass(cls): + cls._fake_dapr_server.stop() + + +class ConversationTestBaseSync(ConversationTestBase, unittest.TestCase): + """Base class for conversation tests with common setup.""" + + def setUp(self): + super().setUp() + self.client = DaprClient(f'{self.scheme}localhost:{self.grpc_port}') + + def tearDown(self): + super().tearDown() + self.client.close() + + +class ConversationTestBaseAsync(ConversationTestBase, unittest.IsolatedAsyncioTestCase): + """Base class for conversation tests with common setup.""" + + async def asyncSetUp(self): + await super().asyncSetUp() + self.client = AsyncDaprClient(f'{self.scheme}localhost:{self.grpc_port}') + + async def asyncTearDown(self): + await super().asyncTearDown() + await self.client.close() + + +class ConversationAlpha1SyncTests(ConversationTestBaseSync): + """Synchronous Alpha1 conversation API tests.""" + + def test_basic_conversation_alpha1(self): + """Test basic Alpha1 conversation functionality.""" + inputs = [ + ConversationInput(content='Hello', role='user'), + ConversationInput(content='How are you?', role='user'), + ] + + response = self.client.converse_alpha1(name='test-llm', inputs=inputs) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 2) + self.assertIn('Hello', response.outputs[0].result) + self.assertIn('How are you?', response.outputs[1].result) + + def test_conversation_alpha1_with_options(self): + """Test Alpha1 conversation with various options.""" + inputs = [ConversationInput(content='Hello with options', role='user', scrub_pii=True)] + + response = self.client.converse_alpha1( + name='test-llm', + inputs=inputs, + context_id='test-context-123', + temperature=0.7, + scrub_pii=True, + metadata={'test_key': 'test_value'}, + ) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(response.context_id, 'test-context-123') + + def test_alpha1_parameter_conversion(self): + """Test Alpha1 parameter conversion with raw Python values.""" + inputs = [ConversationInput(content='Test with parameters', role='user')] + + # Test with raw Python parameters - these should be automatically converted + response = self.client.converse_alpha1( + name='test-llm', + inputs=inputs, + parameters={ + 'temperature': 0.7, + 'max_tokens': 1000, + 'top_p': 0.9, + 'frequency_penalty': 0.0, + 'presence_penalty': 0.0, + }, + ) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + + def test_alpha1_error_handling(self): + """Test Alpha1 conversation error handling.""" + # Setup server to raise an exception + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Alpha1 test error') + ) + + inputs = [ConversationInput(content='Error test', role='user')] + + with self.assertRaises(DaprGrpcError) as context: + self.client.converse_alpha1(name='test-llm', inputs=inputs) + self.assertIn('Alpha1 test error', str(context.exception)) + + +class ConversationAlpha2SyncTests(ConversationTestBaseSync): + """Synchronous Alpha2 conversation API tests.""" + + def test_basic_conversation_alpha2(self): + """Test basic Alpha2 conversation functionality.""" + user_message = create_user_message('Hello Alpha2!') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'stop') + self.assertIn('Hello Alpha2!', choice.message.content) + + def test_conversation_alpha2_with_system_message(self): + """Test Alpha2 conversation with system message.""" + system_message = create_system_message('You are a helpful assistant.') + user_message = create_user_message('Hello!') + + input_alpha2 = ConversationInputAlpha2( + messages=[system_message, user_message], scrub_pii=False + ) + + response = self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs[0].choices), 2) + + # Check system message response + system_choice = response.outputs[0].choices[0] + self.assertIn('System acknowledged', system_choice.message.content) + + # Check user message response + user_choice = response.outputs[0].choices[1] + self.assertIn('Response to user', user_choice.message.content) + + def test_conversation_alpha2_with_options(self): + """Test Alpha2 conversation with various options.""" + user_message = create_user_message('Alpha2 with options') + input_alpha2 = ConversationInputAlpha2(messages=[user_message], scrub_pii=True) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + context_id='alpha2-context-123', + temperature=0.8, + scrub_pii=True, + metadata={'alpha2_test': 'true'}, + tool_choice='none', + ) + + self.assertIsNotNone(response) + self.assertEqual(response.context_id, 'alpha2-context-123') + + def test_alpha2_parameter_conversion(self): + """Test Alpha2 parameter conversion with various types.""" + user_message = create_user_message('Parameter conversion test') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + parameters={ + 'model': 'gpt-4o-mini', + 'temperature': 0.7, + 'max_tokens': 1000, + 'top_p': 1.0, + 'frequency_penalty': 0.0, + 'presence_penalty': 0.0, + 'stream': False, + }, + ) + + self.assertIsNotNone(response) + + def test_alpha2_error_handling(self): + """Test Alpha2 conversation error handling.""" + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Alpha2 test error') + ) + + user_message = create_user_message('Error test') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + with self.assertRaises(DaprGrpcError) as context: + self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + self.assertIn('Alpha2 test error', str(context.exception)) + + +class ConversationToolCallingSyncTests(ConversationTestBaseSync): + """Synchronous tool calling tests for Alpha2.""" + + def test_tool_calling_weather(self): + """Test tool calling with weather tool.""" + weather_tool = create_weather_tool() + user_message = create_user_message('What is the weather in San Francisco?') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool], tool_choice='auto' + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + self.assertEqual(len(choice.message.tool_calls), 1) + + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + self.assertIn('San Francisco', tool_call.function.arguments) + + def test_tool_calling_calculate(self): + """Test tool calling with calculate tool.""" + calc_tool = create_calculate_tool() + user_message = create_user_message('Calculate 15 * 23') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[calc_tool] + ) + + # Note: Our fake server only triggers weather tools, so this won't return tool calls + # but it tests that the API works with different tools + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertIn('Calculate', choice.message.content) + + def test_multiple_tools(self): + """Test conversation with multiple tools.""" + weather_tool = create_weather_tool() + calc_tool = create_calculate_tool() + + user_message = create_user_message('I need weather and calculation help') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + tools=[weather_tool, calc_tool], + tool_choice='auto', + ) + + self.assertIsNotNone(response) + # The fake server will call weather tool if "weather" is in the message + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + + def test_tool_choice_none(self): + """Test tool choice set to 'none'.""" + + weather_tool = create_weather_tool() + user_message = create_user_message('What is the weather today?') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool], tool_choice='none' + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + # With tool_choice='none', should not make tool calls even if weather is mentioned + # (though our fake server may still trigger based on content) + self.assertIsNotNone(choice.message.content) + + def test_tool_choice_specific(self): + """Test tool choice set to specific tool name.""" + weather_tool = create_weather_tool() + calc_tool = create_calculate_tool() + + user_message = create_user_message('What is the weather like?') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + tools=[weather_tool, calc_tool], + tool_choice='get_weather', + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + if choice.finish_reason == 'tool_calls': + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + + +class ConversationMultiTurnSyncTests(ConversationTestBaseSync): + """Multi-turn conversation tests for Alpha2.""" + + def test_multi_turn_conversation(self): + """Test multi-turn conversation with different message types.""" + with DaprClient(f'{self.scheme}localhost:{self.grpc_port}') as client: + # Create a conversation with system, user, and assistant messages + system_message = create_system_message('You are a helpful AI assistant.') + user_message1 = create_user_message('Hello, how are you?') + assistant_message = create_assistant_message('I am doing well, thank you!') + user_message2 = create_user_message('What can you help me with?') + + input_alpha2 = ConversationInputAlpha2( + messages=[system_message, user_message1, assistant_message, user_message2] + ) + + response = client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs[0].choices), 4) + + # Check each response type + choices = response.outputs[0].choices + self.assertIn('System acknowledged', choices[0].message.content) + self.assertIn('Response to user', choices[1].message.content) + self.assertIn('Assistant continued', choices[2].message.content) + self.assertIn('Response to user', choices[3].message.content) + + def test_tool_calling_workflow(self): + """Test complete tool calling workflow.""" + with DaprClient(f'{self.scheme}localhost:{self.grpc_port}') as client: + # Step 1: User asks for weather + weather_tool = create_weather_tool() + user_message = create_user_message('What is the weather in Tokyo?') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response1 = client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool] + ) + + # Should get tool call + self.assertIsNotNone(response1) + choice = response1.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + tool_call = choice.message.tool_calls[0] + + # Step 2: Send tool result back + tool_result_message = create_tool_message( + tool_id=tool_call.id, + name='get_weather', + content='{"temperature": 18, "condition": "cloudy", "humidity": 75}', + ) + + result_input = ConversationInputAlpha2(messages=[tool_result_message]) + + response2 = client.converse_alpha2(name='test-llm', inputs=[result_input]) + + # Should get processed tool result + self.assertIsNotNone(response2) + result_choice = response2.outputs[0].choices[0] + self.assertIn('Tool result processed', result_choice.message.content) + + def test_conversation_context_continuity(self): + """Test conversation context continuity with context_id.""" + with DaprClient(f'{self.scheme}localhost:{self.grpc_port}') as client: + context_id = 'multi-turn-test-123' + + # First turn + user_message1 = create_user_message('My name is Alice.') + input1 = ConversationInputAlpha2(messages=[user_message1]) + + response1 = client.converse_alpha2( + name='test-llm', inputs=[input1], context_id=context_id + ) + + self.assertEqual(response1.context_id, context_id) + + # Second turn with same context + user_message2 = create_user_message('What is my name?') + input2 = ConversationInputAlpha2(messages=[user_message2]) + + response2 = client.converse_alpha2( + name='test-llm', inputs=[input2], context_id=context_id + ) + + self.assertEqual(response2.context_id, context_id) + self.assertIsNotNone(response2.outputs[0].choices[0].message.content) + + +class ConversationAsyncTests(ConversationTestBaseAsync): + """Asynchronous conversation API tests.""" + + async def test_basic_async_conversation_alpha1(self): + """Test basic async Alpha1 conversation.""" + inputs = [ + ConversationInput(content='Hello async', role='user'), + ConversationInput(content='How are you async?', role='user'), + ] + + response = await self.client.converse_alpha1(name='test-llm', inputs=inputs) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 2) + self.assertIn('Hello async', response.outputs[0].result) + + async def test_basic_async_conversation_alpha2(self): + """Test basic async Alpha2 conversation.""" + user_message = create_user_message('Hello async Alpha2!') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = await self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertIn('Hello async Alpha2!', choice.message.content) + + async def test_async_tool_calling(self): + """Test async tool calling.""" + weather_tool = create_weather_tool() + user_message = create_user_message('Async weather request for London') + + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = await self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool] + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + + async def test_concurrent_async_conversations(self): + """Test multiple concurrent async conversations.""" + + async def run_alpha1_conversation(message, session_id): + inputs = [ConversationInput(content=message, role='user')] + response = await self.client.converse_alpha1( + name='test-llm', inputs=inputs, context_id=session_id + ) + return response.outputs[0].result + + async def run_alpha2_conversation(message, session_id): + user_message = create_user_message(message) + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + response = await self.client.converse_alpha2( + name='test-llm', inputs=[input_alpha2], context_id=session_id + ) + return response.outputs[0].choices[0].message.content + + # Run concurrent conversations with both Alpha1 and Alpha2 + tasks = [ + run_alpha1_conversation('First Alpha1 message', 'concurrent-alpha1'), + run_alpha2_conversation('First Alpha2 message', 'concurrent-alpha2'), + run_alpha1_conversation('Second Alpha1 message', 'concurrent-alpha1-2'), + run_alpha2_conversation('Second Alpha2 message', 'concurrent-alpha2-2'), + ] + + results = await asyncio.gather(*tasks) + + self.assertEqual(len(results), 4) + for result in results: + self.assertIsNotNone(result) + self.assertIsInstance(result, str) + + async def test_async_multi_turn_with_tools(self): + """Test async multi-turn conversation with tool calling.""" + # First turn: user asks for weather + weather_tool = create_weather_tool() + user_message = create_user_message('Async weather for Paris') + input1 = ConversationInputAlpha2(messages=[user_message]) + + response1 = await self.client.converse_alpha2( + name='test-llm', + inputs=[input1], + tools=[weather_tool], + context_id='async-multi-turn', + ) + + # Should get tool call + self.assertEqual(response1.outputs[0].choices[0].finish_reason, 'tool_calls') + tool_call = response1.outputs[0].choices[0].message.tool_calls[0] + + # Second turn: provide tool result + tool_result_message = create_tool_message( + tool_id=tool_call.id, + name='get_weather', + content='{"temperature": 22, "condition": "sunny"}', + ) + input2 = ConversationInputAlpha2(messages=[tool_result_message]) + + response2 = await self.client.converse_alpha2( + name='test-llm', inputs=[input2], context_id='async-multi-turn' + ) + + self.assertIsNotNone(response2) + self.assertIn('Tool result processed', response2.outputs[0].choices[0].message.content) + + async def test_async_error_handling(self): + """Test async conversation error handling.""" + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Async test error') + ) + + inputs = [ConversationInput(content='Async error test', role='user')] + + with self.assertRaises(DaprGrpcError) as context: + await self.client.converse_alpha1(name='test-llm', inputs=inputs) + self.assertIn('Async test error', str(context.exception)) + + +class ConversationParameterTests(ConversationTestBaseSync): + """Tests for parameter handling and conversion.""" + + def test_parameter_edge_cases(self): + """Test parameter conversion with edge cases.""" + user_message = create_user_message('Edge cases test') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + response = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + parameters={ + 'int32_max': 2147483647, # Int32 maximum + 'int64_large': 9999999999, # Requires Int64 + 'negative_temp': -0.5, # Negative float + 'zero_value': 0, # Zero integer + 'false_flag': False, # Boolean false + 'true_flag': True, # Boolean true + 'empty_string': '', # Empty string + }, + ) + + self.assertIsNotNone(response) + + def test_realistic_provider_parameters(self): + """Test with realistic LLM provider parameters.""" + user_message = create_user_message('Provider parameters test') + input_alpha2 = ConversationInputAlpha2(messages=[user_message]) + + # OpenAI-style parameters + response1 = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + parameters={ + 'model': 'gpt-4o-mini', + 'temperature': 0.7, + 'max_tokens': 1000, + 'top_p': 1.0, + 'frequency_penalty': 0.0, + 'presence_penalty': 0.0, + 'stream': False, + 'tool_choice': 'auto', + }, + ) + + # Anthropic-style parameters + response2 = self.client.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + parameters={ + 'model': 'claude-3-5-sonnet-20241022', + 'max_tokens': 4096, + 'temperature': 0.8, + 'top_p': 0.9, + 'top_k': 250, + 'stream': False, + }, + ) + + self.assertIsNotNone(response1) + self.assertIsNotNone(response2) + + +class ConversationValidationTests(ConversationTestBaseSync): + """Tests for input validation and edge cases.""" + + def test_empty_inputs_alpha1(self): + """Test Alpha1 with empty inputs.""" + response = self.client.converse_alpha1(name='test-llm', inputs=[]) + self.assertIsNotNone(response) + + def test_empty_inputs_alpha2(self): + """Test Alpha2 with empty inputs.""" + response = self.client.converse_alpha2(name='test-llm', inputs=[]) + self.assertIsNotNone(response) + + def test_empty_messages_alpha2(self): + """Test Alpha2 with empty messages in input.""" + input_alpha2 = ConversationInputAlpha2(messages=[]) + response = self.client.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + self.assertIsNotNone(response) + + def test_mixed_alpha1_alpha2_compatibility(self): + """Test that Alpha1 and Alpha2 can be used in the same session.""" + # Alpha1 call + alpha1_inputs = [ConversationInput(content='Alpha1 call', role='user')] + alpha1_response = self.client.converse_alpha1(name='test-llm', inputs=alpha1_inputs) + + # Alpha2 call + user_message = create_user_message('Alpha2 call') + alpha2_input = ConversationInputAlpha2(messages=[user_message]) + alpha2_response = self.client.converse_alpha2(name='test-llm', inputs=[alpha2_input]) + + # Both should work + self.assertIsNotNone(alpha1_response) + self.assertIsNotNone(alpha2_response) + + # Check response structures are different but valid + self.assertTrue(hasattr(alpha1_response, 'outputs')) + self.assertTrue(hasattr(alpha2_response, 'outputs')) + self.assertTrue(hasattr(alpha2_response.outputs[0], 'choices')) + + +class ConversationToolHelpersSyncTests(ConversationTestBaseSync): + """Tests for conversation tool helpers, registry, and backends (sync).""" + + def tearDown(self): + # Cleanup tools with known prefixes + for t in list(get_registered_tools()): + try: + name = t.function.name + if name.startswith('test_') or name.startswith('ns_') or name.startswith('dup_'): + unregister_tool(name) + except Exception: + continue + + def test_tool_decorator_namespace_and_name_override(self): + ns_unique = uuid.uuid4().hex[:6] + name_override = f'test_sum_{ns_unique}' + + @tool_decorator(namespace=f'ns.{ns_unique}', name=name_override) + def foo(x: int, y: int) -> int: + return x + y + + names = {t.function.name for t in get_registered_tools()} + self.assertIn(name_override, names) + unregister_tool(name_override) + + ns_tool = f'ns.{ns_unique}.bar' + + @tool_decorator(namespace=f'ns.{ns_unique}') + def bar(q: int) -> int: + return q * 2 + + names = {t.function.name for t in get_registered_tools()} + self.assertIn(ns_tool, names) + unregister_tool(ns_tool) + + def test_register_tool_duplicate_raises(self): + dup_name = f'dup_tool_{uuid.uuid4().hex[:6]}' + ct = ConversationTools( + function=ConversationToolsFunction(name=dup_name, parameters={'type': 'object'}), + backend=FunctionBackend(lambda: None), + ) + register_tool(dup_name, ct) + try: + with self.assertRaises(ValueError): + register_tool(dup_name, ct) + finally: + unregister_tool(dup_name) + + def test_conversationtools_invoke_without_backend_raises(self): + ct = ConversationTools( + function=ConversationToolsFunction( + name='test_no_backend', parameters={'type': 'object'} + ), + backend=None, + ) + with self.assertRaises(ToolExecutionError): + ct.invoke({'a': 1}) + + async def run(): + with self.assertRaises(ToolExecutionError): + await ct.ainvoke({'a': 1}) + + asyncio.run(run()) + + def test_functionbackend_sync_and_async_and_timeout(self): + def mul(a: int, b: int) -> int: + return a * b + + fb_sync = FunctionBackend(mul) + self.assertEqual( + fb_sync.invoke(ConversationToolsFunction(name='mul'), {'a': 3, 'b': 5}), + 15, + ) + + async def run_sync_via_async(): + res = await fb_sync.ainvoke(ConversationToolsFunction(name='mul'), {'a': 2, 'b': 7}) + self.assertEqual(res, 14) + + asyncio.run(run_sync_via_async()) + + async def wait_and_return(x: int, delay: float = 0.01) -> int: + await asyncio.sleep(delay) + return x + + fb_async = FunctionBackend(wait_and_return) + with self.assertRaises(ToolExecutionError): + fb_async.invoke(ConversationToolsFunction(name='wait'), {'x': 1}) + + async def run_async_ok(): + res = await fb_async.ainvoke(ConversationToolsFunction(name='wait'), {'x': 42}) + self.assertEqual(res, 42) + + asyncio.run(run_async_ok()) + + async def run_async_timeout(): + with self.assertRaises(ToolExecutionError): + await fb_async.ainvoke( + ConversationToolsFunction(name='wait'), + {'x': 1, 'delay': 0.2}, + timeout=0.01, + ) + + asyncio.run(run_async_timeout()) + + with self.assertRaises(ToolArgumentError): + fb_sync.invoke(ConversationToolsFunction(name='mul'), {'a': 1}) + + async def run_missing_arg_async(): + with self.assertRaises(ToolArgumentError): + await fb_sync.ainvoke(ConversationToolsFunction(name='mul'), {'a': 1}) + + asyncio.run(run_missing_arg_async()) + + def test_conversationtoolsfunction_from_function_and_schema(self): + def greet(name: str, punctuation: str = '!') -> str: + """Say hello. + + Args: + name: Person to greet + punctuation: Trailing punctuation + """ + + return f'Hello, {name}{punctuation}' + + spec = ConversationToolsFunction.from_function(greet, register=False) + schema = spec.schema_as_dict() + self.assertIn('name', schema.get('properties', {})) + self.assertIn('name', schema.get('required', [])) + self.assertIn('punctuation', schema.get('properties', {})) + + spec2 = ConversationToolsFunction.from_function(greet, register=True) + try: + names = {t.function.name for t in get_registered_tools()} + self.assertIn(spec2.name, names) + finally: + unregister_tool(spec2.name) + + def test_message_helpers_and_to_proto(self): + user_msg = conversation.create_user_message('hi') + self.assertIsNotNone(user_msg.of_user) + self.assertEqual(user_msg.of_user.content[0].text, 'hi') + proto_user = user_msg.to_proto() + self.assertEqual(proto_user.of_user.content[0].text, 'hi') + + sys_msg = conversation.create_system_message('sys') + proto_sys = sys_msg.to_proto() + self.assertEqual(proto_sys.of_system.content[0].text, 'sys') + + tc = conversation.ConversationToolCalls( + id='abc123', + function=conversation.ConversationToolCallsOfFunction(name='fn', arguments='{}'), + ) + asst_msg = conversation.ConversationMessage( + of_assistant=conversation.ConversationMessageOfAssistant( + content=[conversation.ConversationMessageContent(text='ok')], + tool_calls=[tc], + ) + ) + proto_asst = asst_msg.to_proto() + self.assertEqual(proto_asst.of_assistant.content[0].text, 'ok') + self.assertEqual(proto_asst.of_assistant.tool_calls[0].function.name, 'fn') + + tool_msg = conversation.create_tool_message('tid1', 'get_weather', 'cloudy') + proto_tool = tool_msg.to_proto() + self.assertEqual(proto_tool.of_tool.tool_id, 'tid1') + self.assertEqual(proto_tool.of_tool.name, 'get_weather') + self.assertEqual(proto_tool.of_tool.content[0].text, 'cloudy') + + +class ConversationToolHelpersAsyncTests(ConversationTestBaseAsync): + async def asyncTearDown(self): + for t in list(get_registered_tools()): + try: + name = t.function.name + if name.startswith('test_'): + unregister_tool(name) + except Exception: + continue + + async def test_execute_registered_tool_async(self): + unique = uuid.uuid4().hex[:8] + tool_name = f'test_async_{unique}' + + @tool_decorator(name=tool_name) + async def echo(value: str, delay: float = 0.0) -> str: + await asyncio.sleep(delay) + return value + + out = await execute_registered_tool_async(tool_name, {'value': 'hello'}) + self.assertEqual(out, 'hello') + + with self.assertRaises(ToolExecutionError): + await execute_registered_tool_async( + tool_name, {'value': 'slow', 'delay': 0.2}, timeout=0.01 + ) + unregister_tool(tool_name) + + +class TestStringifyToolOutputIntegration(unittest.TestCase): + def test_create_tool_message_with_bytes_and_bytearray(self): + import base64 + + # bytes + raw = bytes([0, 1, 2, 250, 255]) + msg = create_tool_message('tidb', 'bin', raw) + self.assertTrue(msg.of_tool.content[0].text.startswith('base64:')) + self.assertEqual( + msg.of_tool.content[0].text, + 'base64:' + base64.b64encode(raw).decode('ascii'), + ) + # bytearray + ba = bytearray(raw) + msg2 = create_tool_message('tidb2', 'bin', ba) + self.assertEqual( + msg2.of_tool.content[0].text, + 'base64:' + base64.b64encode(bytes(ba)).decode('ascii'), + ) + + def test_create_tool_message_with_dataclass_and_plain_object(self): + import json + from dataclasses import dataclass + + @dataclass + class P: + x: int + y: str + + p = P(3, 'z') + msg = create_tool_message('tiddc', 'dc', p) + self.assertEqual(json.loads(msg.of_tool.content[0].text), {'x': 3, 'y': 'z'}) + + class Plain: + def __init__(self): + self.a = 1 + self.b = 'b' + self.fn = lambda: 42 # filtered out + + obj = Plain() + msg2 = create_tool_message('tidobj', 'plain', obj) + self.assertEqual(json.loads(msg2.of_tool.content[0].text), {'a': 1, 'b': 'b'}) + + def test_create_tool_message_json_failure_falls_back_to_str(self): + class Bad: + def __init__(self): + self.s = {1, 2, 3} # set not JSON serializable + + def __str__(self): + return 'badobj' + + m = create_tool_message('tidbad', 'bad', Bad()) + self.assertEqual(m.of_tool.content[0].text, 'badobj') + + +class TestIndentLines(unittest.TestCase): + def test_single_line_with_indent(self): + result = conversation._indent_lines('Note', 'Hello', 2) + self.assertEqual(result, ' Note: Hello') + + def test_multiline_example(self): + text = 'This is a long\nmultiline\ntext block' + result = conversation._indent_lines('Description', text, 4) + expected = ( + ' Description: This is a long\n' + ' multiline\n' + ' text block' + ) + self.assertEqual(result, expected) + + def test_zero_indent(self): + result = conversation._indent_lines('Title', 'Line one\nLine two', 0) + expected = 'Title: Line one\n' ' Line two' + self.assertEqual(result, expected) + + def test_empty_string(self): + result = conversation._indent_lines('Empty', '', 3) + # Should end with a space after colon + self.assertEqual(result, ' Empty: ') + + def test_none_text(self): + result = conversation._indent_lines('NoneCase', None, 1) + self.assertEqual(result, ' NoneCase: ') + + def test_title_length_affects_indent(self): + # Title length is 1, indent_after_first_line should be indent + len(title) + 2 + # indent=2, len(title)=1 => 2 + 1 + 2 = 5 spaces on continuation lines + result = conversation._indent_lines('T', 'a\nb', 2) + expected = ' T: a\n' ' b' + self.assertEqual(result, expected) + + +class TestToAssistantMessages(unittest.TestCase): + def test_single_choice_content_only(self): + # Prepare a response with a single output and single choice, content only + msg = ConversationResultAlpha2Message(content='Hello from assistant!', tool_calls=[]) + choice = ConversationResultAlpha2Choices(finish_reason='stop', index=0, message=msg) + response = ConversationResponseAlpha2( + context_id='ctx1', outputs=[ConversationResultAlpha2(choices=[choice])] + ) + + out = response.to_assistant_messages() + + self.assertIsInstance(out, list) + self.assertEqual(len(out), 1) + self.assertIsInstance(out[0], ConversationMessage) + self.assertIsNotNone(out[0].of_assistant) + self.assertIsInstance(out[0].of_assistant, ConversationMessageOfAssistant) + self.assertEqual(len(out[0].of_assistant.content), 1) + self.assertEqual(out[0].of_assistant.content[0].text, 'Hello from assistant!') + self.assertEqual(len(out[0].of_assistant.tool_calls), 0) + + def test_multiple_outputs_and_choices(self): + # Prepare response with 2 outputs, each with 2 choices + def make_choice(idx: int, text: str) -> ConversationResultAlpha2Choices: + return ConversationResultAlpha2Choices( + finish_reason='stop', + index=idx, + message=ConversationResultAlpha2Message(content=text, tool_calls=[]), + ) + + outputs = [ + ConversationResultAlpha2(choices=[make_choice(0, 'A1'), make_choice(1, 'A2')]), + ConversationResultAlpha2(choices=[make_choice(0, 'B1'), make_choice(1, 'B2')]), + ] + + response = ConversationResponseAlpha2(context_id=None, outputs=outputs) + out = response.to_assistant_messages() + + # Expect 4 assistant messages in order + self.assertEqual(len(out), 4) + texts = [m.of_assistant.content[0].text for m in out] + self.assertEqual(texts, ['A1', 'A2', 'B1', 'B2']) + + def test_choice_with_tool_calls_preserved(self): + tool_call = ConversationToolCalls( + id='call-123', + function=ConversationToolCallsOfFunction( + name='get_weather', arguments='{"location":"Paris","unit":"celsius"}' + ), + ) + msg = ConversationResultAlpha2Message(content='', tool_calls=[tool_call]) + choice = ConversationResultAlpha2Choices(finish_reason='tool_calls', index=0, message=msg) + response = ConversationResponseAlpha2( + context_id='ctx2', outputs=[ConversationResultAlpha2(choices=[choice])] + ) + + out = response.to_assistant_messages() + + self.assertEqual(len(out), 1) + asst = out[0].of_assistant + self.assertIsNotNone(asst) + self.assertEqual(len(asst.content), 0) + self.assertEqual(len(asst.tool_calls), 1) + tc = asst.tool_calls[0] + self.assertEqual(tc.id, 'call-123') + self.assertIsNotNone(tc.function) + self.assertEqual(tc.function.name, 'get_weather') + self.assertEqual(tc.function.arguments, '{"location":"Paris","unit":"celsius"}') + + def test_empty_and_none_outputs(self): + # Empty list outputs + response_empty = ConversationResponseAlpha2(context_id=None, outputs=[]) + self.assertEqual(response_empty.to_assistant_messages(), []) + + # None outputs (even though type says List, code handles None via `or []`) + response_none = ConversationResponseAlpha2(context_id=None, outputs=None) # type: ignore[arg-type] + self.assertEqual(response_none.to_assistant_messages(), []) + + +class ExecuteRegisteredToolSyncTests(unittest.TestCase): + def tearDown(self): + # Cleanup all tools we may have registered by name prefix + # (names are randomized per test to avoid collisions) + pass # Names are unique per test; we explicitly unregister in tests + + def test_sync_success_with_kwargs_and_sequence_and_json(self): + name = f'test_add_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + def add(a: int, b: int) -> int: + return a + b + + try: + # kwargs mapping + out = execute_registered_tool(name, {'a': 2, 'b': 3}) + self.assertEqual(out, 5) + + # sequence args + out2 = execute_registered_tool(name, [10, 5]) + self.assertEqual(out2, 15) + + # JSON string params + out3 = execute_registered_tool(name, json.dumps({'a': '7', 'b': '8'})) + self.assertEqual(out3, 15) + finally: + unregister_tool(name) + + def test_sync_invalid_params_type_raises(self): + name = f'test_echo_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + def echo(x: str) -> str: + return x + + try: + with self.assertRaises(ToolArgumentError): + execute_registered_tool(name, 123) # not Mapping/Sequence/None + finally: + unregister_tool(name) + + def test_sync_unregistered_tool_raises(self): + name = f'does_not_exist_{uuid.uuid4().hex[:8]}' + with self.assertRaises(ToolNotFoundError): + execute_registered_tool(name, {'a': 1}) + + def test_sync_tool_exception_wrapped(self): + name = f'test_fail_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + def fail() -> None: + raise ValueError('boom') + + try: + with self.assertRaises(ToolExecutionError): + execute_registered_tool(name) + finally: + unregister_tool(name) + + +class ExecuteRegisteredToolAsyncTests(unittest.IsolatedAsyncioTestCase): + async def asyncTearDown(self): + # Nothing persistent; individual tests unregister. + pass + + async def test_async_success_and_json_params(self): + name = f'test_async_echo_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + async def echo(value: str) -> str: + await asyncio.sleep(0) + return value + + try: + out = await execute_registered_tool_async(name, {'value': 'hi'}) + self.assertEqual(out, 'hi') + + out2 = await execute_registered_tool_async(name, json.dumps({'value': 'ok'})) + self.assertEqual(out2, 'ok') + finally: + unregister_tool(name) + + async def test_async_invalid_params_type_raises(self): + name = f'test_async_inv_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + async def one(x: int) -> int: + return x + + try: + with self.assertRaises(ToolArgumentError): + await execute_registered_tool_async(name, 3.14) # invalid type + finally: + unregister_tool(name) + + async def test_async_unregistered_tool_raises(self): + name = f'does_not_exist_{uuid.uuid4().hex[:8]}' + with self.assertRaises(ToolNotFoundError): + await execute_registered_tool_async(name, None) + + async def test_async_tool_exception_wrapped(self): + name = f'test_async_fail_{uuid.uuid4().hex[:8]}' + + @tool_decorator(name=name) + async def fail_async() -> None: + await asyncio.sleep(0) + raise RuntimeError('nope') + + try: + with self.assertRaises(ToolExecutionError): + await execute_registered_tool_async(name) + finally: + unregister_tool(name) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/clients/test_conversation_helpers.py b/tests/clients/test_conversation_helpers.py new file mode 100644 index 000000000..62f2f69ae --- /dev/null +++ b/tests/clients/test_conversation_helpers.py @@ -0,0 +1,2153 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +import io +import json +import base64 +import unittest +import warnings +from contextlib import redirect_stdout +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, List, Literal, Optional, Union, Set +from dapr.conf import settings +from dapr.clients.grpc._conversation_helpers import ( + stringify_tool_output, + bind_params_to_func, + function_to_json_schema, + _extract_docstring_args, + _python_type_to_json_schema, + extract_docstring_summary, + ToolArgumentError, +) +from dapr.clients.grpc.conversation import ( + ConversationToolsFunction, + ConversationMessageOfUser, + ConversationMessageContent, + ConversationToolCalls, + ConversationToolCallsOfFunction, + ConversationMessageOfAssistant, + ConversationMessageOfTool, + ConversationMessage, + ConversationMessageOfDeveloper, + ConversationMessageOfSystem, +) + + +def test_string_passthrough(): + assert stringify_tool_output('hello') == 'hello' + + +def test_json_serialization_collections(): + data = {'a': 1, 'b': [2, 'x'], 'c': {'k': True}} + out = stringify_tool_output(data) + # Must be a JSON string we can parse back to the same structure + parsed = json.loads(out) + assert parsed == data + + +class Color(Enum): + RED = 'red' + BLUE = 'blue' + + +def test_enum_serialization_uses_value_and_is_json_string(): + out = stringify_tool_output(Color.RED) + # json.dumps on a string value yields a quoted JSON string + assert out == json.dumps('red', ensure_ascii=False) + + +@dataclass +class Point: + x: int + y: int + + +def test_dataclass_serialization_to_json_dict(): + p = Point(1, 2) + out = stringify_tool_output(p) + parsed = json.loads(out) + assert parsed == {'x': 1, 'y': 2} + + +def test_bytes_and_bytearray_to_base64_prefixed(): + b = bytes([0, 1, 2, 250, 255]) + expected = 'base64:' + base64.b64encode(b).decode('ascii') + assert stringify_tool_output(b) == expected + + ba = bytearray(b) + expected_ba = 'base64:' + base64.b64encode(bytes(ba)).decode('ascii') + assert stringify_tool_output(ba) == expected_ba + + +class WithDict: + def __init__(self): + self.x = 1 + self.y = 'y' + self.fn = lambda: 42 # callable should be filtered out + + +def test_object_with___dict___becomes_dict_without_callables(): + obj = WithDict() + out = stringify_tool_output(obj) + parsed = json.loads(out) + assert parsed == {'x': 1, 'y': 'y'} + + +class UnserializableButStr: + def __init__(self): + self.bad = {1, 2, 3} # set is not JSON serializable + + def __str__(self): + return 'myobj' + + +def test_fallback_to_str_when_json_fails(): + obj = UnserializableButStr() + out = stringify_tool_output(obj) + assert out == 'myobj' + + +class BadStr: + def __init__(self): + self.bad = {1, 2, 3} + + def __str__(self): + raise RuntimeError('boom') + + +def test_last_resort_unserializable_marker_when_str_raises(): + obj = BadStr() + out = stringify_tool_output(obj) + assert out == '' + + +def _example_get_flights( + *, + flight_data: List[str], + trip: Literal['round-trip', 'one-way', 'multi-city'], + passengers: int, + seat: Literal['economy', 'premium-economy', 'business', 'first'], + fetch_mode: Literal['common', 'fallback', 'force-fallback', 'local'] = 'common', + max_stops: Optional[int] = None, +): + return { + 'flight_data': flight_data, + 'trip': trip, + 'passengers': passengers, + 'seat': seat, + 'fetch_mode': fetch_mode, + 'max_stops': max_stops, + } + + +def test_bind_params_basic_coercion_from_examples(): + params = { + 'flight_data': ['AUS', 'OPO'], + 'trip': 'one-way', + 'passengers': '1', # should coerce to int + 'seat': 'economy', + 'fetch_mode': 'common', + 'max_stops': 0, + } + bound = bind_params_to_func(_example_get_flights, params) + # Ensure type coercion happened + assert isinstance(bound.kwargs['passengers'], int) + assert bound.kwargs['passengers'] == 1 + assert isinstance(bound.kwargs['max_stops'], int) + # Function should still run with coerced params + result = _example_get_flights(*bound.args, **bound.kwargs) + assert result['passengers'] == 1 + assert result['trip'] == 'one-way' + assert result['seat'] == 'economy' + + +def test_literal_schema_generation_from_examples(): + schema = function_to_json_schema(_example_get_flights) + props = schema['properties'] + + # flight_data -> array of strings + assert props['flight_data']['type'] == 'array' + assert props['flight_data']['items']['type'] == 'string' + + # trip -> enum of strings + assert props['trip']['type'] == 'string' + assert set(props['trip']['enum']) == {'round-trip', 'one-way', 'multi-city'} + + # passengers -> integer + assert props['passengers']['type'] == 'integer' + + # seat -> enum of strings + assert props['seat']['type'] == 'string' + assert set(props['seat']['enum']) == {'economy', 'premium-economy', 'business', 'first'} + + # fetch_mode -> enum with default provided in function (not necessarily in schema, but not required) + assert props['fetch_mode']['type'] == 'string' + assert set(props['fetch_mode']['enum']) == {'common', 'fallback', 'force-fallback', 'local'} + + # max_stops -> optional int (not required) + assert props['max_stops']['type'] == 'integer' + + # Required fields reflect parameters without defaults + # Note: order not guaranteed + required = set(schema['required']) + assert {'flight_data', 'trip', 'passengers', 'seat'}.issubset(required) + assert 'fetch_mode' not in required + assert 'max_stops' not in required + + +# Define minimal stand-in classes to test class coercion behavior +class FlightData: + def __init__( + self, date: str, from_airport: str, to_airport: str, max_stops: Optional[int] = None + ): + self.date = date + self.from_airport = from_airport + self.to_airport = to_airport + self.max_stops = max_stops + + +class Passengers: + def __init__(self, adults: int, children: int, infants_in_seat: int, infants_on_lap: int): + self.adults = adults + self.children = children + self.infants_in_seat = infants_in_seat + self.infants_on_lap = infants_on_lap + + +def _example_get_flights_with_classes( + *, + flight_data: List[FlightData], + trip: Literal['round-trip', 'one-way', 'multi-city'], + passengers: Passengers, + seat: Literal['economy', 'premium-economy', 'business', 'first'], + fetch_mode: Literal['common', 'fallback', 'force-fallback', 'local'] = 'common', + max_stops: Optional[int] = None, +): + return { + 'flight_data': flight_data, + 'trip': trip, + 'passengers': passengers, + 'seat': seat, + 'fetch_mode': fetch_mode, + 'max_stops': max_stops, + } + + +def test_class_coercion_and_schema_from_examples(): + # Verify schema generation includes class fields + schema = function_to_json_schema(_example_get_flights_with_classes) + props = schema['properties'] + + # flight_data is array of objects with class fields + fd_schema = props['flight_data']['items'] + assert fd_schema['type'] == 'object' + for key in ['date', 'from_airport', 'to_airport']: + assert key in fd_schema['properties'] + assert fd_schema['properties'][key]['type'] == 'string' + # Optional int field + assert fd_schema['properties']['max_stops']['type'] == 'integer' + + # passengers object has proper fields + p_schema = props['passengers'] + assert p_schema['type'] == 'object' + for key in ['adults', 'children', 'infants_in_seat', 'infants_on_lap']: + assert p_schema['properties'][key]['type'] == 'integer' + + # Provide dicts to be coerced into class instances + params = { + 'flight_data': [ + {'date': '2025-09-01', 'from_airport': 'AUS', 'to_airport': 'OPO', 'max_stops': '1'}, + {'date': '2025-09-10', 'from_airport': 'OPO', 'to_airport': 'AUS'}, + ], + 'trip': 'round-trip', + 'passengers': {'adults': 1, 'children': 0, 'infants_in_seat': 0, 'infants_on_lap': 0}, + 'seat': 'economy', + 'fetch_mode': 'common', + 'max_stops': 1, + } + + bound = bind_params_to_func(_example_get_flights_with_classes, params) + result = _example_get_flights_with_classes(*bound.args, **bound.kwargs) + + # Ensure coerced instances + assert all(isinstance(fd, FlightData) for fd in result['flight_data']) + assert isinstance(result['passengers'], Passengers) + # Ensure coercion of max_stops inside FlightData + assert result['flight_data'][0].max_stops == 1 + + +# ---- Additional function_to_json_schema tests for dataclass and other types ---- + + +@dataclass +class Person: + name: str + age: int = 0 # default -> not required in schema + + +def _fn_with_dataclass(user: Person, teammates: Optional[List[Person]] = None): + return True + + +def test_function_to_json_schema_with_dataclass_param(): + schema = function_to_json_schema(_fn_with_dataclass) + props = schema['properties'] + + # user -> dataclass object + assert props['user']['type'] == 'object' + assert set(props['user']['properties'].keys()) == {'name', 'age'} + assert props['user']['properties']['name']['type'] == 'string' + assert props['user']['properties']['age']['type'] == 'integer' + # required should include 'user' (function param) and within dataclass, field default logic is internal; + # for function level required, user has no default -> required + assert 'user' in schema['required'] + + # teammates -> Optional[List[Person]] + assert props['teammates']['type'] == 'array' + assert props['teammates']['items']['type'] == 'object' + assert set(props['teammates']['items']['properties'].keys()) == {'name', 'age'} + # teammates is Optional -> not required at top level + assert 'teammates' not in schema['required'] + + +class Pet(Enum): + DOG = 'dog' + CAT = 'cat' + + +def _fn_with_enum(pet: Pet): + return True + + +def test_function_to_json_schema_with_enum_param(): + schema = function_to_json_schema(_fn_with_enum) + pet_schema = schema['properties']['pet'] + assert pet_schema['type'] == 'string' + assert set(pet_schema['enum']) == {'dog', 'cat'} + + +def _fn_with_dict(meta: Dict[str, int]): + return True + + +def test_function_to_json_schema_with_dict_str_int(): + schema = function_to_json_schema(_fn_with_dict) + meta_schema = schema['properties']['meta'] + assert meta_schema['type'] == 'object' + assert meta_schema['additionalProperties']['type'] == 'integer' + + +def _fn_with_bytes(data: bytes): + return True + + +def test_function_to_json_schema_with_bytes(): + schema = function_to_json_schema(_fn_with_bytes) + data_schema = schema['properties']['data'] + assert data_schema['type'] == 'string' + assert data_schema.get('format') == 'byte' + + +def _fn_with_union(identifier: Union[int, str]): + return True + + +def test_function_to_json_schema_with_true_union_anyof(): + schema = function_to_json_schema(_fn_with_union) + id_schema = schema['properties']['identifier'] + assert 'anyOf' in id_schema + types = {opt.get('type') for opt in id_schema['anyOf']} + assert types == {'integer', 'string'} + + +def _fn_with_unsupported_type(s: Set[int]): + return True + + +def test_function_to_json_schema_unsupported_type_raises(): + try: + function_to_json_schema(_fn_with_unsupported_type) + assert False, 'Expected TypeError or ValueError for unsupported type' + except (TypeError, ValueError): + pass + + +class TestPythonTypeToJsonSchema(unittest.TestCase): + """Test the _python_type_to_json_schema function.""" + + def test_basic_types(self): + """Test conversion of basic Python types.""" + test_cases = [ + (str, {'type': 'string'}), + (int, {'type': 'integer'}), + (float, {'type': 'number'}), + (bool, {'type': 'boolean'}), + (bytes, {'type': 'string', 'format': 'byte'}), + ] + + for python_type, expected in test_cases: + with self.subTest(python_type=python_type): + result = _python_type_to_json_schema(python_type) + self.assertEqual(result['type'], expected['type']) + if 'format' in expected: + self.assertEqual(result['format'], expected['format']) + + def test_optional_types(self): + """Test Optional[T] types (Union[T, None]).""" + # Optional[str] should resolve to string + result = _python_type_to_json_schema(Optional[str]) + self.assertEqual(result['type'], 'string') + + # Optional[int] should resolve to integer + result = _python_type_to_json_schema(Optional[int]) + self.assertEqual(result['type'], 'integer') + + def test_list_types(self): + """Test List[T] types.""" + # List[str] + result = _python_type_to_json_schema(List[str]) + expected = {'type': 'array', 'items': {'type': 'string'}} + self.assertEqual(result, expected) + + # List[int] + result = _python_type_to_json_schema(List[int]) + expected = {'type': 'array', 'items': {'type': 'integer'}} + self.assertEqual(result, expected) + + def test_dict_types(self): + """Test Dict[str, T] types.""" + result = _python_type_to_json_schema(Dict[str, int]) + expected = {'type': 'object', 'additionalProperties': {'type': 'integer'}} + self.assertEqual(result, expected) + + def test_enum_types(self): + """Test Enum types.""" + + class Color(Enum): + RED = 'red' + GREEN = 'green' + BLUE = 'blue' + + result = _python_type_to_json_schema(Color) + expected = {'type': 'string', 'enum': ['red', 'green', 'blue']} + self.assertEqual(result['type'], expected['type']) + self.assertEqual(set(result['enum']), set(expected['enum'])) + + def test_union_types(self): + """Test Union types.""" + result = _python_type_to_json_schema(Union[str, int]) + self.assertIn('anyOf', result) + self.assertEqual(len(result['anyOf']), 2) + + # Should contain both string and integer schemas + types = [schema['type'] for schema in result['anyOf']] + self.assertIn('string', types) + self.assertIn('integer', types) + + def test_dataclass_types(self): + """Test dataclass types.""" + + @dataclass + class Person: + name: str + age: int = 25 + + result = _python_type_to_json_schema(Person) + + self.assertEqual(result['type'], 'object') + self.assertIn('properties', result) + self.assertIn('required', result) + + # Check properties + self.assertIn('name', result['properties']) + self.assertIn('age', result['properties']) + self.assertEqual(result['properties']['name']['type'], 'string') + self.assertEqual(result['properties']['age']['type'], 'integer') + + # Check required fields (name is required, age has default) + self.assertIn('name', result['required']) + self.assertNotIn('age', result['required']) + + def test_pydantic_models(self): + """Test Pydantic model types.""" + try: + from pydantic import BaseModel + + class SearchParams(BaseModel): + query: str + limit: int = 10 + include_images: bool = False + tags: Optional[List[str]] = None + + result = _python_type_to_json_schema(SearchParams) + + # Pydantic models should generate their own schema + self.assertIn('type', result) + # The exact structure depends on Pydantic version, but it should have properties + if 'properties' in result: + self.assertIn('query', result['properties']) + except ImportError: + self.skipTest('Pydantic not available for testing') + + def test_nested_types(self): + """Test complex nested type combinations.""" + # Optional[List[str]] + result = _python_type_to_json_schema(Optional[List[str]]) + self.assertEqual(result['type'], 'array') + self.assertEqual(result['items']['type'], 'string') + + # List[Optional[int]] + result = _python_type_to_json_schema(List[Optional[int]]) + self.assertEqual(result['type'], 'array') + self.assertEqual(result['items']['type'], 'integer') + + # Dict[str, List[int]] + result = _python_type_to_json_schema(Dict[str, List[int]]) + self.assertEqual(result['type'], 'object') + self.assertEqual(result['additionalProperties']['type'], 'array') + self.assertEqual(result['additionalProperties']['items']['type'], 'integer') + + def test_complex_dataclass_with_nested_types(self): + """Test dataclass with complex nested types.""" + + @dataclass + class Address: + street: str + city: str + zipcode: Optional[str] = None + + @dataclass + class Person: + name: str + addresses: List[Address] + metadata: Dict[str, str] + tags: Optional[List[str]] = None + + result = _python_type_to_json_schema(Person) + + self.assertEqual(result['type'], 'object') + self.assertIn('name', result['properties']) + self.assertIn('addresses', result['properties']) + self.assertIn('metadata', result['properties']) + self.assertIn('tags', result['properties']) + + # Check nested structures + self.assertEqual(result['properties']['addresses']['type'], 'array') + self.assertEqual(result['properties']['metadata']['type'], 'object') + self.assertEqual(result['properties']['tags']['type'], 'array') + + # Required fields + self.assertIn('name', result['required']) + self.assertIn('addresses', result['required']) + self.assertIn('metadata', result['required']) + self.assertNotIn('tags', result['required']) + + def test_enum_with_different_types(self): + """Test enums with different value types.""" + + class Status(Enum): + ACTIVE = 1 + INACTIVE = 0 + PENDING = 2 + + class Priority(Enum): + LOW = 'low' + MEDIUM = 'medium' + HIGH = 'high' + + # String enum + result = _python_type_to_json_schema(Priority) + self.assertEqual(result['type'], 'string') + self.assertEqual(set(result['enum']), {'low', 'medium', 'high'}) + + # Integer enum + result = _python_type_to_json_schema(Status) + self.assertEqual(result['type'], 'string') + self.assertEqual(set(result['enum']), {1, 0, 2}) + + def test_none_type(self): + """Test None type handling.""" + result = _python_type_to_json_schema(type(None)) + self.assertEqual(result['type'], 'null') + + def test_realistic_function_types(self): + """Test types from realistic function signatures.""" + # Weather function parameters + result = _python_type_to_json_schema(str) # location + self.assertEqual(result['type'], 'string') + + # Optional unit with enum + class TemperatureUnit(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + + result = _python_type_to_json_schema(Optional[TemperatureUnit]) + self.assertEqual(result['type'], 'string') + self.assertEqual(set(result['enum']), {'celsius', 'fahrenheit'}) + + # Search function with complex params + @dataclass + class SearchOptions: + max_results: int = 10 + include_metadata: bool = True + filters: Optional[Dict[str, str]] = None + + result = _python_type_to_json_schema(SearchOptions) + self.assertEqual(result['type'], 'object') + self.assertIn('max_results', result['properties']) + self.assertIn('include_metadata', result['properties']) + self.assertIn('filters', result['properties']) + + def test_list_without_type_args(self): + """Test bare List type without type arguments.""" + result = _python_type_to_json_schema(list) + self.assertEqual(result['type'], 'array') + self.assertNotIn('items', result) + + def test_dict_without_type_args(self): + """Test bare Dict type without type arguments.""" + result = _python_type_to_json_schema(dict) + self.assertEqual(result['type'], 'object') + self.assertNotIn('additionalProperties', result) + + +class TestExtractDocstringInfo(unittest.TestCase): + """Test the extract_docstring_info function.""" + + def test_google_style_docstring(self): + """Test Google-style docstring parsing.""" + + def sample_function(name: str, age: int) -> str: + """A sample function. + + Args: + name: The person's name + age: The person's age in years + """ + return f'{name} is {age}' + + result = _extract_docstring_args(sample_function) + expected = {'name': "The person's name", 'age': "The person's age in years"} + self.assertEqual(result, expected) + + def test_no_docstring(self): + """Test function with no docstring.""" + + def no_doc_function(param): + pass + + result = _extract_docstring_args(no_doc_function) + self.assertEqual(result, {}) + + def test_docstring_without_args(self): + """Test docstring without Args section.""" + + def simple_function(param): + """Just a simple function.""" + pass + + result = _extract_docstring_args(simple_function) + self.assertEqual(result, {}) + + def test_multiline_param_description(self): + """Test parameter descriptions that span multiple lines.""" + + def complex_function(param1: str) -> str: + """A complex function. + + Args: + param1: This is a long description + that spans multiple lines + for testing purposes + """ + return param1 + + result = _extract_docstring_args(complex_function) + expected = { + 'param1': 'This is a long description that spans multiple lines for testing purposes' + } + self.assertEqual(result, expected) + + def test_sphinx_style_docstring(self): + """Test Sphinx-style docstring parsing.""" + + def sphinx_function(location: str, unit: str) -> str: + """Get weather information. + + :param location: The city or location name + :param unit: Temperature unit (celsius or fahrenheit) + :type location: str + :type unit: str + :returns: Weather information string + :rtype: str + """ + return f'Weather in {location}' + + result = _extract_docstring_args(sphinx_function) + expected = { + 'location': 'The city or location name', + 'unit': 'Temperature unit (celsius or fahrenheit)', + } + self.assertEqual(result, expected) + + def test_sphinx_style_with_parameter_keyword(self): + """Test Sphinx-style with :parameter: instead of :param:.""" + + def sphinx_function2(query: str, limit: int) -> str: + """Search for data. + + :parameter query: The search query string + :parameter limit: Maximum number of results + """ + return f'Results for {query}' + + result = _extract_docstring_args(sphinx_function2) + expected = {'query': 'The search query string', 'limit': 'Maximum number of results'} + self.assertEqual(result, expected) + + def test_sphinx_style_multiline_descriptions(self): + """Test Sphinx-style with multi-line parameter descriptions.""" + + def sphinx_multiline_function(data: str) -> str: + """Process complex data. + + :param data: The input data to process, which can be + quite complex and may require special handling + for optimal results + :returns: Processed data + """ + return data + + result = _extract_docstring_args(sphinx_multiline_function) + expected = { + 'data': 'The input data to process, which can be quite complex and may require special handling for optimal results' + } + self.assertEqual(result, expected) + + def test_numpy_style_docstring(self): + """Test NumPy-style docstring parsing.""" + + def numpy_function(x: float, y: float) -> float: + """Calculate distance. + + Parameters + ---------- + x : float + The x coordinate + y : float + The y coordinate + + Returns + ------- + float + The calculated distance + """ + return (x**2 + y**2) ** 0.5 + + result = _extract_docstring_args(numpy_function) + expected = {'x': 'The x coordinate', 'y': 'The y coordinate'} + self.assertEqual(result, expected) + + def test_mixed_style_preference(self): + """Test that Sphinx-style takes precedence when both styles are present.""" + + def mixed_function(param1: str, param2: int) -> str: + """Function with mixed documentation styles. + + :param param1: Sphinx-style description for param1 + :param param2: Sphinx-style description for param2 + + Args: + param1: Google-style description for param1 + param2: Google-style description for param2 + """ + return f'{param1}: {param2}' + + result = _extract_docstring_args(mixed_function) + expected = { + 'param1': 'Sphinx-style description for param1', + 'param2': 'Sphinx-style description for param2', + } + self.assertEqual(result, expected) + + def test_unsupported_format_warning(self): + """Test that unsupported docstring formats trigger a warning.""" + + def unsupported_function(param1: str, param2: int) -> str: + """Function with unsupported parameter documentation format. + + This function takes param1 which is a string input, + and param2 which is an integer argument. + """ + return f'{param1}: {param2}' + + with self.assertWarns(UserWarning) as warning_context: + result = _extract_docstring_args(unsupported_function) + + # Should return empty dict since no supported format found + self.assertEqual(result, {}) + + # Check warning message content + warning_message = str(warning_context.warning) + self.assertIn('unsupported_function', warning_message) + self.assertIn('supported format', warning_message) + self.assertIn('Google, NumPy, or Sphinx style', warning_message) + + def test_informal_style_warning(self): + """Test that informal parameter documentation triggers a warning.""" + + def informal_function(filename: str, mode: str) -> str: + """Open and read a file. + + The filename parameter should be the path to the file. + The mode parameter controls how the file is opened. + """ + return f'Reading {filename} in {mode} mode' + + with self.assertWarns(UserWarning): + result = _extract_docstring_args(informal_function) + + self.assertEqual(result, {}) + + def test_no_warning_for_no_params(self): + """Test that functions without parameter docs don't trigger warnings.""" + + def simple_function() -> str: + """Simple function with no parameters documented.""" + return 'hello' + + # Should not raise any warnings + with warnings.catch_warnings(): + warnings.simplefilter('error') # Turn warnings into errors + result = _extract_docstring_args(simple_function) + + self.assertEqual(result, {}) + + def test_no_warning_for_valid_formats(self): + """Test that valid formats don't trigger warnings.""" + + def google_function(param: str) -> str: + """Function with Google-style docs. + + Args: + param: A parameter description + """ + return param + + # Should not raise any warnings + with warnings.catch_warnings(): + warnings.simplefilter('error') # Turn warnings into errors + result = _extract_docstring_args(google_function) + + self.assertEqual(result, {'param': 'A parameter description'}) + + +class TestFunctionToJsonSchema(unittest.TestCase): + """Test the function_to_json_schema function.""" + + def test_simple_function(self): + """Test a simple function with basic types.""" + + def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get weather for a location. + + Args: + location: The city name + unit: Temperature unit + """ + return f'Weather in {location}' + + result = function_to_json_schema(get_weather) + + # Check structure + self.assertEqual(result['type'], 'object') + self.assertIn('properties', result) + self.assertIn('required', result) + + # Check properties + self.assertIn('location', result['properties']) + self.assertIn('unit', result['properties']) + self.assertEqual(result['properties']['location']['type'], 'string') + self.assertEqual(result['properties']['unit']['type'], 'string') + + # Check descriptions + self.assertEqual(result['properties']['location']['description'], 'The city name') + self.assertEqual(result['properties']['unit']['description'], 'Temperature unit') + + # Check required (location is required, unit has default) + self.assertIn('location', result['required']) + self.assertNotIn('unit', result['required']) + + def test_function_with_complex_types(self): + """Test function with complex type hints.""" + + def search_data( + query: str, + limit: int = 10, + filters: Optional[List[str]] = None, + metadata: Dict[str, str] = None, + ) -> Dict[str, any]: + """Search for data. + + Args: + query: Search query + limit: Maximum results + filters: Optional search filters + metadata: Additional metadata + """ + return {} + + result = function_to_json_schema(search_data) + + # Check all parameters are present + props = result['properties'] + self.assertIn('query', props) + self.assertIn('limit', props) + self.assertIn('filters', props) + self.assertIn('metadata', props) + + # Check types + self.assertEqual(props['query']['type'], 'string') + self.assertEqual(props['limit']['type'], 'integer') + self.assertEqual(props['filters']['type'], 'array') + self.assertEqual(props['filters']['items']['type'], 'string') + self.assertEqual(props['metadata']['type'], 'object') + + # Check required (only query is required) + self.assertEqual(result['required'], ['query']) + + def test_function_with_enum(self): + """Test function with Enum parameter.""" + + class Priority(Enum): + LOW = 'low' + HIGH = 'high' + + def create_task(name: str, priority: Priority = Priority.LOW) -> str: + """Create a task. + + Args: + name: Task name + priority: Task priority level + """ + return f'Task: {name}' + + result = function_to_json_schema(create_task) + + # Check enum handling + priority_prop = result['properties']['priority'] + self.assertEqual(priority_prop['type'], 'string') + self.assertIn('enum', priority_prop) + self.assertEqual(set(priority_prop['enum']), {'low', 'high'}) + + def test_function_no_parameters(self): + """Test function with no parameters.""" + + def get_time() -> str: + """Get current time.""" + return '12:00' + + result = function_to_json_schema(get_time) + + self.assertEqual(result['type'], 'object') + self.assertEqual(result['properties'], {}) + self.assertEqual(result['required'], []) + + def test_function_with_args_kwargs(self): + """Test function with *args and **kwargs (should be ignored).""" + + def flexible_function(name: str, *args, **kwargs) -> str: + """A flexible function.""" + return name + + result = function_to_json_schema(flexible_function) + + # Should only include 'name', not *args or **kwargs + self.assertEqual(list(result['properties'].keys()), ['name']) + self.assertEqual(result['required'], ['name']) + + def test_realistic_weather_function(self): + """Test realistic weather API function.""" + + class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + + def get_weather( + location: str, unit: Units = Units.FAHRENHEIT, include_forecast: bool = False + ) -> str: + """Get current weather for a location. + + Args: + location: The city and state or country + unit: Temperature unit preference + include_forecast: Whether to include 5-day forecast + """ + return f'Weather in {location}' + + result = function_to_json_schema(get_weather) + + # Check structure + self.assertEqual(result['type'], 'object') + props = result['properties'] + + # Check location (required string) + self.assertEqual(props['location']['type'], 'string') + self.assertEqual(props['location']['description'], 'The city and state or country') + self.assertIn('location', result['required']) + + # Check unit (optional enum) + self.assertEqual(props['unit']['type'], 'string') + self.assertEqual(set(props['unit']['enum']), {'celsius', 'fahrenheit'}) + self.assertNotIn('unit', result['required']) + + # Check forecast flag (optional boolean) + self.assertEqual(props['include_forecast']['type'], 'boolean') + self.assertNotIn('include_forecast', result['required']) + + def test_realistic_search_function(self): + """Test realistic search function with complex parameters.""" + + @dataclass + class SearchFilters: + category: Optional[str] = None + price_min: Optional[float] = None + price_max: Optional[float] = None + + def search_products( + query: str, + max_results: int = 20, + sort_by: str = 'relevance', + filters: Optional[SearchFilters] = None, + include_metadata: bool = True, + ) -> List[Dict[str, str]]: + """Search for products in catalog. + + Args: + query: Search query string + max_results: Maximum number of results to return + sort_by: Sort order (relevance, price, rating) + filters: Optional search filters + include_metadata: Whether to include product metadata + """ + return [] + + result = function_to_json_schema(search_products) + + props = result['properties'] + + # Check required query + self.assertEqual(props['query']['type'], 'string') + self.assertIn('query', result['required']) + + # Check optional integer with default + self.assertEqual(props['max_results']['type'], 'integer') + self.assertNotIn('max_results', result['required']) + + # Check string with default + self.assertEqual(props['sort_by']['type'], 'string') + self.assertNotIn('sort_by', result['required']) + + # Check optional dataclass + self.assertEqual(props['filters']['type'], 'object') + self.assertNotIn('filters', result['required']) + + # Check boolean with default + self.assertEqual(props['include_metadata']['type'], 'boolean') + self.assertNotIn('include_metadata', result['required']) + + def test_realistic_database_function(self): + """Test realistic database query function.""" + + def query_users( + filter_conditions: Dict[str, str], + limit: int = 100, + offset: int = 0, + order_by: Optional[str] = None, + include_inactive: bool = False, + ) -> List[Dict[str, any]]: + """Query users from database. + + Args: + filter_conditions: Key-value pairs for filtering + limit: Maximum number of users to return + offset: Number of records to skip + order_by: Field to sort by + include_inactive: Whether to include inactive users + """ + return [] + + result = function_to_json_schema(query_users) + + props = result['properties'] + + # Check required dict parameter + self.assertEqual(props['filter_conditions']['type'], 'object') + self.assertIn('filter_conditions', result['required']) + + # Check integer parameters with defaults + for param in ['limit', 'offset']: + self.assertEqual(props[param]['type'], 'integer') + self.assertNotIn(param, result['required']) + + # Check optional string + self.assertEqual(props['order_by']['type'], 'string') + self.assertNotIn('order_by', result['required']) + + # Check boolean flag + self.assertEqual(props['include_inactive']['type'], 'boolean') + self.assertNotIn('include_inactive', result['required']) + + def test_pydantic_function_parameter(self): + """Test function with Pydantic model parameter.""" + try: + from pydantic import BaseModel + + class UserProfile(BaseModel): + name: str + email: str + age: Optional[int] = None + preferences: Dict[str, bool] = {} + + def update_user(user_id: str, profile: UserProfile, notify: bool = True) -> str: + """Update user profile. + + Args: + user_id: Unique user identifier + profile: User profile data + notify: Whether to send notification + """ + return 'updated' + + result = function_to_json_schema(update_user) + + props = result['properties'] + + # Check required string + self.assertEqual(props['user_id']['type'], 'string') + self.assertIn('user_id', result['required']) + + # Check Pydantic model (should have proper schema) + self.assertIn('profile', props) + self.assertIn('profile', result['required']) + + # Check boolean flag + self.assertEqual(props['notify']['type'], 'boolean') + self.assertNotIn('notify', result['required']) + + except ImportError: + self.skipTest('Pydantic not available for testing') + + +class TestExtractDocstringSummary(unittest.TestCase): + """Test the extract_docstring_summary function.""" + + def test_simple_docstring(self): + """Test function with simple one-line docstring.""" + + def simple_function(): + """Simple one-line description.""" + pass + + result = extract_docstring_summary(simple_function) + self.assertEqual(result, 'Simple one-line description.') + + def test_full_docstring_with_extended_summary(self): + """Test function with full docstring including extended summary.""" + + def complex_function(): + """Get weather information for a specific location. + + This function retrieves current weather data including temperature, + humidity, and precipitation for the given location. + + Args: + location: The city or location to get weather for + unit: Temperature unit (celsius or fahrenheit) + + Returns: + Weather information as a string + + Raises: + ValueError: If location is invalid + """ + pass + + result = extract_docstring_summary(complex_function) + expected = ( + 'Get weather information for a specific location. ' + 'This function retrieves current weather data including temperature, ' + 'humidity, and precipitation for the given location.' + ) + self.assertEqual(result, expected) + + def test_multiline_summary_before_args(self): + """Test function with multiline summary that stops at Args section.""" + + def multiline_summary_function(): + """Complex function that does many things. + + This is an extended description that spans multiple lines + and provides more context about what the function does. + + Args: + param1: First parameter + """ + pass + + result = extract_docstring_summary(multiline_summary_function) + expected = ( + 'Complex function that does many things. ' + 'This is an extended description that spans multiple lines ' + 'and provides more context about what the function does.' + ) + self.assertEqual(result, expected) + + def test_no_docstring(self): + """Test function without docstring.""" + + def no_docstring_function(): + pass + + result = extract_docstring_summary(no_docstring_function) + self.assertIsNone(result) + + def test_empty_docstring(self): + """Test function with empty docstring.""" + + def empty_docstring_function(): + """""" + pass + + result = extract_docstring_summary(empty_docstring_function) + self.assertIsNone(result) + + def test_docstring_with_only_whitespace(self): + """Test function with docstring containing only whitespace.""" + + def whitespace_docstring_function(): + """ """ + pass + + result = extract_docstring_summary(whitespace_docstring_function) + self.assertIsNone(result) + + def test_docstring_stops_at_various_sections(self): + """Test that summary extraction stops at various section headers.""" + + def function_with_returns(): + """Function description. + + Returns: + Something useful + """ + pass + + def function_with_raises(): + """Function description. + + Raises: + ValueError: If something goes wrong + """ + pass + + def function_with_note(): + """Function description. + + Note: + This is important to remember + """ + pass + + # Test each section header + for func in [function_with_returns, function_with_raises, function_with_note]: + result = extract_docstring_summary(func) + self.assertEqual(result, 'Function description.') + + def test_docstring_with_parameters_section(self): + """Test docstring with Parameters section (alternative to Args).""" + + def function_with_parameters(): + """Process data efficiently. + + Parameters: + data: Input data to process + options: Processing options + """ + pass + + result = extract_docstring_summary(function_with_parameters) + self.assertEqual(result, 'Process data efficiently.') + + def test_docstring_with_example_section(self): + """Test docstring with Example section.""" + + def function_with_example(): + """Calculate the area of a circle. + + Example: + >>> calculate_area(5) + 78.54 + """ + pass + + result = extract_docstring_summary(function_with_example) + self.assertEqual(result, 'Calculate the area of a circle.') + + def test_case_insensitive_section_headers(self): + """Test that section header matching is case insensitive.""" + + def function_with_uppercase_args(): + """Function with uppercase section. + + ARGS: + param: A parameter + """ + pass + + result = extract_docstring_summary(function_with_uppercase_args) + self.assertEqual(result, 'Function with uppercase section.') + + def test_sphinx_style_summary_extraction(self): + """Test that Sphinx-style docstrings stop at :param: sections.""" + + def sphinx_function(): + """Calculate mathematical operations. + + This function performs various mathematical calculations + with high precision and error handling. + + :param x: First number + :param y: Second number + :returns: Calculation result + """ + pass + + result = extract_docstring_summary(sphinx_function) + expected = ( + 'Calculate mathematical operations. ' + 'This function performs various mathematical calculations ' + 'with high precision and error handling.' + ) + self.assertEqual(result, expected) + + def test_mixed_sphinx_google_summary(self): + """Test summary extraction stops at first section marker (Sphinx or Google).""" + + def mixed_function(): + """Process data with multiple algorithms. + + This is an extended description that provides + more context about the processing methods. + + :param data: Input data + + Args: + additional: More parameters + """ + pass + + result = extract_docstring_summary(mixed_function) + expected = ( + 'Process data with multiple algorithms. ' + 'This is an extended description that provides ' + 'more context about the processing methods.' + ) + self.assertEqual(result, expected) + + +class TestConversationToolsFunctionFromFunction(unittest.TestCase): + """Test the ConversationToolsFunction.from_function method.""" + + def test_from_function_basic(self): + """Test creating ConversationToolsFunction from a basic function.""" + + def test_function(param1: str, param2: int = 10): + """Test function for conversion. + + Args: + param1: First parameter + param2: Second parameter with default + """ + return f'{param1}: {param2}' + + result = ConversationToolsFunction.from_function(test_function) + + # Check basic properties + self.assertEqual(result.name, 'test_function') + self.assertEqual(result.description, 'Test function for conversion.') + self.assertIsInstance(result.parameters, dict) + + # Check that parameters schema was generated + self.assertEqual(result.parameters['type'], 'object') + self.assertIn('properties', result.parameters) + self.assertIn('required', result.parameters) + + def test_from_function_with_complex_docstring(self): + """Test from_function with complex docstring extracts only summary.""" + + def complex_function(location: str): + """Get weather information for a location. + + This function provides comprehensive weather data including + current conditions and forecasts. + + Args: + location: The location to get weather for + + Returns: + str: Weather information + + Raises: + ValueError: If location is invalid + + Example: + >>> get_weather("New York") + "Sunny, 72°F" + """ + return f'Weather for {location}' + + result = ConversationToolsFunction.from_function(complex_function) + + expected_description = ( + 'Get weather information for a location. ' + 'This function provides comprehensive weather data including ' + 'current conditions and forecasts.' + ) + self.assertEqual(result.description, expected_description) + + def test_from_function_no_docstring(self): + """Test from_function with function that has no docstring.""" + + def no_doc_function(param): + return param + + result = ConversationToolsFunction.from_function(no_doc_function) + + self.assertEqual(result.name, 'no_doc_function') + self.assertIsNone(result.description) + self.assertIsInstance(result.parameters, dict) + + def test_from_function_simple_docstring(self): + """Test from_function with simple one-line docstring.""" + + def simple_function(): + """Simple function description.""" + pass + + result = ConversationToolsFunction.from_function(simple_function) + + self.assertEqual(result.name, 'simple_function') + self.assertEqual(result.description, 'Simple function description.') + + def test_from_function_sphinx_style_summary(self): + """Test from_function extracts only summary from Sphinx-style docstring.""" + + def sphinx_function(location: str): + """Get weather information for a location. + + This function provides comprehensive weather data including + current conditions and forecasts using various APIs. + + :param location: The location to get weather for + :type location: str + :returns: Weather information string + :rtype: str + :raises ValueError: If location is invalid + """ + return f'Weather for {location}' + + result = ConversationToolsFunction.from_function(sphinx_function) + + expected_description = ( + 'Get weather information for a location. ' + 'This function provides comprehensive weather data including ' + 'current conditions and forecasts using various APIs.' + ) + self.assertEqual(result.description, expected_description) + + def test_from_function_google_style_summary(self): + """Test from_function extracts only summary from Google-style docstring.""" + + def google_function(data: str): + """Process input data efficiently. + + This function handles various data formats and applies + multiple processing algorithms for optimal results. + + Args: + data: The input data to process + + Returns: + str: Processed data string + + Raises: + ValueError: If data format is invalid + """ + return f'Processed {data}' + + result = ConversationToolsFunction.from_function(google_function) + + expected_description = ( + 'Process input data efficiently. ' + 'This function handles various data formats and applies ' + 'multiple processing algorithms for optimal results.' + ) + self.assertEqual(result.description, expected_description) + + +class TestIntegrationScenarios(unittest.TestCase): + """Test real-world integration scenarios.""" + + def test_restaurant_finder_scenario(self): + """Test the restaurant finder example from the documentation.""" + from enum import Enum + from typing import List, Optional + + class PriceRange(Enum): + BUDGET = 'budget' + MODERATE = 'moderate' + EXPENSIVE = 'expensive' + + def find_restaurants( + location: str, + cuisine: str = 'any', + price_range: PriceRange = PriceRange.MODERATE, + max_results: int = 5, + dietary_restrictions: Optional[List[str]] = None, + ) -> str: + """Find restaurants in a specific location. + + Args: + location: The city or neighborhood to search + cuisine: Type of cuisine (italian, chinese, mexican, etc.) + price_range: Budget preference for dining + max_results: Maximum number of restaurant recommendations + dietary_restrictions: Special dietary needs (vegetarian, gluten-free, etc.) + """ + return f'Found restaurants in {location}' + + schema = function_to_json_schema(find_restaurants) + + # Comprehensive validation + self.assertEqual(schema['type'], 'object') + + # Check all properties exist + props = schema['properties'] + self.assertIn('location', props) + self.assertIn('cuisine', props) + self.assertIn('price_range', props) + self.assertIn('max_results', props) + self.assertIn('dietary_restrictions', props) + + # Check types + self.assertEqual(props['location']['type'], 'string') + self.assertEqual(props['cuisine']['type'], 'string') + self.assertEqual(props['price_range']['type'], 'string') + self.assertEqual(props['max_results']['type'], 'integer') + self.assertEqual(props['dietary_restrictions']['type'], 'array') + self.assertEqual(props['dietary_restrictions']['items']['type'], 'string') + + # Check enum values + self.assertEqual(set(props['price_range']['enum']), {'budget', 'moderate', 'expensive'}) + + # Check descriptions + self.assertIn('description', props['location']) + self.assertIn('description', props['cuisine']) + self.assertIn('description', props['price_range']) + + # Check required (only location is required) + self.assertEqual(schema['required'], ['location']) + + def test_weather_api_scenario(self): + """Test a weather API scenario with validation.""" + from enum import Enum + from typing import Optional + + class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + KELVIN = 'kelvin' + + def get_weather_forecast( + latitude: float, + longitude: float, + units: Units = Units.CELSIUS, + days: int = 7, + include_hourly: bool = False, + api_key: Optional[str] = None, + ) -> Dict[str, any]: + """Get weather forecast for coordinates. + + Args: + latitude: Latitude coordinate + longitude: Longitude coordinate + units: Temperature units for response + days: Number of forecast days + include_hourly: Whether to include hourly forecasts + api_key: Optional API key override + """ + return {'forecast': []} + + schema = function_to_json_schema(get_weather_forecast) + + # Check numeric types + self.assertEqual(schema['properties']['latitude']['type'], 'number') + self.assertEqual(schema['properties']['longitude']['type'], 'number') + self.assertEqual(schema['properties']['days']['type'], 'integer') + self.assertEqual(schema['properties']['include_hourly']['type'], 'boolean') + + # Check enum + self.assertEqual(schema['properties']['units']['type'], 'string') + self.assertEqual( + set(schema['properties']['units']['enum']), {'celsius', 'fahrenheit', 'kelvin'} + ) + + # Check required fields + self.assertEqual(set(schema['required']), {'latitude', 'longitude'}) + + +class TestTracePrintUserMixin(unittest.TestCase): + def test_user_trace_print_with_name_and_multiple_contents(self): + msg = ConversationMessageOfUser( + name='alice', + content=[ + ConversationMessageContent(text='hello'), + ConversationMessageContent(text='how are you?'), + ], + ) + buf = io.StringIO() + with redirect_stdout(buf): + msg.trace_print(indent=2) + out = buf.getvalue().splitlines() + # Name line with indent + self.assertEqual(' name: alice', out[0]) + # Content lines with computed indentation + self.assertEqual(' content[0]: hello', out[1]) + self.assertEqual(' content[1]: how are you?', out[2]) + + +class TestTracePrintAssistant(unittest.TestCase): + def test_assistant_trace_print_with_tool_calls(self): + tool_calls = [ + ConversationToolCalls( + id='id1', + function=ConversationToolCallsOfFunction( + name='get_weather', arguments='{"location":"Paris"}' + ), + ) + ] + msg = ConversationMessageOfAssistant( + name='helper', + content=[ConversationMessageContent(text='checking weather')], + tool_calls=tool_calls, + ) + buf = io.StringIO() + with redirect_stdout(buf): + msg.trace_print(indent=0) + lines = buf.getvalue().strip().splitlines() + # Name line + self.assertEqual(lines[0], 'name: helper') + # Content line + self.assertEqual(lines[1], 'content[0]: checking weather') + # Tool calls header and entry + self.assertEqual(lines[2], 'tool_calls: 1') + self.assertEqual(lines[3], ' [0] id=id1 function=get_weather({"location":"Paris"})') + + +class TestTracePrintTool(unittest.TestCase): + def test_tool_trace_print_multiline_content(self): + msg = ConversationMessageOfTool( + tool_id='tid-123', + name='get_weather', + content=[ + ConversationMessageContent(text='line1\nline2\nline3'), + ], + ) + buf = io.StringIO() + with redirect_stdout(buf): + msg.trace_print(indent=2) + lines = buf.getvalue().splitlines() + # tool_id and name printed with indent + self.assertEqual(lines[0], ' tool_id: tid-123') + self.assertEqual(lines[1], ' name: get_weather') + # First line has the content[0] prefix with indent + self.assertEqual(lines[2], ' content[0]: line1') + # Subsequent lines are printed as-is per implementation + self.assertEqual(lines[3], 'line2') + self.assertEqual(lines[4], 'line3') + + +class TestTracePrintConversationMessage(unittest.TestCase): + def test_conversation_message_headers_for_all_roles(self): + msg = ConversationMessage( + of_user=ConversationMessageOfUser( + name='bob', content=[ConversationMessageContent(text='hi')] + ), + of_assistant=ConversationMessageOfAssistant( + content=[ConversationMessageContent(text='hello')] + ), + of_tool=ConversationMessageOfTool( + tool_id='t1', + name='tool.fn', + content=[ConversationMessageContent(text='ok')], + ), + of_developer=ConversationMessageOfDeveloper( + name='dev', content=[ConversationMessageContent(text='turn on feature x')] + ), + of_system=ConversationMessageOfSystem( + name='policy', content=[ConversationMessageContent(text='Follow company policy.')] + ), + ) + buf = io.StringIO() + with redirect_stdout(buf): + msg.trace_print(indent=0) + out = buf.getvalue().splitlines() + # First line is an empty line due to initial print() + self.assertEqual(out[0], '') + # Headers for each role appear + # Developer header and content + self.assertEqual(out[1], 'client[devel] --------------> LLM[assistant]:') + self.assertEqual(out[2], ' name: dev') + self.assertEqual(out[3], ' content[0]: turn on feature x') + # System header and content + self.assertEqual(out[4], 'client[system] --------------> LLM[assistant]:') + self.assertEqual(out[5], ' name: policy') + self.assertEqual(out[6], ' content[0]: Follow company policy.') + # Delegated lines for user (name first, then content) + self.assertEqual(out[7], 'client[user] --------------> LLM[assistant]:') + self.assertEqual(out[8], ' name: bob') + self.assertEqual(out[9], ' content[0]: hi') + # Assistant header and content + self.assertEqual(out[10], 'client <------------- LLM[assistant]:') + self.assertIn(' content[0]: hello', out[11]) + # Tool header and content + self.assertEqual(out[12], 'client[tool] -------------> LLM[assistant]:') + self.assertEqual(out[13], ' tool_id: t1') + self.assertEqual(out[14], ' name: tool.fn') + self.assertEqual(out[15], ' content[0]: ok') + + +class TestLargeEnumBehavior(unittest.TestCase): + def setUp(self): + # Save originals + self._orig_max = settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS + self._orig_beh = settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR + + def tearDown(self): + # Restore + settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS = self._orig_max + settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR = self._orig_beh + + def test_large_enum_compacted_to_string(self): + # Make threshold tiny to trigger large-enum path + settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS = 2 + settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR = 'string' + + class BigEnum(Enum): + A = 'a' + B = 'b' + C = 'c' + D = 'd' + + schema = _python_type_to_json_schema(BigEnum) + # Should be compacted to string with description and examples + self.assertEqual(schema.get('type'), 'string') + self.assertIn('description', schema) + self.assertIn('examples', schema) + self.assertTrue(len(schema['examples']) > 0) + + def test_large_enum_error_mode(self): + settings.DAPR_CONVERSATION_TOOLS_MAX_ENUM_ITEMS = 1 + settings.DAPR_CONVERSATION_TOOLS_LARGE_ENUM_BEHAVIOR = 'error' + + from enum import Enum + + class BigEnum(Enum): + A = 'a' + B = 'b' + + with self.assertRaises(ValueError): + _python_type_to_json_schema(BigEnum) + + +class TestCoercionsAndBinding(unittest.TestCase): + def test_coerce_bool_variants(self): + def f(flag: bool) -> bool: + return flag + + # True-ish variants + for v in ['true', 'True', 'YES', '1', 'on', ' y ']: + bound = bind_params_to_func(f, {'flag': v}) + self.assertIs(f(*bound.args, **bound.kwargs), True) + + # False-ish variants + for v in ['false', 'False', 'NO', '0', 'off', ' n ']: + bound = bind_params_to_func(f, {'flag': v}) + self.assertIs(f(*bound.args, **bound.kwargs), False) + + # Invalid + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'flag': 'maybe'}) + + def test_literal_numeric_from_string(self): + def g(x: Literal[1, 2, 3]) -> int: + return x # type: ignore[return-value] + + bound = bind_params_to_func(g, {'x': '2'}) + self.assertEqual(g(*bound.args, **bound.kwargs), 2) + + def test_unexpected_kwarg_is_rejected(self): + def h(a: int) -> int: + return a + + with self.assertRaises(Exception): + bind_params_to_func(h, {'a': 1, 'extra': 2}) + + def test_dataclass_arg_validation(self): + @dataclass + class P: + x: int + y: str + + def k(p: P) -> str: + return p.y + + # Passing an instance is fine + p = P(1, 'ok') + bound = bind_params_to_func(k, {'p': p}) + self.assertEqual(k(*bound.args, **bound.kwargs), 'ok') + + # Passing a dict should fail for dataclass per implementation + with self.assertRaises(ToolArgumentError): + bind_params_to_func(k, {'p': {'x': 1, 'y': 'nope'}}) + + +class TestPlainClassSchema(unittest.TestCase): + def test_plain_class_init_signature(self): + class C: + def __init__(self, a: int, b: str = 'x'): + self.a = a + self.b = b + + schema = _python_type_to_json_schema(C) + self.assertEqual(schema['type'], 'object') + props = schema['properties'] + self.assertIn('a', props) + self.assertIn('b', props) + # Only 'a' is required + self.assertIn('required', schema) + self.assertEqual(schema['required'], ['a']) + + def test_plain_class_slots_fallback(self): + class D: + __slots__ = ('m', 'n') + m: int + n: Optional[str] + + schema = _python_type_to_json_schema(D) + # Implementation builds properties from __slots__ with required for non-optional + self.assertEqual(schema['type'], 'object') + self.assertIn('properties', schema) + self.assertIn('m', schema['properties']) + self.assertIn('n', schema['properties']) + self.assertEqual(schema['properties']['m']['type'], 'integer') + self.assertEqual(schema['properties']['n']['type'], 'string') + self.assertIn('required', schema) + self.assertEqual(schema['required'], ['m']) + + +class TestDocstringUnsupportedWarning(unittest.TestCase): + def test_informal_param_info_warning(self): + def unsupported(x: int, y: str): + """Do something. + + The x parameter should be an integer indicating repetitions. The y parameter is used for labeling. + """ + return x, y + + # _extract_docstring_args is used via function_to_json_schema or directly. Use direct import path + from dapr.clients.grpc._conversation_helpers import _extract_docstring_args + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + res = _extract_docstring_args(unsupported) + self.assertEqual(res, {}) + self.assertTrue( + any('appears to contain parameter information' in str(wi.message) for wi in w) + ) + + +class TestLiteralSchemaMapping(unittest.TestCase): + def test_literal_strings_schema(self): + T = Literal['a', 'b', 'c'] + schema = _python_type_to_json_schema(T) + self.assertEqual(schema.get('type'), 'string') + self.assertEqual(set(schema['enum']), {'a', 'b', 'c'}) + + def test_literal_ints_schema(self): + T = Literal[1, 2, 3] + schema = _python_type_to_json_schema(T) + self.assertEqual(schema.get('type'), 'integer') + self.assertEqual(set(schema['enum']), {1, 2, 3}) + + def test_literal_nullable_string_schema(self): + T = Literal[None, 'x', 'y'] + schema = _python_type_to_json_schema(T) + # non-null types only string, should set 'type' to 'string' and include None in enum + self.assertEqual(schema.get('type'), 'string') + self.assertIn(None, schema['enum']) + self.assertIn('x', schema['enum']) + self.assertIn('y', schema['enum']) + + def test_literal_mixed_types_no_unified_type(self): + T = Literal['x', 1] + schema = _python_type_to_json_schema(T) + # Mixed non-null types -> no unified 'type' should be set + self.assertNotIn('type', schema) + self.assertEqual(set(schema['enum']), {'x', 1}) + + def test_literal_enum_members_normalized(self): + from enum import Enum + + class Mode(Enum): + FAST = 'fast' + SLOW = 'slow' + + T = Literal[Mode.FAST, Mode.SLOW] + schema = _python_type_to_json_schema(T) + self.assertEqual(schema.get('type'), 'string') + self.assertEqual(set(schema['enum']), {'fast', 'slow'}) + + def test_literal_bytes_and_bytearray_schema(self): + T = Literal[b'a', bytearray(b'b')] + schema = _python_type_to_json_schema(T) + # bytes/bytearray are coerced to string type for schema typing + self.assertEqual(schema.get('type'), 'string') + # The enum preserves the literal values as provided + self.assertIn(b'a', schema['enum']) + self.assertIn(bytearray(b'b'), schema['enum']) + + +# --- Helpers for Coercion tests + + +class Mode(Enum): + RED = 'red' + BLUE = 'blue' + + +@dataclass +class DC: + x: int + y: str + + +class Plain: + def __init__(self, a: int, b: str = 'x') -> None: + self.a = a + self.b = b + + +class TestScalarCoercions(unittest.TestCase): + def test_int_from_str_and_float_and_invalid(self): + def f(a: int) -> int: + return a + + # str -> int + bound = bind_params_to_func(f, {'a': ' 42 '}) + self.assertEqual(f(*bound.args, **bound.kwargs), 42) + + # float integral -> int + bound = bind_params_to_func(f, {'a': 3.0}) + self.assertEqual(f(*bound.args, **bound.kwargs), 3) + + # float non-integral -> error + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'a': 3.14}) + + def test_float_from_int_and_str(self): + def g(x: float) -> float: + return x + + bound = bind_params_to_func(g, {'x': 2}) + self.assertEqual(g(*bound.args, **bound.kwargs), 2.0) + + bound = bind_params_to_func(g, {'x': ' 3.5 '}) + self.assertEqual(g(*bound.args, **bound.kwargs), 3.5) + + def test_str_from_non_str(self): + def h(s: str) -> str: + return s + + bound = bind_params_to_func(h, {'s': 123}) + self.assertEqual(h(*bound.args, **bound.kwargs), '123') + + def test_bool_variants_and_invalid(self): + def b(flag: bool) -> bool: + return flag + + for v in ['true', 'False', 'YES', 'no', '1', '0', 'on', 'off']: + bound = bind_params_to_func(b, {'flag': v}) + # Ensure conversion yields actual bool + self.assertIsInstance(b(*bound.args, **bound.kwargs), bool) + + with self.assertRaises(ToolArgumentError): + bind_params_to_func(b, {'flag': 'maybe'}) + + +class TestEnumCoercions(unittest.TestCase): + def test_enum_by_value_and_name_and_case_insensitive(self): + def f(m: Mode) -> Mode: + return m + + # by value + bound = bind_params_to_func(f, {'m': 'red'}) + self.assertEqual(f(*bound.args, **bound.kwargs), Mode.RED) + + # by exact name + bound = bind_params_to_func(f, {'m': 'BLUE'}) + self.assertEqual(f(*bound.args, **bound.kwargs), Mode.BLUE) + + # by case-insensitive name + bound = bind_params_to_func(f, {'m': 'red'}) # value already tested; use name lower + self.assertEqual(f(*bound.args, **bound.kwargs), Mode.RED) + + # invalid + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'m': 'green'}) + + +class TestCoerceAndValidateBranches(unittest.TestCase): + def test_optional_and_union(self): + def f(a: Optional[int], b: Union[str, int]) -> tuple: + return a, b + + bound = bind_params_to_func(f, {'a': '2', 'b': 5}) + # Union[str, int] tries str first; 5 is coerced to '5' + self.assertEqual(f(*bound.args, **bound.kwargs), (2, '5')) + + bound = bind_params_to_func(f, {'a': None, 'b': 'hello'}) + self.assertEqual(f(*bound.args, **bound.kwargs), (None, 'hello')) + + def test_list_and_dict_coercion(self): + def g(xs: List[int], mapping: Dict[int, float]) -> tuple: + return xs, mapping + + bound = bind_params_to_func(g, {'xs': ['1', '2', '3'], 'mapping': {'1': '2.5', 3: 4}}) + xs, mapping = g(*bound.args, **bound.kwargs) + self.assertEqual(xs, [1, 2, 3]) + self.assertEqual(mapping, {1: 2.5, 3: 4.0}) + + # Wrong type for list + with self.assertRaises(ToolArgumentError): + bind_params_to_func(g, {'xs': 'not-a-list', 'mapping': {}}) + + # Wrong type for dict + with self.assertRaises(ToolArgumentError): + bind_params_to_func(g, {'xs': [1], 'mapping': 'not-a-dict'}) + + def test_dataclass_optional_and_rejection_of_dict(self): + def f(p: Optional[DC]) -> Optional[str]: + return None if p is None else p.y + + # inst = DC(1, 'ok') + # bound = bind_params_to_func(f, {'p': inst}) + # self.assertEqual(f(*bound.args, **bound.kwargs), 'ok') + # + # bound = bind_params_to_func(f, {'p': None}) + # self.assertIsNone(f(*bound.args, **bound.kwargs)) + + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'p': {'x': 1, 'y': 'no'}}) + + def test_plain_class_construction_from_dict_and_missing_arg(self): + def f(p: Plain) -> int: + return p.a + + # Construct from dict with coercion + bound = bind_params_to_func(f, {'p': {'a': '3'}}) + res = f(*bound.args, **bound.kwargs) + self.assertEqual(res, 3) + self.assertIsInstance(bound.arguments['p'], Plain) + self.assertEqual(bound.arguments['p'].b, 'x') # default applied + + # Missing required arg + with self.assertRaises(ToolArgumentError): + bind_params_to_func(f, {'p': {}}) + + def test_any_and_isinstance_fallback(self): + class C: + ... + + def f(a: Any, c: C) -> tuple: + return a, c + + c = C() + with self.assertRaises(ToolArgumentError) as ctx: + bind_params_to_func(f, {'a': object(), 'c': c}) + # _coerce_and_validate raises TypeError for Any; bind wraps it in ToolArgumentError + self.assertIsInstance(ctx.exception.__cause__, TypeError) + + +# ---- Helpers for test stringify + + +class Shade(Enum): + LIGHT = 'light' + DARK = 'dark' + + +@dataclass +class Pair: + a: int + b: str + + +class PlainWithDict: + def __init__(self): + self.x = 10 + self.y = 'y' + self.fn = lambda: 1 # callable should be filtered out + + +class TestStringifyToolOutputMore(unittest.TestCase): + def test_bytes_and_bytearray_branch(self): + raw = bytes([1, 2, 3, 254, 255]) + expected = 'base64:' + base64.b64encode(raw).decode('ascii') + self.assertEqual(stringify_tool_output(raw), expected) + + ba = bytearray(raw) + expected_ba = 'base64:' + base64.b64encode(bytes(ba)).decode('ascii') + self.assertEqual(stringify_tool_output(ba), expected_ba) + + def test_default_encoder_enum_dataclass_and___dict__(self): + # Enum -> value via default encoder (JSON string) + out_enum = stringify_tool_output(Shade.DARK) + self.assertEqual(out_enum, json.dumps('dark', ensure_ascii=False)) + + # Dataclass -> asdict via default encoder + p = Pair(3, 'z') + out_dc = stringify_tool_output(p) + self.assertEqual(json.loads(out_dc), {'a': 3, 'b': 'z'}) + + # __dict__ plain object -> filtered dict via default encoder + obj = PlainWithDict() + out_obj = stringify_tool_output(obj) + self.assertEqual(json.loads(out_obj), {'x': 10, 'y': 'y'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/clients/test_dapr_grpc_client.py b/tests/clients/test_dapr_grpc_client.py index d1884147f..e0713f703 100644 --- a/tests/clients/test_dapr_grpc_client.py +++ b/tests/clients/test_dapr_grpc_client.py @@ -36,7 +36,6 @@ from dapr.clients.grpc._request import ( TransactionalStateOperation, TransactionOperationType, - ConversationInput, ) from dapr.clients.grpc._jobs import Job from dapr.clients.grpc._state import StateOptions, Consistency, Concurrency, StateItem @@ -50,6 +49,7 @@ WorkflowRuntimeStatus, TopicEventResponse, ) +from dapr.clients.grpc import conversation class DaprGrpcClientTests(unittest.TestCase): @@ -1191,8 +1191,8 @@ def test_converse_alpha1_basic(self): dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') inputs = [ - ConversationInput(content='Hello', role='user'), - ConversationInput(content='How are you?', role='user'), + conversation.ConversationInput(content='Hello', role='user'), + conversation.ConversationInput(content='How are you?', role='user'), ] response = dapr.converse_alpha1(name='test-llm', inputs=inputs) @@ -1206,7 +1206,7 @@ def test_converse_alpha1_basic(self): def test_converse_alpha1_with_options(self): dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') - inputs = [ConversationInput(content='Hello', role='user', scrub_pii=True)] + inputs = [conversation.ConversationInput(content='Hello', role='user', scrub_pii=True)] response = dapr.converse_alpha1( name='test-llm', @@ -1229,12 +1229,317 @@ def test_converse_alpha1_error_handling(self): status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Invalid argument') ) - inputs = [ConversationInput(content='Hello', role='user')] + inputs = [conversation.ConversationInput(content='Hello', role='user')] with self.assertRaises(DaprGrpcError) as context: dapr.converse_alpha1(name='test-llm', inputs=inputs) self.assertTrue('Invalid argument' in str(context.exception)) + def test_converse_alpha2_basic_user_message(self): + """Test basic Alpha2 conversation with user messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create user message + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + name='TestUser', + content=[conversation.ConversationMessageContent(text='Hello, how are you?')], + ) + ) + + # Create Alpha2 input + input_alpha2 = conversation.ConversationInputAlpha2( + messages=[user_message], scrub_pii=False + ) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response structure + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'stop') + self.assertEqual(choice.index, 0) + self.assertEqual(choice.message.content, 'Response to user: Hello, how are you?') + self.assertEqual(len(choice.message.tool_calls), 0) + + def test_converse_alpha2_with_tools_weather_request(self): + """Test Alpha2 conversation with tool calling for weather requests.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create weather tool + weather_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='get_weather', + description='Get current weather information', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string', 'description': 'Location for weather info'} + }, + 'required': ['location'], + }, + ) + ) + + # Create user message asking for weather + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text="What's the weather like?")] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = dapr.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool], tool_choice='auto' + ) + + # Check response structure with tool call + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + self.assertEqual(choice.index, 0) + self.assertEqual(choice.message.content, "I'll check the weather for you.") + self.assertEqual(len(choice.message.tool_calls), 1) + + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + self.assertEqual( + tool_call.function.arguments, '{"location": "San Francisco", "unit": "celsius"}' + ) + self.assertTrue(tool_call.id.startswith('call_')) + + def test_converse_alpha2_system_message(self): + """Test Alpha2 conversation with system messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create system message + system_message = conversation.ConversationMessage( + of_system=conversation.ConversationMessageOfSystem( + content=[ + conversation.ConversationMessageContent(text='You are a helpful assistant.') + ] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[system_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, 'System acknowledged: You are a helpful assistant.' + ) + + def test_converse_alpha2_developer_message(self): + """Test Alpha2 conversation with developer messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create developer message + developer_message = conversation.ConversationMessage( + of_developer=conversation.ConversationMessageOfDeveloper( + name='DevTeam', + content=[ + conversation.ConversationMessageContent(text='Debug: Processing user input') + ], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[developer_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, 'Developer note processed: Debug: Processing user input' + ) + + def test_converse_alpha2_tool_message(self): + """Test Alpha2 conversation with tool messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create tool message + tool_message = conversation.ConversationMessage( + of_tool=conversation.ConversationMessageOfTool( + tool_id='call_123', + name='get_weather', + content=[ + conversation.ConversationMessageContent( + text='{"temperature": 22, "condition": "sunny"}' + ) + ], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[tool_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, + 'Tool result processed: {"temperature": 22, "condition": "sunny"}', + ) + + def test_converse_alpha2_assistant_message(self): + """Test Alpha2 conversation with assistant messages.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create assistant message + assistant_message = conversation.ConversationMessage( + of_assistant=conversation.ConversationMessageOfAssistant( + content=[conversation.ConversationMessageContent(text='I understand your request.')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[assistant_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.message.content, 'Assistant continued: I understand your request.') + + def test_converse_alpha2_multiple_messages(self): + """Test Alpha2 conversation with multiple messages in one input.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create multiple messages + system_message = conversation.ConversationMessage( + of_system=conversation.ConversationMessageOfSystem( + content=[conversation.ConversationMessageContent(text='You are helpful.')] + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Hello!')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[system_message, user_message]) + + response = dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + # Check response has choices for both messages + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 2) + + # Check individual responses + self.assertEqual( + response.outputs[0].choices[0].message.content, 'System acknowledged: You are helpful.' + ) + self.assertEqual(response.outputs[0].choices[1].message.content, 'Response to user: Hello!') + + def test_converse_alpha2_with_context_and_options(self): + """Test Alpha2 conversation with context ID and various options.""" + from google.protobuf.any_pb2 import Any as GrpcAny + + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Continue our conversation')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message], scrub_pii=True) + + # Create custom parameters + params = {'custom_param': GrpcAny(value=b'{"setting": "value"}')} + + response = dapr.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + context_id='chat-session-123', + parameters=params, + metadata={'env': 'test'}, + scrub_pii=True, + temperature=0.7, + tool_choice='none', + ) + + # Check response + self.assertIsNotNone(response) + self.assertEqual(response.context_id, 'chat-session-123') + choice = response.outputs[0].choices[0] + self.assertEqual(choice.message.content, 'Response to user: Continue our conversation') + + def test_converse_alpha2_error_handling(self): + """Test Alpha2 conversation error handling.""" + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Setup server to raise an exception + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Alpha2 Invalid argument') + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Test error')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + with self.assertRaises(DaprGrpcError) as context: + dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + self.assertTrue('Alpha2 Invalid argument' in str(context.exception)) + + def test_converse_alpha2_tool_choice_specific(self): + """Test Alpha2 conversation with specific tool choice.""" + + dapr = DaprGrpcClient(f'{self.scheme}localhost:{self.grpc_port}') + + # Create multiple tools + weather_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='get_weather', description='Get weather information' + ) + ) + + calculator_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='calculate', description='Perform calculations' + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text="What's the weather today?")] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = dapr.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + tools=[weather_tool, calculator_tool], + tool_choice='get_weather', # Force specific tool + ) + + # Even though we specified a specific tool, our mock will still trigger + # based on content matching "weather" + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + if 'weather' in choice.message.content.lower(): + self.assertEqual(choice.finish_reason, 'tool_calls') + # # Tests for Jobs API (Alpha) # diff --git a/tests/clients/test_dapr_grpc_client_async.py b/tests/clients/test_dapr_grpc_client_async.py index 1e3210b26..50043912d 100644 --- a/tests/clients/test_dapr_grpc_client_async.py +++ b/tests/clients/test_dapr_grpc_client_async.py @@ -29,7 +29,8 @@ from .fake_dapr_server import FakeDaprSidecar from dapr.conf import settings from dapr.clients.grpc._helpers import to_bytes -from dapr.clients.grpc._request import TransactionalStateOperation, ConversationInput +from dapr.clients.grpc._request import TransactionalStateOperation +from dapr.clients.grpc import conversation from dapr.clients.grpc._jobs import Job from dapr.clients.grpc._state import StateOptions, Consistency, Concurrency, StateItem from dapr.clients.grpc._crypto import EncryptOptions, DecryptOptions @@ -1119,8 +1120,8 @@ async def test_converse_alpha1_basic(self): dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') inputs = [ - ConversationInput(content='Hello', role='user'), - ConversationInput(content='How are you?', role='user'), + conversation.ConversationInput(content='Hello', role='user'), + conversation.ConversationInput(content='How are you?', role='user'), ] response = await dapr.converse_alpha1(name='test-llm', inputs=inputs) @@ -1135,7 +1136,7 @@ async def test_converse_alpha1_basic(self): async def test_converse_alpha1_with_options(self): dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') - inputs = [ConversationInput(content='Hello', role='user', scrub_pii=True)] + inputs = [conversation.ConversationInput(content='Hello', role='user', scrub_pii=True)] response = await dapr.converse_alpha1( name='test-llm', @@ -1159,13 +1160,300 @@ async def test_converse_alpha1_error_handling(self): status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Invalid argument') ) - inputs = [ConversationInput(content='Hello', role='user')] + inputs = [conversation.ConversationInput(content='Hello', role='user')] with self.assertRaises(DaprGrpcError) as context: await dapr.converse_alpha1(name='test-llm', inputs=inputs) self.assertTrue('Invalid argument' in str(context.exception)) await dapr.close() + async def test_converse_alpha2_basic_user_message(self): + """Test basic Alpha2 conversation with user messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + name='TestUser', + content=[conversation.ConversationMessageContent(text='Hello, how are you?')], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2( + messages=[user_message], scrub_pii=False + ) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'stop') + self.assertEqual(choice.index, 0) + self.assertEqual(choice.message.content, 'Response to user: Hello, how are you?') + self.assertEqual(len(choice.message.tool_calls), 0) + await dapr.close() + + async def test_converse_alpha2_with_tools_weather_request(self): + """Test Alpha2 conversation with tool calling for weather requests (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + weather_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='get_weather', + description='Get current weather information', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string', 'description': 'Location for weather info'} + }, + 'required': ['location'], + }, + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text="What's the weather like?")] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = await dapr.converse_alpha2( + name='test-llm', inputs=[input_alpha2], tools=[weather_tool], tool_choice='auto' + ) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 1) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.finish_reason, 'tool_calls') + self.assertEqual(choice.index, 0) + self.assertEqual(choice.message.content, "I'll check the weather for you.") + self.assertEqual(len(choice.message.tool_calls), 1) + tool_call = choice.message.tool_calls[0] + self.assertEqual(tool_call.function.name, 'get_weather') + self.assertEqual( + tool_call.function.arguments, '{"location": "San Francisco", "unit": "celsius"}' + ) + self.assertTrue(tool_call.id.startswith('call_')) + await dapr.close() + + async def test_converse_alpha2_system_message(self): + """Test Alpha2 conversation with system messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + system_message = conversation.ConversationMessage( + of_system=conversation.ConversationMessageOfSystem( + content=[ + conversation.ConversationMessageContent(text='You are a helpful assistant.') + ] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[system_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, 'System acknowledged: You are a helpful assistant.' + ) + await dapr.close() + + async def test_converse_alpha2_developer_message(self): + """Test Alpha2 conversation with developer messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + developer_message = conversation.ConversationMessage( + of_developer=conversation.ConversationMessageOfDeveloper( + name='DevTeam', + content=[ + conversation.ConversationMessageContent(text='Debug: Processing user input') + ], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[developer_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, 'Developer note processed: Debug: Processing user input' + ) + await dapr.close() + + async def test_converse_alpha2_tool_message(self): + """Test Alpha2 conversation with tool messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + tool_message = conversation.ConversationMessage( + of_tool=conversation.ConversationMessageOfTool( + tool_id='call_123', + name='get_weather', + content=[ + conversation.ConversationMessageContent( + text='{"temperature": 22, "condition": "sunny"}' + ) + ], + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[tool_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual( + choice.message.content, + 'Tool result processed: {"temperature": 22, "condition": "sunny"}', + ) + await dapr.close() + + async def test_converse_alpha2_assistant_message(self): + """Test Alpha2 conversation with assistant messages (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + assistant_message = conversation.ConversationMessage( + of_assistant=conversation.ConversationMessageOfAssistant( + content=[conversation.ConversationMessageContent(text='I understand your request.')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[assistant_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + self.assertEqual(choice.message.content, 'Assistant continued: I understand your request.') + await dapr.close() + + async def test_converse_alpha2_multiple_messages(self): + """Test Alpha2 conversation with multiple messages in one input (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + system_message = conversation.ConversationMessage( + of_system=conversation.ConversationMessageOfSystem( + content=[conversation.ConversationMessageContent(text='You are helpful.')] + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Hello!')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[system_message, user_message]) + + response = await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + + self.assertIsNotNone(response) + self.assertEqual(len(response.outputs), 1) + self.assertEqual(len(response.outputs[0].choices), 2) + self.assertEqual( + response.outputs[0].choices[0].message.content, + 'System acknowledged: You are helpful.', + ) + self.assertEqual(response.outputs[0].choices[1].message.content, 'Response to user: Hello!') + await dapr.close() + + async def test_converse_alpha2_with_context_and_options(self): + """Test Alpha2 conversation with context ID and various options (async).""" + from google.protobuf.any_pb2 import Any as GrpcAny + + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Continue our conversation')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message], scrub_pii=True) + + params = {'custom_param': GrpcAny(value=b'{"setting": "value"}')} + + response = await dapr.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + context_id='chat-session-123', + parameters=params, + metadata={'env': 'test'}, + scrub_pii=True, + temperature=0.7, + tool_choice='none', + ) + + self.assertIsNotNone(response) + self.assertEqual(response.context_id, 'chat-session-123') + choice = response.outputs[0].choices[0] + self.assertEqual(choice.message.content, 'Response to user: Continue our conversation') + await dapr.close() + + async def test_converse_alpha2_error_handling(self): + """Test Alpha2 conversation error handling (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + self._fake_dapr_server.raise_exception_on_next_call( + status_pb2.Status(code=code_pb2.INVALID_ARGUMENT, message='Alpha2 Invalid argument') + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text='Test error')] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + with self.assertRaises(DaprGrpcError) as context: + await dapr.converse_alpha2(name='test-llm', inputs=[input_alpha2]) + self.assertTrue('Alpha2 Invalid argument' in str(context.exception)) + await dapr.close() + + async def test_converse_alpha2_tool_choice_specific(self): + """Test Alpha2 conversation with specific tool choice (async).""" + dapr = DaprGrpcClientAsync(f'{self.scheme}localhost:{self.grpc_port}') + + weather_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='get_weather', description='Get weather information' + ) + ) + calculator_tool = conversation.ConversationTools( + function=conversation.ConversationToolsFunction( + name='calculate', description='Perform calculations' + ) + ) + + user_message = conversation.ConversationMessage( + of_user=conversation.ConversationMessageOfUser( + content=[conversation.ConversationMessageContent(text="What's the weather today?")] + ) + ) + + input_alpha2 = conversation.ConversationInputAlpha2(messages=[user_message]) + + response = await dapr.converse_alpha2( + name='test-llm', + inputs=[input_alpha2], + tools=[weather_tool, calculator_tool], + tool_choice='get_weather', + ) + + self.assertIsNotNone(response) + choice = response.outputs[0].choices[0] + if 'weather' in choice.message.content.lower(): + self.assertEqual(choice.finish_reason, 'tool_calls') + await dapr.close() + # # Tests for Jobs API (Alpha) - Async # diff --git a/tests/clients/test_dapr_grpc_helpers.py b/tests/clients/test_dapr_grpc_helpers.py new file mode 100644 index 000000000..9e794aab7 --- /dev/null +++ b/tests/clients/test_dapr_grpc_helpers.py @@ -0,0 +1,189 @@ +import base64 +import unittest + +from google.protobuf.struct_pb2 import Struct +from google.protobuf import json_format +from google.protobuf.json_format import ParseError +from google.protobuf.any_pb2 import Any as GrpcAny +from google.protobuf.wrappers_pb2 import ( + BoolValue, + StringValue, + Int32Value, + Int64Value, + DoubleValue, + BytesValue, +) + +from dapr.clients.grpc._helpers import ( + convert_value_to_struct, + convert_dict_to_grpc_dict_of_any, +) + + +class TestConvertValueToStruct(unittest.TestCase): + def test_struct_passthrough_same_instance(self): + # Prepare a Struct + original = Struct() + json_format.ParseDict({'a': 1, 'b': 'x'}, original) + + # It should return the exact same instance + result = convert_value_to_struct(original) + self.assertIs(result, original) + + def test_simple_and_nested_dict_conversion(self): + payload = { + 'a': 'b', + 'n': 3, + 't': True, + 'f': 1.5, + 'none': None, + 'list': [1, 'x', False, None], + 'obj': {'k': 'v', 'inner': {'i': 2, 'j': None}}, + } + struct = convert_value_to_struct(payload) + + # Convert back to dict to assert equivalence + back = json_format.MessageToDict(struct, preserving_proto_field_name=True) + self.assertEqual( + back, + { + 'a': 'b', + 'n': 3, + 't': True, + 'f': 1.5, + 'none': None, + 'list': [1, 'x', False, None], + 'obj': {'k': 'v', 'inner': {'i': 2, 'j': None}}, + }, + ) + + def test_invalid_non_dict_non_bytes_types_raise(self): + for bad in [ + 'str', + 42, + 3.14, + True, + None, + [1, 2, 3], + ]: + with self.subTest(value=bad): + with self.assertRaises(ValueError) as ctx: + convert_value_to_struct(bad) # type: ignore[arg-type] + self.assertIn('Value must be a dictionary, got', str(ctx.exception)) + + def test_bytes_input_raises_parse_error(self): + data = b'hello world' + # The implementation base64-encodes bytes then attempts to ParseDict a string, + # which results in a ParseError from protobuf's json_format. + with self.assertRaises(ParseError) as ctx: + convert_value_to_struct(data) + msg = str(ctx.exception) + # Ensure the base64 string is what would have been produced (implementation detail) + expected_b64 = base64.b64encode(data).decode('utf-8') + self.assertIn(expected_b64, msg) + + def test_dict_with_non_string_key_raises_wrapped_value_error(self): + # Struct JSON object keys must be strings; non-string key should cause parse error + bad_dict = {1: 'a', 'ok': 2} # type: ignore[dict-item] + with self.assertRaises(ValueError) as ctx: + convert_value_to_struct(bad_dict) # type: ignore[arg-type] + self.assertIn('Unsupported parameter type or value', str(ctx.exception)) + + def test_json_roundtrip_struct_to_dict_to_json(self): + import json + + # Start with a JSON string (could come from any external source) + original_json = json.dumps( + { + 'a': 'b', + 'n': 3, + 't': True, + 'f': 1.5, + 'none': None, + 'list': [1, 'x', False, None], + 'obj': {'k': 'v', 'inner': {'i': 2, 'j': None}}, + } + ) + + # JSON -> dict + original_dict = json.loads(original_json) + + # dict -> Struct + struct = convert_value_to_struct(original_dict) + + # Struct -> dict + back_to_dict = json_format.MessageToDict(struct, preserving_proto_field_name=True) + + # dict -> JSON + final_json = json.dumps(back_to_dict, separators=(',', ':'), sort_keys=True) + + # Validate: parsing final_json should yield the original_dict structure + # Note: We compare dicts to avoid key-order issues and formatting differences + self.assertEqual(json.loads(final_json), original_dict) + + +class TestConvertDictToGrpcDictOfAny(unittest.TestCase): + def test_none_and_empty_return_empty_dict(self): + self.assertEqual(convert_dict_to_grpc_dict_of_any(None), {}) + self.assertEqual(convert_dict_to_grpc_dict_of_any({}), {}) + + def test_basic_types_conversion(self): + params = { + 's': 'hello', + 'b': True, + 'i32': 123, + 'i64': 2**40, + 'f': 3.14, + 'bytes': b'abc', + } + result = convert_dict_to_grpc_dict_of_any(params) + + # Ensure all keys present + self.assertEqual(set(result.keys()), set(params.keys())) + + # Check each Any contains the proper wrapper with correct value + sv = StringValue() + self.assertTrue(result['s'].Unpack(sv)) + self.assertEqual(sv.value, 'hello') + + bv = BoolValue() + self.assertTrue(result['b'].Unpack(bv)) + self.assertEqual(bv.value, True) + + i32v = Int32Value() + self.assertTrue(result['i32'].Unpack(i32v)) + self.assertEqual(i32v.value, 123) + + i64v = Int64Value() + self.assertTrue(result['i64'].Unpack(i64v)) + self.assertEqual(i64v.value, 2**40) + + dv = DoubleValue() + self.assertTrue(result['f'].Unpack(dv)) + self.assertAlmostEqual(dv.value, 3.14) + + byv = BytesValue() + self.assertTrue(result['bytes'].Unpack(byv)) + self.assertEqual(byv.value, b'abc') + + def test_pass_through_existing_any_instances(self): + # Prepare Any values + any_s = GrpcAny() + any_s.Pack(StringValue(value='x')) + any_i = GrpcAny() + any_i.Pack(Int64Value(value=9999999999)) + + params = {'s': any_s, 'i': any_i} + result = convert_dict_to_grpc_dict_of_any(params) + + # Should be the exact same Any instances + self.assertIs(result['s'], any_s) + self.assertIs(result['i'], any_i) + + def test_unsupported_type_raises_value_error(self): + with self.assertRaises(ValueError): + convert_dict_to_grpc_dict_of_any({'bad': [1, 2, 3]}) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tox.ini b/tox.ini index 0c9ebeabb..ebd403c3f 100644 --- a/tox.ini +++ b/tox.ini @@ -75,6 +75,25 @@ commands_pre = pip3 install -e {toxinidir}/ext/dapr-ext-fastapi/ allowlist_externals=* +[testenv:example-component] +; This environment is used to validate a specific example component. +; Usage: tox -e example-component -- component_name +; Example: tox -e example-component -- conversation +passenv = HOME +basepython = python3 +changedir = ./examples/ +deps = + mechanical-markdown +commands = + ./validate.sh {posargs} + +commands_pre = + pip3 install -e {toxinidir}/ + pip3 install -e {toxinidir}/ext/dapr-ext-workflow/ + pip3 install -e {toxinidir}/ext/dapr-ext-grpc/ + pip3 install -e {toxinidir}/ext/dapr-ext-fastapi/ +allowlist_externals=* + [testenv:type] basepython = python3 usedevelop = False From a06d4fcb509ac69d54725097dc45c2d205c39373 Mon Sep 17 00:00:00 2001 From: Filinto Duran <1373693+filintod@users.noreply.github.com> Date: Fri, 12 Sep 2025 12:38:47 -0500 Subject: [PATCH 03/15] update docs with tool calling helpers info (#838) Signed-off-by: Filinto Duran <1373693+filintod@users.noreply.github.com> --- daprdocs/content/en/python-sdk-docs/_index.md | 7 + .../en/python-sdk-docs/conversation.md | 295 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 daprdocs/content/en/python-sdk-docs/conversation.md diff --git a/daprdocs/content/en/python-sdk-docs/_index.md b/daprdocs/content/en/python-sdk-docs/_index.md index 454a5afb6..b8689eb99 100644 --- a/daprdocs/content/en/python-sdk-docs/_index.md +++ b/daprdocs/content/en/python-sdk-docs/_index.md @@ -67,6 +67,13 @@ Python SDK imports are subpackages included with the main SDK install, but need +
+
+
Conversation
+

Use the Dapr Conversation API (Alpha) for LLM interactions, tools, and multi-turn flows.

+ +
+
Learn more about _all_ of the [available Dapr Python SDK imports](https://github.com/dapr/python-sdk/tree/master/dapr). diff --git a/daprdocs/content/en/python-sdk-docs/conversation.md b/daprdocs/content/en/python-sdk-docs/conversation.md new file mode 100644 index 000000000..db67a6c49 --- /dev/null +++ b/daprdocs/content/en/python-sdk-docs/conversation.md @@ -0,0 +1,295 @@ +title: "Conversation API (Python) – Recommended Usage" +linkTitle: "Conversation" +weight: 11000 +type: docs +description: Recommended patterns for using Dapr Conversation API in Python with and without tools, including multi‑turn flows and safety guidance. +--- + +The Dapr Conversation API is currently in alpha. This page presents the recommended, minimal patterns to use it effectively with the Python SDK: +- Plain requests (no tools) +- Requests with tools (functions as tools) +- Multi‑turn flows with tool execution +- Async variants +- Important safety notes for executing tool calls + +## Prerequisites + +- [Dapr CLI]({{% ref install-dapr-cli.md %}}) installed +- Initialized [Dapr environment]({{% ref install-dapr-selfhost.md %}}) +- [Python 3.9+](https://www.python.org/downloads/) installed +- [Dapr Python package]({{% ref "python#installation" %}}) installed +- A configured LLM component (for example, OpenAI or Azure OpenAI) in your Dapr environment + +For full, end‑to‑end flows and provider setup, see: +- The SDK examples under Conversation: + - [TOOL-CALL-QUICKSTART.md](https://github.com/dapr/python-sdk/blob/main/examples/conversation/TOOL-CALL-QUICKSTART.md) + - [real_llm_providers_example.py](https://github.com/dapr/python-sdk/blob/main/examples/conversation/real_llm_providers_example.py) + +## Plain conversation (no tools) + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + +# Build a single‑turn Alpha2 input +user_msg = conversation.create_user_message("What's Dapr?") +alpha2_input = conversation.ConversationInputAlpha2(messages=[user_msg]) + +with DaprClient() as client: + resp = client.converse_alpha2( + name="echo", # replace with your LLM component name + inputs=[alpha2_input], + temperature=1, + ) + + for msg in resp.to_assistant_messages(): + if msg.of_assistant.content: + print(msg.of_assistant.content[0].text) +``` + +Key points: +- Use `conversation.create_user_message` to build messages. +- Wrap into `ConversationInputAlpha2(messages=[...])` and pass to `converse_alpha2`. +- Use `response.to_assistant_messages()` to iterate assistant outputs. + +## Tools: decorator‑based (recommended) + +Decorator-based tools offer a clean, ergonomic approach. Define a function with clear type hints and detail docstring, this is important for the LLM to understand how or when to invoke the tool; +decorate it with `@conversation.tool`. Registered tools can be passed to the LLM and invoked via tool calls. + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + """Get current weather for a location.""" + # Replace with a real implementation + return f"Weather in {location} (unit={unit})" + +user_msg = conversation.create_user_message("What's the weather in Paris?") +alpha2_input = conversation.ConversationInputAlpha2(messages=[user_msg]) + +with DaprClient() as client: + response = client.converse_alpha2( + name="openai", # your LLM component + inputs=[alpha2_input], + tools=conversation.get_registered_tools(), # tools registered by @conversation.tool + tool_choice='auto', + temperature=1, + ) + + # Inspect assistant messages, including any tool calls + for msg in response.to_assistant_messages(): + if msg.of_assistant.tool_calls: + for tc in msg.of_assistant.tool_calls: + print(f"Tool call: {tc.function.name} args={tc.function.arguments}") + elif msg.of_assistant.content: + print(msg.of_assistant.content[0].text) +``` + +Notes: +- Use `conversation.get_registered_tools()` to collect all `@conversation.tool` decorated functions. +- The binder validates/coerces params using your function signature. Keep annotations accurate. + +## Minimal multi‑turn with tools + +This is the go‑to loop for tool‑using conversations: + +{{% alert title="Warning" color="warning" %}} +Do not blindly auto‑execute tool calls returned by the LLM unless you trust all tools registered. Treat tool names and arguments as untrusted input. +- Validate inputs and enforce guardrails (allow‑listed tools, argument schemas, side‑effect constraints). +- For async or I/O‑bound tools, prefer `conversation.execute_registered_tool_async(..., timeout=...)` and set conservative timeouts. +- Consider adding a policy layer or a user confirmation step before execution in sensitive contexts. +- Log and monitor tool usage; fail closed when validation fails. +{{% /alert %}} + +```python +from dapr.clients import DaprClient +from dapr.clients.grpc import conversation + +@conversation.tool +def get_weather(location: str, unit: str = 'fahrenheit') -> str: + return f"Weather in {location} (unit={unit})" + +history: list[conversation.ConversationMessage] = [ + conversation.create_user_message("What's the weather in San Francisco?")] + +with DaprClient() as client: + # Turn 1 + resp1 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=history)], + tools=conversation.get_registered_tools(), + tool_choice='auto', + temperature=1, + ) + + # Append assistant messages; execute tool calls; append tool results + for msg in resp1.to_assistant_messages(): + history.append(msg) + for tc in msg.of_assistant.tool_calls: + # IMPORTANT: validate inputs and enforce guardrails in production + tool_output = conversation.execute_registered_tool( + tc.function.name, tc.function.arguments + ) + history.append( + conversation.create_tool_message( + tool_id=tc.id, name=tc.function.name, content=str(tool_output) + ) + ) + + # Turn 2 (LLM sees tool result) + history.append(conversation.create_user_message("Should I bring an umbrella?")) + resp2 = client.converse_alpha2( + name="openai", + inputs=[conversation.ConversationInputAlpha2(messages=history)], + tools=conversation.get_registered_tools(), + temperature=1, + ) + + for msg in resp2.to_assistant_messages(): + history.append(msg) + if not msg.of_assistant.tool_calls and msg.of_assistant.content: + print(msg.of_assistant.content[0].text) +``` + +Tips: +- Always append assistant messages to history. +- Execute each tool call (with validation) and append a tool message with the tool output. +- The next turn includes these tool results so the LLM can reason with them. + +## Functions as tools: alternatives + +When decorators aren’t practical, two options exist. + +A) Automatic schema from a typed function: + +```python +from enum import Enum +from dapr.clients.grpc import conversation + +class Units(Enum): + CELSIUS = 'celsius' + FAHRENHEIT = 'fahrenheit' + +def get_weather(location: str, unit: Units = Units.FAHRENHEIT) -> str: + return f"Weather in {location}" + +fn = conversation.ConversationToolsFunction.from_function(get_weather) +weather_tool = conversation.ConversationTools(function=fn) +``` + +B) Manual JSON Schema (fallback): + +```python +from dapr.clients.grpc import conversation + +fn = conversation.ConversationToolsFunction( + name='get_weather', + description='Get current weather', + parameters={ + 'type': 'object', + 'properties': { + 'location': {'type': 'string'}, + 'unit': {'type': 'string', 'enum': ['celsius', 'fahrenheit']}, + }, + 'required': ['location'], + }, +) +weather_tool = conversation.ConversationTools(function=fn) +``` + +## Async variant + +Use the asynchronous client and async tool execution helpers as needed. + +```python +import asyncio +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients.grpc import conversation + +@conversation.tool +def get_time() -> str: + return '2025-01-01T12:00:00Z' + +async def main(): + async with AsyncDaprClient() as client: + msg = conversation.create_user_message('What time is it?') + inp = conversation.ConversationInputAlpha2(messages=[msg]) + resp = await client.converse_alpha2( + name='openai', inputs=[inp], tools=conversation.get_registered_tools() + ) + for m in resp.to_assistant_messages(): + if m.of_assistant.content: + print(m.of_assistant.content[0].text) + +asyncio.run(main()) +``` + +If you need to execute tools asynchronously (e.g., network I/O), implement async functions and use `conversation.execute_registered_tool_async` with timeouts. + +## Safety and validation (must‑read) + +An LLM may suggest tool calls. Treat all model‑provided parameters as untrusted input. + +Recommendations: +- Register only trusted functions as tools. Prefer the `@conversation.tool` decorator for clarity and automatic schema generation. +- Use precise type annotations and docstrings. The SDK converts function signatures to JSON schema and binds parameters with type coercion and rejection of unexpected/invalid fields. +- Add guardrails for tools that can cause side effects (filesystem, network, subprocess). Consider allow‑lists, sandboxing, and limits. +- Validate arguments before execution. For example, sanitize file paths or restrict URLs/domains. +- Consider timeouts and concurrency controls. For async tools, pass a timeout to `execute_registered_tool_async(..., timeout=...)`. +- Log and monitor tool usage. Fail closed: if validation fails, avoid executing the tool and inform the user safely. + +See also inline notes in `dapr/clients/grpc/conversation.py` (e.g., `tool()`, `ConversationTools`, `execute_registered_tool`) for parameter binding and error handling details. + + +## Key helper methods (quick reference) + +This section summarizes helper utilities available in dapr.clients.grpc.conversation used throughout the examples. + +- create_user_message(text: str) -> ConversationMessage + - Builds a user role message for Alpha2. Use in history lists. + - Example: `history.append(conversation.create_user_message("Hello"))` + +- create_system_message(text: str) -> ConversationMessage + - Builds a system message to steer the assistant’s behavior. + - Example: `history = [conversation.create_system_message("You are a concise assistant.")]` + +- create_assistant_message(text: str) -> ConversationMessage + - Useful for injecting assistant text in tests or controlled flows. + +- create_tool_message(tool_id: str, name: str, content: Any) -> ConversationMessage + - Converts a tool’s output into a tool message the LLM can read next turn. + - content can be any object; it is stringified safely by the SDK. + - Example: `history.append(conversation.create_tool_message(tool_id=tc.id, name=tc.function.name, content=conversation.execute_registered_tool(tc.function.name, tc.function.arguments)))` + +- get_registered_tools() -> list[ConversationTools] + - Returns all tools currently registered in the in-process registry. + - Includes tools created via: + - @conversation.tool decorator (auto-registered by default), and + - ConversationToolsFunction.from_function with register=True (default). + - Pass this list in converse_alpha2(..., tools=...). + +- register_tool(name: str, t: ConversationTools) / unregister_tool(name: str) + - Manually manage the tool registry (e.g., advanced scenarios, tests, cleanup). + - Names must be unique; unregister to avoid collisions in long-lived processes. + +- execute_registered_tool(name: str, params: Mapping|Sequence|str|None) -> Any + - Synchronously executes a registered tool by name. + - params accepts kwargs (mapping), args (sequence), JSON string, or None. If a JSON string is provided (as commonly returned by LLMs), it is parsed for you. + - Parameters are validated and coerced against the function signature/schema; unexpected or invalid fields raise errors. + - Security: treat params as untrusted; add guardrails for side effects. + +- execute_registered_tool_async(name: str, params: Mapping|Sequence|str|None, *, timeout: float|None=None) -> Any + - Async counterpart. Supports timeouts, which are recommended for I/O-bound tools. + - Prefer this for async tools or when using the aio client. + +- ConversationToolsFunction.from_function(func: Callable, register: bool = True) -> ConversationToolsFunction + - Derives a JSON schema from a typed Python function (annotations + optional docstring) and optionally registers a tool. + - Typical usage: `spec = conversation.ConversationToolsFunction.from_function(my_func)`; then either rely on auto-registration or wrap with `ConversationTools(function=spec)` and call `register_tool(spec.name, tool)` or pass `[tool]` directly to `tools=`. + +- ConversationResponseAlpha2.to_assistant_messages() -> list[ConversationMessage] + - Convenience to transform the response outputs into assistant ConversationMessage objects you can append to history directly (including tool_calls when present). + +Tip: The @conversation.tool decorator is the easiest way to create a tool. It auto-generates the schema from your function, allows an optional namespace/name override, and auto-registers the tool (you can set register=False to defer registration). From 1569fba0be30b2b8e4c978c86cad38218d4cff84 Mon Sep 17 00:00:00 2001 From: Elena Kolevska Date: Fri, 12 Sep 2025 18:51:08 +0100 Subject: [PATCH 04/15] 1.16.0rc2 Signed-off-by: Elena Kolevska --- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 2 +- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 2 +- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 14 files changed, 17 insertions(+), 17 deletions(-) diff --git a/dapr/version/version.py b/dapr/version/version.py index 95693e397..cc4914d18 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index 36548818d..bf327e436 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi>=1.16.0rc1 +dapr-ext-fastapi>=1.16.0rc2 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index 76d2a673f..6b3c699e9 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow>=1.16.0rc1 \ No newline at end of file +dapr-ext-workflow>=1.16.0rc2 \ No newline at end of file diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index 1481c0c94..03b31f71f 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc >= 1.16.0rc1 -dapr >= 1.16.0rc1 +dapr-ext-grpc >= 1.16.0rc2 +dapr >= 1.16.0rc2 diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index 042e1b7fa..8fca8eda3 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc >= 1.16.0rc1 -dapr >= 1.16.0rc1 +dapr-ext-grpc >= 1.16.0rc2 +dapr >= 1.16.0rc2 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index 857632631..cdb6218da 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow>=1.16.0rc1 -dapr>=1.16.0rc1 +dapr-ext-workflow>=1.16.0rc2 +dapr>=1.16.0rc2 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index 95693e397..cc4914d18 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index 4d1c4d61a..93dedbbe3 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc1 + dapr >= 1.16.0rc2 uvicorn >= 0.11.6 fastapi >= 0.60.1 diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index 95693e397..cc4914d18 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index c998af5d7..43b8c05d3 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc1 + dapr >= 1.16.0rc2 cloudevents >= 1.0.0 [options.packages.find] diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index 95693e397..cc4914d18 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index df21ebc28..1ed25c2e0 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc1 + dapr >= 1.16.0rc2 durabletask-dapr >= 0.2.0a7 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index 95693e397..cc4914d18 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc1' +__version__ = '1.16.0rc2' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index d3019d271..68fefcbe6 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr >= 1.16.0rc1 + dapr >= 1.16.0rc2 From a7414eb99f6bf4f5f6e55f106c948773d14f3b4e Mon Sep 17 00:00:00 2001 From: Cassie Coyle Date: Wed, 17 Sep 2025 05:27:37 -0500 Subject: [PATCH 05/15] use latest durabletask (#840) Signed-off-by: Cassandra Coyle --- dev-requirements.txt | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index cbd719859..3e7ca471e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -15,7 +15,7 @@ Flask>=1.1 # needed for auto fix ruff===0.2.2 # needed for dapr-ext-workflow -durabletask-dapr >= 0.2.0a7 +durabletask-dapr >= 0.2.0a8 # needed for .env file loading in examples python-dotenv>=1.0.0 # needed for enhanced schema generation from function features diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index 1ed25c2e0..b74516423 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -25,7 +25,7 @@ packages = find_namespace: include_package_data = True install_requires = dapr >= 1.16.0rc2 - durabletask-dapr >= 0.2.0a7 + durabletask-dapr >= 0.2.0a8 [options.packages.find] include = From ae3e592f78a58c220504a0c507bbf49737f00b31 Mon Sep 17 00:00:00 2001 From: Elena Kolevska Date: Wed, 17 Sep 2025 11:53:47 +0100 Subject: [PATCH 06/15] 1.16.0 Signed-off-by: Elena Kolevska --- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 2 +- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 2 +- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 14 files changed, 17 insertions(+), 17 deletions(-) diff --git a/dapr/version/version.py b/dapr/version/version.py index cc4914d18..ff4ea95e6 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index bf327e436..a5d6d966e 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi>=1.16.0rc2 +dapr-ext-fastapi>=1.16.0 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index 6b3c699e9..c91800c64 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow>=1.16.0rc2 \ No newline at end of file +dapr-ext-workflow>=1.16.0 \ No newline at end of file diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index 03b31f71f..5d83c5872 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc >= 1.16.0rc2 -dapr >= 1.16.0rc2 +dapr-ext-grpc >= 1.16.0 +dapr >= 1.16.0 diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index 8fca8eda3..ec17054a8 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc >= 1.16.0rc2 -dapr >= 1.16.0rc2 +dapr-ext-grpc >= 1.16.0 +dapr >= 1.16.0 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index cdb6218da..3be7c3c78 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow>=1.16.0rc2 -dapr>=1.16.0rc2 +dapr-ext-workflow>=1.16.0 +dapr>=1.16.0 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index cc4914d18..ff4ea95e6 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index 93dedbbe3..ba467c42c 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc2 + dapr >= 1.16.0 uvicorn >= 0.11.6 fastapi >= 0.60.1 diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index cc4914d18..ff4ea95e6 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index 43b8c05d3..b05472b1e 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc2 + dapr >= 1.16.0 cloudevents >= 1.0.0 [options.packages.find] diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index cc4914d18..ff4ea95e6 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index b74516423..edc914a11 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0rc2 + dapr >= 1.16.0 durabletask-dapr >= 0.2.0a8 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index cc4914d18..ff4ea95e6 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0rc2' +__version__ = '1.16.0' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index 68fefcbe6..b86bb2841 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr >= 1.16.0rc2 + dapr >= 1.16.0 From 99314a4575592311b0b4ec5a27409f5388f10e9f Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Wed, 17 Sep 2025 17:51:19 +0200 Subject: [PATCH 07/15] Adds support for interceptors and concurrency_options arguments in the workflow engine (#841) Signed-off-by: Albert Callarisa --- .../dapr/ext/workflow/workflow_runtime.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py index d1f02b354..9f4be6222 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py @@ -15,7 +15,8 @@ import inspect from functools import wraps -from typing import Optional, TypeVar +from typing import Optional, TypeVar, Union, Sequence +import grpc from durabletask import worker, task @@ -34,6 +35,13 @@ TInput = TypeVar('TInput') TOutput = TypeVar('TOutput') +ClientInterceptor = Union[ + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, +] + class WorkflowRuntime: """WorkflowRuntime is the entry point for registering workflows and activities.""" @@ -43,6 +51,10 @@ def __init__( host: Optional[str] = None, port: Optional[str] = None, logger_options: Optional[LoggerOptions] = None, + interceptors: Optional[Sequence[ClientInterceptor]] = None, + maximum_concurrent_activity_work_items: Optional[int] = None, + maximum_concurrent_orchestration_work_items: Optional[int] = None, + maximum_thread_pool_workers: Optional[int] = None, ): self._logger = Logger('WorkflowRuntime', logger_options) metadata = tuple() @@ -62,6 +74,12 @@ def __init__( secure_channel=uri.tls, log_handler=options.log_handler, log_formatter=options.log_formatter, + interceptors=interceptors, + concurrency_options=worker.ConcurrencyOptions( + maximum_concurrent_activity_work_items=maximum_concurrent_activity_work_items, + maximum_concurrent_orchestration_work_items=maximum_concurrent_orchestration_work_items, + maximum_thread_pool_workers=maximum_thread_pool_workers, + ), ) def register_workflow(self, fn: Workflow, *, name: Optional[str] = None): From d005a152a7f94f89648d5d3b8ffb5c3bda25d2c2 Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Tue, 7 Oct 2025 12:40:00 +0200 Subject: [PATCH 08/15] Implement multi-app workflows (#844) * feat: Adds support for cross-app calls. Signed-off-by: Albert Callarisa * Use durabletask alpha.9 Signed-off-by: Albert Callarisa * Added examples for error scenarios in multi-app workflow Signed-off-by: Albert Callarisa * Remove unnecessary hardcoded ports Signed-off-by: Albert Callarisa --------- Signed-off-by: Albert Callarisa --- dev-requirements.txt | 2 +- examples/workflow/README.md | 141 +++++++++++++++++- examples/workflow/cross-app1.py | 58 +++++++ examples/workflow/cross-app2.py | 50 +++++++ examples/workflow/cross-app3.py | 32 ++++ .../ext/workflow/dapr_workflow_context.py | 53 ++++++- .../dapr/ext/workflow/workflow_context.py | 23 ++- ext/dapr-ext-workflow/setup.cfg | 4 +- .../tests/test_dapr_workflow_context.py | 4 +- 9 files changed, 340 insertions(+), 27 deletions(-) create mode 100644 examples/workflow/cross-app1.py create mode 100644 examples/workflow/cross-app2.py create mode 100644 examples/workflow/cross-app3.py diff --git a/dev-requirements.txt b/dev-requirements.txt index 3e7ca471e..461d92391 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -15,7 +15,7 @@ Flask>=1.1 # needed for auto fix ruff===0.2.2 # needed for dapr-ext-workflow -durabletask-dapr >= 0.2.0a8 +durabletask-dapr >= 0.2.0a9 # needed for .env file loading in examples python-dotenv>=1.0.0 # needed for enhanced schema generation from function features diff --git a/examples/workflow/README.md b/examples/workflow/README.md index f5b901d1c..2e09eeef8 100644 --- a/examples/workflow/README.md +++ b/examples/workflow/README.md @@ -20,7 +20,7 @@ pip3 install -r requirements.txt Each of the examples in this directory can be run directly from the command line. ### Simple Workflow -This example represents a workflow that manages counters through a series of activities and child workflows. +This example represents a workflow that manages counters through a series of activities and child workflows. It shows several Dapr Workflow features including: - Basic activity execution with counter increments - Retryable activities with configurable retry policies @@ -57,7 +57,7 @@ timeout_seconds: 30 --> ```sh -dapr run --app-id wf-simple-example --dapr-grpc-port 50001 -- python3 simple.py +dapr run --app-id wf-simple-example -- python3 simple.py ``` @@ -99,7 +99,7 @@ timeout_seconds: 30 --> ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 -- python3 task_chaining.py +dapr run --app-id wfexample -- python3 task_chaining.py ``` @@ -146,7 +146,7 @@ timeout_seconds: 30 --> ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 -- python3 fan_out_fan_in.py +dapr run --app-id wfexample -- python3 fan_out_fan_in.py ``` @@ -186,7 +186,7 @@ This example demonstrates how to use a workflow to interact with a human user. T The Dapr CLI can be started using the following command: ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 +dapr run --app-id wfexample ``` In a separate terminal window, run the following command to start the Python workflow app: @@ -222,7 +222,7 @@ This example demonstrates how to eternally running workflow that polls an endpoi The Dapr CLI can be started using the following command: ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 +dapr run --app-id wfexample ``` In a separate terminal window, run the following command to start the Python workflow app: @@ -254,7 +254,7 @@ This workflow runs forever or until you press `ENTER` to stop it. Starting the a This example demonstrates how to call a child workflow. The Dapr CLI can be started using the following command: ```sh -dapr run --app-id wfexample --dapr-grpc-port 50001 +dapr run --app-id wfexample ``` In a separate terminal window, run the following command to start the Python workflow app: @@ -269,4 +269,129 @@ When you run the example, you will see output like this: *** Calling child workflow 29a7592a1e874b07aad2bb58de309a51-child *** Child workflow 6feadc5370184b4998e50875b20084f6 called ... -``` \ No newline at end of file +``` + + +### Cross-app Workflow + +This example demonstrates how to call child workflows and activities in different apps. The multiple Dapr CLI instances can be started using the following commands: + + + +```sh +dapr run --app-id wfexample3 python3 cross-app3.py & +dapr run --app-id wfexample2 python3 cross-app2.py & +dapr run --app-id wfexample1 python3 cross-app1.py +``` + + +When you run the apps, you will see output like this: +``` +... +app1 - triggering app2 workflow +app2 - triggering app3 activity +... +``` +among others. This shows that the workflow calls are working as expected. + + +#### Error handling on activity calls + +This example demonstrates how the error handling works on activity calls across apps. + +Error handling on activity calls across apps works as normal workflow activity calls. + +In this example we run `app3` in failing mode, which makes the activity call return error constantly. The activity call from `app2` will fail after the retry policy is exhausted. + + + +```sh +export ERROR_ACTIVITY_MODE=true +dapr run --app-id wfexample3 python3 cross-app3.py & +dapr run --app-id wfexample2 python3 cross-app2.py & +dapr run --app-id wfexample1 python3 cross-app1.py +``` + + + +When you run the apps with the `ERROR_ACTIVITY_MODE` environment variable set, you will see output like this: +``` +... +app3 - received activity call +app3 - raising error in activity due to error mode being enabled +app2 - received activity error from app3 +... +``` +among others. This shows that the activity calls are failing as expected, and they are being handled as expected too. + + +#### Error handling on workflow calls + +This example demonstrates how the error handling works on workflow calls across apps. + +Error handling on workflow calls across apps works as normal workflow calls. + +In this example we run `app2` in failing mode, which makes the workflow call return error constantly. The workflow call from `app1` will fail after the retry policy is exhausted. + + + +```sh +export ERROR_WORKFLOW_MODE=true +dapr run --app-id wfexample3 python3 cross-app3.py & +dapr run --app-id wfexample2 python3 cross-app2.py & +dapr run --app-id wfexample1 python3 cross-app1.py +``` + + +When you run the apps with the `ERROR_WORKFLOW_MODE` environment variable set, you will see output like this: +``` +... +app2 - received workflow call +app2 - raising error in workflow due to error mode being enabled +app1 - received workflow error from app2 +... +``` +among others. This shows that the workflow calls are failing as expected, and they are being handled as expected too. + diff --git a/examples/workflow/cross-app1.py b/examples/workflow/cross-app1.py new file mode 100644 index 000000000..f84de662a --- /dev/null +++ b/examples/workflow/cross-app1.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import timedelta + +from durabletask.task import TaskFailedError +import dapr.ext.workflow as wf +import time + +wfr = wf.WorkflowRuntime() + + +@wfr.workflow +def app1_workflow(ctx: wf.DaprWorkflowContext): + print(f'app1 - received workflow call', flush=True) + print(f'app1 - triggering app2 workflow', flush=True) + + try: + retry_policy = wf.RetryPolicy( + max_number_of_attempts=2, + first_retry_interval=timedelta(milliseconds=100), + max_retry_interval=timedelta(seconds=3), + ) + yield ctx.call_child_workflow( + workflow='app2_workflow', + input=None, + app_id='wfexample2', + retry_policy=retry_policy, + ) + print(f'app1 - received workflow result', flush=True) + except TaskFailedError as e: + print(f'app1 - received workflow error from app2', flush=True) + + print(f'app1 - returning workflow result', flush=True) + return 1 + + +if __name__ == '__main__': + wfr.start() + time.sleep(10) # wait for workflow runtime to start + + wf_client = wf.DaprWorkflowClient() + print(f'app1 - triggering app1 workflow', flush=True) + instance_id = wf_client.schedule_new_workflow(workflow=app1_workflow) + + # Wait for the workflow to complete + time.sleep(7) + + wfr.shutdown() diff --git a/examples/workflow/cross-app2.py b/examples/workflow/cross-app2.py new file mode 100644 index 000000000..4cb30874c --- /dev/null +++ b/examples/workflow/cross-app2.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import timedelta +import os + +from durabletask.task import TaskFailedError +import dapr.ext.workflow as wf +import time + +wfr = wf.WorkflowRuntime() + + +@wfr.workflow +def app2_workflow(ctx: wf.DaprWorkflowContext): + print(f'app2 - received workflow call', flush=True) + if os.getenv('ERROR_WORKFLOW_MODE', 'false') == 'true': + print(f'app2 - raising error in workflow due to error mode being enabled', flush=True) + raise ValueError('Error in workflow due to error mode being enabled') + print(f'app2 - triggering app3 activity', flush=True) + try: + retry_policy = wf.RetryPolicy( + max_number_of_attempts=2, + first_retry_interval=timedelta(milliseconds=100), + max_retry_interval=timedelta(seconds=3), + ) + result = yield ctx.call_activity( + 'app3_activity', input=None, app_id='wfexample3', retry_policy=retry_policy + ) + print(f'app2 - received activity result', flush=True) + except TaskFailedError as e: + print(f'app2 - received activity error from app3', flush=True) + + print(f'app2 - returning workflow result', flush=True) + return 2 + + +if __name__ == '__main__': + wfr.start() + time.sleep(15) # wait for workflow runtime to start + wfr.shutdown() diff --git a/examples/workflow/cross-app3.py b/examples/workflow/cross-app3.py new file mode 100644 index 000000000..ecc945ca3 --- /dev/null +++ b/examples/workflow/cross-app3.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Copyright 2025 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +import dapr.ext.workflow as wf +import time + +wfr = wf.WorkflowRuntime() + + +@wfr.activity +def app3_activity(ctx: wf.DaprWorkflowContext) -> int: + print(f'app3 - received activity call', flush=True) + if os.getenv('ERROR_ACTIVITY_MODE', 'false') == 'true': + print(f'app3 - raising error in activity due to error mode being enabled', flush=True) + raise ValueError('Error in activity due to error mode being enabled') + print(f'app3 - returning activity result', flush=True) + return 3 + + +if __name__ == '__main__': + wfr.start() + time.sleep(15) # wait for workflow runtime to start + wfr.shutdown() diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/dapr_workflow_context.py b/ext/dapr-ext-workflow/dapr/ext/workflow/dapr_workflow_context.py index 2dee46fe2..476ab765f 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/dapr_workflow_context.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/dapr_workflow_context.py @@ -63,11 +63,29 @@ def create_timer(self, fire_at: Union[datetime, timedelta]) -> task.Task: def call_activity( self, - activity: Callable[[WorkflowActivityContext, TInput], TOutput], + activity: Union[Callable[[WorkflowActivityContext, TInput], TOutput], str], *, input: TInput = None, retry_policy: Optional[RetryPolicy] = None, + app_id: Optional[str] = None, ) -> task.Task[TOutput]: + # Handle string activity names for cross-app scenarios + if isinstance(activity, str): + activity_name = activity + if app_id is not None: + self._logger.debug( + f'{self.instance_id}: Creating cross-app activity {activity_name} for app {app_id}' + ) + else: + self._logger.debug(f'{self.instance_id}: Creating activity {activity_name}') + + if retry_policy is None: + return self.__obj.call_activity(activity=activity_name, input=input, app_id=app_id) + return self.__obj.call_activity( + activity=activity_name, input=input, retry_policy=retry_policy.obj, app_id=app_id + ) + + # Handle function activity objects (original behavior) self._logger.debug(f'{self.instance_id}: Creating activity {activity.__name__}') if hasattr(activity, '_dapr_alternate_name'): act = activity.__dict__['_dapr_alternate_name'] @@ -75,17 +93,38 @@ def call_activity( # this case should ideally never happen act = activity.__name__ if retry_policy is None: - return self.__obj.call_activity(activity=act, input=input) - return self.__obj.call_activity(activity=act, input=input, retry_policy=retry_policy.obj) + return self.__obj.call_activity(activity=act, input=input, app_id=app_id) + return self.__obj.call_activity( + activity=act, input=input, retry_policy=retry_policy.obj, app_id=app_id + ) def call_child_workflow( self, - workflow: Workflow, + workflow: Union[Workflow, str], *, input: Optional[TInput] = None, instance_id: Optional[str] = None, retry_policy: Optional[RetryPolicy] = None, + app_id: Optional[str] = None, ) -> task.Task[TOutput]: + # Handle string workflow names for cross-app scenarios + if isinstance(workflow, str): + workflow_name = workflow + self._logger.debug(f'{self.instance_id}: Creating child workflow {workflow_name}') + + if retry_policy is None: + return self.__obj.call_sub_orchestrator( + workflow_name, input=input, instance_id=instance_id, app_id=app_id + ) + return self.__obj.call_sub_orchestrator( + workflow_name, + input=input, + instance_id=instance_id, + retry_policy=retry_policy.obj, + app_id=app_id, + ) + + # Handle function workflow objects (original behavior) self._logger.debug(f'{self.instance_id}: Creating child workflow {workflow.__name__}') def wf(ctx: task.OrchestrationContext, inp: TInput): @@ -100,9 +139,11 @@ def wf(ctx: task.OrchestrationContext, inp: TInput): # this case should ideally never happen wf.__name__ = workflow.__name__ if retry_policy is None: - return self.__obj.call_sub_orchestrator(wf, input=input, instance_id=instance_id) + return self.__obj.call_sub_orchestrator( + wf, input=input, instance_id=instance_id, app_id=app_id + ) return self.__obj.call_sub_orchestrator( - wf, input=input, instance_id=instance_id, retry_policy=retry_policy.obj + wf, input=input, instance_id=instance_id, retry_policy=retry_policy.obj, app_id=app_id ) def wait_for_external_event(self, name: str) -> task.Task: diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_context.py b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_context.py index b4c85f6a6..d6e6ba072 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_context.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_context.py @@ -107,18 +107,22 @@ def create_timer(self, fire_at: Union[datetime, timedelta]) -> task.Task: @abstractmethod def call_activity( - self, activity: Activity[TOutput], *, input: Optional[TInput] = None + self, + activity: Union[Activity[TOutput], str], + *, + input: Optional[TInput] = None, + app_id: Optional[str] = None, ) -> task.Task[TOutput]: """Schedule an activity for execution. Parameters ---------- - activity: Activity[TInput, TOutput] - A reference to the activity function to call. + activity: Activity[TInput, TOutput] | str + A reference to the activity function to call, or a string name for cross-app activities. input: TInput | None The JSON-serializable input (or None) to pass to the activity. - return_type: task.Task[TOutput] - The JSON-serializable output type to expect from the activity result. + app_id: str | None + The AppID that will execute the activity. Returns ------- @@ -130,22 +134,25 @@ def call_activity( @abstractmethod def call_child_workflow( self, - orchestrator: Workflow[TOutput], + orchestrator: Union[Workflow[TOutput], str], *, input: Optional[TInput] = None, instance_id: Optional[str] = None, + app_id: Optional[str] = None, ) -> task.Task[TOutput]: """Schedule child-workflow function for execution. Parameters ---------- - orchestrator: Orchestrator[TInput, TOutput] - A reference to the orchestrator function to call. + orchestrator: Orchestrator[TInput, TOutput] | str + A reference to the orchestrator function to call, or a string name for cross-app workflows. input: TInput The optional JSON-serializable input to pass to the orchestrator function. instance_id: str A unique ID to use for the sub-orchestration instance. If not specified, a random UUID will be used. + app_id: str + The AppID that will execute the workflow. Returns ------- diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index edc914a11..1d54cc08b 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -25,11 +25,11 @@ packages = find_namespace: include_package_data = True install_requires = dapr >= 1.16.0 - durabletask-dapr >= 0.2.0a8 + durabletask-dapr >= 0.2.0a9 [options.packages.find] include = dapr.* -exclude = +exclude = tests diff --git a/ext/dapr-ext-workflow/tests/test_dapr_workflow_context.py b/ext/dapr-ext-workflow/tests/test_dapr_workflow_context.py index 9fdfe0440..3ae5fdaf5 100644 --- a/ext/dapr-ext-workflow/tests/test_dapr_workflow_context.py +++ b/ext/dapr-ext-workflow/tests/test_dapr_workflow_context.py @@ -36,10 +36,10 @@ def __init__(self): def create_timer(self, fire_at): return mock_create_timer - def call_activity(self, activity, input): + def call_activity(self, activity, input, app_id): return mock_call_activity - def call_sub_orchestrator(self, orchestrator, input, instance_id): + def call_sub_orchestrator(self, orchestrator, input, instance_id, app_id): return mock_call_sub_orchestrator def set_custom_status(self, custom_status): From d4c022734ea480751746e10c2fafa9b96f1d5c2e Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Wed, 8 Oct 2025 11:11:03 +0200 Subject: [PATCH 09/15] chore: Rename wait_until_ready to wait_for_sidecar (#843) Signed-off-by: Albert Callarisa Co-authored-by: Elena Kolevska --- dapr/aio/clients/grpc/client.py | 2 +- dapr/aio/clients/grpc/subscription.py | 2 +- dapr/clients/grpc/client.py | 2 +- dapr/clients/grpc/subscription.py | 2 +- dapr/clients/health.py | 10 ++++++++++ dapr/clients/http/client.py | 2 +- tests/clients/test_heatlhcheck.py | 16 ++++++++-------- 7 files changed, 23 insertions(+), 13 deletions(-) diff --git a/dapr/aio/clients/grpc/client.py b/dapr/aio/clients/grpc/client.py index 995b82680..1b76dcb0f 100644 --- a/dapr/aio/clients/grpc/client.py +++ b/dapr/aio/clients/grpc/client.py @@ -153,7 +153,7 @@ def __init__( max_grpc_message_length (int, optional): The maximum grpc send and receive message length in bytes. """ - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() self.retry_policy = retry_policy or RetryPolicy() useragent = f'dapr-sdk-python/{__version__}' diff --git a/dapr/aio/clients/grpc/subscription.py b/dapr/aio/clients/grpc/subscription.py index 9aabf8b28..e0e380ca6 100644 --- a/dapr/aio/clients/grpc/subscription.py +++ b/dapr/aio/clients/grpc/subscription.py @@ -51,7 +51,7 @@ async def outgoing_request_iterator(): async def reconnect_stream(self): await self.close() - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() print('Attempting to reconnect...') await self.start() diff --git a/dapr/clients/grpc/client.py b/dapr/clients/grpc/client.py index e4ffb2646..6c276dd3f 100644 --- a/dapr/clients/grpc/client.py +++ b/dapr/clients/grpc/client.py @@ -145,7 +145,7 @@ def __init__( message length in bytes. retry_policy (RetryPolicy optional): Specifies retry behaviour """ - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() self.retry_policy = retry_policy or RetryPolicy() useragent = f'dapr-sdk-python/{__version__}' diff --git a/dapr/clients/grpc/subscription.py b/dapr/clients/grpc/subscription.py index 111946b1b..6dcfcb4dd 100644 --- a/dapr/clients/grpc/subscription.py +++ b/dapr/clients/grpc/subscription.py @@ -65,7 +65,7 @@ def outgoing_request_iterator(): def reconnect_stream(self): self.close() - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() print('Attempting to reconnect...') self.start() diff --git a/dapr/clients/health.py b/dapr/clients/health.py index e3daec79d..37c42a875 100644 --- a/dapr/clients/health.py +++ b/dapr/clients/health.py @@ -15,6 +15,7 @@ import urllib.request import urllib.error import time +from warnings import warn from dapr.clients.http.conf import DAPR_API_TOKEN_HEADER, USER_AGENT_HEADER, DAPR_USER_AGENT from dapr.clients.http.helpers import get_api_url @@ -24,6 +25,15 @@ class DaprHealth: @staticmethod def wait_until_ready(): + warn( + 'This method is deprecated. Use DaprHealth.wait_for_sidecar instead.', + DeprecationWarning, + stacklevel=2, + ) + DaprHealth.wait_for_sidecar() + + @staticmethod + def wait_for_sidecar(): health_url = f'{get_api_url()}/healthz/outbound' headers = {USER_AGENT_HEADER: DAPR_USER_AGENT} if settings.DAPR_API_TOKEN is not None: diff --git a/dapr/clients/http/client.py b/dapr/clients/http/client.py index 86e9ab6f0..f6f95aa74 100644 --- a/dapr/clients/http/client.py +++ b/dapr/clients/http/client.py @@ -51,7 +51,7 @@ def __init__( timeout (int, optional): Timeout in seconds, defaults to 60. headers_callback (lambda: Dict[str, str]], optional): Generates header for each request. """ - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() self._timeout = aiohttp.ClientTimeout(total=timeout) self._serializer = message_serializer diff --git a/tests/clients/test_heatlhcheck.py b/tests/clients/test_heatlhcheck.py index f3be8a475..d447e072c 100644 --- a/tests/clients/test_heatlhcheck.py +++ b/tests/clients/test_heatlhcheck.py @@ -24,13 +24,13 @@ class DaprHealthCheckTests(unittest.TestCase): @patch.object(settings, 'DAPR_HTTP_ENDPOINT', 'http://domain.com:3500') @patch('urllib.request.urlopen') - def test_wait_until_ready_success(self, mock_urlopen): + def test_wait_for_sidecar_success(self, mock_urlopen): mock_urlopen.return_value.__enter__.return_value = MagicMock(status=200) try: - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() except Exception as e: - self.fail(f'wait_until_ready() raised an exception unexpectedly: {e}') + self.fail(f'wait_for_sidecar() raised an exception unexpectedly: {e}') mock_urlopen.assert_called_once() @@ -45,13 +45,13 @@ def test_wait_until_ready_success(self, mock_urlopen): @patch.object(settings, 'DAPR_HTTP_ENDPOINT', 'http://domain.com:3500') @patch.object(settings, 'DAPR_API_TOKEN', 'mytoken') @patch('urllib.request.urlopen') - def test_wait_until_ready_success_with_api_token(self, mock_urlopen): + def test_wait_for_sidecar_success_with_api_token(self, mock_urlopen): mock_urlopen.return_value.__enter__.return_value = MagicMock(status=200) try: - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() except Exception as e: - self.fail(f'wait_until_ready() raised an exception unexpectedly: {e}') + self.fail(f'wait_for_sidecar() raised an exception unexpectedly: {e}') mock_urlopen.assert_called_once() @@ -64,13 +64,13 @@ def test_wait_until_ready_success_with_api_token(self, mock_urlopen): @patch.object(settings, 'DAPR_HEALTH_TIMEOUT', '2.5') @patch('urllib.request.urlopen') - def test_wait_until_ready_timeout(self, mock_urlopen): + def test_wait_for_sidecar_timeout(self, mock_urlopen): mock_urlopen.return_value.__enter__.return_value = MagicMock(status=500) start = time.time() with self.assertRaises(TimeoutError): - DaprHealth.wait_until_ready() + DaprHealth.wait_for_sidecar() self.assertGreaterEqual(time.time() - start, 2.5) self.assertGreater(mock_urlopen.call_count, 1) From 6745935c79a95abb089893e234069a9ccd42ff93 Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Mon, 13 Oct 2025 13:47:02 +0200 Subject: [PATCH 10/15] 1.16.1rc1 (#846) Signed-off-by: Albert Callarisa --- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 4 ++-- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 4 ++-- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 14 files changed, 19 insertions(+), 19 deletions(-) diff --git a/dapr/version/version.py b/dapr/version/version.py index ff4ea95e6..8c6c12960 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index a5d6d966e..9496602ed 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi>=1.16.0 +dapr-ext-fastapi>=1.16.1rc1 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index c91800c64..a70b02692 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow>=1.16.0 \ No newline at end of file +dapr-ext-workflow>=1.16.1rc1 diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index 5d83c5872..e77f5d6e2 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc >= 1.16.0 -dapr >= 1.16.0 +dapr-ext-grpc >= 1.16.1rc1 +dapr >= 1.16.1rc1 diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index ec17054a8..514e2606a 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc >= 1.16.0 -dapr >= 1.16.0 +dapr-ext-grpc >= 1.16.1rc1 +dapr >= 1.16.1rc1 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index 3be7c3c78..fab86e728 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow>=1.16.0 -dapr>=1.16.0 +dapr-ext-workflow>=1.16.1rc1 +dapr>=1.16.1rc1 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index ff4ea95e6..8c6c12960 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index ba467c42c..8b6080ebf 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0 + dapr >= 1.16.1rc1 uvicorn >= 0.11.6 fastapi >= 0.60.1 @@ -32,5 +32,5 @@ install_requires = include = dapr.* -exclude = +exclude = tests diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index ff4ea95e6..8c6c12960 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index b05472b1e..d08757c78 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,12 +24,12 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0 + dapr >= 1.16.1rc1 cloudevents >= 1.0.0 [options.packages.find] include = dapr.* -exclude = +exclude = tests diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index ff4ea95e6..8c6c12960 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index 1d54cc08b..83869566c 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.0 + dapr >= 1.16.1rc1 durabletask-dapr >= 0.2.0a9 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index ff4ea95e6..8c6c12960 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.0' +__version__ = '1.16.1rc1' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index b86bb2841..531a9aea5 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr >= 1.16.0 + dapr >= 1.16.1rc1 From 6126b72e1aa7dd86ce91788e300a0cd89855e7b4 Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Fri, 16 Jan 2026 09:19:38 +0100 Subject: [PATCH 11/15] Release 1.16.1 (#884) * fix(test): correct metadata testcase for release 1.17 (#878) Signed-off-by: Casper Nielsen * Set version to 1.16.1 Signed-off-by: Albert Callarisa --------- Signed-off-by: Casper Nielsen Signed-off-by: Albert Callarisa Co-authored-by: Casper Nielsen --- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/metadata/README.md | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 2 +- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 2 +- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 15 files changed, 19 insertions(+), 19 deletions(-) diff --git a/dapr/version/version.py b/dapr/version/version.py index 8c6c12960..33fff4321 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1rc1' +__version__ = '1.16.1' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index 9496602ed..bfca817e8 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi>=1.16.1rc1 +dapr-ext-fastapi>=1.16.1 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index a70b02692..89f71c20a 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow>=1.16.1rc1 +dapr-ext-workflow>=1.16.1 diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index e77f5d6e2..0ac614717 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc >= 1.16.1rc1 -dapr >= 1.16.1rc1 +dapr-ext-grpc >= 1.16.1 +dapr >= 1.16.1 diff --git a/examples/metadata/README.md b/examples/metadata/README.md index eafe3723b..409df01b8 100644 --- a/examples/metadata/README.md +++ b/examples/metadata/README.md @@ -41,7 +41,7 @@ expected_stdout_lines: - "== APP == registered_components:" - "== APP == name=lockstore type=lock.redis version= capabilities=[]" - "== APP == name=pubsub type=pubsub.redis version=v1 capabilities=[]" - - "== APP == name=statestore type=state.redis version=v1 capabilities=['ACTOR', 'ETAG', 'TRANSACTIONAL'" + - "== APP == name=statestore type=state.redis version=v1 capabilities=['ACTOR', 'ETAG', 'KEYS_LIKE', 'TRANSACTIONAL', 'TTL'" - "== APP == We will update our custom label value and check it was persisted" - "== APP == We added a custom label named [is-this-our-metadata-example]" - "== APP == Its old value was [yes] but now it is [You bet it is!]" @@ -64,7 +64,7 @@ The output should be as follows: == APP == registered_components: == APP == name=lockstore type=lock.redis version= capabilities=[] == APP == name=pubsub type=pubsub.redis version=v1 capabilities=[] -== APP == name=statestore type=state.redis version=v1 capabilities=['ACTOR', 'ETAG', 'TRANSACTIONAL', 'TTL'] +== APP == name=statestore type=state.redis version=v1 capabilities=['ACTOR', 'ETAG', 'KEYS_LIKE', 'TRANSACTIONAL', 'TTL'] == APP == We will update our custom label value and check it was persisted == APP == We added a custom label named [is-this-our-metadata-example] == APP == Its old value was [yes] but now it is [You bet it is!] diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index 514e2606a..a349a8cd9 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc >= 1.16.1rc1 -dapr >= 1.16.1rc1 +dapr-ext-grpc >= 1.16.1 +dapr >= 1.16.1 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index fab86e728..2a34fde3d 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow>=1.16.1rc1 -dapr>=1.16.1rc1 +dapr-ext-workflow>=1.16.1 +dapr>=1.16.1 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index 8c6c12960..33fff4321 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1rc1' +__version__ = '1.16.1' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index 8b6080ebf..fad9af625 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.1rc1 + dapr >= 1.16.1 uvicorn >= 0.11.6 fastapi >= 0.60.1 diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index 8c6c12960..33fff4321 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1rc1' +__version__ = '1.16.1' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index d08757c78..70ed13b0c 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.1rc1 + dapr >= 1.16.1 cloudevents >= 1.0.0 [options.packages.find] diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index 8c6c12960..33fff4321 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1rc1' +__version__ = '1.16.1' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index 83869566c..840bb59fd 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.1rc1 + dapr >= 1.16.1 durabletask-dapr >= 0.2.0a9 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index 8c6c12960..33fff4321 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1rc1' +__version__ = '1.16.1' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index 531a9aea5..f459d9ddd 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr >= 1.16.1rc1 + dapr >= 1.16.1 From 3d6608fee90470c8449dd3b399ef492cb5962f7d Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Wed, 25 Mar 2026 14:41:06 +0100 Subject: [PATCH 12/15] Release 1.16.2 (#966) * fix cloudevents version Signed-off-by: Albert Callarisa * Fix: Run validations with a hardcoded cli and runtime versions Signed-off-by: Albert Callarisa * Fix tests Signed-off-by: Albert Callarisa --------- Signed-off-by: Albert Callarisa --- .github/workflows/validate_examples.yaml | 16 +++------------- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- .../demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/metadata/README.md | 6 +++--- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 2 +- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 4 ++-- .../dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 16 files changed, 24 insertions(+), 34 deletions(-) diff --git a/.github/workflows/validate_examples.yaml b/.github/workflows/validate_examples.yaml index 9617223a4..6c27bd2d5 100644 --- a/.github/workflows/validate_examples.yaml +++ b/.github/workflows/validate_examples.yaml @@ -37,7 +37,8 @@ jobs: GOOS: linux GOARCH: amd64 GOPROXY: https://proxy.golang.org - DAPR_INSTALL_URL: https://raw.githubusercontent.com/dapr/cli/master/install/install.sh + DAPR_RUNTIME_VER: 1.16.10 + DAPR_CLI_VER: 1.16.5 DAPR_CLI_REF: ${{ github.event.inputs.daprcli_commit }} DAPR_REF: ${{ github.event.inputs.daprdapr_commit }} CHECKOUT_REPO: ${{ github.repository }} @@ -62,17 +63,6 @@ jobs: with: repository: ${{ env.CHECKOUT_REPO }} ref: ${{ env.CHECKOUT_REF }} - - uses: azure/setup-helm@v4 - - name: Determine latest Dapr Runtime version - run: | - helm repo add dapr https://dapr.github.io/helm-charts/ && helm repo update && export RUNTIME_VERSION=$(helm search repo dapr/dapr --devel --versions | awk '/dapr\/dapr/ {print $3; exit}' ) - echo "DAPR_RUNTIME_VER=$RUNTIME_VERSION" >> $GITHUB_ENV - echo "Found $RUNTIME_VERSION" - - name: Determine latest Dapr Cli version - run: | - export CLI_VERSION=$(curl "https://api.github.com/repos/dapr/cli/releases?per_page=1&page=1" --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' | jq '.[0].tag_name'| tr -d '",v') - echo "DAPR_CLI_VER=$CLI_VERSION" >> $GITHUB_ENV - echo "Found $CLI_VERSION" - name: Set up Python ${{ matrix.python_ver }} uses: actions/setup-python@v5 with: @@ -82,7 +72,7 @@ jobs: python -m pip install --upgrade pip pip install setuptools wheel twine tox - name: Set up Dapr CLI - run: wget -q ${{ env.DAPR_INSTALL_URL }} -O - | /bin/bash -s ${{ env.DAPR_CLI_VER }} + run: wget -q https://raw.githubusercontent.com/dapr/cli/master/install/install.sh -O - | /bin/bash -s ${{ env.DAPR_CLI_VER }} - name: Set up Go ${{ env.GOVER }} if: env.DAPR_REF != '' || env.DAPR_CLI_REF != '' uses: actions/setup-go@v5 diff --git a/dapr/version/version.py b/dapr/version/version.py index 33fff4321..29962c965 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1' +__version__ = '1.16.2' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index bfca817e8..340f494cd 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi>=1.16.1 +dapr-ext-fastapi>=1.16.2 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index 89f71c20a..c4988d9b0 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow>=1.16.1 +dapr-ext-workflow>=1.16.2 diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index 0ac614717..a1f9ece04 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc >= 1.16.1 -dapr >= 1.16.1 +dapr-ext-grpc >= 1.16.2 +dapr >= 1.16.2 diff --git a/examples/metadata/README.md b/examples/metadata/README.md index 409df01b8..6cc08a283 100644 --- a/examples/metadata/README.md +++ b/examples/metadata/README.md @@ -7,7 +7,7 @@ two methods in that API: 2. **set_metadata**: Adds a custom label to the Dapr sidecar information stored by the Metadata endpoint. -It creates a client using `DaprClient`, uses a set of components defined in the +It creates a client using `DaprClient`, uses a set of components defined in the [`./components/`](./components/) folder and invokes the two APIs from [Metadata API]. @@ -41,7 +41,7 @@ expected_stdout_lines: - "== APP == registered_components:" - "== APP == name=lockstore type=lock.redis version= capabilities=[]" - "== APP == name=pubsub type=pubsub.redis version=v1 capabilities=[]" - - "== APP == name=statestore type=state.redis version=v1 capabilities=['ACTOR', 'ETAG', 'KEYS_LIKE', 'TRANSACTIONAL', 'TTL'" + - "== APP == name=statestore type=state.redis version=v1 capabilities=['ACTOR', 'ETAG', 'TRANSACTIONAL', 'TTL'" - "== APP == We will update our custom label value and check it was persisted" - "== APP == We added a custom label named [is-this-our-metadata-example]" - "== APP == Its old value was [yes] but now it is [You bet it is!]" @@ -64,7 +64,7 @@ The output should be as follows: == APP == registered_components: == APP == name=lockstore type=lock.redis version= capabilities=[] == APP == name=pubsub type=pubsub.redis version=v1 capabilities=[] -== APP == name=statestore type=state.redis version=v1 capabilities=['ACTOR', 'ETAG', 'KEYS_LIKE', 'TRANSACTIONAL', 'TTL'] +== APP == name=statestore type=state.redis version=v1 capabilities=['ACTOR', 'ETAG', 'TRANSACTIONAL', 'TTL'] == APP == We will update our custom label value and check it was persisted == APP == We added a custom label named [is-this-our-metadata-example] == APP == Its old value was [yes] but now it is [You bet it is!] diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index a349a8cd9..29e5d6e50 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc >= 1.16.1 -dapr >= 1.16.1 +dapr-ext-grpc >= 1.16.2 +dapr >= 1.16.2 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index 2a34fde3d..4cb5f389f 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow>=1.16.1 -dapr>=1.16.1 +dapr-ext-workflow>=1.16.2 +dapr>=1.16.2 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index 33fff4321..29962c965 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1' +__version__ = '1.16.2' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index fad9af625..f06f39523 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.1 + dapr >= 1.16.2 uvicorn >= 0.11.6 fastapi >= 0.60.1 diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index 33fff4321..29962c965 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1' +__version__ = '1.16.2' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index 70ed13b0c..ec96042d8 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,8 +24,8 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.1 - cloudevents >= 1.0.0 + dapr >= 1.16.2 + cloudevents >= 1.0.0, < 2.0.0 [options.packages.find] include = diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index 33fff4321..29962c965 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1' +__version__ = '1.16.2' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index 840bb59fd..7fc20aeb7 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.1 + dapr >= 1.16.2 durabletask-dapr >= 0.2.0a9 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index 33fff4321..29962c965 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.1' +__version__ = '1.16.2' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index f459d9ddd..a0f0b201c 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr >= 1.16.1 + dapr >= 1.16.2 From ebff4551bb8421aac165d1d5546d4e4d85f0921c Mon Sep 17 00:00:00 2001 From: seherv <627709+seherv@users.noreply.github.com> Date: Tue, 19 May 2026 20:33:00 +0200 Subject: [PATCH 13/15] Fix builds for backport of drain_ongoing_call_timeout=None (#1037) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- dapr/clients/grpc/_helpers.py | 2 +- dev-requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dapr/clients/grpc/_helpers.py b/dapr/clients/grpc/_helpers.py index c68b0f56a..c178e207d 100644 --- a/dapr/clients/grpc/_helpers.py +++ b/dapr/clients/grpc/_helpers.py @@ -63,7 +63,7 @@ def unpack(data: GrpcAny, message: GrpcMessage) -> None: """ if not isinstance(message, GrpcMessage): raise ValueError('output message is not protocol buffer message object') - if not data.Is(message.DESCRIPTOR): + if not data.Is(message.DESCRIPTOR): # type: ignore[arg-type] raise ValueError(f'invalid type. serialized message type: {data.type_url}') data.Unpack(message) diff --git a/dev-requirements.txt b/dev-requirements.txt index 461d92391..129cbdb25 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -9,7 +9,7 @@ wheel opentelemetry-sdk opentelemetry-instrumentation-grpc httpx>=0.24 -pyOpenSSL>=23.2.0 +pyOpenSSL>=23.2.0,<26.2 # needed for type checking Flask>=1.1 # needed for auto fix From 0df5da09350343f4638237ede8ef86bd0cb53f98 Mon Sep 17 00:00:00 2001 From: Dapr Bot <56698301+dapr-bot@users.noreply.github.com> Date: Tue, 19 May 2026 13:03:26 -0700 Subject: [PATCH 14/15] default drain_ongoing_call_timeout to None (#1016) (#1018) * default drain_ongoing_call_timeout to None ActorRuntimeConfig hardcoded drain_ongoing_call_timeout to 60s. The Dapr runtime's placement dissemination timeout defaults to 30s and drain blocks the placement LOCK -> UPDATE -> UNLOCK round, so a 60s drain stalls the disseminator and resets the placement stream. Match the other SDKs (.NET, Go, Java, JS) by leaving the field unset, so daprd applies its 2s default (api.DefaultOngoingCallTimeout). Omit the field from as_dict() when None so a JSON null is not sent to the runtime. * Update comment --------- (cherry picked from commit 77e3bcf180f0e4e012b74b186bdccc70c54d8232) Signed-off-by: joshvanl Signed-off-by: dapr-bot Co-authored-by: Josh van Leeuwen Co-authored-by: Sam --- dapr/actor/runtime/config.py | 16 +++++++++++----- tests/actor/test_actor.py | 2 +- tests/actor/test_actor_runtime.py | 2 +- tests/actor/test_actor_runtime_config.py | 19 +++++++++++++------ 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/dapr/actor/runtime/config.py b/dapr/actor/runtime/config.py index 8d42ebda4..4e64e5fdb 100644 --- a/dapr/actor/runtime/config.py +++ b/dapr/actor/runtime/config.py @@ -117,7 +117,7 @@ def __init__( self, actor_idle_timeout: Optional[timedelta] = timedelta(hours=1), actor_scan_interval: Optional[timedelta] = timedelta(seconds=30), - drain_ongoing_call_timeout: Optional[timedelta] = timedelta(minutes=1), + drain_ongoing_call_timeout: Optional[timedelta] = None, drain_rebalanced_actors: Optional[bool] = True, reentrancy: Optional[ActorReentrancyConfig] = None, reminders_storage_partitions: Optional[int] = None, @@ -130,9 +130,13 @@ def __init__( actor_scan_interval (datetime.timedelta): The duration which specifies how often to scan for actors to deactivate idle actors. Actors that have been idle longer than actor_idle_timeout will be deactivated. - drain_ongoing_call_timeout (datetime.timedelta): The duration which specifies the - timeout for the current active actor method to finish before actor deactivation. - If there is no current actor method call, this is ignored. + drain_ongoing_call_timeout (Optional[datetime.timedelta]): The duration which + specifies the timeout for the current active actor method to finish before + actor deactivation. If there is no current actor method call, this is + ignored. Defaults to None, which omits the field from the configuration + sent to daprd so the runtime applies its own default. An explicit value + must be shorter than the daprd placement dissemination timeout, otherwise + daprd will clamp it. drain_rebalanced_actors (bool): If true, Dapr will wait for drain_ongoing_call_timeout to allow a current actor call to complete before trying to deactivate an actor. reentrancy (ActorReentrancyConfig): Configure the reentrancy behavior for an actor. @@ -175,10 +179,12 @@ def as_dict(self) -> Dict[str, Any]: configDict: Dict[str, Any] = { 'actorIdleTimeout': self._actor_idle_timeout, 'actorScanInterval': self._actor_scan_interval, - 'drainOngoingCallTimeout': self._drain_ongoing_call_timeout, 'drainRebalancedActors': self._drain_rebalanced_actors, } + if self._drain_ongoing_call_timeout is not None: + configDict['drainOngoingCallTimeout'] = self._drain_ongoing_call_timeout + if self._reentrancy: configDict.update({'reentrancy': self._reentrancy.as_dict()}) diff --git a/tests/actor/test_actor.py b/tests/actor/test_actor.py index d9b602c9d..a12dc5ba2 100644 --- a/tests/actor/test_actor.py +++ b/tests/actor/test_actor.py @@ -67,7 +67,7 @@ def test_actor_config(self): self.assertTrue(config._drain_rebalanced_actors) self.assertEqual(timedelta(hours=1), config._actor_idle_timeout) self.assertEqual(timedelta(seconds=30), config._actor_scan_interval) - self.assertEqual(timedelta(minutes=1), config._drain_ongoing_call_timeout) + self.assertIsNone(config._drain_ongoing_call_timeout) self.assertEqual(2, len(config._entities)) # apply new config diff --git a/tests/actor/test_actor_runtime.py b/tests/actor/test_actor_runtime.py index f17f96cc8..9076e0056 100644 --- a/tests/actor/test_actor_runtime.py +++ b/tests/actor/test_actor_runtime.py @@ -63,7 +63,7 @@ def test_actor_config(self): self.assertTrue(config._drain_rebalanced_actors) self.assertEqual(timedelta(hours=1), config._actor_idle_timeout) self.assertEqual(timedelta(seconds=30), config._actor_scan_interval) - self.assertEqual(timedelta(minutes=1), config._drain_ongoing_call_timeout) + self.assertIsNone(config._drain_ongoing_call_timeout) self.assertEqual(3, len(config._entities)) # apply new config diff --git a/tests/actor/test_actor_runtime_config.py b/tests/actor/test_actor_runtime_config.py index 7bbd8cefc..18fe86c42 100644 --- a/tests/actor/test_actor_runtime_config.py +++ b/tests/actor/test_actor_runtime_config.py @@ -69,13 +69,14 @@ def test_default_config(self): self.assertEqual(config._actor_idle_timeout, timedelta(seconds=3600)) self.assertEqual(config._actor_scan_interval, timedelta(seconds=30)) - self.assertEqual(config._drain_ongoing_call_timeout, timedelta(seconds=60)) + self.assertIsNone(config._drain_ongoing_call_timeout) self.assertEqual(config._drain_rebalanced_actors, True) self.assertEqual(config._reentrancy, None) self.assertEqual(config._entities, set()) self.assertEqual(config._entitiesConfig, []) self.assertNotIn('reentrancy', config.as_dict().keys()) self.assertNotIn('remindersStoragePartitions', config.as_dict().keys()) + self.assertNotIn('drainOngoingCallTimeout', config.as_dict().keys()) self.assertEqual(config.as_dict()['entitiesConfig'], []) def test_default_config_with_reentrancy(self): @@ -84,7 +85,7 @@ def test_default_config_with_reentrancy(self): self.assertEqual(config._actor_idle_timeout, timedelta(seconds=3600)) self.assertEqual(config._actor_scan_interval, timedelta(seconds=30)) - self.assertEqual(config._drain_ongoing_call_timeout, timedelta(seconds=60)) + self.assertIsNone(config._drain_ongoing_call_timeout) self.assertEqual(config._drain_rebalanced_actors, True) self.assertEqual(config._reentrancy, reentrancyConfig) self.assertEqual(config._entities, set()) @@ -110,7 +111,8 @@ def test_config_with_actor_type_config(self): self.assertEqual(config._actor_scan_interval, timedelta(seconds=30)) d = config.as_dict() - self.assertEqual(config._drain_ongoing_call_timeout, timedelta(seconds=60)) + self.assertIsNone(config._drain_ongoing_call_timeout) + self.assertNotIn('drainOngoingCallTimeout', d) self.assertEqual(d['entitiesConfig'][0]['entities'], ['testactor1']) self.assertEqual(d['entitiesConfig'][0]['actorScanInterval'], timedelta(seconds=10)) self.assertEqual(d['entitiesConfig'][0]['reentrancy']['enabled'], True) @@ -130,7 +132,7 @@ def test_update_entities(self): self.assertEqual(config._actor_idle_timeout, timedelta(seconds=3600)) self.assertEqual(config._actor_scan_interval, timedelta(seconds=30)) - self.assertEqual(config._drain_ongoing_call_timeout, timedelta(seconds=60)) + self.assertIsNone(config._drain_ongoing_call_timeout) self.assertEqual(config._drain_rebalanced_actors, True) self.assertEqual(config._entities, {'actortype1'}) self.assertEqual(config._entitiesConfig, []) @@ -141,7 +143,7 @@ def test_update_entities_two_types(self): config.update_entities(['actortype1', 'actortype1']) self.assertEqual(config._actor_idle_timeout, timedelta(seconds=3600)) self.assertEqual(config._actor_scan_interval, timedelta(seconds=30)) - self.assertEqual(config._drain_ongoing_call_timeout, timedelta(seconds=60)) + self.assertIsNone(config._drain_ongoing_call_timeout) self.assertEqual(config._drain_rebalanced_actors, True) self.assertEqual(config._entities, {'actortype1', 'actortype1'}) self.assertEqual(config._entitiesConfig, []) @@ -164,12 +166,17 @@ def test_set_reminders_storage_partitions(self): config = ActorRuntimeConfig(reminders_storage_partitions=12) self.assertEqual(config._actor_idle_timeout, timedelta(seconds=3600)) self.assertEqual(config._actor_scan_interval, timedelta(seconds=30)) - self.assertEqual(config._drain_ongoing_call_timeout, timedelta(seconds=60)) + self.assertIsNone(config._drain_ongoing_call_timeout) self.assertEqual(config._drain_rebalanced_actors, True) self.assertNotIn('reentrancy', config.as_dict().keys()) self.assertEqual(config._reminders_storage_partitions, 12) self.assertEqual(config.as_dict()['remindersStoragePartitions'], 12) + def test_explicit_drain_ongoing_call_timeout(self): + config = ActorRuntimeConfig(drain_ongoing_call_timeout=timedelta(seconds=10)) + self.assertEqual(config._drain_ongoing_call_timeout, timedelta(seconds=10)) + self.assertEqual(config.as_dict()['drainOngoingCallTimeout'], timedelta(seconds=10)) + if __name__ == '__main__': unittest.main() From 6c5414c63a1e6eb63995b163950db6439d3e48f2 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 2 Jun 2026 03:45:46 -0500 Subject: [PATCH 15/15] chore: bump for patch 1.16 release (#1073) Signed-off-by: Samantha Coyle --- .github/workflows/validate_examples.yaml | 2 +- dapr/version/version.py | 2 +- examples/demo_actor/demo_actor/requirements.txt | 2 +- examples/demo_workflow/demo_workflow/requirements.txt | 2 +- examples/invoke-simple/requirements.txt | 4 ++-- examples/w3c-tracing/requirements.txt | 4 ++-- examples/workflow/requirements.txt | 4 ++-- ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py | 2 +- ext/dapr-ext-fastapi/setup.cfg | 2 +- ext/dapr-ext-grpc/dapr/ext/grpc/version.py | 2 +- ext/dapr-ext-grpc/setup.cfg | 2 +- ext/dapr-ext-workflow/dapr/ext/workflow/version.py | 2 +- ext/dapr-ext-workflow/setup.cfg | 2 +- ext/flask_dapr/flask_dapr/version.py | 2 +- ext/flask_dapr/setup.cfg | 2 +- 15 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/validate_examples.yaml b/.github/workflows/validate_examples.yaml index 6c27bd2d5..8fb93477d 100644 --- a/.github/workflows/validate_examples.yaml +++ b/.github/workflows/validate_examples.yaml @@ -37,7 +37,7 @@ jobs: GOOS: linux GOARCH: amd64 GOPROXY: https://proxy.golang.org - DAPR_RUNTIME_VER: 1.16.10 + DAPR_RUNTIME_VER: 1.16.14 DAPR_CLI_VER: 1.16.5 DAPR_CLI_REF: ${{ github.event.inputs.daprcli_commit }} DAPR_REF: ${{ github.event.inputs.daprdapr_commit }} diff --git a/dapr/version/version.py b/dapr/version/version.py index 29962c965..f34f6f964 100644 --- a/dapr/version/version.py +++ b/dapr/version/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.2' +__version__ = '1.16.3' diff --git a/examples/demo_actor/demo_actor/requirements.txt b/examples/demo_actor/demo_actor/requirements.txt index 340f494cd..46a1065b2 100644 --- a/examples/demo_actor/demo_actor/requirements.txt +++ b/examples/demo_actor/demo_actor/requirements.txt @@ -1 +1 @@ -dapr-ext-fastapi>=1.16.2 +dapr-ext-fastapi>=1.16.3 diff --git a/examples/demo_workflow/demo_workflow/requirements.txt b/examples/demo_workflow/demo_workflow/requirements.txt index c4988d9b0..ac3b01543 100644 --- a/examples/demo_workflow/demo_workflow/requirements.txt +++ b/examples/demo_workflow/demo_workflow/requirements.txt @@ -1 +1 @@ -dapr-ext-workflow>=1.16.2 +dapr-ext-workflow>=1.16.3 diff --git a/examples/invoke-simple/requirements.txt b/examples/invoke-simple/requirements.txt index a1f9ece04..8060a375c 100644 --- a/examples/invoke-simple/requirements.txt +++ b/examples/invoke-simple/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-grpc >= 1.16.2 -dapr >= 1.16.2 +dapr-ext-grpc >= 1.16.3 +dapr >= 1.16.3 diff --git a/examples/w3c-tracing/requirements.txt b/examples/w3c-tracing/requirements.txt index 29e5d6e50..76aeceed5 100644 --- a/examples/w3c-tracing/requirements.txt +++ b/examples/w3c-tracing/requirements.txt @@ -1,5 +1,5 @@ -dapr-ext-grpc >= 1.16.2 -dapr >= 1.16.2 +dapr-ext-grpc >= 1.16.3 +dapr >= 1.16.3 opentelemetry-sdk opentelemetry-instrumentation-grpc opentelemetry-exporter-zipkin diff --git a/examples/workflow/requirements.txt b/examples/workflow/requirements.txt index 4cb5f389f..ad48dc926 100644 --- a/examples/workflow/requirements.txt +++ b/examples/workflow/requirements.txt @@ -1,2 +1,2 @@ -dapr-ext-workflow>=1.16.2 -dapr>=1.16.2 +dapr-ext-workflow>=1.16.3 +dapr>=1.16.3 diff --git a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py index 29962c965..f34f6f964 100644 --- a/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py +++ b/ext/dapr-ext-fastapi/dapr/ext/fastapi/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.2' +__version__ = '1.16.3' diff --git a/ext/dapr-ext-fastapi/setup.cfg b/ext/dapr-ext-fastapi/setup.cfg index f06f39523..1e50af320 100644 --- a/ext/dapr-ext-fastapi/setup.cfg +++ b/ext/dapr-ext-fastapi/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.2 + dapr >= 1.16.3 uvicorn >= 0.11.6 fastapi >= 0.60.1 diff --git a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py index 29962c965..f34f6f964 100644 --- a/ext/dapr-ext-grpc/dapr/ext/grpc/version.py +++ b/ext/dapr-ext-grpc/dapr/ext/grpc/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.2' +__version__ = '1.16.3' diff --git a/ext/dapr-ext-grpc/setup.cfg b/ext/dapr-ext-grpc/setup.cfg index ec96042d8..0abd6daa2 100644 --- a/ext/dapr-ext-grpc/setup.cfg +++ b/ext/dapr-ext-grpc/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.2 + dapr >= 1.16.3 cloudevents >= 1.0.0, < 2.0.0 [options.packages.find] diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py index 29962c965..f34f6f964 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/version.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.2' +__version__ = '1.16.3' diff --git a/ext/dapr-ext-workflow/setup.cfg b/ext/dapr-ext-workflow/setup.cfg index 7fc20aeb7..2b92a62df 100644 --- a/ext/dapr-ext-workflow/setup.cfg +++ b/ext/dapr-ext-workflow/setup.cfg @@ -24,7 +24,7 @@ python_requires = >=3.9 packages = find_namespace: include_package_data = True install_requires = - dapr >= 1.16.2 + dapr >= 1.16.3 durabletask-dapr >= 0.2.0a9 [options.packages.find] diff --git a/ext/flask_dapr/flask_dapr/version.py b/ext/flask_dapr/flask_dapr/version.py index 29962c965..f34f6f964 100644 --- a/ext/flask_dapr/flask_dapr/version.py +++ b/ext/flask_dapr/flask_dapr/version.py @@ -13,4 +13,4 @@ limitations under the License. """ -__version__ = '1.16.2' +__version__ = '1.16.3' diff --git a/ext/flask_dapr/setup.cfg b/ext/flask_dapr/setup.cfg index a0f0b201c..23a1ff553 100644 --- a/ext/flask_dapr/setup.cfg +++ b/ext/flask_dapr/setup.cfg @@ -26,4 +26,4 @@ include_package_data = true zip_safe = false install_requires = Flask >= 1.1 - dapr >= 1.16.2 + dapr >= 1.16.3