Python: added ChatClientBase with function calling (#147)

* added ChatClientBase with function calling

* streaming update

* fixed typing

* test setup

* small update

* src setup

* removed src, updated test naming

* fixed test command

* alolow args

* updated test run

* added unit test folder to azure

* added init and unit test to azure

* added other cross tests

* restructured

* reset test run

* fix name

* removed always

* updated test

* extend pytest.xml locations

* run surface always

* added decorators for FC and marked tests

* fixed mypy settings and added tests

* fix override import

* removed import
This commit is contained in:
Eduard van Valkenburg
2025-07-10 11:18:15 +02:00
committed by GitHub
Unverified
parent daf4788868
commit 3449902b03
50 changed files with 1377 additions and 357 deletions
+4 -5
View File
@@ -35,15 +35,14 @@ jobs:
cache-suffix: ${{ runner.os }}-${{ matrix.python-version }}
cache-dependency-glob: "**/uv.lock"
- name: Install the project
run: uv sync --all-extras --dev -U --prerelease=if-necessary-or-explicit
run: uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit
- name: Test with pytest
env:
PYTHON_GIL: ${{ matrix.gil }}
run: uv run --frozen pytest --junitxml=pytest.xml ./tests/unit
run: uv run --frozen poe test --junitxml=pytest.xml
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
with:
path: python/pytest.xml
path: python/**/pytest.xml
summary: true
display-options: fEX
fail-on-empty: true
+1 -1
View File
@@ -13,7 +13,7 @@ repos:
- id: check-json
name: Check JSON files
files: \.json$
exclude: ^python\/\.vscode\/.*
exclude: ^.*\.vscode\/.*
- id: end-of-file-fixer
name: Fix End of File
files: \.py$
-5
View File
@@ -19,11 +19,6 @@
"editor.defaultFormatter": "charliermarsh.ruff"
},
"python.analysis.autoFormatStrings": true,
"python.analysis.extraPaths": [
"${workspaceFolder}/agent_framework",
"${workspaceFolder}/extensions/openai",
"${workspaceFolder}/extensions/azure"
],
"python.analysis.importFormat": "relative",
"python.analysis.packageIndexDepths": [
{
-82
View File
@@ -1,82 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, Sequence
from typing import Any, Generic, Protocol, TypeVar, runtime_checkable
from ._types import ChatMessage, ChatResponse, ChatResponseUpdate, GeneratedEmbeddings
TInput = TypeVar("TInput", contravariant=True)
TEmbedding = TypeVar("TEmbedding")
# region: ChatClient Protocol
@runtime_checkable
class ChatClient(Protocol):
"""A protocol for a chat client that can generate responses."""
async def get_response(
self,
messages: ChatMessage | Sequence[ChatMessage],
**kwargs: Any,
) -> ChatResponse:
"""Sends input and returns the response.
Args:
messages: The sequence of input messages to send.
**kwargs: Additional options for the request, such as ai_model_id, temperature, etc.
See `ChatOptions` for more details.
Returns:
The response messages generated by the client.
Raises:
ValueError: If the input message sequence is `None`.
"""
...
async def get_streaming_response(
self,
messages: ChatMessage | Sequence[ChatMessage],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Sends input messages and streams the response.
Args:
messages: The sequence of input messages to send.
**kwargs: Additional options for the request, such as ai_model_id, temperature, etc.
See `ChatOptions` for more details.
Yields:
An async iterable of chat response updates containing the content of the response messages
generated by the client.
Raises:
ValueError: If the input message sequence is `None`.
"""
...
# region: Embedding Client
@runtime_checkable
class EmbeddingGenerator(Protocol, Generic[TInput, TEmbedding]):
"""A protocol for an embedding generator that can create embeddings from input data."""
async def generate(
self,
input_data: Sequence[TInput],
**kwargs: Any,
) -> GeneratedEmbeddings[TEmbedding]:
"""Generates an embedding for the given input data.
Args:
input_data: The input data to generate an embedding for.
**kwargs: Additional options for the request.
Returns:
The generated embedding, this acts like a list, but has additional metadata and usage details.
"""
...
@@ -1 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -1 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = ["__version__"]
@@ -24,28 +24,52 @@ classifiers = [
]
dependencies = [
"agent-framework",
"agent-framework-openai",
"agent-framework-openai"
]
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
[tool.ruff]
extend = "../../pyproject.toml"
include = ["agent-framework/openai/**", "tests/*.py"]
[tool.poe]
include = "../../shared_tasks.toml"
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework", "samples"]
extend = "../../pyproject.toml"
exclude = ['tests']
[tool.uv.sources]
agent-framework = { workspace = true }
agent-framework-openai = { workspace = true }
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
disallow_any_unimported = true
[tool.bandit]
targets = ["agent_framework"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.uv.build-backend]
module-name = "azure"
module-root = "agent_framework"
module-name = "agent_framework.azure"
module-root = ""
namespace = true
[build-system]
requires = ["uv_build>=0.7.19,<0.8.0"]
build-backend = "uv_build"
build-backend = "uv_build"
@@ -0,0 +1,32 @@
# Copyright (c) Microsoft. All rights reserved.
from pytest import mark
@mark.xfail(reason="Not solved")
def test_self():
try:
from agent_framework.azure import __version__
except ImportError:
__version__ = None
assert __version__ is not None
@mark.xfail(reason="Not solved")
def test_openai():
try:
from agent_framework.openai import __version__
except ImportError:
__version__ = None
assert __version__ is not None
def test_agent_framework():
try:
from agent_framework import TextContent
except ImportError:
TextContent = None
assert TextContent is not None
text = TextContent("Hello, world!")
assert text is not None
+15
View File
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
}
]
}
@@ -35,6 +35,8 @@ _IMPORTS = {
"ChatOptions": "._types",
"ChatToolMode": "._types",
"ChatClient": "._clients",
"ChatClientBase": "._clients",
"use_tool_calling": "._clients",
"EmbeddingGenerator": "._clients",
"InputGuardrail": ".guard_rails",
"OutputGuardrail": ".guard_rails",
@@ -2,7 +2,7 @@
from . import __version__ # type: ignore[attr-defined]
from ._agents import Agent, AgentThread
from ._clients import ChatClient, EmbeddingGenerator
from ._clients import ChatClient, ChatClientBase, EmbeddingGenerator, use_tool_calling
from ._logging import get_logger
from ._tools import AITool, ai_function
from ._types import (
@@ -36,6 +36,7 @@ __all__ = [
"Agent",
"AgentThread",
"ChatClient",
"ChatClientBase",
"ChatFinishReason",
"ChatMessage",
"ChatOptions",
@@ -60,4 +61,5 @@ __all__ = [
"__version__",
"ai_function",
"get_logger",
"use_tool_calling",
]
@@ -0,0 +1,530 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence, Sequence
from functools import wraps
from typing import Annotated, Any, Generic, Literal, Protocol, TypeVar, runtime_checkable
from pydantic import BaseModel, PrivateAttr, StringConstraints
from ._logging import get_logger
from ._pydantic import AFBaseModel
from ._tools import AIFunction, AITool
from ._types import (
AIContents,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
ChatToolMode,
FunctionCallContent,
FunctionResultContent,
GeneratedEmbeddings,
)
TInput = TypeVar("TInput", contravariant=True)
TEmbedding = TypeVar("TEmbedding")
TInnerGetResponse = TypeVar("TInnerGetResponse", bound=Callable[..., Awaitable[ChatResponse]])
TInnerGetStreamingResponse = TypeVar(
"TInnerGetStreamingResponse", bound=Callable[..., AsyncIterable[ChatResponseUpdate]]
)
TChatClientBase = TypeVar("TChatClientBase", bound="ChatClientBase")
logger = get_logger()
# region: Tool Calling Functions and Decorators
def _merge_function_results(
messages: list[ChatMessage],
) -> ChatMessage:
"""Combine multiple function result content types to one chat message content type.
This method combines the FunctionResultContent items from separate ChatMessageContent messages,
and is used in the event that the `context.terminate = True` condition is met.
"""
contents: list[Any] = []
for message in messages:
contents.extend([item for item in message.contents if isinstance(item, FunctionResultContent)])
return ChatMessage(
role="tool",
contents=contents,
)
async def _auto_invoke_function(
function_call_content: FunctionCallContent,
custom_args: dict[str, Any] | None = None,
*,
tool_map: dict[str, AIFunction[BaseModel, Any]],
sequence_index: int | None = None,
request_index: int | None = None,
) -> AIContents:
"""Invoke a function call requested by the agent, applying filters that are defined in the agent."""
tool: AIFunction[BaseModel, Any] | None = tool_map.get(function_call_content.name)
if tool is None:
raise KeyError(f"No tool or function named '{function_call_content.name}'")
parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})
# Merge with user-supplied args; right-hand side dominates, so parsed args win on conflicts.
merged_args: dict[str, Any] = (custom_args or {}) | parsed_args
args = tool.input_model.model_validate(merged_args)
exception = None
try:
function_result = await tool.invoke(arguments=args)
except Exception as ex:
exception = ex
function_result = None
return FunctionResultContent(
call_id=function_call_content.call_id,
exception=exception,
result=function_result,
)
def _tool_to_json_schema_spec(tool: AITool) -> dict[str, Any]:
"""Convert a AITool to the JSON Schema function specification format."""
return {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters(),
},
}
def _prepare_tools_and_tool_choice(chat_options: ChatOptions) -> None:
"""Prepare the tools and tool choice for the chat options."""
chat_tool_mode: ChatToolMode | None = chat_options.tool_choice # type: ignore
if chat_tool_mode is None or chat_tool_mode == ChatToolMode.NONE:
chat_options.tools = None
chat_options.tool_choice = ChatToolMode.NONE.mode
return
chat_options.tools = [
(_tool_to_json_schema_spec(t) if isinstance(t, AITool) else t) for t in chat_options.tools or []
]
chat_options.tool_choice = chat_tool_mode.mode
def _tool_call_non_streaming(func: TInnerGetResponse) -> TInnerGetResponse:
"""Decorate the internal _inner_get_response method to enable tool calls.
Remarks:
Relies on a class that has the _tool_map attribute for the executable tools to call.
"""
@wraps(func)
async def wrapper(
self: "ChatClientBase",
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
response: ChatResponse | None = None
fcc_messages: list[ChatMessage] = []
for attempt_idx in range(self.maximum_iterations_per_request):
response = await func(self, messages=messages, chat_options=chat_options)
# if there are function calls, we will handle them first
function_calls = [it for it in response.messages[0].contents if isinstance(it, FunctionCallContent)]
if function_calls:
# Run all function calls concurrently
results = await asyncio.gather(*[
_auto_invoke_function(
function_call,
custom_args=kwargs,
tool_map=self._tool_map,
sequence_index=seq_idx,
request_index=attempt_idx,
)
for seq_idx, function_call in enumerate(function_calls)
])
# add a single ChatMessage to the response with the results
response.messages.append(ChatMessage(role="tool", contents=results))
# response should contain 2 messages after this,
# one with function call contents
# and one with function result contents
# the amount and call_id's should match
# this runs in every but the first run
# we need to keep track of all function call messages
fcc_messages.extend(response.messages)
# and add them as additional context to the messages
messages.extend(response.messages)
continue
# If we reach this point, it means there were no function calls to handle,
# we'll add the previous function call and responses
# to the front of the list, so that the final response is the last one
# TODO (eavanvalkenburg): control this behavior?
if fcc_messages:
for msg in reversed(fcc_messages):
response.messages.insert(0, msg)
return response
# Failsafe: give up on tools, ask model for plain answer
chat_options.tool_choice = "none"
_prepare_tools_and_tool_choice(chat_options=chat_options)
response = await func(self, messages=messages, chat_options=chat_options)
if fcc_messages:
for msg in reversed(fcc_messages):
response.messages.insert(0, msg)
return response
return wrapper # type: ignore[reportReturnType, return-value]
def _tool_call_streaming(func: TInnerGetStreamingResponse) -> TInnerGetStreamingResponse:
"""Decorate the internal _inner_get_response method to enable tool calls.
Remarks:
Relies on a class that has the _tool_map attribute for the executable tools to call.
"""
@wraps(func)
async def wrapper(
self: "ChatClientBase",
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
for attempt_idx in range(self.maximum_iterations_per_request):
function_call_returned = False
all_messages: list[ChatResponseUpdate] = []
async for update in func(self, messages=messages, chat_options=chat_options):
if update.contents and any(isinstance(item, FunctionCallContent) for item in update.contents):
all_messages.append(update)
function_call_returned = True
yield update
if not function_call_returned:
return
# There is one FunctionCallContent response stream in the messages, combining now to create
# the full completion depending on the prompt, the message may contain both function call
# content and others
response: ChatResponse = ChatResponse.from_chat_response_updates(all_messages)
function_calls = [item for item in response.messages[0].contents if isinstance(item, FunctionCallContent)]
messages.append(response.messages[0])
if function_calls:
# Run all function calls concurrently
results = await asyncio.gather(*[
_auto_invoke_function(
function_call,
custom_args=kwargs,
tool_map=self._tool_map,
sequence_index=seq_idx,
request_index=attempt_idx,
)
for seq_idx, function_call in enumerate(function_calls)
])
yield ChatResponseUpdate(contents=results, role="tool")
response.messages.append(ChatMessage(role="tool", contents=results))
messages.extend(response.messages)
continue
# Failsafe: give up on tools, ask model for plain answer
chat_options.tool_choice = "none"
_prepare_tools_and_tool_choice(chat_options=chat_options)
async for update in func(self, messages=messages, chat_options=chat_options, **kwargs):
yield update
return wrapper # type: ignore[reportReturnType, return-value]
def use_tool_calling(cls: type[TChatClientBase]) -> type[TChatClientBase]:
inner_response = getattr(cls, "_inner_get_response", None)
if inner_response is not None:
cls._inner_get_response = _tool_call_non_streaming(inner_response) # type: ignore
inner_streaming_response = getattr(cls, "_inner_get_streaming_response", None)
if inner_streaming_response is not None:
cls._inner_get_streaming_response = _tool_call_streaming(inner_streaming_response) # type: ignore
return cls
# region: ChatClient Protocol
@runtime_checkable
class ChatClient(Protocol):
"""A protocol for a chat client that can generate responses."""
async def get_response(
self,
messages: str | ChatMessage | Sequence[ChatMessage],
**kwargs: Any,
) -> ChatResponse:
"""Sends input and returns the response.
Args:
messages: The sequence of input messages to send.
**kwargs: Additional options for the request, such as ai_model_id, temperature, etc.
See `ChatOptions` for more details.
Returns:
The response messages generated by the client.
Raises:
ValueError: If the input message sequence is `None`.
"""
...
async def get_streaming_response(
self,
messages: str | ChatMessage | Sequence[ChatMessage],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Sends input messages and streams the response.
Args:
messages: The sequence of input messages to send.
**kwargs: Additional options for the request, such as ai_model_id, temperature, etc.
See `ChatOptions` for more details.
Yields:
An async iterable of chat response updates containing the content of the response messages
generated by the client.
Raises:
ValueError: If the input message sequence is `None`.
"""
...
class ChatClientBase(AFBaseModel, ABC):
"""Base class for chat clients."""
ai_model_id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
maximum_iterations_per_request: int = 10
_tool_map: dict[str, AIFunction[BaseModel, Any]] = PrivateAttr(default_factory=dict) # type: ignore
# region Internal methods to be implemented by the derived classes
@abstractmethod
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
"""Send a chat request to the AI service.
Args:
messages: The chat messages to send.
chat_options: The options for the request.
kwargs: Any additional keyword arguments.
Returns:
The chat response contents representing the response(s).
"""
@abstractmethod
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Send a streaming chat request to the AI service.
Args:
messages: The chat messages to send.
chat_options: The chat_options for the request.
kwargs: Any additional keyword arguments.
Yields:
ChatResponseUpdate: The streaming chat message contents.
"""
# Below is needed for mypy: https://mypy.readthedocs.io/en/stable/more_types.html#asynchronous-iterators
if False:
yield
await asyncio.sleep(0) # pragma: no cover
# This is a no-op, but it allows the method to be async and return an AsyncIterable.
# The actual implementation should yield ChatResponseUpdate instances as needed.
# endregion
# region Public method
async def get_response(
self,
messages: str | ChatMessage | list[ChatMessage],
*,
model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
top_p: float | None = None,
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: Sequence[AITool] | None = None,
response_format: type[BaseModel] | None = None,
user: str | None = None,
stop: str | Sequence[str] | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
presence_penalty: float | None = None,
seed: int | None = None,
store: bool | None = None,
metadata: dict[str, Any] | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> ChatResponse:
"""Get a response from a chat client.
Args:
messages: the message or messages to send to the model
model: the model to use for the request
max_tokens: the maximum number of tokens to generate
temperature: the sampling temperature to use
top_p: the nucleus sampling probability to use
tool_choice: the tool choice for the request
tools: the tools to use for the request
response_format: the format of the response
user: the user to associate with the request
stop: the stop sequence(s) for the request
frequency_penalty: the frequency penalty to use
logit_bias: the logit bias to use
presence_penalty: the presence penalty to use
seed: the random seed to use
store: whether to store the response
metadata: additional metadata to include in the request
additional_properties: additional properties to include in the request
kwargs: any additional keyword arguments,
will only be passed to functions that are called.
Returns:
A chat response from the model.
"""
if tools is not None:
self._tool_map = {tool.name: tool for tool in tools if isinstance(tool, AIFunction)}
chat_options = ChatOptions(
ai_model_id=model,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=tools,
response_format=response_format,
user=user,
stop=stop,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
presence_penalty=presence_penalty,
seed=seed,
store=store,
metadata=metadata,
additional_properties=additional_properties or {},
)
if isinstance(messages, str):
messages = [ChatMessage(role="user", text=messages)]
if isinstance(messages, ChatMessage):
messages = [messages]
_prepare_tools_and_tool_choice(chat_options=chat_options)
return await self._inner_get_response(messages=messages, chat_options=chat_options, **kwargs)
async def get_streaming_response(
self,
messages: str | ChatMessage | list[ChatMessage],
*,
model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
top_p: float | None = None,
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: Sequence[AITool] | None = None,
response_format: type[BaseModel] | None = None,
user: str | None = None,
stop: str | Sequence[str] | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
presence_penalty: float | None = None,
seed: int | None = None,
store: bool | None = None,
metadata: dict[str, Any] | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Get a streaming response from a chat client.
Args:
messages: the message or messages to send to the model
model: the model to use for the request
max_tokens: the maximum number of tokens to generate
temperature: the sampling temperature to use
top_p: the nucleus sampling probability to use
tool_choice: the tool choice for the request
tools: the tools to use for the request
response_format: the format of the response
user: the user to associate with the request
stop: the stop sequence(s) for the request
frequency_penalty: the frequency penalty to use
logit_bias: the logit bias to use
presence_penalty: the presence penalty to use
seed: the random seed to use
store: whether to store the response
metadata: additional metadata to include in the request
additional_properties: additional properties to include in the request
kwargs: any additional keyword arguments
Yields:
A stream representing the response(s) from the LLM.
"""
if tools is not None:
self._tool_map = {tool.name: tool for tool in tools if isinstance(tool, AIFunction)}
chat_options = ChatOptions(
ai_model_id=model,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=tools,
response_format=response_format,
user=user,
stop=stop,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
presence_penalty=presence_penalty,
seed=seed,
store=store,
metadata=metadata,
additional_properties=additional_properties or {},
**kwargs,
)
if isinstance(messages, str):
messages = [ChatMessage(role="user", text=messages)]
if isinstance(messages, ChatMessage):
messages = [messages]
_prepare_tools_and_tool_choice(chat_options=chat_options)
async for update in self._inner_get_streaming_response(messages=messages, chat_options=chat_options, **kwargs):
yield update
# region: Embedding Client
@runtime_checkable
class EmbeddingGenerator(Protocol, Generic[TInput, TEmbedding]):
"""A protocol for an embedding generator that can create embeddings from input data."""
async def generate(
self,
input_data: Sequence[TInput],
**kwargs: Any,
) -> GeneratedEmbeddings[TEmbedding]:
"""Generates an embedding for the given input data.
Args:
input_data: The input data to generate an embedding for.
**kwargs: Additional options for the request.
Returns:
The generated embedding, this acts like a list, but has additional metadata and usage details.
"""
...
@@ -2,7 +2,7 @@
import functools
import inspect
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from typing import Any, Generic, Protocol, TypeVar, runtime_checkable
from pydantic import BaseModel, create_model
@@ -23,12 +23,16 @@ class AITool(Protocol):
"""Return a string representation of the tool."""
...
def parameters(self) -> Mapping[str, Any]:
"""Return the parameters of the tool as a JSON schema."""
...
ArgsT = TypeVar("ArgsT", bound=BaseModel)
ReturnT = TypeVar("ReturnT")
class AIFunction(Generic[ArgsT, ReturnT]):
class AIFunction(AITool, Generic[ArgsT, ReturnT]):
"""A tool that represents a function that can be called by an AI service."""
def __init__(
@@ -55,14 +59,17 @@ class AIFunction(Generic[ArgsT, ReturnT]):
self.additional_properties: dict[str, Any] | None = kwargs
self._func = func
def model_json_schema(self) -> dict[str, Any]:
"""Return the JSON schema of the input model."""
def parameters(self) -> dict[str, Any]:
"""Return the parameter json schemas of the input model."""
return self.input_model.model_json_schema()
def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]:
"""Call the wrapped function with the provided arguments."""
return self._func(*args, **kwargs)
def __str__(self) -> str:
return f"AIFunction(name={self.name}, description={self.description})"
async def invoke(
self,
*,
@@ -1,18 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
import json
import re
import sys
from collections.abc import AsyncIterable, Iterable, Iterator, MutableSequence, Sequence
from collections.abc import AsyncIterable, Iterable, Iterator, Mapping, MutableSequence, Sequence
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, overload
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
from agent_framework.exceptions import AgentFrameworkException
from ._pydantic import AFBaseModel
from ._tools import AITool
if sys.version_info >= (3, 12):
pass # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
@@ -170,7 +171,16 @@ def _process_update(response: "ChatResponse", update: "ChatResponseUpdate") -> N
if update.message_id:
message.message_id = update.message_id
for content in update.contents:
if isinstance(content, UsageContent):
if (
isinstance(content, FunctionCallContent)
and len(message.contents) > 0
and isinstance(message.contents[-1], FunctionCallContent)
):
try:
message.contents[-1] += content
except AgentFrameworkException:
message.contents.append(content)
elif isinstance(content, UsageContent):
if response.usage_details is None:
response.usage_details = UsageDetails()
response.usage_details += content.details
@@ -579,7 +589,7 @@ class FunctionCallContent(AIContent):
"""The function call identifier."""
name: str
"""The name of the function requested."""
arguments: dict[str, Any | None] | None = None
arguments: str | dict[str, Any | None] | None = None
"""The arguments requested to be provided to the function."""
exception: Exception | None = None
"""Any exception that occurred while mapping the original function call data to this representation."""
@@ -589,7 +599,7 @@ class FunctionCallContent(AIContent):
*,
call_id: str,
name: str,
arguments: dict[str, Any | None] | None = None,
arguments: str | dict[str, Any | None] | None = None,
exception: Exception | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
@@ -600,7 +610,8 @@ class FunctionCallContent(AIContent):
Args:
call_id: The function call identifier.
name: The name of the function requested.
arguments: The arguments requested to be provided to the function.
arguments: The arguments requested to be provided to the function,
can be a string to allow gradual completion of the args.
exception: Any exception that occurred while mapping the original function call data to this representation.
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
@@ -616,6 +627,42 @@ class FunctionCallContent(AIContent):
**kwargs,
)
def parse_arguments(self) -> dict[str, Any | None] | None:
if isinstance(self.arguments, str):
# If arguments are a string, try to parse it as JSON
try:
loaded = json.loads(self.arguments)
if isinstance(loaded, dict):
return loaded # type:ignore
return {"raw": loaded}
except (json.JSONDecodeError, TypeError):
return {"raw": self.arguments}
return self.arguments
def __add__(self, other: "FunctionCallContent") -> "FunctionCallContent":
if not isinstance(other, FunctionCallContent):
raise TypeError("Incompatible type")
if self.call_id != other.call_id:
raise AgentFrameworkException("Incompatible function call contents")
if not self.arguments:
arguments = other.arguments
elif not other.arguments:
arguments = self.arguments
elif isinstance(self.arguments, str) and isinstance(other.arguments, str):
arguments = self.arguments + other.arguments
elif isinstance(self.arguments, dict) and isinstance(other.arguments, dict):
arguments = {**self.arguments, **other.arguments}
else:
raise TypeError("Incompatible argument types")
return FunctionCallContent(
call_id=self.call_id,
name=self.name,
arguments=arguments,
exception=self.exception or other.exception,
additional_properties={**(self.additional_properties or {}), **(other.additional_properties or {})},
raw_representation=self.raw_representation or other.raw_representation,
)
class FunctionResultContent(AIContent):
"""Represents the result of a function call.
@@ -1324,6 +1371,14 @@ class ChatToolMode(AFBaseModel):
"""Returns a ChatToolMode that requires the specified function to be called."""
return cls(mode="required", required_function_name=function_name)
def __eq__(self, other: object) -> bool:
"""Checks equality with another ChatToolMode or string."""
if isinstance(other, str):
return self.mode == other
if isinstance(other, ChatToolMode):
return self.mode == other.mode and self.required_function_name == other.required_function_name
return False
ChatToolMode.AUTO = ChatToolMode(mode="auto") # type: ignore[assignment]
ChatToolMode.REQUIRED_ANY = ChatToolMode(mode="required") # type: ignore[assignment]
@@ -1334,45 +1389,47 @@ class ChatOptions(AFBaseModel):
"""Common request settings for AI services."""
ai_model_id: Annotated[str | None, Field(serialization_alias="model")] = None
frequency_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None
logit_bias: dict[str | int, float] | None = None
max_tokens: Annotated[int | None, Field(gt=0)] = None
messages: list[dict[str, Any]] | None = Field(default=None, description="List of messages for the chat completion.")
presence_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None
temperature: Annotated[float | None, Field(ge=0.0, le=2.0)] = None
top_p: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None = None
tools: Sequence[AITool] | Sequence[Mapping[str, Any]] | None = None
response_format: type[BaseModel] | None = Field(
default=None, description="Structured output response format schema. Must be a valid Pydantic model."
)
seed: int | None = None
stop: str | list[str] | None = None
temperature: Annotated[float | None, Field(ge=0.0, le=2.0)] = None
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None
tools: list[AITool] | None = None
top_p: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
user: str | None = None
stop: str | Sequence[str] | None = None
frequency_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None
logit_bias: Mapping[str | int, float] | None = None
presence_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None
seed: int | None = None
store: bool | None = None
metadata: dict[str, str] | None = None
additional_properties: dict[str, Any] = Field(
metadata: Mapping[str, str] | None = None
additional_properties: Mapping[str, Any] = Field(
default_factory=dict, description="Provider-specific additional properties."
)
@field_validator("tool_choice", mode="before")
@classmethod
def _validate_tool_mode(cls, data: dict[str, Any]) -> dict[str, Any]:
def _validate_tool_mode(
cls, tool_choice: ChatToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None
) -> ChatToolMode:
"""Validates the tool_choice field to ensure it is a valid ChatToolMode."""
if isinstance(data, dict):
tool_choice = data.get("tool_choice")
if isinstance(tool_choice, str):
if tool_choice == "auto":
data["tool_choice"] = ChatToolMode.AUTO
elif tool_choice == "required":
data["tool_choice"] = ChatToolMode.REQUIRED_ANY
elif tool_choice == "none":
data["tool_choice"] = ChatToolMode.NONE
else:
raise ValueError(f"Invalid tool choice: {tool_choice}")
elif isinstance(tool_choice, dict):
data["tool_choice"] = ChatToolMode.model_validate(tool_choice)
return data
if not tool_choice:
return ChatToolMode.NONE
if isinstance(tool_choice, str):
match tool_choice:
case "auto":
return ChatToolMode.AUTO
case "required":
return ChatToolMode.REQUIRED_ANY
case "none":
return ChatToolMode.NONE
case _:
raise ValidationError(f"Invalid tool choice: {tool_choice}")
if isinstance(tool_choice, (dict, Mapping)):
return ChatToolMode.model_validate(tool_choice)
return tool_choice
def to_provider_settings(self, by_alias: bool = True, exclude: set[str] | None = None) -> dict[str, Any]:
"""Convert the ChatOptions to a dictionary suitable for provider requests.
+122
View File
@@ -0,0 +1,122 @@
[project]
name = "agent-framework"
description = "Microsoft Agent Framework for building AI Agents with Python."
authors = [{ name = "Microsoft", email = "SK-Support@microsoft.com"}]
readme = "../../README.md"
requires-python = ">=3.10"
version = "0.1.0b1"
license = {file = "../../LICENSE"}
urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Framework :: Pydantic :: 2",
"Typing :: Typed",
]
dependencies = [
"agent-framework-azure",
"agent-framework-openai",
"pydantic>=2.11.7",
"typing-extensions>=4.14.0",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
dev-dependencies = [
"pre-commit >= 3.7",
"ruff>=0.11.8",
"pytest>=8.4.1",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.2.1",
"pytest-xdist[psutil]>=3.8.0",
"pytest-timeout>=2.3.1",
"mypy>=1.16.1",
"pyright>=1.1.402",
#tasks
"poethepoet>=0.36.0",
"rich",
"tomli",
"tomli-w",
"markdownify",
# Documentation
"myst-nb==1.1.2",
"pydata-sphinx-theme==0.16.0",
"sphinx-copybutton",
"sphinx-design",
"sphinx",
"sphinxcontrib-apidoc",
"autodoc_pydantic~=2.2",
"pygments",
"sphinxext-rediraffe",
"opentelemetry-instrumentation-openai",
"markdown-it-py[linkify]",
# Documentation tooling
"diskcache",
"redis",
"sphinx-autobuild",
]
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.ruff]
extend = "../../pyproject.toml"
[tool.pyright]
extend = "../../pyproject.toml"
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
disallow_any_unimported = true
[tool.bandit]
targets = ["agent_framework"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.uv.build-backend]
module-name = "agent_framework"
module-root = ""
namespace = true
[build-system]
requires = ["uv_build>=0.7.19,<0.8.0"]
build-backend = "uv_build"
@@ -0,0 +1,307 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import sys
from collections.abc import AsyncIterable, MutableSequence, Sequence
from typing import Any
from pydantic import Field
from pytest import fixture
from agent_framework import (
ChatClient,
ChatClientBase,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
ChatRole,
EmbeddingGenerator,
FunctionCallContent,
FunctionResultContent,
GeneratedEmbeddings,
TextContent,
ai_function,
use_tool_calling,
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore
else:
from typing_extensions import override # type: ignore[import]
class MockChatClient:
"""Simple implementation of a chat client."""
async def get_response(
self,
messages: ChatMessage | Sequence[ChatMessage],
**kwargs: Any,
) -> ChatResponse:
# Implement the method
return ChatResponse(messages=ChatMessage(role="assistant", text="test response"))
async def get_streaming_response(
self,
messages: ChatMessage | Sequence[ChatMessage],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# Implement the method
yield ChatResponseUpdate(text=TextContent(text="test streaming response"), role="assistant")
yield ChatResponseUpdate(contents=[TextContent(text="another update")], role="assistant")
@use_tool_calling
class MockChatClientBase(ChatClientBase):
"""Mock implementation of the ChatClientBase."""
run_responses: list[ChatResponse] = Field(default_factory=list)
streaming_responses: list[list[ChatResponseUpdate]] = Field(default_factory=list)
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
"""Send a chat request to the AI service.
Args:
messages: The chat messages to send.
chat_options: The options for the request.
kwargs: Any additional keyword arguments.
Returns:
The chat response contents representing the response(s).
"""
if not self.run_responses or chat_options.tool_choice == "none":
return ChatResponse(messages=ChatMessage(role="assistant", text=f"test response - {messages[0].text}"))
return self.run_responses.pop(0)
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
if not self.streaming_responses or chat_options.tool_choice == "none":
yield ChatResponseUpdate(text=f"update - {messages[0].text}", role="assistant")
return
response = self.streaming_responses.pop(0)
for update in response:
yield update
await asyncio.sleep(0)
class MockEmbeddingGenerator:
"""Simple implementation of an embedding generator."""
async def generate(
self,
input_data: Sequence[str],
**kwargs: Any,
) -> GeneratedEmbeddings[list[float]]:
# Implement the method
embeddings = GeneratedEmbeddings[list[float]]()
for i, _ in enumerate(input_data):
embeddings.append([0.0 * 1, 0.1 * 1, 0.2 * 1, 0.3 * i, 0.4 * i])
return embeddings
@fixture
def chat_client() -> MockChatClient:
return MockChatClient()
@fixture
def chat_client_base() -> MockChatClientBase:
return MockChatClientBase(ai_model_id="test")
@fixture
def embedding_generator() -> MockEmbeddingGenerator:
gen: EmbeddingGenerator[str, list[float]] = MockEmbeddingGenerator()
return gen
def test_chat_client_type(chat_client: MockChatClient):
assert isinstance(chat_client, ChatClient)
async def test_chat_client_get_response(chat_client: MockChatClient):
response = await chat_client.get_response(ChatMessage(role="user", text="Hello"))
assert response.text == "test response"
assert response.messages[0].role == ChatRole.ASSISTANT
async def test_chat_client_get_streaming_response(chat_client: MockChatClient):
async for update in chat_client.get_streaming_response(ChatMessage(role="user", text="Hello")):
assert update.text == "test streaming response" or update.text == "another update"
assert update.role == ChatRole.ASSISTANT
def test_embedding_generator_type(embedding_generator: MockEmbeddingGenerator):
assert isinstance(embedding_generator, EmbeddingGenerator)
async def test_embedding_generator_generate(embedding_generator: MockEmbeddingGenerator):
input_data = ["Hello", "world"]
embeddings = await embedding_generator.generate(input_data)
assert len(embeddings) == len(input_data)
for emb in embeddings:
assert len(emb) == 5
def test_base_client(chat_client_base: MockChatClientBase):
assert isinstance(chat_client_base, ChatClientBase)
assert isinstance(chat_client_base, ChatClient)
async def test_base_client_get_response(chat_client_base: MockChatClientBase):
response = await chat_client_base.get_response(ChatMessage(role="user", text="Hello"))
assert response.messages[0].role == ChatRole.ASSISTANT
assert response.messages[0].text == "test response - Hello"
async def test_base_client_get_streaming_response(chat_client_base: MockChatClientBase):
async for update in chat_client_base.get_streaming_response(ChatMessage(role="user", text="Hello")):
assert update.text == "update - Hello" or update.text == "another update"
async def test_base_client_with_function_calling(chat_client_base: MockChatClientBase):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
assert exec_counter == 1
assert len(response.messages) == 3
assert response.messages[0].role == ChatRole.ASSISTANT
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert response.messages[0].contents[0].name == "test_function"
assert response.messages[0].contents[0].arguments == '{"arg1": "value1"}'
assert response.messages[0].contents[0].call_id == "1"
assert response.messages[1].role == ChatRole.TOOL
assert isinstance(response.messages[1].contents[0], FunctionResultContent)
assert response.messages[1].contents[0].call_id == "1"
assert response.messages[1].contents[0].result == "Processed value1"
assert response.messages[2].role == ChatRole.ASSISTANT
assert response.messages[2].text == "done"
async def test_base_client_with_function_calling_disabled(chat_client_base: MockChatClientBase):
chat_client_base.maximum_iterations_per_request = 0
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
assert exec_counter == 0
assert len(response.messages) == 1
assert response.messages[0].role == ChatRole.ASSISTANT
assert response.messages[0].text == "test response - hello"
async def test_base_client_with_streaming_function_calling(chat_client_base: MockChatClientBase):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1":')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='"value1"}')],
role="assistant",
),
],
[
ChatResponseUpdate(
contents=[TextContent(text="Processed value1")],
role="assistant",
)
],
]
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]):
updates.append(update)
assert len(updates) == 4 # two updates with the function call, the function result and the final text
assert updates[0].contents[0].call_id == "1"
assert updates[1].contents[0].call_id == "1"
assert updates[2].contents[0].call_id == "1"
assert updates[3].text == "Processed value1"
assert exec_counter == 1
async def test_base_client_with_streaming_function_calling_disabled(chat_client_base: MockChatClientBase):
chat_client_base.maximum_iterations_per_request = 0
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1":')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='"value1"}')],
role="assistant",
),
],
[
ChatResponseUpdate(
contents=[TextContent(text="Processed value1")],
role="assistant",
)
],
]
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]):
updates.append(update)
assert len(updates) == 1
assert exec_counter == 0
@@ -0,0 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
from pytest import mark
@mark.xfail(reason="Not solved")
def test_openai():
try:
from agent_framework.openai import __version__
except ImportError:
__version__ = None
assert __version__ is not None
@mark.xfail(reason="Not solved")
def test_azure():
try:
from agent_framework.azure import __version__
except ImportError:
__version__ = None
assert __version__ is not None
@@ -14,7 +14,7 @@ def test_ai_function_decorator():
assert isinstance(test_tool, AITool)
assert test_tool.name == "test_tool"
assert test_tool.description == "A test tool"
assert test_tool.model_json_schema() == {
assert test_tool.parameters() == {
"properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}},
"required": ["x", "y"],
"title": "test_tool_input",
@@ -34,7 +34,7 @@ async def test_ai_function_decorator_with_async():
assert isinstance(async_test_tool, AITool)
assert async_test_tool.name == "async_test_tool"
assert async_test_tool.description == "An async test tool"
assert async_test_tool.model_json_schema() == {
assert async_test_tool.parameters() == {
"properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}},
"required": ["x", "y"],
"title": "async_test_tool_input",
@@ -0,0 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = ["__version__"]
@@ -27,25 +27,48 @@ dependencies = [
"openai>=1.93.0",
]
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
[tool.ruff]
extend = "../../pyproject.toml"
include = ["agent-framework/openai/**", "tests/*.py"]
[tool.poe]
include = "../../shared_tasks.toml"
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework", "samples"]
extend = "../../pyproject.toml"
exclude = ['tests', ".venv"]
[tool.uv.sources]
agent-framework = { workspace = true }
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
disallow_any_unimported = true
[tool.bandit]
targets = ["agent_framework"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.uv.build-backend]
module-name = "openai"
module-root = "agent_framework"
module-name = "agent_framework.openai"
module-root = ""
[build-system]
requires = ["uv_build>=0.7.19,<0.8.0"]
build-backend = "uv_build"
build-backend = "uv_build"
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
from pytest import mark
@mark.xfail(reason="Not solved")
def test_self():
try:
from agent_framework.openai import __version__
except ImportError:
__version__ = None
assert __version__ is not None
def test_agent_framework():
try:
from agent_framework import TextContent
except ImportError:
TextContent = None
assert TextContent is not None
text = TextContent("Hello, world!")
assert text is not None
+27 -84
View File
@@ -1,37 +1,10 @@
[project]
name = "agent-framework"
name = "agent-framework-project"
description = "Microsoft Agent Framework for building AI Agents with Python."
authors = [{ name = "Microsoft", email = "SK-Support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "0.1.0b1"
license = {file = "LICENSE"}
urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Framework :: Pydantic :: 2",
"Typing :: Typed",
]
dependencies = [
"agent-framework-azure",
"agent-framework-openai",
"pydantic>=2.11.7",
"typing-extensions>=4.14.0",
]
version = "0.0.0"
[tool.uv]
prerelease = "if-necessary-or-explicit"
dev-dependencies = [
[dependency-groups]
dev = [
"pre-commit >= 3.7",
"ruff>=0.11.8",
"pytest>=8.4.1",
@@ -65,24 +38,21 @@ dev-dependencies = [
"redis",
"sphinx-autobuild",
]
[tool.uv]
package = false
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
[tool.uv.workspace]
members = ["extensions/*"]
members = ["packages/*"]
[tool.uv.sources]
agent-framework = { workspace = true }
agent-framework-openai = { workspace = true }
agent-framework-azure = { workspace = true }
@@ -94,7 +64,7 @@ line-length = 120
target-version = "py310"
fix = true
include = ["*.py", "*.pyi", "**/pyproject.toml", "*.ipynb"]
exclude = ["docs/*", "run_tasks_in_extensions_if_exists.py", "check_md_code_blocks.py"]
exclude = ["docs/*", "run_tasks_in_packages_if_exists.py", "check_md_code_blocks.py"]
preview = true
[tool.ruff.lint]
@@ -133,7 +103,7 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
# Ignore all directories named `tests` and `samples`.
"tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"]
"**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"]
"samples/**" = ["D", "INP", "ERA001", "RUF", "S"]
"*.ipynb" = ["CPY", "E501"]
@@ -149,6 +119,7 @@ min-file-size = 1
[tool.pyright]
include = ["agent_framework", "samples"]
exclude = ["**/tests/**", "docs", "**/.venv/**"]
typeCheckingMode = "strict"
reportUnnecessaryIsInstance = false
reportMissingTypeStubs = false
@@ -170,52 +141,32 @@ disallow_any_unimported = true
[tool.bandit]
targets = ["agent_framework"]
exclude_dirs = ["tests", "./run_tasks_in_extensions_if_exists.py", "./check_md_code_blocks.py", "docs"]
exclude_dirs = ["tests", "./run_tasks_in_packages_if_exists.py", "./check_md_code_blocks.py", "docs"]
[tool.poe]
executor.type = "uv"
[tool.poe.tasks]
markdown-code-lint = """python check_md_code_blocks.py ../README.md ./docs/agent-framework/**/*.md README.md"""
samples-code-check = """pyright ./samples"""
docs-clean = "rm -rf docs/build"
docs-build = "sphinx-build docs/agent-framework docs/build"
docs-serve = "sphinx-autobuild --watch src docs/agent-framework docs/build --port 8000 --jobs auto"
docs-serve = "sphinx-autobuild --watch docs/agent-framework docs/build --port 8000 --jobs auto"
docs-check = "sphinx-build --fail-on-warning docs/agent-framework docs/build"
docs-check-examples = "sphinx-build -b code_lint docs/agent-framework docs/build"
pre-commit-install = "uv run pre-commit install --install-hooks --overwrite"
install = "uv sync --all-extras --dev -U --prerelease=if-necessary-or-explicit"
install = "uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit"
test = "python run_tasks_in_packages_if_exists.py test"
fmt = "python run_tasks_in_packages_if_exists.py fmt"
format.ref = "fmt"
lint = "python run_tasks_in_packages_if_exists.py lint"
pyright = "python run_tasks_in_packages_if_exists.py pyright"
mypy = "python run_tasks_in_packages_if_exists.py mypy"
build = "python run_tasks_in_packages_if_exists.py build"
# combined checks
check = ["fmt", "lint", "pyright", "mypy", "test", "markdown-code-lint", "samples-code-check"]
pre-commit-check = ["fmt", "lint", "pyright", "test", "markdown-code-lint", "samples-code-check"]
pre-commit-check = ["fmt", "lint", "pyright", "markdown-code-lint", "samples-code-check"]
[tool.poe.tasks.fmt]
sequence = [
{ cmd = "python run_tasks_in_extensions_if_exists.py fmt"},
{ cmd = "ruff format agent_framework tests samples" }
]
[tool.poe.tasks.lint]
sequence = [
{ cmd = "python run_tasks_in_extensions_if_exists.py lint" },
{ cmd = "ruff check --fix --exit-non-zero-on-fix agent_framework tests samples" }
]
[tool.poe.tasks.pyright]
sequence = [
{ cmd = "python run_tasks_in_extensions_if_exists.py pyright" },
{ cmd = "pyright" }
]
[tool.poe.tasks.mypy]
sequence = [
{ cmd = "python run_tasks_in_extensions_if_exists.py mypy" },
{ cmd = "mypy --config-file pyproject.toml agent_framework" }
]
[tool.poe.tasks.test]
sequence = [
{ cmd = "python run_tasks_in_extensions_if_exists.py test" },
{ cmd = "pytest --cov=agent_framework --cov-report=term-missing:skip-covered tests/unit/" }
]
[tool.poe.tasks.build]
sequence = [
{ cmd = "python run_tasks_in_extensions_if_exists.py build" },
{ cmd = "uv build" }
]
[tool.poe.tasks.venv]
cmd = "uv venv --python $python"
args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }]
@@ -226,11 +177,3 @@ sequence = [
{ ref = "pre-commit-install" }
]
args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }]
[tool.uv.build-backend]
module-name = "agent_framework"
module-root = ""
[build-system]
requires = ["uv_build>=0.7.19,<0.8.0"]
build-backend = "uv_build"
+3 -2
View File
@@ -2,6 +2,7 @@
fmt = "ruff format"
format.ref = "fmt"
lint = "ruff check"
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework tests"
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework"
pyright = "pyright"
build = "uv build"
build = "uv build"
test = "pytest --cov=agent_framework --cov-report=term-missing:skip-covered tests/unit"
-93
View File
@@ -1,93 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, Sequence
from typing import Any
from pytest import fixture
from agent_framework import (
ChatClient,
ChatMessage,
ChatResponse,
ChatResponseUpdate,
ChatRole,
EmbeddingGenerator,
GeneratedEmbeddings,
TextContent,
)
class ImplementedChatClient:
"""Simple implementation of a chat client."""
async def get_response(
self,
messages: ChatMessage | Sequence[ChatMessage],
**kwargs: Any,
) -> ChatResponse:
# Implement the method
return ChatResponse(messages=ChatMessage(role="assistant", text="test response"))
async def get_streaming_response(
self,
messages: ChatMessage | Sequence[ChatMessage],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# Implement the method
yield ChatResponseUpdate(text=TextContent(text="test streaming response"), role="assistant")
yield ChatResponseUpdate(contents=[TextContent(text="another update")], role="assistant")
class ImplementedEmbeddingGenerator:
"""Simple implementation of an embedding generator."""
async def generate(
self,
input_data: Sequence[str],
**kwargs: Any,
) -> GeneratedEmbeddings[list[float]]:
# Implement the method
embeddings = GeneratedEmbeddings[list[float]]()
for i, _ in enumerate(input_data):
embeddings.append([0.0 * 1, 0.1 * 1, 0.2 * 1, 0.3 * i, 0.4 * i])
return embeddings
@fixture
def chat_client() -> ImplementedChatClient:
return ImplementedChatClient()
@fixture
def embedding_generator() -> ImplementedEmbeddingGenerator:
gen: EmbeddingGenerator[str, list[float]] = ImplementedEmbeddingGenerator()
return gen
def test_chat_client_type(chat_client: ImplementedChatClient):
assert isinstance(chat_client, ChatClient)
async def test_chat_client_get_response(chat_client: ImplementedChatClient):
response = await chat_client.get_response(ChatMessage(role="user", text="Hello"))
assert response.text == "test response"
assert response.messages[0].role == ChatRole.ASSISTANT
async def test_chat_client_get_streaming_response(chat_client: ImplementedChatClient):
async for update in chat_client.get_streaming_response(ChatMessage(role="user", text="Hello")):
assert update.text == "test streaming response" or update.text == "another update"
assert update.role == ChatRole.ASSISTANT
def test_embedding_generator_type(embedding_generator: ImplementedEmbeddingGenerator):
assert isinstance(embedding_generator, EmbeddingGenerator)
async def test_embedding_generator_generate(embedding_generator: ImplementedEmbeddingGenerator):
input_data = ["Hello", "world"]
embeddings = await embedding_generator.generate(input_data)
assert len(embeddings) == len(input_data)
for emb in embeddings:
assert len(emb) == 5
+90 -17
View File
@@ -20,6 +20,7 @@ members = [
"agent-framework",
"agent-framework-azure",
"agent-framework-openai",
"agent-framework-project",
]
[[package]]
@@ -37,7 +38,7 @@ wheels = [
[[package]]
name = "agent-framework"
version = "0.1.0b1"
source = { editable = "." }
source = { editable = "packages/main" }
dependencies = [
{ name = "agent-framework-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -80,8 +81,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-azure", editable = "extensions/agent-framework-azure" },
{ name = "agent-framework-openai", editable = "extensions/agent-framework-openai" },
{ name = "agent-framework-azure", editable = "packages/azure" },
{ name = "agent-framework-openai", editable = "packages/openai" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "typing-extensions", specifier = ">=4.14.0" },
]
@@ -121,7 +122,7 @@ dev = [
[[package]]
name = "agent-framework-azure"
version = "0.1.0b1"
source = { editable = "extensions/agent-framework-azure" }
source = { editable = "packages/azure" }
dependencies = [
{ name = "agent-framework", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -129,14 +130,14 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "agent-framework", editable = "." },
{ name = "agent-framework-openai", editable = "extensions/agent-framework-openai" },
{ name = "agent-framework", editable = "packages/main" },
{ name = "agent-framework-openai", editable = "packages/openai" },
]
[[package]]
name = "agent-framework-openai"
version = "0.1.0b1"
source = { editable = "extensions/agent-framework-openai" }
source = { editable = "packages/openai" }
dependencies = [
{ name = "agent-framework", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -144,10 +145,82 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "agent-framework", editable = "." },
{ name = "agent-framework", editable = "packages/main" },
{ name = "openai", specifier = ">=1.93.0" },
]
[[package]]
name = "agent-framework-project"
version = "0.0.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "autodoc-pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "diskcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "markdown-it-py", extra = ["linkify"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "markdownify", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "myst-nb", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "opentelemetry-instrumentation-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pre-commit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pydata-sphinx-theme", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest-timeout", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest-xdist", extra = ["psutil"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
{ name = "sphinx-autobuild", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "sphinx-copybutton", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "sphinx-design", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "sphinxcontrib-apidoc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "sphinxext-rediraffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "tomli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "tomli-w", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [
{ name = "autodoc-pydantic", specifier = "~=2.2" },
{ name = "diskcache" },
{ name = "markdown-it-py", extras = ["linkify"] },
{ name = "markdownify" },
{ name = "mypy", specifier = ">=1.16.1" },
{ name = "myst-nb", specifier = "==1.1.2" },
{ name = "opentelemetry-instrumentation-openai" },
{ name = "poethepoet", specifier = ">=0.36.0" },
{ name = "pre-commit", specifier = ">=3.7" },
{ name = "pydata-sphinx-theme", specifier = "==0.16.0" },
{ name = "pygments" },
{ name = "pyright", specifier = ">=1.1.402" },
{ name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
{ name = "pytest-cov", specifier = ">=6.2.1" },
{ name = "pytest-timeout", specifier = ">=2.3.1" },
{ name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" },
{ name = "redis" },
{ name = "rich" },
{ name = "ruff", specifier = ">=0.11.8" },
{ name = "sphinx" },
{ name = "sphinx-autobuild" },
{ name = "sphinx-copybutton" },
{ name = "sphinx-design" },
{ name = "sphinxcontrib-apidoc" },
{ name = "sphinxext-rediraffe" },
{ name = "tomli" },
{ name = "tomli-w" },
]
[[package]]
name = "alabaster"
version = "1.0.0"
@@ -255,11 +328,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2025.6.15"
version = "2025.7.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" },
{ url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" },
]
[[package]]
@@ -1280,7 +1353,7 @@ wheels = [
[[package]]
name = "openai"
version = "1.93.0"
version = "1.93.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1292,9 +1365,9 @@ dependencies = [
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/d7/e91c6a9cf71726420cddf539852ee4c29176ebb716a702d9118d0409fd8e/openai-1.93.0.tar.gz", hash = "sha256:988f31ade95e1ff0585af11cc5a64510225e4f5cd392698c675d0a9265b8e337", size = 486573, upload-time = "2025-06-27T21:21:39.421Z" }
sdist = { url = "https://files.pythonhosted.org/packages/18/2b/0d93a981afe76b4e89c57b69bf421b5f15878983fccbad321f142ba6e89b/openai-1.93.2.tar.gz", hash = "sha256:4a7312b426b5e4c98b78dfa1148b5683371882de3ad3d5f7c8e0c74f3cc90778", size = 487225, upload-time = "2025-07-08T15:38:00.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/46/a10d9df4673df56f71201d129ba1cb19eaff3366d08c8664d61a7df52e65/openai-1.93.0-py3-none-any.whl", hash = "sha256:3d746fe5498f0dd72e0d9ab706f26c91c0f646bf7459e5629af8ba7c9dbdf090", size = 755038, upload-time = "2025-06-27T21:21:37.532Z" },
{ url = "https://files.pythonhosted.org/packages/03/22/f7b90b519e8a5867dc96d411615eb7f987d2d5474c22e7d37c7170a132da/openai-1.93.2-py3-none-any.whl", hash = "sha256:5adbbebd48eae160e6d68efc4c0a4f7cb1318a44c62d9fc626cec229f418eab4", size = 755084, upload-time = "2025-07-08T15:37:58.045Z" },
]
[[package]]
@@ -1671,15 +1744,15 @@ wheels = [
[[package]]
name = "pyright"
version = "1.1.402"
version = "1.1.403"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207, upload-time = "2025-06-11T08:48:35.759Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004, upload-time = "2025-06-11T08:48:33.998Z" },
{ url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" },
]
[[package]]