Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/python-setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ runs:
- name: Install the project
shell: bash
run: |
cd python && uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit
cd python && uv sync --all-packages --all-extras --all-groups --prerelease=if-necessary-or-explicit
8 changes: 6 additions & 2 deletions python/.github/skills/python-package-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ uv run poe venv --python 3.12
# Intentionally upgrade a specific dependency to reduce lockfile conflicts
uv lock --upgrade-package <dependency-name> && uv run poe install

# Refresh all dev dependency pins, lockfile, and validation in one run
# Refresh exact development dependency-group pins, lockfile, and validation in one run
uv run poe upgrade-dev-dependencies

# First, run workspace-wide lower/upper compatibility gates
Expand All @@ -69,7 +69,11 @@ uv run poe add-dependency-and-validate-bounds --package core --dependency "<depe
- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--package "*"` and omitting `--dependency`.
- Prefer targeted lock updates with `uv lock --upgrade-package <dependency-name>` to reduce `uv.lock` merge conflicts.
- Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command.
- Use `upgrade-dev-dependencies` for repo-wide dev tooling refreshes; it repins dev dependencies, refreshes `uv.lock`, and reruns `check`, `typing`, and `test`.
- Keep shared tooling and source/type-check support in the root or package `dev` group. Put package-specific test
fixtures in a `test` group, and use a feature-named group for local-only executable dependencies that cannot be
expressed in published runtime metadata.
- Use `upgrade-dev-dependencies` for repo-wide development dependency refreshes; it repins exact dependencies
across development groups, refreshes `uv.lock`, and reruns `check`, `typing`, and `test`.

## Lazy Loading Pattern

Expand Down
16 changes: 10 additions & 6 deletions python/DEV_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ uv python install 3.10 3.11 3.12 3.13
PYTHON_VERSION="3.10"
uv venv --python $PYTHON_VERSION
# Install AF and all dependencies
uv sync --dev
uv sync --all-groups
# Install all the tools and dependencies
uv run poe install
# Install prek hooks
Expand Down Expand Up @@ -215,7 +215,7 @@ uv venv

and then you can run the following tasks:
```bash
uv sync --all-extras --dev
uv sync --all-extras --all-groups
```

After this initial setup, you can use the following tasks to manage your development environment. It is advised to use the following setup command since that also installs the prek hooks.
Expand All @@ -229,13 +229,17 @@ uv run poe setup -P 3.12
```

#### `install`
Install all dependencies (including extras and dev dependencies) from the lockfile using frozen resolution:
Install all dependencies (including extras and dependency groups) from the lockfile using frozen resolution:
```bash
uv run poe install
```
The root `dev` group contains shared tooling and source/type-check support. Package-specific test fixtures use
`test` groups, while dependencies needed for a locally executable optional feature may use a feature-named group
such as the lab package's `tau2` group.
For intentional dependency upgrades, run `uv lock --upgrade-package <dependency-name>` and then run `uv run poe install`.

For repo-wide dev tooling refreshes, run `uv run poe upgrade-dev-dependencies` to repin dev dependencies, refresh `uv.lock`, and rerun validation, typing, and tests.
For repo-wide development dependency refreshes, run `uv run poe upgrade-dev-dependencies` to repin exact
dependencies in development groups, refresh `uv.lock`, and rerun validation, typing, and tests.

#### `venv`
Create a virtual environment with specified Python version or switch python version:
Expand Down Expand Up @@ -392,11 +396,11 @@ uv run poe add-dependency-and-validate-bounds -P core -D "<dependency-spec>"
```

#### `upgrade-dev-dependencies`
Refresh exact dev dependency pins across the workspace, run `uv lock --upgrade`, reinstall from the frozen lockfile, then rerun validation, typing, and tests:
Refresh exact development dependency pins across the workspace, run `uv lock --upgrade`, reinstall from the frozen lockfile, then rerun validation, typing, and tests:
```bash
uv run poe upgrade-dev-dependencies
```
Use this for repo-wide dev tooling refreshes. For targeted runtime dependency upgrades, prefer `uv lock --upgrade-package <dependency-name>` plus the package-scoped bound validation tasks above.
Use this for repo-wide development tooling and dependency-group refreshes. For targeted runtime dependency upgrades, prefer `uv lock --upgrade-package <dependency-name>` plus the package-scoped bound validation tasks above.

### Building and Publishing

Expand Down
3 changes: 0 additions & 3 deletions python/packages/azurefunctions/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ dependencies = [
"azure-functions-durable>=1.3.1,<2",
]

[dependency-groups]
dev = []

[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def test_restricted_decode_allows_openai_response_types():
input_tokens=10,
output_tokens=20,
total_tokens=30,
input_tokens_details=InputTokensDetails(cached_tokens=0),
input_tokens_details=InputTokensDetails(cached_tokens=0, cache_write_tokens=0),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
)
encoded = encode_checkpoint_value(usage)
Expand Down
73 changes: 34 additions & 39 deletions python/packages/devui/agent_framework_devui/_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from typing import Any, Union, cast
from uuid import uuid4

from agent_framework import Content, Message
from agent_framework import Content, Message, UsageDetails, add_usage_details
from openai.types.responses import (
Response,
ResponseContentPartAddedEvent,
Expand Down Expand Up @@ -83,6 +83,25 @@ def _workflow_output_metadata(event_type: Any, executor_id: Any) -> dict[str, An
}


def _response_usage(usage_details: UsageDetails) -> ResponseUsage:
input_tokens = int(usage_details.get("input_token_count") or 0)
output_tokens = int(usage_details.get("output_token_count") or 0)
total_token_count = usage_details.get("total_token_count")

return ResponseUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=int(total_token_count) if total_token_count is not None else input_tokens + output_tokens,
input_tokens_details=InputTokensDetails(
cached_tokens=int(usage_details.get("cache_read_input_token_count") or 0),
cache_write_tokens=int(usage_details.get("cache_creation_input_token_count") or 0),
),
output_tokens_details=OutputTokensDetails(
reasoning_tokens=int(usage_details.get("reasoning_output_token_count") or 0)
),
)


def _serialize_content_recursive(value: Any) -> Any:
"""Recursively serialize Agent Framework Content objects to JSON-compatible values.

Expand Down Expand Up @@ -152,7 +171,7 @@ def __init__(self, max_contexts: int = 1000) -> None:
self._max_contexts = max_contexts

# Track usage per request for final Response.usage (OpenAI standard)
self._usage_accumulator: dict[str, dict[str, int]] = {}
self._usage_accumulator: dict[str, UsageDetails] = {}

# Register content type mappers for all 12 Agent Framework content types
self.content_mappers = {
Expand Down Expand Up @@ -392,28 +411,20 @@ async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrame

# Get usage from accumulator (OpenAI standard)
request_id = str(id(request))
usage_data = self._usage_accumulator.get(request_id)

if usage_data:
usage = ResponseUsage(
input_tokens=usage_data["input_tokens"],
output_tokens=usage_data["output_tokens"],
total_tokens=usage_data["total_tokens"],
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
)
# Cleanup accumulator
del self._usage_accumulator[request_id]
usage_data = self._usage_accumulator.pop(request_id, None)

if usage_data is not None:
usage = _response_usage(usage_data)
else:
# Fallback: estimate if no usage was tracked
input_token_count = len(str(request.input)) // 4 if request.input else 0
output_token_count = len(full_content) // 4
usage = ResponseUsage(
input_tokens=input_token_count,
output_tokens=output_token_count,
total_tokens=input_token_count + output_token_count,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
usage = _response_usage(
UsageDetails(
input_token_count=input_token_count,
output_token_count=output_token_count,
total_token_count=input_token_count + output_token_count,
)
)

return OpenAIResponse(
Expand Down Expand Up @@ -1484,19 +1495,11 @@ async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> Non
None - no event emitted (usage goes in final Response.usage)
"""
# Extract usage from UsageContent.usage_details (UsageDetails object)
details = _to_str_dict(getattr(content, "usage_details", None)) or {}
total_tokens = int(details.get("total_token_count", 0) or 0)
prompt_tokens = int(details.get("input_token_count", 0) or 0)
completion_tokens = int(details.get("output_token_count", 0) or 0)
details = cast(UsageDetails, _to_str_dict(getattr(content, "usage_details", None)) or {})

# Accumulate for final Response.usage
request_id = context.get("request_id", "default")
if request_id not in self._usage_accumulator:
self._usage_accumulator[request_id] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}

self._usage_accumulator[request_id]["input_tokens"] += prompt_tokens
self._usage_accumulator[request_id]["output_tokens"] += completion_tokens
self._usage_accumulator[request_id]["total_tokens"] += total_tokens
self._usage_accumulator[request_id] = add_usage_details(self._usage_accumulator.get(request_id), details)

logger.debug(f"Accumulated usage for {request_id}: {self._usage_accumulator[request_id]}")

Expand Down Expand Up @@ -1865,21 +1868,13 @@ async def _create_error_response(self, error_message: str, request: AgentFramewo
status="completed",
)

usage = ResponseUsage(
input_tokens=0,
output_tokens=0,
total_tokens=0,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
)

return OpenAIResponse(
id=f"resp_{uuid.uuid4().hex[:12]}",
object="response",
created_at=datetime.now().timestamp(),
model=request.model or "devui",
output=[response_output_message],
usage=usage,
usage=_response_usage(UsageDetails()),
parallel_tool_calls=False,
tool_choice="none",
tools=[],
Expand Down
57 changes: 57 additions & 0 deletions python/packages/devui/tests/devui/test_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,63 @@ async def test_mixed_content_types(mapper: MessageMapper, test_request: AgentFra
assert "response.function_call_arguments.delta" in event_types


async def test_usage_content_preserves_token_details(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Test usage aggregation preserves cache and reasoning token details."""
first_update = create_test_agent_update([
Content.from_usage({
"input_token_count": 10,
"output_token_count": 5,
"total_token_count": 15,
"cache_creation_input_token_count": 3,
"cache_read_input_token_count": 4,
"reasoning_output_token_count": 2,
})
])
second_update = create_test_agent_update([
Content.from_usage({
"input_token_count": 4,
"output_token_count": 3,
"total_token_count": 7,
"cache_creation_input_token_count": 1,
"cache_read_input_token_count": 2,
"reasoning_output_token_count": 1,
})
])

assert await mapper.convert_event(first_update, test_request) == []
assert await mapper.convert_event(second_update, test_request) == []

response = await mapper.aggregate_to_response([], test_request)

assert response.usage is not None
assert response.usage.input_tokens == 14
assert response.usage.output_tokens == 8
assert response.usage.total_tokens == 22
assert response.usage.input_tokens_details.cached_tokens == 6
assert response.usage.input_tokens_details.cache_write_tokens == 4
assert response.usage.output_tokens_details.reasoning_tokens == 3


async def test_zero_usage_content_does_not_use_estimate(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Test explicit zero usage remains zero instead of falling back to estimates."""
update = create_test_agent_update([
Content.from_usage({"input_token_count": 0, "output_token_count": 0, "total_token_count": 0})
])

assert await mapper.convert_event(update, test_request) == []

response = await mapper.aggregate_to_response([], test_request)

assert response.usage is not None
assert response.usage.input_tokens == 0
assert response.usage.output_tokens == 0
assert response.usage.total_tokens == 0


# =============================================================================
# Agent Lifecycle Event Tests
# =============================================================================
Expand Down
2 changes: 1 addition & 1 deletion python/packages/foundry/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ dependencies = [
"agent-framework-openai>=1.10.0,<2",
"aiohttp>=3.9,<4",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.2.0,<3.0",
"azure-ai-projects>=2.2.0,<2.3.0",
]

[tool.uv]
Expand Down
2 changes: 1 addition & 1 deletion python/packages/hosting-responses/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ dependencies = [
]

[dependency-groups]
dev = [
test = [
"fastapi>=0.115.0,<0.138.1",
"httpx>=0.28.1",
]
Expand Down
6 changes: 4 additions & 2 deletions python/packages/lab/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ dev = [
"rich>=13.7.1,<15.0.0",
"tomli==2.4.1",
"tomli-w==1.2.0",
# tau2 from source (not available on PyPI)
"prek==0.4.8",
]
# tau2 is an executable optional feature fetched from source because it is not available on PyPI.
tau2 = [
"tau2@ git+https://github.com/sierra-research/tau2-bench@5ba9e3e56db57c5e4114bf7f901291f09b2c5619",
"prek==0.4.5",
]

[project.scripts]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import sys
from collections.abc import Sequence
from importlib import import_module
from typing import Any, ClassVar, Generic, TypedDict

from agent_framework import (
Expand All @@ -17,7 +18,24 @@
)
from agent_framework._settings import SecretString
from agent_framework.observability import EmbeddingTelemetryLayer
from mistralai.client import Mistral


def _load_mistral_client_class() -> Any:
try:
mistral_class = getattr(import_module("mistralai.client"), "Mistral", None)
except ModuleNotFoundError as exc:
if exc.name != "mistralai.client":
raise
mistral_class = None

if mistral_class is None:
mistral_class = getattr(import_module("mistralai"), "Mistral", None)
if mistral_class is None:
raise ImportError("The installed mistralai package does not expose the Mistral client class.")
return mistral_class


Mistral: Any = _load_mistral_client_class()

if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
Expand Down Expand Up @@ -93,7 +111,7 @@ def __init__(
model: str | None = None,
api_key: str | SecretString | None = None,
server_url: str | None = None,
client: Mistral | None = None,
client: Any | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
Expand Down Expand Up @@ -231,7 +249,7 @@ def __init__(
model: str | None = None,
api_key: str | SecretString | None = None,
server_url: str | None = None,
client: Mistral | None = None,
client: Any | None = None,
otel_provider_name: str | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
Expand Down
3 changes: 2 additions & 1 deletion python/packages/mistral/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.11.0,<2",
"mistralai>=2.0.0,<3",
# Mistral 1.x retains the embeddings API without the OpenTelemetry semantic-conventions cap in 2.x.
"mistralai>=1.8.1,<3",
]

[tool.uv]
Expand Down
Loading
Loading