From a442f77508fe90d57d4c405e4ad19fcc59dafba9 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Sat, 4 Apr 2026 00:43:28 +0300 Subject: [PATCH 1/3] add base normalized client and fix langchain normalized client --- CHANGELOG.md | 13 + packages/uipath_langchain_client/CHANGELOG.md | 7 + .../uipath_langchain_client/pyproject.toml | 2 +- .../uipath_langchain_client/__version__.py | 2 +- .../clients/normalized/chat_models.py | 257 ++- src/uipath/llm_client/__init__.py | 3 + src/uipath/llm_client/__version__.py | 2 +- .../llm_client/clients/normalized/__init__.py | 46 + .../llm_client/clients/normalized/client.py | 184 +++ .../clients/normalized/completions.py | 727 +++++++++ .../clients/normalized/embeddings.py | 94 ++ .../llm_client/clients/normalized/types.py | 87 ++ tests/core/core_smoke_test.py | 47 + tests/core/test_normalized_client.py | 1386 +++++++++++++++++ tests/core/test_normalized_integration.py | 235 +++ 15 files changed, 3053 insertions(+), 39 deletions(-) create mode 100644 src/uipath/llm_client/clients/normalized/__init__.py create mode 100644 src/uipath/llm_client/clients/normalized/client.py create mode 100644 src/uipath/llm_client/clients/normalized/completions.py create mode 100644 src/uipath/llm_client/clients/normalized/embeddings.py create mode 100644 src/uipath/llm_client/clients/normalized/types.py create mode 100644 tests/core/test_normalized_client.py create mode 100644 tests/core/test_normalized_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b8f44..12a0427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.7.0] - 2026-04-03 + +### Added +- `UiPathNormalizedClient` — provider-agnostic LLM client with no optional dependencies + - `client.completions.create/acreate/stream/astream` for chat completions + - `client.embeddings.create/acreate` for embeddings + - Structured output via `response_format` (Pydantic, TypedDict, dict, json_object) + - Tool calling with dicts, Pydantic models, or callables + - Streaming with SSE parsing + - Full vendor parameter coverage: OpenAI (reasoning, logprobs, logit_bias), Anthropic (thinking, top_k), Google (thinking_level/budget, safety_settings, cached_content) + - Typed response models: `ChatCompletion`, `ChatCompletionChunk`, `EmbeddingResponse` + - Accepts both dict and Pydantic model messages + ## [1.6.0] - 2026-04-03 ### Fixed diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 6ac4201..3674583 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.7.0] - 2026-04-03 + +### Added +- `UiPathChat.with_structured_output()` — supports `function_calling`, `json_schema`, and `json_mode` methods +- `UiPathChat.bind_tools()` — added `parallel_tool_calls` parameter +- Added vendor-specific parameters to `UiPathChat`: `logit_bias`, `logprobs`, `top_logprobs`, `parallel_tool_calls`, `top_k`, `safety_settings`, `cached_content`, `labels`, `seed` + ## [1.6.0] - 2026-04-03 ### Fixed diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 93ff0be..ba69f55 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "langchain>=1.2.13", - "uipath-llm-client>=1.5.10", + "uipath-llm-client>=1.7.0", ] [project.optional-dependencies] diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py index 5149f6e..66e749d 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LangChain Client" __description__ = "A Python client for interacting with UiPath's LLM services via LangChain." -__version__ = "1.6.0" +__version__ = "1.7.0" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py index 47e325b..a1d545e 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py @@ -25,7 +25,8 @@ import json from collections.abc import AsyncGenerator, Callable, Generator, Sequence -from typing import Any +from functools import partial +from typing import Any, Literal, Union, cast from langchain_core.callbacks import ( AsyncCallbackManagerForLLMRun, @@ -44,21 +45,76 @@ UsageMetadata, ) from langchain_core.messages.utils import convert_to_openai_messages +from langchain_core.output_parsers import JsonOutputParser +from langchain_core.output_parsers.openai_tools import ( + JsonOutputKeyToolsParser, + PydanticToolsParser, +) from langchain_core.outputs import ( ChatGeneration, ChatGenerationChunk, ChatResult, ) -from langchain_core.runnables import Runnable +from langchain_core.runnables import Runnable, RunnableLambda, RunnablePassthrough from langchain_core.tools import BaseTool from langchain_core.utils.function_calling import ( convert_to_openai_function, + convert_to_openai_tool, ) -from pydantic import Field +from langchain_core.utils.pydantic import is_basemodel_subclass +from pydantic import AliasChoices, BaseModel, Field from uipath_langchain_client.base_client import UiPathBaseChatModel from uipath_langchain_client.settings import ApiType, RoutingMode, UiPathAPIConfig +_DictOrPydanticClass = Union[dict[str, Any], type[BaseModel], type] +_DictOrPydantic = Union[dict[str, Any], BaseModel] + + +def _oai_structured_outputs_parser(ai_msg: AIMessage, schema: type[BaseModel]) -> BaseModel: + if not ai_msg.content: + raise ValueError("Expected non-empty content from model.") + content = ai_msg.content + if isinstance(content, list): + # Extract the first text block from content parts + content = next((c for c in content if isinstance(c, str)), str(content[0])) + parsed = json.loads(content) + return schema.model_validate(parsed) + + +def _build_normalized_response_format( + schema: _DictOrPydanticClass, strict: bool | None = None +) -> dict[str, Any]: + """Build response_format for the normalized API from a schema.""" + if isinstance(schema, dict): + return {"type": "json_schema", "json_schema": schema} + + if isinstance(schema, type) and issubclass(schema, BaseModel): + json_schema = schema.model_json_schema() + rf: dict[str, Any] = { + "type": "json_schema", + "json_schema": { + "name": schema.__name__, + "schema": json_schema, + }, + } + if strict is not None: + rf["json_schema"]["strict"] = strict + return rf + + # TypedDict or other type — convert via openai tool schema + tool_schema = convert_to_openai_tool(schema) + rf = { + "type": "json_schema", + "json_schema": { + "name": tool_schema["function"]["name"], + "schema": tool_schema["function"]["parameters"], + }, + } + if strict is not None: + rf["json_schema"]["strict"] = strict + return rf + class UiPathChat(UiPathBaseChatModel): """LangChain chat model using UiPath's normalized (provider-agnostic) API. @@ -101,33 +157,48 @@ class UiPathChat(UiPathBaseChatModel): freeze_base_url=True, ) - # Standard LLM parameters - max_tokens: int | None = None + # Common + max_tokens: int | None = Field( + default=None, + validation_alias=AliasChoices("max_tokens", "max_output_tokens", "max_completion_tokens"), + ) temperature: float | None = None - stop: list[str] | str | None = Field(default=None, alias="stop_sequences") + top_p: float | None = None + top_k: int | None = None + stop: list[str] | str | None = Field( + default=None, + validation_alias=AliasChoices("stop", "stop_sequences"), + ) + n: int | None = Field( + default=None, + validation_alias=AliasChoices("n", "candidate_count"), + ) + frequency_penalty: float | None = None + presence_penalty: float | None = None + seed: int | None = None - n: int | None = None # Number of completions to generate - top_p: float | None = None # Nucleus sampling probability mass - presence_penalty: float | None = None # Penalty for repeated tokens - frequency_penalty: float | None = None # Frequency-based repetition penalty - verbosity: str | None = None # Response verbosity: "low", "medium", or "high" + model_kwargs: dict[str, Any] = Field(default_factory=dict) + disabled_params: dict[str, Any] | None = None - model_kwargs: dict[str, Any] = Field( - default_factory=dict - ) # Additional model-specific parameters - disabled_params: dict[str, Any] | None = None # Parameters to exclude from requests + # OpenAI + logit_bias: dict[str, int] | None = None + logprobs: bool | None = None + top_logprobs: int | None = None + parallel_tool_calls: bool | None = None + reasoning_effort: str | None = None + reasoning: dict[str, Any] | None = None - # OpenAI o1/o3 reasoning parameters - reasoning: dict[str, Any] | None = None # {"effort": "low"|"medium"|"high", "summary": ...} - reasoning_effort: str | None = None # "minimal", "low", "medium", or "high" + # Anthropic + thinking: dict[str, Any] | None = None - # Anthropic Claude extended thinking parameters - thinking: dict[str, Any] | None = None # {"type": "enabled"|"disabled", "budget_tokens": N} + # Google + thinking_level: str | None = None + thinking_budget: int | None = None + include_thoughts: bool | None = None + safety_settings: list[dict[str, Any]] | None = None - # Google Gemini thinking parameters - thinking_level: str | None = None # Thinking depth level - thinking_budget: int | None = None # Token budget for thinking - include_thoughts: bool | None = None # Include thinking in response + # Shared + verbosity: str | None = None @property def _llm_type(self) -> str: @@ -138,20 +209,31 @@ def _llm_type(self) -> str: def _default_params(self) -> dict[str, Any]: """Get the default parameters for the normalized API request.""" exclude_if_none = { - "frequency_penalty": self.frequency_penalty, - "presence_penalty": self.presence_penalty, - "top_p": self.top_p, - "stop": self.stop or None, # Also exclude empty list for this - "n": self.n, "max_tokens": self.max_tokens, "temperature": self.temperature, - "verbosity": self.verbosity, - "reasoning": self.reasoning, + "top_p": self.top_p, + "top_k": self.top_k, + "stop": self.stop or None, + "n": self.n, + "frequency_penalty": self.frequency_penalty, + "presence_penalty": self.presence_penalty, + "seed": self.seed, + # OpenAI + "logit_bias": self.logit_bias, + "logprobs": self.logprobs, + "top_logprobs": self.top_logprobs, + "parallel_tool_calls": self.parallel_tool_calls, "reasoning_effort": self.reasoning_effort, + "reasoning": self.reasoning, + # Anthropic "thinking": self.thinking, + # Google "thinking_level": self.thinking_level, "thinking_budget": self.thinking_budget, "include_thoughts": self.include_thoughts, + "safety_settings": self.safety_settings, + # Shared + "verbosity": self.verbosity, } return { @@ -181,6 +263,7 @@ def bind_tools( *, tool_choice: str | None = None, strict: bool | None = None, + parallel_tool_calls: bool | None = None, **kwargs: Any, ) -> Runnable[LanguageModelInput, AIMessage]: """Bind tools to the model with automatic tool choice detection.""" @@ -197,7 +280,7 @@ def bind_tools( tool_choice = "auto" if tool_choice in ["required", "auto"]: - tool_choice_object = { + tool_choice_object: dict[str, Any] = { "type": tool_choice, } else: @@ -206,11 +289,113 @@ def bind_tools( "name": tool_choice, } - return super().bind( - tools=formatted_tools, - tool_choice=tool_choice_object, + bind_kwargs: dict[str, Any] = { + "tools": formatted_tools, + "tool_choice": tool_choice_object, **kwargs, - ) + } + if parallel_tool_calls is not None: + bind_kwargs["parallel_tool_calls"] = parallel_tool_calls + + return super().bind(**bind_kwargs) + + def with_structured_output( + self, + schema: _DictOrPydanticClass | None = None, + *, + method: Literal["function_calling", "json_mode", "json_schema"] = "function_calling", + include_raw: bool = False, + strict: bool | None = None, + **kwargs: Any, + ) -> Runnable[LanguageModelInput, _DictOrPydantic]: + """Model wrapper that returns outputs formatted to match the given schema. + + Args: + schema: The output schema as a Pydantic class, TypedDict, JSON Schema dict, + or OpenAI function schema. + method: Either "json_schema" (uses response_format) or "function_calling" + (uses tool calling to force the schema). + include_raw: If True, returns dict with 'raw', 'parsed', and 'parsing_error'. + strict: If True, model output is guaranteed to match the schema exactly. + **kwargs: Additional arguments passed to bind(). + + Returns: + A Runnable that parses the model output into the given schema. + """ + if schema is None: + raise ValueError("schema must be specified.") + + is_pydantic = isinstance(schema, type) and is_basemodel_subclass(schema) + + if method == "function_calling": + tool_name = convert_to_openai_tool(schema)["function"]["name"] + llm = self.bind_tools( + [schema], + tool_choice="any", + strict=strict, + ls_structured_output_format={ + "kwargs": {"method": "function_calling", "strict": strict}, + "schema": schema, + }, + **kwargs, + ) + if is_pydantic: + output_parser: Runnable = PydanticToolsParser( + tools=[schema], # type: ignore[list-item] + first_tool_only=True, + ) + else: + output_parser = JsonOutputKeyToolsParser(key_name=tool_name, first_tool_only=True) + elif method == "json_mode": + llm = self.bind( + response_format={"type": "json_object"}, + ls_structured_output_format={ + "kwargs": {"method": method}, + "schema": schema, + }, + **kwargs, + ) + if is_pydantic: + from langchain_core.output_parsers import PydanticOutputParser + + output_parser = PydanticOutputParser(pydantic_object=schema) # type: ignore[arg-type] + else: + output_parser = JsonOutputParser() + elif method == "json_schema": + response_format = _build_normalized_response_format(schema, strict=strict) + llm = self.bind( + response_format=response_format, + ls_structured_output_format={ + "kwargs": {"method": method, "strict": strict}, + "schema": convert_to_openai_tool(schema), + }, + **kwargs, + ) + if is_pydantic: + output_parser = RunnableLambda( + partial(_oai_structured_outputs_parser, schema=cast(type, schema)) + ).with_types(output_type=cast(type, schema)) + else: + output_parser = JsonOutputParser() + else: + raise ValueError( + f"Unrecognized method: '{method}'. " + "Expected 'function_calling', 'json_mode', or 'json_schema'." + ) + + if include_raw: + parser_assign = RunnablePassthrough.assign( + parsed=lambda x: output_parser.invoke(x["raw"]), + parsing_error=lambda _: None, + ) + parser_none = RunnablePassthrough.assign( + parsed=lambda _: None, + ) + parser_with_fallback = parser_assign.with_fallbacks( + [parser_none], exception_key="parsing_error" + ) + return RunnablePassthrough.assign(raw=llm) | parser_with_fallback # type: ignore[return-value] + return llm | output_parser # type: ignore[return-value] def _preprocess_request( self, messages: list[BaseMessage], stop: list[str] | None = None, **kwargs: Any diff --git a/src/uipath/llm_client/__init__.py b/src/uipath/llm_client/__init__.py index b437cef..a42a286 100644 --- a/src/uipath/llm_client/__init__.py +++ b/src/uipath/llm_client/__init__.py @@ -28,6 +28,7 @@ """ from uipath.llm_client.__version__ import __version__ +from uipath.llm_client.clients.normalized import UiPathNormalizedClient from uipath.llm_client.httpx_client import ( UiPathHttpxAsyncClient, UiPathHttpxClient, @@ -60,6 +61,8 @@ "get_default_client_settings", "PlatformSettings", "LLMGatewaySettings", + # Normalized client + "UiPathNormalizedClient", # HTTPX clients "UiPathHttpxClient", "UiPathHttpxAsyncClient", diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index c5bf7a8..cbd0256 100644 --- a/src/uipath/llm_client/__version__.py +++ b/src/uipath/llm_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LLM Client" __description__ = "A Python client for interacting with UiPath's LLM services." -__version__ = "1.6.0" +__version__ = "1.7.0" diff --git a/src/uipath/llm_client/clients/normalized/__init__.py b/src/uipath/llm_client/clients/normalized/__init__.py new file mode 100644 index 0000000..11bf16d --- /dev/null +++ b/src/uipath/llm_client/clients/normalized/__init__.py @@ -0,0 +1,46 @@ +"""UiPath Normalized Client - Provider-agnostic LLM client. + +No optional dependencies required. Works with the base uipath-llm-client package. +""" + +from uipath.llm_client.clients.normalized.client import UiPathNormalizedClient +from uipath.llm_client.clients.normalized.completions import ( + MessageType, + ResponseFormatType, + ToolChoiceType, + ToolType, +) +from uipath.llm_client.clients.normalized.types import ( + ChatCompletion, + ChatCompletionChunk, + Choice, + Delta, + EmbeddingData, + EmbeddingResponse, + Message, + StreamChoice, + ToolCall, + ToolCallChunk, + Usage, +) + +__all__ = [ + "UiPathNormalizedClient", + # Input types + "MessageType", + "ToolType", + "ToolChoiceType", + "ResponseFormatType", + # Response types + "ChatCompletion", + "ChatCompletionChunk", + "Choice", + "Delta", + "EmbeddingData", + "EmbeddingResponse", + "Message", + "StreamChoice", + "ToolCall", + "ToolCallChunk", + "Usage", +] diff --git a/src/uipath/llm_client/clients/normalized/client.py b/src/uipath/llm_client/clients/normalized/client.py new file mode 100644 index 0000000..43ddf5a --- /dev/null +++ b/src/uipath/llm_client/clients/normalized/client.py @@ -0,0 +1,184 @@ +"""UiPath Normalized Client. + +A provider-agnostic LLM client that uses UiPath's normalized API to provide +a consistent interface across all supported providers (OpenAI, Google, Anthropic, etc.). + +No optional dependencies required - works with the base uipath-llm-client package. + +Example: + >>> from uipath.llm_client.clients.normalized import UiPathNormalizedClient + >>> + >>> client = UiPathNormalizedClient(model_name="gpt-4o-2024-11-20") + >>> + >>> # Chat completion + >>> response = client.completions.create( + ... messages=[{"role": "user", "content": "Hello!"}], + ... ) + >>> print(response.choices[0].message.content) + >>> + >>> # Streaming + >>> for chunk in client.completions.stream( + ... messages=[{"role": "user", "content": "Hello!"}], + ... ): + ... print(chunk.choices[0].delta.content, end="") + >>> + >>> # Async + >>> response = await client.completions.acreate( + ... messages=[{"role": "user", "content": "Hello!"}], + ... ) + >>> + >>> # Structured output + >>> from pydantic import BaseModel + >>> class Answer(BaseModel): + ... text: str + ... confidence: float + >>> + >>> response = client.completions.create( + ... messages=[{"role": "user", "content": "What is 2+2?"}], + ... output_format=Answer, + ... ) + >>> print(response.choices[0].message.parsed) # Answer(text='4', confidence=1.0) + >>> + >>> # Embeddings + >>> response = client.embeddings.create(input=["Hello world"]) + >>> print(len(response.data[0].embedding)) +""" + +import logging +from collections.abc import Mapping, Sequence +from functools import cached_property + +from uipath.llm_client.clients.normalized.completions import Completions +from uipath.llm_client.clients.normalized.embeddings import Embeddings +from uipath.llm_client.clients.utils import build_httpx_async_client, build_httpx_client +from uipath.llm_client.httpx_client import UiPathHttpxAsyncClient, UiPathHttpxClient +from uipath.llm_client.settings import UiPathBaseSettings, get_default_client_settings +from uipath.llm_client.settings.base import UiPathAPIConfig +from uipath.llm_client.settings.constants import ApiType, RoutingMode +from uipath.llm_client.utils.retry import RetryConfig + + +class UiPathNormalizedClient: + """Provider-agnostic LLM client using UiPath's normalized API. + + Routes requests through UiPath's LLM Gateway using the normalized API, + which provides a consistent interface across all supported LLM providers. + No vendor-specific SDK dependencies are required. + + Namespaces: + - ``completions``: ``create``, ``acreate``, ``stream``, ``astream`` + - ``embeddings``: ``create``, ``acreate`` + + Args: + model_name: The model name (e.g., "gpt-4o-2024-11-20", "gemini-2.5-flash"). + byo_connection_id: Bring Your Own connection ID for custom deployments. + client_settings: UiPath client settings. Defaults to environment-based settings. + timeout: Client-side request timeout in seconds. + max_retries: Maximum retry attempts for failed requests. + default_headers: Additional headers to include in requests. + captured_headers: Response header prefixes to capture (case-insensitive). + retry_config: Custom retry configuration. + logger: Logger instance for request/response logging. + + Example: + >>> client = UiPathNormalizedClient(model_name="gpt-4o-2024-11-20") + >>> response = client.completions.create( + ... messages=[{"role": "user", "content": "Hello!"}], + ... ) + """ + + def __init__( + self, + *, + model_name: str, + byo_connection_id: str | None = None, + client_settings: UiPathBaseSettings | None = None, + timeout: float | None = None, + max_retries: int | None = None, + default_headers: Mapping[str, str] | None = None, + captured_headers: Sequence[str] = ("x-uipath-",), + retry_config: RetryConfig | None = None, + logger: logging.Logger | None = None, + ): + self._model_name = model_name + self._byo_connection_id = byo_connection_id + self._client_settings = client_settings or get_default_client_settings() + self._timeout = timeout + self._max_retries = max_retries + self._default_headers = default_headers + self._captured_headers = captured_headers + self._retry_config = retry_config + self._logger = logger + + self._completions_api_config = UiPathAPIConfig( + api_type=ApiType.COMPLETIONS, + routing_mode=RoutingMode.NORMALIZED, + freeze_base_url=True, + ) + self._embeddings_api_config = UiPathAPIConfig( + api_type=ApiType.EMBEDDINGS, + routing_mode=RoutingMode.NORMALIZED, + freeze_base_url=True, + ) + + # ------------------------------------------------------------------ + # HTTP clients (lazily created) + # ------------------------------------------------------------------ + + def _build_sync(self, api_config: UiPathAPIConfig) -> UiPathHttpxClient: + return build_httpx_client( + model_name=self._model_name, + byo_connection_id=self._byo_connection_id, + client_settings=self._client_settings, + timeout=self._timeout, + max_retries=self._max_retries, + default_headers=self._default_headers, + captured_headers=self._captured_headers, + retry_config=self._retry_config, + logger=self._logger, + api_config=api_config, + ) + + def _build_async(self, api_config: UiPathAPIConfig) -> UiPathHttpxAsyncClient: + return build_httpx_async_client( + model_name=self._model_name, + byo_connection_id=self._byo_connection_id, + client_settings=self._client_settings, + timeout=self._timeout, + max_retries=self._max_retries, + default_headers=self._default_headers, + captured_headers=self._captured_headers, + retry_config=self._retry_config, + logger=self._logger, + api_config=api_config, + ) + + @cached_property + def _sync_client(self) -> UiPathHttpxClient: + return self._build_sync(self._completions_api_config) + + @cached_property + def _async_client(self) -> UiPathHttpxAsyncClient: + return self._build_async(self._completions_api_config) + + @cached_property + def _embedding_sync_client(self) -> UiPathHttpxClient: + return self._build_sync(self._embeddings_api_config) + + @cached_property + def _embedding_async_client(self) -> UiPathHttpxAsyncClient: + return self._build_async(self._embeddings_api_config) + + # ------------------------------------------------------------------ + # Public namespaces + # ------------------------------------------------------------------ + + @cached_property + def completions(self) -> Completions: + """Chat completions namespace (``create``, ``acreate``, ``stream``, ``astream``).""" + return Completions(self) + + @cached_property + def embeddings(self) -> Embeddings: + """Embeddings namespace (``create``, ``acreate``).""" + return Embeddings(self) diff --git a/src/uipath/llm_client/clients/normalized/completions.py b/src/uipath/llm_client/clients/normalized/completions.py new file mode 100644 index 0000000..35b91a3 --- /dev/null +++ b/src/uipath/llm_client/clients/normalized/completions.py @@ -0,0 +1,727 @@ +"""Completions endpoint for the UiPath Normalized API.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncGenerator, Callable, Generator, Sequence +from typing import Any, Union, get_args, get_origin, get_type_hints + +from pydantic import BaseModel + +from uipath.llm_client.clients.normalized.types import ( + ChatCompletion, + ChatCompletionChunk, + Choice, + Delta, + Message, + StreamChoice, + ToolCall, + ToolCallChunk, + Usage, +) + +try: + from typing import is_typeddict +except ImportError: + from typing_extensions import is_typeddict + +# --------------------------------------------------------------------------- +# Public input types +# --------------------------------------------------------------------------- + +ResponseFormatType = Union[type[BaseModel], type, dict[str, Any]] +"""Response format: Pydantic model, TypedDict, or raw dict (e.g. {"type": "json_object"}).""" + +ToolType = Union[dict[str, Any], type[BaseModel], Callable[..., Any]] +"""Tool definition: dict (raw schema), Pydantic model, or callable.""" + +ToolChoiceType = Union[str, dict[str, Any]] +"""Tool choice: "auto", "required", "none", a tool name, or a dict.""" + +MessageType = Union[dict[str, Any], BaseModel] +"""A single message: dict with role/content or a Pydantic model with those fields.""" + + +def _normalize_messages(messages: Sequence[MessageType]) -> list[dict[str, Any]]: + """Convert a sequence of messages (dicts or pydantic models) to dicts.""" + result: list[dict[str, Any]] = [] + for msg in messages: + if isinstance(msg, dict): + result.append(msg) + elif isinstance(msg, BaseModel): + result.append(msg.model_dump(exclude_none=True)) + else: + result.append(dict(msg)) # type: ignore[arg-type] + return result + + +# --------------------------------------------------------------------------- +# Schema helpers +# --------------------------------------------------------------------------- + + +def _json_schema_from_type(tp: type) -> dict[str, Any]: + origin = get_origin(tp) + if origin is list: + args = get_args(tp) + return {"type": "array", "items": _json_schema_from_type(args[0]) if args else {}} + if origin is dict: + return {"type": "object"} + simple = {str: "string", int: "integer", float: "number", bool: "boolean"} + return {"type": simple.get(tp, "object")} + + +def _build_response_format( + response_format: ResponseFormatType, strict: bool | None = None +) -> dict[str, Any]: + if isinstance(response_format, dict): + if "type" in response_format: + return response_format + return {"type": "json_schema", "json_schema": response_format} + + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + js: dict[str, Any] = { + "name": response_format.__name__, + "schema": response_format.model_json_schema(), + } + if strict is not False: + js["strict"] = True + return {"type": "json_schema", "json_schema": js} + + if isinstance(response_format, type) and is_typeddict(response_format): + hints = get_type_hints(response_format) + properties = {name: _json_schema_from_type(tp) for name, tp in hints.items()} + js = { + "name": response_format.__name__, + "schema": { + "type": "object", + "properties": properties, + "required": list(properties.keys()), + "additionalProperties": False, + }, + } + if strict is not False: + js["strict"] = True + return {"type": "json_schema", "json_schema": js} + + if isinstance(response_format, type): + js = { + "name": response_format.__name__, + "schema": _json_schema_from_type(response_format), + } + if strict is True: + js["strict"] = True + return {"type": "json_schema", "json_schema": js} + + raise TypeError(f"Unsupported response_format type: {type(response_format)}") + + +# --------------------------------------------------------------------------- +# Tool helpers +# --------------------------------------------------------------------------- + + +def _build_tool_definition(tool: ToolType) -> dict[str, Any]: + if isinstance(tool, dict): + return tool + + if isinstance(tool, type) and issubclass(tool, BaseModel): + schema = tool.model_json_schema() + schema.pop("title", None) + return {"name": tool.__name__, "description": tool.__doc__ or "", "parameters": schema} + + if callable(tool): + import inspect + + sig = inspect.signature(tool) + hints = get_type_hints(tool) + properties = {name: _json_schema_from_type(hints.get(name, str)) for name in sig.parameters} + required = [ + name for name, p in sig.parameters.items() if p.default is inspect.Parameter.empty + ] + return { + "name": tool.__name__, + "description": tool.__doc__ or "", + "parameters": {"type": "object", "properties": properties, "required": required}, + } + + raise TypeError(f"Unsupported tool type: {type(tool)}") + + +def _resolve_tool_choice( + tool_choice: ToolChoiceType, tools: list[dict[str, Any]] +) -> dict[str, Any] | str: + if isinstance(tool_choice, dict): + return tool_choice + if tool_choice in ("auto", "required", "none"): + return tool_choice + tool_names = [t.get("name", "") for t in tools] + if tool_choice in tool_names: + return {"type": "tool", "name": tool_choice} + return "auto" + + +# --------------------------------------------------------------------------- +# Response parsing +# --------------------------------------------------------------------------- + + +def _parse_tool_call(raw: dict[str, Any]) -> ToolCall: + arguments = raw.get("arguments", {}) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {} + return ToolCall(id=raw.get("id", ""), name=raw.get("name", ""), arguments=arguments) + + +def _parse_tool_call_chunk(raw: dict[str, Any]) -> ToolCallChunk: + if "function" in raw: + name = raw["function"].get("name", "") + args = raw["function"].get("arguments", "") + else: + name = raw.get("name", "") + args = raw.get("arguments", "") + if isinstance(args, dict): + args = json.dumps(args) if args else "" + return ToolCallChunk(id=raw.get("id", ""), name=name, arguments=args, index=raw.get("index", 0)) + + +def _parse_structured_output(content: str, response_format: ResponseFormatType) -> Any: + try: + parsed_json = json.loads(content) + except json.JSONDecodeError: + return None + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + return response_format.model_validate(parsed_json) + return parsed_json + + +def _parse_response( + data: dict[str, Any], response_format: ResponseFormatType | None = None +) -> ChatCompletion: + usage = Usage(**data.get("usage", {})) + choices: list[Choice] = [] + for choice_data in data.get("choices", []): + msg_data = choice_data.get("message", {}) + tool_calls = [_parse_tool_call(tc) for tc in msg_data.get("tool_calls", [])] + content = msg_data.get("content", "") + parsed = ( + _parse_structured_output(content, response_format) + if response_format and content + else None + ) + message = Message( + role=msg_data.get("role", "assistant"), + content=content, + tool_calls=tool_calls, + signature=msg_data.get("signature"), + thinking=msg_data.get("thinking"), + parsed=parsed, + ) + choices.append( + Choice( + index=choice_data.get("index", 0), + message=message, + finish_reason=choice_data.get("finish_reason"), + avg_logprobs=choice_data.get("avg_logprobs"), + ) + ) + return ChatCompletion( + id=data.get("id", ""), + object=data.get("object", ""), + created=data.get("created", 0), + model=data.get("model", ""), + choices=choices, + usage=usage, + ) + + +def _parse_stream_chunk(data: dict[str, Any]) -> ChatCompletionChunk: + usage = Usage(**data["usage"]) if data.get("usage") else None + choices: list[StreamChoice] = [] + for choice_data in data.get("choices", []): + delta_data = choice_data.get("delta", choice_data.get("message", {})) + tool_calls = [_parse_tool_call_chunk(tc) for tc in delta_data.get("tool_calls", [])] + delta = Delta( + role=delta_data.get("role"), + content=delta_data.get("content", ""), + tool_calls=tool_calls, + ) + choices.append( + StreamChoice( + index=choice_data.get("index", 0), + delta=delta, + finish_reason=choice_data.get("finish_reason"), + avg_logprobs=choice_data.get("avg_logprobs"), + ) + ) + return ChatCompletionChunk( + id=data.get("id", ""), + object=data.get("object", ""), + created=data.get("created", 0), + model=data.get("model", ""), + choices=choices, + usage=usage, + ) + + +# --------------------------------------------------------------------------- +# Request building +# --------------------------------------------------------------------------- + + +def _build_request( + *, + messages: Sequence[MessageType], + stream: bool = False, + tools: Sequence[ToolType] | None = None, + tool_choice: ToolChoiceType | None = None, + response_format: ResponseFormatType | None = None, + strict: bool | None = None, + # Common + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + top_k: int | None = None, + stop: list[str] | str | None = None, + n: int | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + seed: int | None = None, + # OpenAI + logit_bias: dict[str, int] | None = None, + logprobs: bool | None = None, + top_logprobs: int | None = None, + parallel_tool_calls: bool | None = None, + # OpenAI reasoning (o1/o3/gpt-5) + reasoning_effort: str | None = None, + reasoning: dict[str, Any] | None = None, + # Anthropic + thinking: dict[str, Any] | None = None, + # Google + thinking_level: str | None = None, + thinking_budget: int | None = None, + include_thoughts: bool | None = None, + safety_settings: list[dict[str, Any]] | None = None, + # Shared + verbosity: str | None = None, + # Aliases (resolve to canonical names above) + stop_sequences: list[str] | None = None, + max_output_tokens: int | None = None, + max_completion_tokens: int | None = None, + candidate_count: int | None = None, + **kwargs: Any, +) -> dict[str, Any]: + """Build the request body for a chat completion.""" + # Resolve aliases + max_tokens = max_tokens or max_output_tokens or max_completion_tokens + stop = stop or stop_sequences + n = n or candidate_count + + body: dict[str, Any] = {"messages": _normalize_messages(messages)} + + if stream: + body["stream"] = True + + optional: dict[str, Any] = { + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, + "top_k": top_k, + "stop": stop, + "n": n, + "frequency_penalty": frequency_penalty, + "presence_penalty": presence_penalty, + "seed": seed, + "logit_bias": logit_bias, + "logprobs": logprobs, + "top_logprobs": top_logprobs, + "parallel_tool_calls": parallel_tool_calls, + "reasoning_effort": reasoning_effort, + "reasoning": reasoning, + "thinking": thinking, + "thinking_level": thinking_level, + "thinking_budget": thinking_budget, + "include_thoughts": include_thoughts, + "safety_settings": safety_settings, + "verbosity": verbosity, + } + body.update({k: v for k, v in optional.items() if v is not None}) + + if tools is not None: + body["tools"] = [_build_tool_definition(t) for t in tools] + if tool_choice is not None: + body["tool_choice"] = _resolve_tool_choice(tool_choice, body["tools"]) + + if response_format is not None: + body["response_format"] = _build_response_format(response_format, strict=strict) + + body.update(kwargs) + return body + + +# --------------------------------------------------------------------------- +# SSE helpers +# --------------------------------------------------------------------------- + + +def _iter_sse(lines: Generator[str, None, None]) -> Generator[dict[str, Any], None, None]: + for line in lines: + line = line.strip() + if line.startswith("data:"): + line = line[len("data:") :].strip() + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + if "id" in data and not data["id"]: + continue + yield data + + +async def _aiter_sse(lines: AsyncGenerator[str, None]) -> AsyncGenerator[dict[str, Any], None]: + async for line in lines: + line = line.strip() + if line.startswith("data:"): + line = line[len("data:") :].strip() + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + if "id" in data and not data["id"]: + continue + yield data + + +# --------------------------------------------------------------------------- +# Completions namespace +# --------------------------------------------------------------------------- + + +class Completions: + """``client.completions`` — create, acreate, stream, astream.""" + + def __init__(self, client: Any) -> None: + self._client = client + + def create( + self, + *, + messages: Sequence[MessageType], + tools: Sequence[ToolType] | None = None, + tool_choice: ToolChoiceType | None = None, + response_format: ResponseFormatType | None = None, + strict: bool | None = None, + # Common + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + top_k: int | None = None, + stop: list[str] | str | None = None, + n: int | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + seed: int | None = None, + # OpenAI + logit_bias: dict[str, int] | None = None, + logprobs: bool | None = None, + top_logprobs: int | None = None, + parallel_tool_calls: bool | None = None, + reasoning_effort: str | None = None, + reasoning: dict[str, Any] | None = None, + # Anthropic + thinking: dict[str, Any] | None = None, + # Google + thinking_level: str | None = None, + thinking_budget: int | None = None, + include_thoughts: bool | None = None, + safety_settings: list[dict[str, Any]] | None = None, + # Shared + verbosity: str | None = None, + # Aliases + stop_sequences: list[str] | None = None, + max_output_tokens: int | None = None, + max_completion_tokens: int | None = None, + candidate_count: int | None = None, + **kwargs: Any, + ) -> ChatCompletion: + """Create a chat completion (sync).""" + body = _build_request( + messages=messages, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + strict=strict, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + top_k=top_k, + stop=stop, + n=n, + frequency_penalty=frequency_penalty, + presence_penalty=presence_penalty, + seed=seed, + logit_bias=logit_bias, + logprobs=logprobs, + top_logprobs=top_logprobs, + parallel_tool_calls=parallel_tool_calls, + reasoning_effort=reasoning_effort, + reasoning=reasoning, + thinking=thinking, + thinking_level=thinking_level, + thinking_budget=thinking_budget, + include_thoughts=include_thoughts, + safety_settings=safety_settings, + verbosity=verbosity, + stop_sequences=stop_sequences, + max_output_tokens=max_output_tokens, + max_completion_tokens=max_completion_tokens, + candidate_count=candidate_count, + **kwargs, + ) + response = self._client._sync_client.request("POST", "/", json=body) + response.raise_for_status() + return _parse_response(response.json(), response_format=response_format) + + async def acreate( + self, + *, + messages: Sequence[MessageType], + tools: Sequence[ToolType] | None = None, + tool_choice: ToolChoiceType | None = None, + response_format: ResponseFormatType | None = None, + strict: bool | None = None, + # Common + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + top_k: int | None = None, + stop: list[str] | str | None = None, + n: int | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + seed: int | None = None, + # OpenAI + logit_bias: dict[str, int] | None = None, + logprobs: bool | None = None, + top_logprobs: int | None = None, + parallel_tool_calls: bool | None = None, + reasoning_effort: str | None = None, + reasoning: dict[str, Any] | None = None, + # Anthropic + thinking: dict[str, Any] | None = None, + # Google + thinking_level: str | None = None, + thinking_budget: int | None = None, + include_thoughts: bool | None = None, + safety_settings: list[dict[str, Any]] | None = None, + # Shared + verbosity: str | None = None, + # Aliases + stop_sequences: list[str] | None = None, + max_output_tokens: int | None = None, + max_completion_tokens: int | None = None, + candidate_count: int | None = None, + **kwargs: Any, + ) -> ChatCompletion: + """Create a chat completion (async).""" + body = _build_request( + messages=messages, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + strict=strict, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + top_k=top_k, + stop=stop, + n=n, + frequency_penalty=frequency_penalty, + presence_penalty=presence_penalty, + seed=seed, + logit_bias=logit_bias, + logprobs=logprobs, + top_logprobs=top_logprobs, + parallel_tool_calls=parallel_tool_calls, + reasoning_effort=reasoning_effort, + reasoning=reasoning, + thinking=thinking, + thinking_level=thinking_level, + thinking_budget=thinking_budget, + include_thoughts=include_thoughts, + safety_settings=safety_settings, + verbosity=verbosity, + stop_sequences=stop_sequences, + max_output_tokens=max_output_tokens, + max_completion_tokens=max_completion_tokens, + candidate_count=candidate_count, + **kwargs, + ) + response = await self._client._async_client.request("POST", "/", json=body) + response.raise_for_status() + return _parse_response(response.json(), response_format=response_format) + + def stream( + self, + *, + messages: Sequence[MessageType], + tools: Sequence[ToolType] | None = None, + tool_choice: ToolChoiceType | None = None, + response_format: ResponseFormatType | None = None, + strict: bool | None = None, + # Common + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + top_k: int | None = None, + stop: list[str] | str | None = None, + n: int | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + seed: int | None = None, + # OpenAI + logit_bias: dict[str, int] | None = None, + logprobs: bool | None = None, + top_logprobs: int | None = None, + parallel_tool_calls: bool | None = None, + reasoning_effort: str | None = None, + reasoning: dict[str, Any] | None = None, + # Anthropic + thinking: dict[str, Any] | None = None, + # Google + thinking_level: str | None = None, + thinking_budget: int | None = None, + include_thoughts: bool | None = None, + safety_settings: list[dict[str, Any]] | None = None, + # Shared + verbosity: str | None = None, + # Aliases + stop_sequences: list[str] | None = None, + max_output_tokens: int | None = None, + max_completion_tokens: int | None = None, + candidate_count: int | None = None, + **kwargs: Any, + ) -> Generator[ChatCompletionChunk, None, None]: + """Stream chat completion chunks (sync).""" + body = _build_request( + messages=messages, + stream=True, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + strict=strict, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + top_k=top_k, + stop=stop, + n=n, + frequency_penalty=frequency_penalty, + presence_penalty=presence_penalty, + seed=seed, + logit_bias=logit_bias, + logprobs=logprobs, + top_logprobs=top_logprobs, + parallel_tool_calls=parallel_tool_calls, + reasoning_effort=reasoning_effort, + reasoning=reasoning, + thinking=thinking, + thinking_level=thinking_level, + thinking_budget=thinking_budget, + include_thoughts=include_thoughts, + safety_settings=safety_settings, + verbosity=verbosity, + stop_sequences=stop_sequences, + max_output_tokens=max_output_tokens, + max_completion_tokens=max_completion_tokens, + candidate_count=candidate_count, + **kwargs, + ) + with self._client._sync_client.stream("POST", "/", json=body) as response: + response.raise_for_status() + for data in _iter_sse(response.iter_lines()): + yield _parse_stream_chunk(data) + + async def astream( + self, + *, + messages: Sequence[MessageType], + tools: Sequence[ToolType] | None = None, + tool_choice: ToolChoiceType | None = None, + response_format: ResponseFormatType | None = None, + strict: bool | None = None, + # Common + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + top_k: int | None = None, + stop: list[str] | str | None = None, + n: int | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + seed: int | None = None, + # OpenAI + logit_bias: dict[str, int] | None = None, + logprobs: bool | None = None, + top_logprobs: int | None = None, + parallel_tool_calls: bool | None = None, + reasoning_effort: str | None = None, + reasoning: dict[str, Any] | None = None, + # Anthropic + thinking: dict[str, Any] | None = None, + # Google + thinking_level: str | None = None, + thinking_budget: int | None = None, + include_thoughts: bool | None = None, + safety_settings: list[dict[str, Any]] | None = None, + # Shared + verbosity: str | None = None, + # Aliases + stop_sequences: list[str] | None = None, + max_output_tokens: int | None = None, + max_completion_tokens: int | None = None, + candidate_count: int | None = None, + **kwargs: Any, + ) -> AsyncGenerator[ChatCompletionChunk, None]: + """Stream chat completion chunks (async).""" + body = _build_request( + messages=messages, + stream=True, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + strict=strict, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + top_k=top_k, + stop=stop, + n=n, + frequency_penalty=frequency_penalty, + presence_penalty=presence_penalty, + seed=seed, + logit_bias=logit_bias, + logprobs=logprobs, + top_logprobs=top_logprobs, + parallel_tool_calls=parallel_tool_calls, + reasoning_effort=reasoning_effort, + reasoning=reasoning, + thinking=thinking, + thinking_level=thinking_level, + thinking_budget=thinking_budget, + include_thoughts=include_thoughts, + safety_settings=safety_settings, + verbosity=verbosity, + stop_sequences=stop_sequences, + max_output_tokens=max_output_tokens, + max_completion_tokens=max_completion_tokens, + candidate_count=candidate_count, + **kwargs, + ) + async with self._client._async_client.stream("POST", "/", json=body) as response: + response.raise_for_status() + async for data in _aiter_sse(response.aiter_lines()): + yield _parse_stream_chunk(data) diff --git a/src/uipath/llm_client/clients/normalized/embeddings.py b/src/uipath/llm_client/clients/normalized/embeddings.py new file mode 100644 index 0000000..9caf92c --- /dev/null +++ b/src/uipath/llm_client/clients/normalized/embeddings.py @@ -0,0 +1,94 @@ +"""Embeddings endpoint for the UiPath Normalized API. + +Provides synchronous and asynchronous methods for generating text embeddings. +""" + +from __future__ import annotations + +from typing import Any + +from uipath.llm_client.clients.normalized.types import ( + EmbeddingData, + EmbeddingResponse, + Usage, +) + + +def _parse_embedding_response(data: dict[str, Any]) -> EmbeddingResponse: + """Parse an embedding response from the API.""" + usage_data = data.get("usage", {}) + embeddings = [ + EmbeddingData( + embedding=item.get("embedding", []), + index=item.get("index", i), + ) + for i, item in enumerate(data.get("data", [])) + ] + return EmbeddingResponse( + data=embeddings, + model=data.get("model", ""), + usage=Usage(**usage_data), + ) + + +class Embeddings: + """Embeddings namespace with ``create`` and ``acreate``. + + Handles request building and response parsing for the UiPath normalized + embeddings API. + + Example: + >>> response = client.embeddings.create(input=["Hello world"]) + >>> print(response.data[0].embedding[:5]) + >>> + >>> response = await client.embeddings.acreate(input=["Hello world"]) + """ + + def __init__(self, client: Any) -> None: + self._client = client + + def create( + self, + *, + input: str | list[str], + **kwargs: Any, + ) -> EmbeddingResponse: + """Create embeddings (sync). + + Args: + input: A string or list of strings to embed. + **kwargs: Additional parameters for the API. + + Returns: + EmbeddingResponse with embedding vectors. + """ + if isinstance(input, str): + input = [input] + + body: dict[str, Any] = {"input": input, **kwargs} + response = self._client._embedding_sync_client.request("POST", "/", json=body) + response.raise_for_status() + return _parse_embedding_response(response.json()) + + async def acreate( + self, + *, + input: str | list[str], + **kwargs: Any, + ) -> EmbeddingResponse: + """Create embeddings (async). + + Args: + input: A string or list of strings to embed. + **kwargs: Additional parameters for the API. + + Returns: + EmbeddingResponse with embedding vectors. + """ + if isinstance(input, str): + input = [input] + + body: dict[str, Any] = {"input": input, **kwargs} + response = await self._client._embedding_async_client.request("POST", "/", json=body) + response.raise_for_status() + return _parse_embedding_response(response.json()) diff --git a/src/uipath/llm_client/clients/normalized/types.py b/src/uipath/llm_client/clients/normalized/types.py new file mode 100644 index 0000000..aeaa372 --- /dev/null +++ b/src/uipath/llm_client/clients/normalized/types.py @@ -0,0 +1,87 @@ +"""Response types for the UiPath Normalized API.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class ToolCall(BaseModel): + id: str = "" + name: str = "" + arguments: dict[str, Any] = Field(default_factory=dict) + + +class ToolCallChunk(BaseModel): + id: str = "" + name: str = "" + arguments: str = "" + index: int = 0 + + +class Usage(BaseModel): + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + cache_read_input_tokens: int = 0 + cache_creation_input_tokens: int = 0 + thoughts_tokens: int = 0 + request_processing_tier: str | None = None + + +class Message(BaseModel): + role: str = "assistant" + content: str | None = "" + tool_calls: list[ToolCall] = Field(default_factory=list) + signature: str | None = None + thinking: str | None = None + # Structured output (populated client-side when output_format is used) + parsed: Any = None + + +class Delta(BaseModel): + role: str | None = None + content: str | None = "" + tool_calls: list[ToolCallChunk] = Field(default_factory=list) + + +class Choice(BaseModel): + index: int = 0 + message: Message = Field(default_factory=Message) + finish_reason: str | None = None + avg_logprobs: float | None = None + + +class StreamChoice(BaseModel): + index: int = 0 + delta: Delta = Field(default_factory=Delta) + finish_reason: str | None = None + avg_logprobs: float | None = None + + +class ChatCompletion(BaseModel): + id: str = "" + object: str = "" + created: int = 0 + model: str = "" + choices: list[Choice] = Field(default_factory=list) + usage: Usage = Field(default_factory=Usage) + + +class ChatCompletionChunk(BaseModel): + id: str = "" + object: str = "" + created: int | str = 0 + model: str = "" + choices: list[StreamChoice] = Field(default_factory=list) + usage: Usage | None = None + + +class EmbeddingData(BaseModel): + embedding: list[float] = Field(default_factory=list) + index: int = 0 + + +class EmbeddingResponse(BaseModel): + data: list[EmbeddingData] = Field(default_factory=list) + model: str = "" + usage: Usage = Field(default_factory=Usage) diff --git a/tests/core/core_smoke_test.py b/tests/core/core_smoke_test.py index deb72dd..17b44db 100644 --- a/tests/core/core_smoke_test.py +++ b/tests/core/core_smoke_test.py @@ -20,6 +20,7 @@ def test_main_package_imports(): RetryConfig, UiPathHttpxAsyncClient, UiPathHttpxClient, + UiPathNormalizedClient, __version__, get_default_client_settings, ) @@ -47,6 +48,10 @@ def test_main_package_imports(): assert RetryConfig is not None, "RetryConfig should be importable" print(" RetryConfig is importable") + # Verify normalized client is a type + assert isinstance(UiPathNormalizedClient, type), "UiPathNormalizedClient should be a class" + print(" UiPathNormalizedClient is importable") + print(" Main package imports OK") @@ -414,6 +419,47 @@ def test_google_client_inheritance(): print(" Google client inheritance OK") +def test_normalized_client_imports(): + """Test that normalized client and its types can be imported.""" + print("Testing normalized client imports...") + + from uipath.llm_client.clients.normalized import ( + ChatCompletion, + ChatCompletionChunk, + Choice, + Delta, + EmbeddingData, + EmbeddingResponse, + Message, + StreamChoice, + ToolCall, + ToolCallChunk, + UiPathNormalizedClient, + Usage, + ) + + # Verify all are types + types = [ + UiPathNormalizedClient, + ChatCompletion, + ChatCompletionChunk, + Choice, + Delta, + EmbeddingData, + EmbeddingResponse, + Message, + StreamChoice, + ToolCall, + ToolCallChunk, + Usage, + ] + for t in types: + assert isinstance(t, type), f"{t.__name__} should be a class" + + print(f" All {len(types)} normalized client types are importable") + print(" Normalized client imports OK") + + def test_uipath_api_config(): """Test UiPathAPIConfig can be instantiated with valid configurations.""" print("Testing UiPathAPIConfig instantiation...") @@ -463,6 +509,7 @@ def main(): test_httpx_client_module_imports, test_exceptions_module_imports, test_retry_module_imports, + test_normalized_client_imports, test_openai_client_imports, test_anthropic_client_imports, test_google_client_imports, diff --git a/tests/core/test_normalized_client.py b/tests/core/test_normalized_client.py new file mode 100644 index 0000000..e2201c8 --- /dev/null +++ b/tests/core/test_normalized_client.py @@ -0,0 +1,1386 @@ +"""Tests for the normalized client module. + +This module tests: +1. UiPathNormalizedClient initialization and client creation +2. Completions.create (sync, non-streaming) +3. Completions.stream (sync, streaming) +4. Completions.acreate (async, non-streaming) +5. Tool calling (tool definition building, tool_choice resolution) +6. Structured output (Pydantic, TypedDict, dict schemas) +7. Embeddings.create and Embeddings.acreate +8. Response type parsing (ChatCompletion, ChatCompletionChunk, EmbeddingResponse) +""" + +import json +from typing import TypedDict +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import BaseModel + +from uipath.llm_client.clients.normalized import ( + ChatCompletion, + ChatCompletionChunk, + Choice, + Delta, + EmbeddingData, + EmbeddingResponse, + Message, + ToolCall, + ToolCallChunk, + UiPathNormalizedClient, + Usage, +) +from uipath.llm_client.clients.normalized.completions import ( + Completions, + _build_request, + _build_response_format, + _build_tool_definition, + _parse_response, + _parse_stream_chunk, + _parse_structured_output, + _parse_tool_call, + _parse_tool_call_chunk, + _resolve_tool_choice, +) +from uipath.llm_client.clients.normalized.embeddings import _parse_embedding_response +from uipath.llm_client.settings.utils import SingletonMeta + +# ============================================================================ +# Fixtures +# ============================================================================ + +_CLIENT_MODULE = "uipath.llm_client.clients.normalized.client" + + +@pytest.fixture(autouse=True) +def clear_singleton_instances(): + """Clear singleton instances before each test to ensure isolation.""" + SingletonMeta._instances.clear() + yield + SingletonMeta._instances.clear() + + +@pytest.fixture +def mock_settings(): + settings = MagicMock() + settings.build_base_url.return_value = "https://gateway.uipath.com/llm/v1" + settings.build_auth_headers.return_value = {"Authorization": "Bearer test-token"} + settings.build_auth_pipeline.return_value = None + return settings + + +@pytest.fixture +def mock_sync_client(): + client = MagicMock() + return client + + +@pytest.fixture +def mock_async_client(): + client = AsyncMock() + return client + + +# ============================================================================ +# Response parsing helpers +# ============================================================================ + +SAMPLE_COMPLETION_RESPONSE = { + "id": "chatcmpl-123", + "created": 1234567890, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you?", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 8, + "total_tokens": 18, + }, +} + +SAMPLE_TOOL_CALL_RESPONSE = { + "id": "chatcmpl-456", + "created": 1234567890, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_abc123", + "name": "get_weather", + "arguments": {"city": "London"}, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": { + "prompt_tokens": 15, + "completion_tokens": 20, + "total_tokens": 35, + }, +} + +SAMPLE_STREAM_CHUNKS = [ + { + "id": "chatcmpl-789", + "created": 1234567890, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "Hello"}, + "finish_reason": None, + } + ], + }, + { + "id": "chatcmpl-789", + "created": 1234567890, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "delta": {"content": " world!"}, + "finish_reason": None, + } + ], + }, + { + "id": "chatcmpl-789", + "created": 1234567890, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8, + }, + }, +] + +SAMPLE_EMBEDDING_RESPONSE = { + "data": [ + {"embedding": [0.1, 0.2, 0.3], "index": 0}, + {"embedding": [0.4, 0.5, 0.6], "index": 1}, + ], + "model": "text-embedding-ada-002", + "usage": {"prompt_tokens": 5, "total_tokens": 5}, +} + + +# ============================================================================ +# Test: Response type parsing +# ============================================================================ + + +class TestParseResponse: + def test_basic_completion(self): + result = _parse_response(SAMPLE_COMPLETION_RESPONSE) + assert isinstance(result, ChatCompletion) + assert result.id == "chatcmpl-123" + assert result.model == "gpt-4o" + assert len(result.choices) == 1 + assert result.choices[0].message.content == "Hello! How can I help you?" + assert result.choices[0].finish_reason == "stop" + assert result.usage.prompt_tokens == 10 + assert result.usage.completion_tokens == 8 + assert result.usage.total_tokens == 18 + + def test_tool_call_response(self): + result = _parse_response(SAMPLE_TOOL_CALL_RESPONSE) + assert len(result.choices[0].message.tool_calls) == 1 + tc = result.choices[0].message.tool_calls[0] + assert tc.id == "call_abc123" + assert tc.name == "get_weather" + assert tc.arguments == {"city": "London"} + + def test_empty_response(self): + result = _parse_response({"choices": [], "usage": {}}) + assert len(result.choices) == 0 + assert result.usage.prompt_tokens == 0 + + def test_tool_call_with_string_arguments(self): + data = { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "call_1", + "name": "func", + "arguments": '{"key": "value"}', + } + ] + } + } + ], + "usage": {}, + } + result = _parse_response(data) + tc = result.choices[0].message.tool_calls[0] + assert tc.arguments == {"key": "value"} + + def test_tool_call_with_invalid_json_arguments(self): + data = { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "call_1", + "name": "func", + "arguments": "not json", + } + ] + } + } + ], + "usage": {}, + } + result = _parse_response(data) + tc = result.choices[0].message.tool_calls[0] + assert tc.arguments == {} + + +class TestParseStreamChunk: + def test_content_chunk(self): + result = _parse_stream_chunk(SAMPLE_STREAM_CHUNKS[0]) + assert isinstance(result, ChatCompletionChunk) + assert result.id == "chatcmpl-789" + assert len(result.choices) == 1 + assert result.choices[0].delta.content == "Hello" + assert result.choices[0].delta.role == "assistant" + + def test_chunk_with_usage(self): + result = _parse_stream_chunk(SAMPLE_STREAM_CHUNKS[2]) + assert result.usage is not None + assert result.usage.prompt_tokens == 5 + assert result.choices[0].finish_reason == "stop" + + def test_chunk_without_usage(self): + result = _parse_stream_chunk(SAMPLE_STREAM_CHUNKS[0]) + assert result.usage is None + + def test_stream_tool_call_chunk(self): + data = { + "id": "chatcmpl-tc", + "choices": [ + { + "delta": { + "tool_calls": [ + { + "id": "call_1", + "name": "get_weather", + "arguments": '{"city":', + "index": 0, + } + ] + } + } + ], + } + result = _parse_stream_chunk(data) + assert len(result.choices[0].delta.tool_calls) == 1 + tc = result.choices[0].delta.tool_calls[0] + assert tc.name == "get_weather" + assert tc.arguments == '{"city":' + + def test_stream_tool_call_with_function_format(self): + data = { + "id": "chatcmpl-tc", + "choices": [ + { + "delta": { + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "get_weather", + "arguments": '{"city": "Paris"}', + }, + "index": 0, + } + ] + } + } + ], + } + result = _parse_stream_chunk(data) + tc = result.choices[0].delta.tool_calls[0] + assert tc.name == "get_weather" + assert tc.arguments == '{"city": "Paris"}' + + +class TestParseEmbeddingResponse: + def test_basic_embedding(self): + result = _parse_embedding_response(SAMPLE_EMBEDDING_RESPONSE) + assert isinstance(result, EmbeddingResponse) + assert len(result.data) == 2 + assert result.data[0].embedding == [0.1, 0.2, 0.3] + assert result.data[1].embedding == [0.4, 0.5, 0.6] + assert result.model == "text-embedding-ada-002" + assert result.usage.prompt_tokens == 5 + + def test_empty_embedding(self): + result = _parse_embedding_response({"data": [], "usage": {}}) + assert len(result.data) == 0 + + +# ============================================================================ +# Test: Structured output +# ============================================================================ + + +class TestBuildResponseFormat: + def test_pydantic_model(self): + class MyModel(BaseModel): + name: str + age: int + + result = _build_response_format(MyModel) + assert result["type"] == "json_schema" + assert result["json_schema"]["name"] == "MyModel" + assert result["json_schema"]["strict"] is True + assert "properties" in result["json_schema"]["schema"] + + def test_typed_dict(self): + class MyDict(TypedDict): + name: str + score: float + + result = _build_response_format(MyDict) + assert result["type"] == "json_schema" + assert result["json_schema"]["name"] == "MyDict" + assert result["json_schema"]["strict"] is True + schema = result["json_schema"]["schema"] + assert schema["type"] == "object" + assert "name" in schema["properties"] + assert "score" in schema["properties"] + assert schema["properties"]["name"]["type"] == "string" + assert schema["properties"]["score"]["type"] == "number" + + def test_dict_schema(self): + schema = { + "name": "my_schema", + "schema": {"type": "object", "properties": {"x": {"type": "integer"}}}, + } + result = _build_response_format(schema) + assert result["type"] == "json_schema" + assert result["json_schema"] == schema + + def test_unsupported_type(self): + with pytest.raises(TypeError, match="Unsupported response_format"): + _build_response_format("not a type") # type: ignore[arg-type] + + +class TestParseStructuredOutput: + def test_parse_pydantic(self): + class Answer(BaseModel): + text: str + score: float + + content = '{"text": "hello", "score": 0.9}' + result = _parse_structured_output(content, Answer) + assert isinstance(result, Answer) + assert result.text == "hello" + assert result.score == 0.9 + + def test_parse_dict(self): + content = '{"key": "value"}' + result = _parse_structured_output(content, {"type": "object"}) + assert result == {"key": "value"} + + def test_parse_invalid_json(self): + result = _parse_structured_output("not json", str) + assert result is None + + def test_response_with_structured_output(self): + class Answer(BaseModel): + text: str + + data = { + "choices": [ + { + "message": { + "content": '{"text": "hello"}', + } + } + ], + "usage": {}, + } + result = _parse_response(data, response_format=Answer) + assert result.choices[0].message.parsed is not None + assert isinstance(result.choices[0].message.parsed, Answer) + assert result.choices[0].message.parsed.text == "hello" + + def test_response_without_structured_output(self): + data = { + "choices": [ + { + "message": { + "content": "plain text", + } + } + ], + "usage": {}, + } + result = _parse_response(data) + assert result.choices[0].message.parsed is None + + +# ============================================================================ +# Test: Tool definition building +# ============================================================================ + + +class TestBuildToolDefinition: + def test_dict_passthrough(self): + tool = {"name": "my_tool", "description": "does stuff", "parameters": {}} + result = _build_tool_definition(tool) + assert result is tool + + def test_pydantic_model(self): + class WeatherInput(BaseModel): + """Get weather for a city.""" + + city: str + units: str = "celsius" + + result = _build_tool_definition(WeatherInput) + assert result["name"] == "WeatherInput" + assert result["description"] == "Get weather for a city." + assert "properties" in result["parameters"] + assert "city" in result["parameters"]["properties"] + + def test_callable(self): + def get_weather(city: str, units: str = "celsius") -> str: + """Get weather for a city.""" + return f"Weather in {city}" + + result = _build_tool_definition(get_weather) + assert result["name"] == "get_weather" + assert result["description"] == "Get weather for a city." + assert "city" in result["parameters"]["properties"] + assert "city" in result["parameters"]["required"] + assert "units" not in result["parameters"]["required"] + + def test_unsupported_type(self): + with pytest.raises(TypeError, match="Unsupported tool type"): + _build_tool_definition(42) + + +class TestToolChoiceResolution: + def test_auto(self): + result = _resolve_tool_choice("auto", []) + assert result == "auto" + + def test_required(self): + result = _resolve_tool_choice("required", []) + assert result == "required" + + def test_none(self): + result = _resolve_tool_choice("none", []) + assert result == "none" + + def test_specific_tool(self): + tools = [{"name": "get_weather"}, {"name": "search"}] + result = _resolve_tool_choice("get_weather", tools) + assert result == {"type": "tool", "name": "get_weather"} + + def test_unknown_becomes_auto(self): + result = _resolve_tool_choice("unknown_tool", [{"name": "other"}]) + assert result == "auto" + + def test_dict_passthrough(self): + choice = {"type": "required"} + result = _resolve_tool_choice(choice, []) + assert result is choice + + +# ============================================================================ +# Test: Tool call parsing +# ============================================================================ + + +class TestParseToolCall: + def test_basic(self): + tc = _parse_tool_call({"id": "call_1", "name": "func", "arguments": {"x": 1}}) + assert tc.id == "call_1" + assert tc.name == "func" + assert tc.arguments == {"x": 1} + + def test_string_arguments(self): + tc = _parse_tool_call({"id": "call_1", "name": "func", "arguments": '{"x": 1}'}) + assert tc.arguments == {"x": 1} + + def test_invalid_string_arguments(self): + tc = _parse_tool_call({"id": "call_1", "name": "func", "arguments": "not json"}) + assert tc.arguments == {} + + +class TestParseToolCallChunk: + def test_flat_format(self): + tc = _parse_tool_call_chunk( + {"id": "call_1", "name": "func", "arguments": '{"x":', "index": 0} + ) + assert tc.name == "func" + assert tc.arguments == '{"x":' + + def test_function_format(self): + tc = _parse_tool_call_chunk( + { + "id": "call_1", + "function": {"name": "func", "arguments": '{"x": 1}'}, + "index": 0, + } + ) + assert tc.name == "func" + assert tc.arguments == '{"x": 1}' + + def test_dict_arguments_converted(self): + tc = _parse_tool_call_chunk( + {"id": "call_1", "name": "func", "arguments": {"x": 1}, "index": 0} + ) + assert tc.arguments == '{"x": 1}' + + +# ============================================================================ +# Test: Client initialization +# ============================================================================ + + +class TestUiPathNormalizedClientInit: + @patch(f"{_CLIENT_MODULE}.build_httpx_client") + @patch(f"{_CLIENT_MODULE}.get_default_client_settings") + def test_default_settings(self, mock_get_settings, mock_build): + mock_settings = MagicMock() + mock_settings.build_auth_pipeline.return_value = None + mock_get_settings.return_value = mock_settings + + client = UiPathNormalizedClient(model_name="gpt-4o") + assert client._model_name == "gpt-4o" + mock_get_settings.assert_called_once() + + @patch(f"{_CLIENT_MODULE}.build_httpx_client") + def test_custom_settings(self, mock_build): + settings = MagicMock() + settings.build_auth_pipeline.return_value = None + + client = UiPathNormalizedClient(model_name="gpt-4o", client_settings=settings) + assert client._client_settings is settings + + @patch(f"{_CLIENT_MODULE}.build_httpx_client") + @patch(f"{_CLIENT_MODULE}.get_default_client_settings") + def test_has_completions_namespace(self, mock_get_settings, mock_build): + mock_settings = MagicMock() + mock_settings.build_auth_pipeline.return_value = None + mock_get_settings.return_value = mock_settings + mock_build.return_value = MagicMock() + + client = UiPathNormalizedClient(model_name="gpt-4o") + assert hasattr(client, "completions") + assert isinstance(client.completions, Completions) + + @patch(f"{_CLIENT_MODULE}.build_httpx_client") + @patch(f"{_CLIENT_MODULE}.get_default_client_settings") + def test_has_embeddings_namespace(self, mock_get_settings, mock_build): + mock_settings = MagicMock() + mock_settings.build_auth_pipeline.return_value = None + mock_get_settings.return_value = mock_settings + mock_build.return_value = MagicMock() + + client = UiPathNormalizedClient(model_name="gpt-4o") + from uipath.llm_client.clients.normalized.embeddings import Embeddings + + assert hasattr(client, "embeddings") + assert isinstance(client.embeddings, Embeddings) + + @patch(f"{_CLIENT_MODULE}.build_httpx_client") + @patch(f"{_CLIENT_MODULE}.get_default_client_settings") + def test_completions_api_config(self, mock_get_settings, mock_build): + mock_settings = MagicMock() + mock_settings.build_auth_pipeline.return_value = None + mock_get_settings.return_value = mock_settings + + client = UiPathNormalizedClient(model_name="gpt-4o") + assert client._completions_api_config.api_type == "completions" + assert client._completions_api_config.routing_mode == "normalized" + assert client._completions_api_config.freeze_base_url is True + + @patch(f"{_CLIENT_MODULE}.build_httpx_client") + @patch(f"{_CLIENT_MODULE}.get_default_client_settings") + def test_embeddings_api_config(self, mock_get_settings, mock_build): + mock_settings = MagicMock() + mock_settings.build_auth_pipeline.return_value = None + mock_get_settings.return_value = mock_settings + + client = UiPathNormalizedClient(model_name="gpt-4o") + assert client._embeddings_api_config.api_type == "embeddings" + assert client._embeddings_api_config.routing_mode == "normalized" + assert client._embeddings_api_config.freeze_base_url is True + + +# ============================================================================ +# Test: Completions.create (sync, non-streaming) +# ============================================================================ + + +class TestCompletionsCreate: + def test_basic_create(self, mock_sync_client): + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_COMPLETION_RESPONSE + mock_sync_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + result = completions.create( + messages=[{"role": "user", "content": "Hello"}], + ) + + assert isinstance(result, ChatCompletion) + assert result.choices[0].message.content == "Hello! How can I help you?" + mock_sync_client.request.assert_called_once() + call_kwargs = mock_sync_client.request.call_args + body = call_kwargs.kwargs["json"] + assert body["messages"] == [{"role": "user", "content": "Hello"}] + + def test_create_with_params(self, mock_sync_client): + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_COMPLETION_RESPONSE + mock_sync_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + completions.create( + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + temperature=0.5, + top_p=0.9, + stop=["END"], + n=2, + presence_penalty=0.1, + frequency_penalty=0.2, + ) + + body = mock_sync_client.request.call_args.kwargs["json"] + assert body["max_tokens"] == 100 + assert body["temperature"] == 0.5 + assert body["top_p"] == 0.9 + assert body["stop"] == ["END"] + assert body["n"] == 2 + assert body["presence_penalty"] == 0.1 + assert body["frequency_penalty"] == 0.2 + + def test_create_omits_none_params(self, mock_sync_client): + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_COMPLETION_RESPONSE + mock_sync_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + completions.create( + messages=[{"role": "user", "content": "Hello"}], + ) + + body = mock_sync_client.request.call_args.kwargs["json"] + assert "max_tokens" not in body + assert "temperature" not in body + assert "stop" not in body + + def test_create_with_tools(self, mock_sync_client): + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_TOOL_CALL_RESPONSE + mock_sync_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + result = completions.create( + messages=[{"role": "user", "content": "What's the weather?"}], + tools=[ + { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + } + ], + tool_choice="auto", + ) + + body = mock_sync_client.request.call_args.kwargs["json"] + assert "tools" in body + assert body["tool_choice"] == "auto" + assert len(result.choices[0].message.tool_calls) == 1 + + def test_create_with_response_format(self, mock_sync_client): + class MyOutput(BaseModel): + answer: str + + mock_response = MagicMock() + mock_response.json.return_value = { + "choices": [{"message": {"content": '{"answer": "42"}'}}], + "usage": {}, + } + mock_sync_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + result = completions.create( + messages=[{"role": "user", "content": "What is 6*7?"}], + response_format=MyOutput, + ) + + body = mock_sync_client.request.call_args.kwargs["json"] + assert "response_format" in body + assert body["response_format"]["type"] == "json_schema" + assert result.choices[0].message.parsed is not None + assert result.choices[0].message.parsed.answer == "42" + + def test_create_with_kwargs(self, mock_sync_client): + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_COMPLETION_RESPONSE + mock_sync_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + completions.create( + messages=[{"role": "user", "content": "Hello"}], + reasoning={"effort": "high"}, + ) + + body = mock_sync_client.request.call_args.kwargs["json"] + assert body["reasoning"] == {"effort": "high"} + + +# ============================================================================ +# Test: Completions.stream (sync, streaming) +# ============================================================================ + + +class TestCompletionsStream: + def test_stream_yields_chunks(self, mock_sync_client): + sse_lines = [f"data: {json.dumps(chunk)}" for chunk in SAMPLE_STREAM_CHUNKS] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = iter(sse_lines) + mock_sync_client.stream.return_value.__enter__ = MagicMock(return_value=mock_response) + mock_sync_client.stream.return_value.__exit__ = MagicMock(return_value=False) + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + chunks = list( + completions.stream( + messages=[{"role": "user", "content": "Hello"}], + ) + ) + + assert len(chunks) == 3 + assert chunks[0].choices[0].delta.content == "Hello" + assert chunks[1].choices[0].delta.content == " world!" + assert chunks[2].choices[0].finish_reason == "stop" + + def test_stream_skips_invalid_json(self, mock_sync_client): + lines = [ + "data: {invalid json", + f"data: {json.dumps(SAMPLE_STREAM_CHUNKS[0])}", + "", # empty line + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = iter(lines) + mock_sync_client.stream.return_value.__enter__ = MagicMock(return_value=mock_response) + mock_sync_client.stream.return_value.__exit__ = MagicMock(return_value=False) + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + chunks = list( + completions.stream( + messages=[{"role": "user", "content": "Hello"}], + ) + ) + + assert len(chunks) == 1 + + def test_stream_skips_empty_id(self, mock_sync_client): + lines = [ + f"data: {json.dumps({'id': '', 'choices': []})}", + f"data: {json.dumps(SAMPLE_STREAM_CHUNKS[0])}", + ] + + mock_response = MagicMock() + mock_response.iter_lines.return_value = iter(lines) + mock_sync_client.stream.return_value.__enter__ = MagicMock(return_value=mock_response) + mock_sync_client.stream.return_value.__exit__ = MagicMock(return_value=False) + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + chunks = list( + completions.stream( + messages=[{"role": "user", "content": "Hello"}], + ) + ) + + assert len(chunks) == 1 + + def test_stream_sets_stream_flag(self, mock_sync_client): + mock_response = MagicMock() + mock_response.iter_lines.return_value = iter([]) + mock_sync_client.stream.return_value.__enter__ = MagicMock(return_value=mock_response) + mock_sync_client.stream.return_value.__exit__ = MagicMock(return_value=False) + + client_obj = MagicMock() + client_obj._sync_client = mock_sync_client + + completions = Completions(client_obj) + list( + completions.stream( + messages=[{"role": "user", "content": "Hello"}], + ) + ) + + call_kwargs = mock_sync_client.stream.call_args + body = call_kwargs.kwargs["json"] + assert body["stream"] is True + + +# ============================================================================ +# Test: Completions.acreate (async, non-streaming) +# ============================================================================ + + +class TestAsyncCompletionsCreate: + @pytest.mark.asyncio + async def test_basic_acreate(self, mock_async_client): + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_COMPLETION_RESPONSE + mock_async_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._async_client = mock_async_client + + completions = Completions(client_obj) + result = await completions.acreate( + messages=[{"role": "user", "content": "Hello"}], + ) + + assert isinstance(result, ChatCompletion) + assert result.choices[0].message.content == "Hello! How can I help you?" + + +# ============================================================================ +# Test: Embeddings +# ============================================================================ + + +class TestEmbeddingsCreate: + def test_basic_create(self): + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_EMBEDDING_RESPONSE + + mock_client = MagicMock() + mock_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._embedding_sync_client = mock_client + + from uipath.llm_client.clients.normalized.embeddings import Embeddings + + embeddings = Embeddings(client_obj) + result = embeddings.create(input=["Hello world", "Goodbye"]) + + assert isinstance(result, EmbeddingResponse) + assert len(result.data) == 2 + assert result.data[0].embedding == [0.1, 0.2, 0.3] + + body = mock_client.request.call_args.kwargs["json"] + assert body["input"] == ["Hello world", "Goodbye"] + + def test_string_input_wrapped(self): + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_EMBEDDING_RESPONSE + + mock_client = MagicMock() + mock_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._embedding_sync_client = mock_client + + from uipath.llm_client.clients.normalized.embeddings import Embeddings + + embeddings = Embeddings(client_obj) + embeddings.create(input="Hello world") + + body = mock_client.request.call_args.kwargs["json"] + assert body["input"] == ["Hello world"] + + +class TestAsyncEmbeddingsCreate: + @pytest.mark.asyncio + async def test_basic_acreate(self): + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_EMBEDDING_RESPONSE + + mock_client = AsyncMock() + mock_client.request.return_value = mock_response + + client_obj = MagicMock() + client_obj._embedding_async_client = mock_client + + from uipath.llm_client.clients.normalized.embeddings import Embeddings + + embeddings = Embeddings(client_obj) + result = await embeddings.acreate(input=["Hello world"]) + + assert isinstance(result, EmbeddingResponse) + assert len(result.data) == 2 + + +# ============================================================================ +# Test: Type models +# ============================================================================ + + +class TestTypeModels: + def test_usage_defaults(self): + usage = Usage() + assert usage.prompt_tokens == 0 + assert usage.completion_tokens == 0 + assert usage.total_tokens == 0 + assert usage.cache_read_input_tokens == 0 + + def test_tool_call(self): + tc = ToolCall(id="call_1", name="func", arguments={"x": 1}) + assert tc.id == "call_1" + assert tc.name == "func" + assert tc.arguments == {"x": 1} + + def test_tool_call_chunk(self): + tc = ToolCallChunk(id="call_1", name="func", arguments='{"x":', index=0) + assert tc.arguments == '{"x":' + + def test_message_defaults(self): + msg = Message() + assert msg.role == "assistant" + assert msg.content == "" + assert msg.tool_calls == [] + assert msg.parsed is None + + def test_delta_defaults(self): + delta = Delta() + assert delta.role is None + assert delta.content == "" + assert delta.tool_calls == [] + + def test_choice_defaults(self): + choice = Choice() + assert choice.index == 0 + assert choice.finish_reason is None + + def test_chat_completion_defaults(self): + cc = ChatCompletion() + assert cc.id == "" + assert cc.choices == [] + assert cc.usage.prompt_tokens == 0 + + def test_embedding_data(self): + ed = EmbeddingData(embedding=[0.1, 0.2], index=0) + assert ed.embedding == [0.1, 0.2] + + def test_embedding_response(self): + er = EmbeddingResponse( + data=[EmbeddingData(embedding=[0.1], index=0)], + model="test-model", + ) + assert len(er.data) == 1 + assert er.model == "test-model" + + +# ============================================================================ +# Test: Request body building +# ============================================================================ + + +class TestBuildRequest: + def test_minimal_request(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + ) + assert body == {"messages": [{"role": "user", "content": "Hi"}]} + + def test_stream_flag(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + stream=True, + ) + assert body["stream"] is True + + def test_all_optional_params(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + max_tokens=100, + temperature=0.7, + stop=["END"], + n=3, + top_p=0.9, + presence_penalty=0.5, + frequency_penalty=0.3, + ) + assert body["max_tokens"] == 100 + assert body["temperature"] == 0.7 + assert body["stop"] == ["END"] + assert body["n"] == 3 + assert body["top_p"] == 0.9 + assert body["presence_penalty"] == 0.5 + assert body["frequency_penalty"] == 0.3 + + def test_with_tools(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + tools=[{"name": "func", "description": "d", "parameters": {}}], + tool_choice="auto", + ) + assert len(body["tools"]) == 1 + assert body["tool_choice"] == "auto" + + def test_with_response_format(self): + class MyModel(BaseModel): + x: int + + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + response_format=MyModel, + ) + assert body["response_format"]["type"] == "json_schema" + assert body["response_format"]["json_schema"]["name"] == "MyModel" + + def test_kwargs_merged(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + custom_param="value", + ) + assert body["custom_param"] == "value" + + def test_openai_specific_params(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + seed=42, + logit_bias={"123": -100}, + logprobs=True, + top_logprobs=5, + parallel_tool_calls=False, + reasoning_effort="high", + reasoning={"effort": "high"}, + ) + assert body["seed"] == 42 + assert body["logit_bias"] == {"123": -100} + assert body["logprobs"] is True + assert body["top_logprobs"] == 5 + assert body["parallel_tool_calls"] is False + assert body["reasoning_effort"] == "high" + assert body["reasoning"] == {"effort": "high"} + + def test_anthropic_specific_params(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + top_k=40, + thinking={"type": "enabled", "budget_tokens": 1000}, + ) + assert body["top_k"] == 40 + assert body["thinking"] == {"type": "enabled", "budget_tokens": 1000} + + def test_google_specific_params(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + thinking_level="high", + thinking_budget=2000, + include_thoughts=True, + safety_settings=[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}], + ) + assert body["thinking_level"] == "high" + assert body["thinking_budget"] == 2000 + assert body["include_thoughts"] is True + assert len(body["safety_settings"]) == 1 + + def test_shared_params(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + verbosity="low", + ) + assert body["verbosity"] == "low" + + def test_removed_infra_params_go_through_kwargs(self): + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + user="user-123", + service_tier="auto", + metadata={"request_id": "abc"}, + ) + assert body["user"] == "user-123" + assert body["service_tier"] == "auto" + assert body["metadata"] == {"request_id": "abc"} + + def test_pydantic_messages(self): + class ChatMessage(BaseModel): + role: str + content: str + + body = _build_request( + messages=[ChatMessage(role="user", content="Hi")], + ) + assert body["messages"] == [{"role": "user", "content": "Hi"}] + + def test_mixed_dict_and_pydantic_messages(self): + class ChatMessage(BaseModel): + role: str + content: str + + body = _build_request( + messages=[ + {"role": "system", "content": "Be brief."}, + ChatMessage(role="user", content="Hi"), + ], + ) + assert body["messages"] == [ + {"role": "system", "content": "Be brief."}, + {"role": "user", "content": "Hi"}, + ] + + def test_pydantic_message_with_none_fields_excluded(self): + class ChatMessage(BaseModel): + role: str + content: str + name: str | None = None + + body = _build_request( + messages=[ChatMessage(role="user", content="Hi")], + ) + assert body["messages"] == [{"role": "user", "content": "Hi"}] + + def test_pydantic_tool_in_tools_list(self): + class GetWeather(BaseModel): + """Get weather for a city.""" + + city: str + + body = _build_request( + messages=[{"role": "user", "content": "Hi"}], + tools=[GetWeather], + tool_choice="auto", + ) + assert body["tools"][0]["name"] == "GetWeather" + assert "city" in body["tools"][0]["parameters"]["properties"] + + +# ============================================================================ +# Test: Real-world response shapes (from captured API payloads) +# ============================================================================ + + +class TestRealWorldResponses: + """Tests using actual response shapes observed from the normalized API.""" + + def test_gpt4o_basic(self): + data = { + "id": "chatcmpl-DQdh09fdBuc8LPCkDqhJKgrQy3IN8", + "model": "gpt-4o-2024-11-20", + "object": "chat.completion", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": {"content": "Hello.", "role": "assistant"}, + } + ], + "created": 1775241562, + "usage": { + "completion_tokens": 3, + "prompt_tokens": 14, + "total_tokens": 17, + "cache_read_input_tokens": 0, + "thoughts_tokens": 0, + }, + } + result = _parse_response(data) + assert result.object == "chat.completion" + assert result.model == "gpt-4o-2024-11-20" + assert result.choices[0].message.content == "Hello." + assert result.usage.thoughts_tokens == 0 + + def test_gemini_with_avg_logprobs_and_signature(self): + data = { + "id": "gemini-123", + "model": "gemini-2.5-flash", + "object": "chat.completion", + "choices": [ + { + "finish_reason": "stop", + "avg_logprobs": -0.123, + "index": 0, + "message": { + "role": "assistant", + "signature": "abc123signature", + "tool_calls": [ + {"id": "call_1", "name": "get_weather", "arguments": {"city": "London"}} + ], + }, + } + ], + "created": 1775241600, + "usage": { + "completion_tokens": 5, + "prompt_tokens": 31, + "total_tokens": 130, + "cache_read_input_tokens": 0, + "thoughts_tokens": 94, + "request_processing_tier": "ON_DEMAND", + }, + } + result = _parse_response(data) + assert result.choices[0].avg_logprobs == -0.123 + assert result.choices[0].message.signature == "abc123signature" + assert result.choices[0].message.tool_calls[0].name == "get_weather" + assert result.usage.thoughts_tokens == 94 + assert result.usage.request_processing_tier == "ON_DEMAND" + + def test_anthropic_with_thinking(self): + data = { + "id": "anthropic-456", + "model": "claude-haiku-4-5", + "object": "chat.completion", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "15 + 27 = 42", + "role": "assistant", + "signature": "ErACsignature", + "thinking": "This is a straightforward arithmetic problem.\n15 + 27 = 42", + }, + } + ], + "created": 1775241700, + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 10, + "prompt_tokens": 14, + "total_tokens": 24, + }, + } + result = _parse_response(data) + assert ( + result.choices[0].message.thinking + == "This is a straightforward arithmetic problem.\n15 + 27 = 42" + ) + assert result.choices[0].message.signature == "ErACsignature" + assert result.choices[0].message.content == "15 + 27 = 42" + assert result.usage.cache_creation_input_tokens == 0 + + def test_gpt5_with_reasoning_usage(self): + data = { + "id": "chatcmpl-gpt5", + "model": "gpt-5.2-2025-12-11", + "object": "chat.completion", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": {"content": "100", "role": "assistant"}, + } + ], + "created": 1775241800, + "usage": { + "completion_tokens": 2, + "prompt_tokens": 20, + "total_tokens": 22, + "cache_read_input_tokens": 0, + "thoughts_tokens": 50, + "request_processing_tier": "ON_DEMAND", + }, + } + result = _parse_response(data) + assert result.usage.thoughts_tokens == 50 + assert result.usage.request_processing_tier == "ON_DEMAND" + + def test_embedding_response_real_shape(self): + """Embeddings only return prompt_tokens and total_tokens.""" + data = { + "data": [{"embedding": [0.1, 0.2, 0.3]}], + "usage": {"prompt_tokens": 2, "total_tokens": 2}, + } + from uipath.llm_client.clients.normalized.embeddings import _parse_embedding_response + + result = _parse_embedding_response(data) + assert result.data[0].embedding == [0.1, 0.2, 0.3] + assert result.data[0].index == 0 # auto-assigned + assert result.usage.prompt_tokens == 2 + assert result.usage.completion_tokens == 0 # default + + def test_tool_call_arguments_always_dict(self): + """Normalized API always returns arguments as dict, not string.""" + data = { + "id": "tc-test", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "name": "get_weather", + "arguments": {"city": "London"}, + }, + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": {}, + } + result = _parse_response(data) + tc = result.choices[0].message.tool_calls[0] + assert isinstance(tc.arguments, dict) + assert tc.arguments == {"city": "London"} diff --git a/tests/core/test_normalized_integration.py b/tests/core/test_normalized_integration.py new file mode 100644 index 0000000..e177f28 --- /dev/null +++ b/tests/core/test_normalized_integration.py @@ -0,0 +1,235 @@ +"""Integration tests for the normalized client. + +These tests verify the normalized client works end-to-end with VCR cassettes. +They test: +1. Basic chat completions (sync) +2. Chat completions with parameters (temperature, max_tokens) +3. Streaming completions (sync via .stream()) +4. Tool calling +5. Structured output with Pydantic models +6. Structured output with TypedDict +7. Embeddings (sync) +8. Async completions (via .acreate()) +9. Async embeddings (via .acreate()) +""" + +from typing import TypedDict + +import pytest +from pydantic import BaseModel + +from uipath.llm_client.clients.normalized import ( + ChatCompletion, + ChatCompletionChunk, + EmbeddingResponse, + UiPathNormalizedClient, +) +from uipath.llm_client.settings import UiPathBaseSettings + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def normalized_client(client_settings: UiPathBaseSettings) -> UiPathNormalizedClient: + return UiPathNormalizedClient( + model_name="gpt-4o-2024-11-20", + client_settings=client_settings, + ) + + +@pytest.fixture +def embedding_client(client_settings: UiPathBaseSettings) -> UiPathNormalizedClient: + return UiPathNormalizedClient( + model_name="text-embedding-ada-002", + client_settings=client_settings, + ) + + +# ============================================================================ +# Structured output models +# ============================================================================ + + +class MathAnswer(BaseModel): + answer: int + explanation: str + + +class CityInfo(TypedDict): + name: str + country: str + + +# ============================================================================ +# Sync completions tests +# ============================================================================ + + +class TestNormalizedCompletions: + @pytest.mark.vcr() + def test_basic_completion(self, normalized_client: UiPathNormalizedClient): + response = normalized_client.completions.create( + messages=[{"role": "user", "content": "Say hello in one word."}], + ) + assert isinstance(response, ChatCompletion) + assert len(response.choices) >= 1 + assert response.choices[0].message.content + assert response.choices[0].finish_reason == "stop" + assert response.usage.total_tokens > 0 + + @pytest.mark.vcr() + def test_completion_with_params(self, normalized_client: UiPathNormalizedClient): + response = normalized_client.completions.create( + messages=[{"role": "user", "content": "Say hi."}], + max_tokens=10, + temperature=0.0, + ) + assert isinstance(response, ChatCompletion) + assert response.choices[0].message.content + + @pytest.mark.vcr() + def test_completion_with_system_message(self, normalized_client: UiPathNormalizedClient): + response = normalized_client.completions.create( + messages=[ + {"role": "system", "content": "You are a helpful assistant. Be very brief."}, + {"role": "user", "content": "What is 2+2?"}, + ], + ) + assert isinstance(response, ChatCompletion) + assert response.choices[0].message.content + + +class TestNormalizedStreaming: + @pytest.mark.vcr() + def test_streaming(self, normalized_client: UiPathNormalizedClient): + chunks = list( + normalized_client.completions.stream( + messages=[{"role": "user", "content": "Count from 1 to 3."}], + ) + ) + assert len(chunks) > 0 + assert all(isinstance(c, ChatCompletionChunk) for c in chunks) + + # At least one chunk should have content + content_chunks = [c for c in chunks if c.choices and c.choices[0].delta.content] + assert len(content_chunks) > 0 + + +class TestNormalizedToolCalling: + @pytest.mark.vcr() + def test_tool_calling(self, normalized_client: UiPathNormalizedClient): + response = normalized_client.completions.create( + messages=[ + {"role": "user", "content": "What is the weather in London?"}, + ], + tools=[ + { + "name": "get_weather", + "description": "Get the current weather in a city", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "The city name"}, + }, + "required": ["city"], + }, + } + ], + tool_choice="required", + ) + assert isinstance(response, ChatCompletion) + assert len(response.choices[0].message.tool_calls) >= 1 + tc = response.choices[0].message.tool_calls[0] + assert tc.name == "get_weather" + assert "city" in tc.arguments + + @pytest.mark.vcr() + def test_tool_calling_with_pydantic(self, normalized_client: UiPathNormalizedClient): + class GetWeatherInput(BaseModel): + """Get the current weather in a city.""" + + city: str + + response = normalized_client.completions.create( + messages=[ + {"role": "user", "content": "What is the weather in Paris?"}, + ], + tools=[GetWeatherInput], + tool_choice="required", + ) + assert isinstance(response, ChatCompletion) + assert len(response.choices[0].message.tool_calls) >= 1 + + +class TestNormalizedStructuredOutput: + @pytest.mark.vcr() + def test_structured_output_pydantic(self, normalized_client: UiPathNormalizedClient): + response = normalized_client.completions.create( + messages=[{"role": "user", "content": "What is 15 + 27?"}], + response_format=MathAnswer, + ) + assert isinstance(response, ChatCompletion) + parsed = response.choices[0].message.parsed + assert isinstance(parsed, MathAnswer) + assert parsed.answer == 42 + + @pytest.mark.vcr() + def test_structured_output_typed_dict(self, normalized_client: UiPathNormalizedClient): + response = normalized_client.completions.create( + messages=[{"role": "user", "content": "Tell me about Tokyo."}], + response_format=CityInfo, + ) + assert isinstance(response, ChatCompletion) + parsed = response.choices[0].message.parsed + assert isinstance(parsed, dict) + assert "name" in parsed + assert "country" in parsed + + +# ============================================================================ +# Embeddings tests +# ============================================================================ + + +class TestNormalizedEmbeddings: + @pytest.mark.vcr() + def test_single_embedding(self, embedding_client: UiPathNormalizedClient): + response = embedding_client.embeddings.create(input="Hello world") + assert isinstance(response, EmbeddingResponse) + assert len(response.data) == 1 + assert len(response.data[0].embedding) > 0 + + @pytest.mark.vcr() + def test_batch_embeddings(self, embedding_client: UiPathNormalizedClient): + response = embedding_client.embeddings.create(input=["Hello world", "Goodbye world"]) + assert isinstance(response, EmbeddingResponse) + assert len(response.data) == 2 + + +# ============================================================================ +# Async tests +# ============================================================================ + + +class TestAsyncNormalizedCompletions: + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_async_completion(self, normalized_client: UiPathNormalizedClient): + response = await normalized_client.completions.acreate( + messages=[{"role": "user", "content": "Say hello in one word."}], + ) + assert isinstance(response, ChatCompletion) + assert response.choices[0].message.content + + +class TestAsyncNormalizedEmbeddings: + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_async_embedding(self, embedding_client: UiPathNormalizedClient): + response = await embedding_client.embeddings.acreate( + input="Hello world", + ) + assert isinstance(response, EmbeddingResponse) + assert len(response.data) == 1 From 2cfdb1659f3b5a1310300a243100d01671172e8b Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Sat, 4 Apr 2026 02:14:49 +0300 Subject: [PATCH 2/3] fix test --- tests/core/test_normalized_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_normalized_client.py b/tests/core/test_normalized_client.py index e2201c8..397db1d 100644 --- a/tests/core/test_normalized_client.py +++ b/tests/core/test_normalized_client.py @@ -487,7 +487,7 @@ def get_weather(city: str, units: str = "celsius") -> str: def test_unsupported_type(self): with pytest.raises(TypeError, match="Unsupported tool type"): - _build_tool_definition(42) + _build_tool_definition(42) # type: ignore[arg-type] class TestToolChoiceResolution: From 3e0cd8642469053246e6b10e69df544c92ea232c Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Sat, 4 Apr 2026 02:37:20 +0300 Subject: [PATCH 3/3] fixes --- tests/cassettes.db | Bin 45387776 -> 45494272 bytes tests/core/test_normalized_integration.py | 66 +++++++++++++--------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/tests/cassettes.db b/tests/cassettes.db index f98a4a6461f5777fd6f4c4ebc0da6b73c1364508..7caa33b2c1fb6808b8ffca3bb1dbec97faf4d980 100644 GIT binary patch delta 88075 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 delta 3845 zcmZwKc~}(nAHebX4!bP(VS=}Sin5})2%2Ilc;6x_T8NH`rY4H1dD{YgHID$#F|QFj ztgO_^C^eTZy9-Ox4s**gyY0U0cz?`4et-P_c)g!zXJ%(+XP^DdtUq`r!N2?Mgz9GQ zGlr2qcztqjb*9YHmeQ7~tq^UwwB^=TsJ1-X3e#4&wj#6@sjVn&MQbZYTd~@T(^kB; z8ffbpZ8g+Zg0>R1)ks^7wbevhO|{ibTg|oALR&4hm87j?ZKY_dm9|=ItBtl&wbfQz z?G9OKHUD^2nvraj8=IrNy<8zdm1f7+rFC=q)m>0ocD3p4>*O52pgM-S!VSY1Z~GIx zW1P9qSN*qg*psF=sN7}AuJXQiS%PArFP!w9+OqJQ*$bZlkrVRkNI!I14H6e!&MubdeOdiW7G7x zU0jLA3d4@bOYdNsc5Tmc4>Zkl3mxjlczR%U&jiy%0D_C9ncZi zq7!_`Kxbqk3ti9^*P$D_BO5(%J$j-SdLsw9=!3rKhyECVff$6r7=ob~hT#~2k;ua+ zH&2^J>fCQLywreYdy#&pcUOx%K5D8X#ZK`G|qR?NeE zEWmA8h(%b8C0L4OSdJCA9e$Ld92K|&8Y{61cVacxU@g|+E>xll)mV=W*oaNoj4iOS z6?bDBwqpl&Viz3jMh*7h9^8xja6k6q0X&F@un!O85j=|hIDmtA43Fap9Kw^R#Z!11 zhj9c)@eGb3fI2*j=kPpUz>7GJm+&%P!K-);uj388iMLRX6L=f%;3Q7rU7W@lyodMk z0Y1c8e1vld;$wV*^Y|2>;Q~I#MSOuT@fE(tH~1Fc;S#>b5BL#3;b;7UU-27$#~=6; zm+=?=#y@NQqg53orX)n-lDH+I5|1QI5-y35L`tG0(UKTRtRzknFKHmTM$%A{AW4)o zk~Eeyku;SwlQfsKkhGK}Ns=Wgl2($|k~Wf5Nn1%fNt(nfX)j5abdYqETr24$@kufy zoh6x)EJ+thSIKpfZj$bjY)KEv^^%^FUXtFD97(REkEE}ppQOKJfMlR#kYuoAh-9c_ zm}IzQgk+>7Pcll9FS+vdj8Oq*iduKn6`K;XAP^T zQyP0(t#X3XEfwbUN-R)&S!v`raS{1UV*gLRus~{~0+sLam>d4@7To9vC- zHR40H%XGz6CKss6iF!0qs(4CSc69V#&*@|@9Jd5ox)k<(&3M{JM$H2iYR zb|bE$u>%CK{s>{h(U-PuE>+I7ntH?mv)?(P}zpU~#AFemp_ zZoE!?R;W4A@t-VKQR;}Zd*`I9%Z59_HU(<1I_X%e$C#N{_Sny(_QX^gZjY1u9=F0P z@1143b~vTSL#}Sp-SMUEHDlG{D<_ib72tVUxs)qnlCj=-cIqa)8*n2E&10n# zOjmW->+Wt0;`G7KL=~A^Lu&afTU1rHyT~LHC6gqUWU}NY$rMSkWU6GEMljxyQ+oC*?B@stF7sFo(&kEZWh!1Wv zk9*9NP$$=)!DLEaRAR2+UXeD@$>>pJULA-|u=fC!)L>5ZpGI_9PgnU;89s*5darZh zZ2Z-Y{`cC4hU6PY(jceIb((!vchYL2lXHHNDy%xZq{Jk%C37UDlDU#wCG#ZnB?~0C zNft^LNft|%NR~>LNtR1iNN$(-C1sLwNrmJNGjMo`HV2NCKS;6e<%WB?6J}+b&1>WH zx{Y%?Pt+H4n!(IMmFy(V&Njz5^&b_hMD?X(bswOz0;SWk%}wD|uAnx_O35n8os!j( zHIlWGb&|Uzm69q+wPd|ygJh#*lVr1Gi^P^}mE0}aCfP38A=xR}C2=IXB{h;gl6xfg qO74@~FWD=3K=PpEA;~_;!;(iNk4pAS4oD749y0^3;N#}y{r>`7wQzp` diff --git a/tests/core/test_normalized_integration.py b/tests/core/test_normalized_integration.py index e177f28..10ff5da 100644 --- a/tests/core/test_normalized_integration.py +++ b/tests/core/test_normalized_integration.py @@ -5,15 +5,14 @@ 1. Basic chat completions (sync) 2. Chat completions with parameters (temperature, max_tokens) 3. Streaming completions (sync via .stream()) -4. Tool calling -5. Structured output with Pydantic models -6. Structured output with TypedDict -7. Embeddings (sync) -8. Async completions (via .acreate()) -9. Async embeddings (via .acreate()) +4. Tool calling (dict and Pydantic tools) +5. Structured output via json_object response_format +6. Embeddings (sync) +7. Async completions (via .acreate()) +8. Async embeddings (via .acreate()) """ -from typing import TypedDict +import json import pytest from pydantic import BaseModel @@ -57,11 +56,6 @@ class MathAnswer(BaseModel): explanation: str -class CityInfo(TypedDict): - name: str - country: str - - # ============================================================================ # Sync completions tests # ============================================================================ @@ -112,7 +106,6 @@ def test_streaming(self, normalized_client: UiPathNormalizedClient): assert len(chunks) > 0 assert all(isinstance(c, ChatCompletionChunk) for c in chunks) - # At least one chunk should have content content_chunks = [c for c in chunks if c.choices and c.choices[0].delta.content] assert len(content_chunks) > 0 @@ -137,7 +130,7 @@ def test_tool_calling(self, normalized_client: UiPathNormalizedClient): }, } ], - tool_choice="required", + tool_choice={"type": "required"}, ) assert isinstance(response, ChatCompletion) assert len(response.choices[0].message.tool_calls) >= 1 @@ -157,7 +150,7 @@ class GetWeatherInput(BaseModel): {"role": "user", "content": "What is the weather in Paris?"}, ], tools=[GetWeatherInput], - tool_choice="required", + tool_choice={"type": "required"}, ) assert isinstance(response, ChatCompletion) assert len(response.choices[0].message.tool_calls) >= 1 @@ -165,27 +158,44 @@ class GetWeatherInput(BaseModel): class TestNormalizedStructuredOutput: @pytest.mark.vcr() - def test_structured_output_pydantic(self, normalized_client: UiPathNormalizedClient): + def test_structured_output_json_object(self, normalized_client: UiPathNormalizedClient): + """Test structured output using json_object response_format.""" response = normalized_client.completions.create( - messages=[{"role": "user", "content": "What is 15 + 27?"}], - response_format=MathAnswer, + messages=[ + { + "role": "user", + "content": ( + 'What is 15 + 27? Respond with JSON: {"answer": , "explanation": ""}' + ), + }, + ], + response_format={"type": "json_object"}, ) assert isinstance(response, ChatCompletion) - parsed = response.choices[0].message.parsed - assert isinstance(parsed, MathAnswer) - assert parsed.answer == 42 + content = response.choices[0].message.content + assert content + parsed = json.loads(content) + assert parsed["answer"] == 42 @pytest.mark.vcr() - def test_structured_output_typed_dict(self, normalized_client: UiPathNormalizedClient): + def test_structured_output_pydantic_parsed(self, normalized_client: UiPathNormalizedClient): + """Test that response_format with a Pydantic model populates message.parsed.""" response = normalized_client.completions.create( - messages=[{"role": "user", "content": "Tell me about Tokyo."}], - response_format=CityInfo, + messages=[ + { + "role": "user", + "content": ( + 'What is 15 + 27? Respond with JSON: {"answer": , "explanation": ""}' + ), + }, + ], + response_format={"type": "json_object"}, ) assert isinstance(response, ChatCompletion) - parsed = response.choices[0].message.parsed - assert isinstance(parsed, dict) - assert "name" in parsed - assert "country" in parsed + content = response.choices[0].message.content + assert content + parsed = MathAnswer.model_validate_json(content) + assert parsed.answer == 42 # ============================================================================