# # Copyright 2026 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 logging import os import time from pathlib import Path from typing import ( Any, ClassVar, Dict, Iterable, List, Literal, Optional, Type, Union, ) from pydantic import BaseModel, Field from dapr_agents.llm.chat import ChatClientBase from dapr_agents.llm.dapr.client import DaprInferenceClientBase from dapr_agents.llm.utils import RequestHandler, ResponseHandler from dapr_agents.prompt.base import PromptTemplateBase from dapr_agents.prompt.prompty import Prompty from dapr_agents.tool import AgentTool from dapr_agents.types.exceptions import DaprRuntimeVersionNotSupportedError from dapr_agents.types.message import ( BaseMessage, LLMChatResponse, ) from dapr_agents.utils import is_version_supported # Lazy import to avoid import issues during test collection def _import_conversation_types(): from dapr.clients.grpc.conversation import ( ConversationInputAlpha2, ConversationMessage, ConversationMessageOfAssistant, ConversationMessageContent, ConversationToolCalls, ConversationToolCallsOfFunction, create_user_message, create_system_message, create_assistant_message, create_tool_message, ) return ( ConversationInputAlpha2, ConversationMessage, ConversationMessageOfAssistant, ConversationMessageContent, ConversationToolCalls, ConversationToolCallsOfFunction, create_user_message, create_system_message, create_assistant_message, create_tool_message, ) logger = logging.getLogger(__name__) class DaprChatClient(DaprInferenceClientBase, ChatClientBase): """ Chat client for Dapr's Inference API. Integrates Prompty-driven prompt templates, tool injection, PII scrubbing, and normalizes the Dapr output into our unified LLMChatResponse schema. **Streaming is not supported.** """ prompty: Optional[Prompty] = Field( default=None, description="Optional Prompty instance for templating." ) prompt_template: Optional[PromptTemplateBase] = Field( default=None, description="Optional prompt-template to format inputs." ) component_name: Optional[str] = None # Support both function_call and json structured output modes SUPPORTED_STRUCTURED_MODES: ClassVar[set[str]] = {"function_call", "json"} def model_post_init(self, __context: Any) -> None: """ After Pydantic init, set up API/type and default LLM component from env. """ self._api = "chat" self._llm_component = self.component_name if not self._llm_component: self._llm_component = os.environ.get("DAPR_LLM_COMPONENT_DEFAULT") if not self._llm_component: logger.debug( "No LLM component provided and no default component found in the environment. Will try to get it from the metadata at runtime." ) super().model_post_init(__context) @classmethod def from_prompty( cls, prompty_source: Union[str, Path], timeout: Union[int, float, Dict[str, Any]] = 1500, ) -> "DaprChatClient": """ Build a DaprChatClient from a Prompty spec. Args: prompty_source: Path or inline Prompty YAML/JSON. timeout: Request timeout in seconds or HTTPX-style dict. Returns: Configured DaprChatClient. """ prompty_instance = Prompty.load(prompty_source) prompt_template = Prompty.to_prompt_template(prompty_instance) return cls.model_validate( { "timeout": timeout, "prompty": prompty_instance, "prompt_template": prompt_template, } ) def translate_response(self, response: dict, model: str) -> dict: """ Convert Dapr Alpha2 response into OpenAI-style ChatCompletion dict. """ if not isinstance(response, dict): logger.error(f"Invalid response type: {type(response)}") raise ValueError(f"Response must be a dictionary, got {type(response)}") # Flatten all output choices from Alpha2 envelope choices: List[Dict[str, Any]] = [] outputs = response.get("outputs", []) or [] if not isinstance(outputs, list): logger.error(f"Invalid outputs type: {type(outputs)}") raise ValueError(f"Outputs must be a list, got {type(outputs)}") for output in outputs: if not isinstance(output, dict): logger.error(f"Invalid output type: {type(output)}") continue output_choices = output.get("choices", []) or [] if not isinstance(output_choices, list): logger.error(f"Invalid choices type: {type(output_choices)}") continue for choice in output_choices: if not isinstance(choice, dict): logger.error(f"Invalid choice type: {type(choice)}") continue # Ensure message is present and has required fields message = choice.get("message", {}) if not isinstance(message, dict): logger.error(f"Invalid message type: {type(message)}") continue # Add required fields if missing if "content" not in message: message["content"] = "" if "role" not in message: message["role"] = "assistant" choice["message"] = message choices.append(choice) return { "choices": choices, "created": int(time.time()), "model": model, "object": "chat.completion", "usage": {"total_tokens": "-1"}, } def convert_to_conversation_inputs(self, inputs: List[Dict[str, Any]]) -> List[Any]: """ Map normalized messages into a single Alpha2 ConversationInput that preserves history. Alpha2 expects a list of ConversationMessage entries inside one ConversationInputAlpha2 for a turn. If there are tool results, they must reference prior assistant tool_calls by id. """ # Lazy import conversation types ( ConversationInputAlpha2, ConversationMessage, ConversationMessageOfAssistant, ConversationMessageContent, ConversationToolCalls, ConversationToolCallsOfFunction, create_user_message, create_system_message, create_assistant_message, create_tool_message, ) = _import_conversation_types() history_messages: List[ConversationMessage] = [] scrub_flags: List[bool] = [] for item in inputs: role = item.get("role") content = item.get("content", "") if role == "user": msg = create_user_message(content) elif role == "system": msg = create_system_message(content) elif role == "assistant": # Preserve assistant tool_calls if present (OpenAI-like schema) tool_calls_data = item.get("tool_calls") or [] if tool_calls_data: converted_calls: List[ConversationToolCalls] = [] for tc in tool_calls_data: fn = tc.get("function", {}) if isinstance(tc, dict) else {} name = fn.get("name", "") arguments = fn.get("arguments", "") # Ensure arguments is a string if not isinstance(arguments, str): try: import json as _json arguments = _json.dumps(arguments) except Exception: arguments = str(arguments) converted_calls.append( ConversationToolCalls( id=tc.get("id", None), function=ConversationToolCallsOfFunction( name=name, arguments=arguments ), ) ) msg = ConversationMessage( of_assistant=ConversationMessageOfAssistant( content=[ConversationMessageContent(text=content)], tool_calls=converted_calls, ) ) else: msg = create_assistant_message(content) elif role == "tool": tool_id = item.get("tool_call_id") or item.get("id") or "" name = item.get("name", "") msg = create_tool_message(tool_id, name, content) else: raise ValueError(f"Unsupported role for Alpha2 conversion: {role}") history_messages.append(msg) scrub_flags.append(bool(item.get("scrubPII"))) # Use scrub_pii if any message requested it scrub_any = any(scrub_flags) if scrub_flags else None return [ConversationInputAlpha2(messages=history_messages, scrub_pii=scrub_any)] def generate( self, messages: Union[ str, Dict[str, Any], BaseMessage, Iterable[Union[Dict[str, Any], BaseMessage]], ] = None, *, input_data: Optional[Dict[str, Any]] = None, llm_component: Optional[str] = None, tools: Optional[List[Union[AgentTool, Dict[str, Any]]]] = None, response_format: Optional[Type[BaseModel]] = None, structured_mode: Literal["function_call", "json"] = "json", scrubPII: bool = False, temperature: Optional[float] = None, **kwargs: Any, ) -> Union[ LLMChatResponse, BaseModel, List[BaseModel], ]: """ Issue a non-streaming chat completion via Dapr. - **Streaming is not supported** and setting `stream=True` will raise. - Returns a unified `LLMChatResponse` (if no `response_format`), or validated Pydantic model(s) when `response_format` is provided. Args: messages: Prebuilt messages or None to use `input_data`. input_data: Variables for Prompty template rendering. llm_component: Dapr component name (defaults from env). tools: AgentTool or dict specifications. response_format: Pydantic model for structured output. structured_mode: "json" (default) or "function_call". scrubPII: Obfuscate sensitive output if True. temperature: Sampling temperature. **kwargs: Other Dapr API parameters. Returns: • `LLMChatResponse` if no `response_format` • Pydantic model (or `List[...]`) when `response_format` is set Raises: ValueError: on invalid `structured_mode`, missing inputs, or if `stream=True`. """ # 1) Validate structured_mode if structured_mode not in self.SUPPORTED_STRUCTURED_MODES: raise ValueError( f"structured_mode must be one of {self.SUPPORTED_STRUCTURED_MODES}" ) # 2) Disallow streaming # Note: response_format is now supported for structured output if kwargs.get("stream"): raise ValueError("Streaming is not supported by DaprChatClient.") # 3) Build messages via Prompty if input_data: if not self.prompt_template: raise ValueError("input_data provided but no prompt_template is set.") messages = self.prompt_template.format_prompt(**input_data) if not messages: raise ValueError("Either 'messages' or 'input_data' must be provided.") # 4) Normalize + merge defaults params: Dict[str, Any] = { "inputs": RequestHandler.normalize_chat_messages(messages) } if self.prompty: params = {**self.prompty.model.parameters.model_dump(), **params, **kwargs} else: params.update(kwargs) # 5) Inject tools + structured directives params = RequestHandler.process_params( params, llm_provider=self.provider, tools=tools, response_format=response_format, structured_mode=structured_mode, ) logger.debug(f"Processed parameters for Dapr: {params}") if response_format: logger.debug(f"Response format: {response_format}") logger.debug(f"Structured mode: {structured_mode}") # 6) Convert to Dapr inputs & call conv_inputs = self.convert_to_conversation_inputs(params["inputs"]) try: logger.debug("Invoking the Dapr Conversation API.") # Log tools/tool_choice/parameters for debugging if params.get("tools"): try: logger.debug( f"Alpha2 tools payload: {[t.get('function', {}).get('name', '') for t in params['tools'] if isinstance(t, dict)]}" ) except Exception: logger.warning( "Alpha2 tools payload present (could not render names)." ) if params.get("tool_choice") is not None: logger.debug(f"Alpha2 tool_choice: {params.get('tool_choice')}") if params.get("parameters") is not None: logger.debug( f"Alpha2 parameters keys: {list(params.get('parameters', {}).keys())}" ) # get metadata information from the dapr client metadata = self.client.get_metadata() _check_dapr_runtime_support(metadata) llm_component = llm_component or self._llm_component if not llm_component: llm_component = _get_llm_component(metadata) # Extract additional API parameters (response_format is sent via the # dedicated response_format field, not duplicated here) api_params = {} if "structured_mode" in params: api_params["structured_mode"] = str(params["structured_mode"]) # Convert tool_choice from dict format to string of 'none', 'auto', or 'required' tool_choice_param = params.get("tool_choice") if isinstance(tool_choice_param, dict): # When a specific function is requested (dict format), use 'required' # to force tool calling, since Dapr doesn't support function-specific selection tool_choice_param = "required" elif isinstance(tool_choice_param, str) and tool_choice_param not in ( "none", "auto", "required", ): # If it's a string but not a valid Dapr value (e.g., a function name), # default to 'required' to force tool calling logger.warning( f"tool_choice value '{tool_choice_param}' is not supported by Dapr. " "Using 'required' instead." ) tool_choice_param = "required" raw = self.client.chat_completion_alpha2( llm=llm_component or self._llm_component, inputs=conv_inputs, scrub_pii=scrubPII, temperature=temperature, tools=params.get("tools"), tool_choice=tool_choice_param, parameters=api_params or None, response_format=_to_dapr_response_format(params.get("response_format")), ) normalized = self.translate_response( raw, llm_component or self._llm_component ) logger.debug(f"Dapr Conversation API response: {raw}") logger.debug(f"Normalized response: {normalized}") except Exception as e: logger.warning(f"Dapr Conversation API call failed: {e}") raise # 7) Hand off to our unified handler (always non‐stream) return ResponseHandler.process_response( response=normalized, llm_provider=self.provider, response_format=response_format, structured_mode=structured_mode, stream=False, ) def _simplify_anyof(schema: Dict[str, Any]) -> Dict[str, Any]: """ Recursively collapse ``anyOf`` unions into a single ``type`` field. The Dapr runtime's ``convertToStructuredOutputSchema`` (Go) requires every sub-schema to have a ``"type"`` string. Pydantic emits ``anyOf: [{"type": "X"}, {"type": "null"}]`` for ``Optional[X]`` fields, which has *no* top-level ``"type"`` and causes a conversion error that crashes the sidecar (nil-logger panic on the error path). This helper rewrites such patterns to ``{"type": "X"}`` (dropping the null variant) so the Go code can process the schema safely. """ result = dict(schema) # Collapse anyOf: [{"type": T}, {"type": "null"}] → {"type": T} if "anyOf" in result and "type" not in result: variants = result.get("anyOf", []) non_null = [ v for v in variants if isinstance(v, dict) and v.get("type") != "null" ] if len(non_null) == 1: collapsed = dict(non_null[0]) # Preserve sibling keys (description, title, default, etc.) for k, v in result.items(): if k != "anyOf" and k not in collapsed: collapsed[k] = v result = collapsed # Recurse into object properties if result.get("type") == "object" and "properties" in result: result["properties"] = { k: _simplify_anyof(v) if isinstance(v, dict) else v for k, v in result["properties"].items() } # Recurse into array items if result.get("type") == "array" and isinstance(result.get("items"), dict): result["items"] = _simplify_anyof(result["items"]) return result def _to_dapr_response_format( oai_format: Optional[Dict[str, Any]], ) -> Optional[Dict[str, Any]]: """ Convert an OpenAI-style response_format dict to the flat JSON schema format expected by the Dapr runtime's convertToStructuredOutputDefinition. OAI format: {"type": "json_schema", "json_schema": {"name": ..., "description": ..., "strict": ..., "schema": {}}} Dapr format: {, "name": ..., "description": ..., "strict": ...} """ if oai_format is None: return None if oai_format.get("type") == "json_schema": inner = oai_format.get("json_schema", {}) schema = inner.get("schema", {}) simplified = _simplify_anyof(schema) return { **simplified, "name": inner.get("name", "response"), "description": inner.get("description", ""), "strict": inner.get("strict", False), } return oai_format def _check_dapr_runtime_support(metadata: "GetMetadataResponse"): # noqa: F821 """Check if the Dapr runtime version is supported for Alpha2 Chat Client.""" extended_metadata = metadata.extended_metadata dapr_runtime_version = extended_metadata.get("daprRuntimeVersion", None) if dapr_runtime_version is not None: # Allow only versions >=1.16.0, edge, and <2.0.0 for Alpha2 Chat Client if not is_version_supported(str(dapr_runtime_version), ">=1.16.0, <2.0.0"): raise DaprRuntimeVersionNotSupportedError( f"!!!!! Dapr Runtime Version {dapr_runtime_version} is not supported with Alpha2 Dapr Chat Client. Only Dapr runtime versions >=1.16.0 and <2.0.0 are supported." ) def _get_llm_component(metadata: "GetMetadataResponse") -> str: # noqa: F821 """Get the LLM component from the metadata.""" conversation_components = [ component for component in metadata.registered_components if component.type.startswith("conversation.") ] if len(conversation_components) == 1: return conversation_components[0].name elif len(conversation_components) > 1: raise ValueError( "Multiple LLM components found in the metadata. Please provide the component name explicitly (e.g. llm = DaprChatClient(component_name='openai')) or environment variable DAPR_LLM_COMPONENT_DEFAULT." ) else: raise ValueError( "No LLM component provided and no default component found in the metadata." )