# # 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 from pathlib import Path from typing import ( Any, ClassVar, Dict, Iterable, Iterator, List, Literal, Optional, Type, Union, ) from pydantic import BaseModel, Field, model_validator from dapr_agents.llm.chat import ChatClientBase from dapr_agents.llm.openai.client.base import OpenAIClientBase 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.llm import AzureOpenAIModelConfig, OpenAIModelConfig from dapr_agents.types.message import ( BaseMessage, LLMChatCandidateChunk, LLMChatResponse, ) import os logger = logging.getLogger(__name__) class OpenAIChatClient(OpenAIClientBase, ChatClientBase): """ Chat client for OpenAI models, layering in Prompty-driven prompt templates and unified request/response handling. Inherits: - OpenAIClientBase: manages API key, base_url, retries, etc. - ChatClientBase: provides chat-specific abstractions. """ model: Optional[str] = Field( default=None, description="Model name or Azure deployment ID." ) 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." ) SUPPORTED_STRUCTURED_MODES: ClassVar[set[str]] = {"json", "function_call"} @model_validator(mode="before") def validate_and_initialize(cls, values: Dict[str, Any]) -> Dict[str, Any]: """ Ensure `.model` is always set. If unset, fall back to `azure_deployment` or default to `"gpt-4o"`. """ env_model = os.environ.get("OPENAI_MODEL") if env_model: values["model"] = env_model elif not values.get("model"): values["model"] = values.get("azure_deployment", "gpt-4o") return values def model_post_init(self, __context: Any) -> None: """After Pydantic init, ensure we're in the “chat” API mode.""" self._api = "chat" super().model_post_init(__context) @classmethod def from_prompty( cls, prompty_source: Union[str, Path], timeout: Union[int, float, Dict[str, Any]] = 1500, ) -> "OpenAIChatClient": """ Load a Prompty file (or inline YAML/JSON string), extract its model configuration and prompt template, and return a fully-wired client. Args: prompty_source: path or inline text for a Prompty spec. timeout: seconds or HTTPX-style timeout, defaults to 1500. Returns: Configured OpenAIChatClient. """ prompty_instance = Prompty.load(prompty_source) prompt_template = Prompty.to_prompt_template(prompty_instance) cfg = prompty_instance.model.configuration common = { "timeout": timeout, "prompty": prompty_instance, "prompt_template": prompt_template, } if isinstance(cfg, OpenAIModelConfig): return cls.model_validate( { **common, "model": cfg.name, "api_key": cfg.api_key, "base_url": cfg.base_url, "organization": cfg.organization, "project": cfg.project, } ) elif isinstance(cfg, AzureOpenAIModelConfig): return cls.model_validate( { **common, "model": cfg.azure_deployment, "api_key": cfg.api_key, "azure_endpoint": cfg.azure_endpoint, "azure_deployment": cfg.azure_deployment, "api_version": cfg.api_version, "organization": cfg.organization, "project": cfg.project, "azure_ad_token": cfg.azure_ad_token, "azure_client_id": cfg.azure_client_id, } ) else: raise ValueError(f"Unsupported model config: {type(cfg)}") def generate( self, messages: Union[ str, Dict[str, Any], BaseMessage, Iterable[Union[Dict[str, Any], BaseMessage]], ] = None, *, input_data: Optional[Dict[str, Any]] = None, model: Optional[str] = None, tools: Optional[List[Union[AgentTool, Dict[str, Any]]]] = None, response_format: Optional[Type[BaseModel]] = None, structured_mode: Literal["json", "function_call"] = "json", stream: bool = False, **kwargs: Any, ) -> Union[ Iterator[LLMChatCandidateChunk], LLMChatResponse, BaseModel, List[BaseModel], ]: """ Issue a chat completion. - If `stream=True` in params, returns an iterator of `LLMChatCandidateChunk`. - Otherwise returns either: • raw `AssistantMessage` wrapped in `LLMChatResponse`, or • validated Pydantic model(s) per `response_format`. Args: messages: pre-built messages or None to use `input_data`. input_data: variables for the Prompty template. model: override client's default model. tools: list of AgentTool or dict specs. response_format: Pydantic model (or list thereof) for structured output. structured_mode: “json” or “function_call” (non-stream only). stream: if True, return an iterator of `LLMChatCandidateChunk`. **kwargs: any other LLM params (temperature, top_p, stream, etc.). Returns: • `Iterator[LLMChatCandidateChunk]` if streaming • `LLMChatResponse` or Pydantic instance(s) if non-streaming Raises: ValueError: on invalid `structured_mode`, missing prompts, etc. """ # 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) If using a prompt template, build messages if input_data: if not self.prompt_template: raise ValueError("No prompt_template set for input_data usage.") logger.info("Formatting messages via prompt_template.") messages = self.prompt_template.format_prompt(**input_data) if not messages: raise ValueError("Either messages or input_data must be provided.") # 3) Normalize messages + merge client/prompty defaults params = {"messages": RequestHandler.normalize_chat_messages(messages)} if self.prompty: params = {**self.prompty.model.parameters.model_dump(), **params, **kwargs} else: params.update(kwargs) # 4) Add the stream parameter explicitly to params params["stream"] = stream # 5) Override model if given params["model"] = model or self.model # 6) Let RequestHandler inject tools / response_format / structured_mode params = RequestHandler.process_params( params, llm_provider=self.provider, tools=tools, response_format=response_format, structured_mode=structured_mode, ) # ensure params are JSON-serializable (datetime -> ISO string) params = RequestHandler.make_params_json_serializable(params) # 7) Call API + hand off to ResponseHandler try: logger.info("Calling OpenAI ChatCompletion...") logger.debug(f"ChatCompletion params: {params}") resp = self.client.chat.completions.create(**params, timeout=self.timeout) logger.info("ChatCompletion response received.") return ResponseHandler.process_response( response=resp, llm_provider=self.provider, response_format=response_format, structured_mode=structured_mode, stream=stream, ) except Exception as e: error_type = type(e).__name__ error_msg = str(e) logger.error(f"OpenAI ChatCompletion API error: {error_type} - {error_msg}") logger.error("Full error details:", exc_info=True) raise ValueError(f"OpenAI API error ({error_type}): {error_msg}") from e