Skip to content
Closed
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: 2 additions & 0 deletions python/packages/core/agent_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
APP_INFO,
USER_AGENT_KEY,
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
get_user_agent_extra_headers,
prepend_agent_framework_to_user_agent,
)
from ._tools import (
Expand Down Expand Up @@ -422,6 +423,7 @@
"evaluator",
"executor",
"function_middleware",
"get_user_agent_extra_headers",
"handler",
"included_messages",
"included_token_count",
Expand Down
18 changes: 18 additions & 0 deletions python/packages/core/agent_framework/_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ def _get_user_agent() -> str:
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"


def get_user_agent_extra_headers() -> dict[str, str]:
"""Return extra headers containing the current User-Agent string for per-request injection.

This function evaluates the user agent at call time, picking up any active
``user_agent_prefix`` context. Use it to supply ``extra_headers`` on individual
API calls so that the User-Agent reflects the current functional area.

When user agent telemetry is disabled, an empty dict is returned.

Returns:
A dict with ``"User-Agent"`` set to the runtime user agent string,
or an empty dict when telemetry is disabled.
"""
if not IS_TELEMETRY_ENABLED:
return {}
return {USER_AGENT_KEY: _get_user_agent()}


def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
"""Prepend "agent-framework" to the User-Agent in the headers.

Expand Down
31 changes: 31 additions & 0 deletions python/packages/core/tests/core/test_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
AGENT_FRAMEWORK_USER_AGENT,
USER_AGENT_KEY,
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
get_user_agent_extra_headers,
prepend_agent_framework_to_user_agent,
)
from agent_framework._telemetry import user_agent_prefix
Expand Down Expand Up @@ -150,3 +151,33 @@ def test_user_agent_prefix_nesting():
# Both removed
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT


# region Test get_user_agent_extra_headers


def test_get_user_agent_extra_headers_returns_user_agent():
"""Test that get_user_agent_extra_headers returns a User-Agent header."""
result = get_user_agent_extra_headers()
assert "User-Agent" in result
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT


def test_get_user_agent_extra_headers_with_prefix():
"""Test that get_user_agent_extra_headers respects user_agent_prefix context."""
with user_agent_prefix("test-host"):
result = get_user_agent_extra_headers()
assert result["User-Agent"].startswith("test-host/")
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]

# After exiting context, prefix is removed
result = get_user_agent_extra_headers()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT


def test_get_user_agent_extra_headers_with_nested_prefix():
"""Test that get_user_agent_extra_headers picks up nested prefixes."""
with user_agent_prefix("outer"), user_agent_prefix("inner"):
result = get_user_agent_extra_headers()
assert "outer" in result["User-Agent"]
assert "inner" in result["User-Agent"]
73 changes: 73 additions & 0 deletions python/packages/foundry_hosting/tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -915,3 +915,76 @@ def test_unsupported_type_raises(self) -> None:


# endregion


# region User Agent Prefix


class TestUserAgentPrefix:
"""Tests that the user_agent_prefix context manager is active during agent execution."""

async def test_user_agent_prefix_set_during_non_streaming(self) -> None:
"""The user agent should contain the foundry-hosting prefix in non-streaming mode."""
from agent_framework._telemetry import _get_user_agent # type: ignore

captured_user_agent: list[str] = []

async def run_and_capture(*args: Any, **kwargs: Any) -> AgentResponse:
captured_user_agent.append(_get_user_agent())
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
Comment on lines +928 to +934

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests import the private _get_user_agent symbol (and suppress typing) even though there is now a public get_user_agent_extra_headers() API that reflects the runtime prefix behavior. Consider asserting against get_user_agent_extra_headers()["User-Agent"] in the first two tests to avoid coupling tests to a private helper.

Copilot uses AI. Check for mistakes.

agent = _make_agent()
agent.run = AsyncMock(side_effect=run_and_capture)
server = _make_server(agent)
resp = await _post(server, input_text="Hi", stream=False)

assert resp.status_code == 200
assert len(captured_user_agent) == 1
assert "foundry-hosting" in captured_user_agent[0]

async def test_user_agent_prefix_set_during_streaming(self) -> None:
"""The user agent should contain the foundry-hosting prefix in streaming mode."""
from agent_framework._telemetry import _get_user_agent # type: ignore

captured_user_agent: list[str] = []

async def _stream_gen() -> AsyncIterator[AgentResponseUpdate]:
captured_user_agent.append(_get_user_agent())
yield AgentResponseUpdate(contents=[Content.from_text("hello")], role="assistant")
Comment on lines +947 to +953

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the non-streaming test: importing private _get_user_agent makes the test brittle. Prefer validating the runtime User-Agent via the public get_user_agent_extra_headers() helper within the mocked run/stream generator.

Copilot uses AI. Check for mistakes.

def run_streaming(*args: Any, **kwargs: Any) -> Any:
if kwargs.get("stream"):
return ResponseStream(_stream_gen()) # type: ignore
raise NotImplementedError

agent = _make_agent()
agent.run = MagicMock(side_effect=run_streaming)
server = _make_server(agent)
resp = await _post(server, stream=True)

assert resp.status_code == 200
assert len(captured_user_agent) == 1
assert "foundry-hosting" in captured_user_agent[0]

async def test_user_agent_extra_headers_during_run(self) -> None:
"""get_user_agent_extra_headers() should include the prefix during a request."""
from agent_framework._telemetry import get_user_agent_extra_headers

captured_headers: list[dict[str, str]] = []

async def run_and_capture(*args: Any, **kwargs: Any) -> AgentResponse:
captured_headers.append(get_user_agent_extra_headers())
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])

agent = _make_agent()
agent.run = AsyncMock(side_effect=run_and_capture)
server = _make_server(agent)
resp = await _post(server, input_text="Hi", stream=False)

assert resp.status_code == 200
assert len(captured_headers) == 1
assert "User-Agent" in captured_headers[0]
assert "foundry-hosting" in captured_headers[0]["User-Agent"]


# endregion
15 changes: 13 additions & 2 deletions python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
from agent_framework._settings import SecretString
from agent_framework._telemetry import USER_AGENT_KEY
from agent_framework._telemetry import USER_AGENT_KEY, get_user_agent_extra_headers
from agent_framework._tools import (
SHELL_TOOL_KIND_VALUE,
FunctionInvocationConfiguration,
Expand Down Expand Up @@ -482,6 +482,13 @@ async def _prepare_request(
client = self.client
validated_options = await self._validate_options(options)
run_options = await self._prepare_options(messages, validated_options)
ua_headers = get_user_agent_extra_headers()
if ua_headers:
existing = run_options.get("extra_headers")
if existing is None:
run_options["extra_headers"] = ua_headers
elif USER_AGENT_KEY not in existing:
run_options["extra_headers"] = {**existing, **ua_headers}
Comment on lines +485 to +491

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as other clients: setting run_options["extra_headers"]["User-Agent"] at runtime can override a caller-provided User-Agent from default_headers (and if extra_headers already contains a User-Agent you currently skip adding the agent-framework UA). To preserve previous behavior, consider composing the runtime agent-framework UA with any existing User-Agent value rather than replacing or skipping it.

Copilot uses AI. Check for mistakes.
return client, run_options, validated_options

def _handle_request_error(self, ex: Exception) -> NoReturn:
Expand Down Expand Up @@ -525,6 +532,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
stream_response = await client.responses.retrieve(
continuation_token["response_id"],
stream=True,
extra_headers=get_user_agent_extra_headers(),
)
Comment on lines 532 to 536

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For continuation-token retrieval, extra_headers=get_user_agent_extra_headers() will override any User-Agent configured on the client’s default_headers (and does not incorporate any caller-provided UA). Consider merging/composing the runtime agent-framework UA with an existing User-Agent value rather than unconditionally passing a new "User-Agent" header.

Copilot uses AI. Check for mistakes.
async for chunk in stream_response:
yield self._parse_chunk_from_openai(
Expand Down Expand Up @@ -572,7 +580,10 @@ async def _get_response() -> ChatResponse:
client = self.client
validated_options = await self._validate_options(options)
try:
response = await client.responses.retrieve(continuation_token["response_id"])
response = await client.responses.retrieve(
continuation_token["response_id"],
extra_headers=get_user_agent_extra_headers(),
)
Comment on lines +583 to +586

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the streaming continuation path: passing extra_headers here will replace any User-Agent configured in default_headers, which is a behavior change vs. the prior prepend/concatenate behavior. Consider composing the runtime agent-framework UA with any existing UA value instead of overriding it.

Copilot uses AI. Check for mistakes.
except Exception as ex:
self._handle_request_error(ex)
return self._parse_response_from_openai(response, options=validated_options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from agent_framework._docstrings import apply_layered_docstring
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
from agent_framework._settings import SecretString
from agent_framework._telemetry import USER_AGENT_KEY
from agent_framework._telemetry import USER_AGENT_KEY, get_user_agent_extra_headers
from agent_framework._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
Expand Down Expand Up @@ -671,6 +671,16 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An
run_options["response_format"] = response_format
else:
run_options["response_format"] = type_to_response_format_param(response_format)

# runtime user-agent header
ua_headers = get_user_agent_extra_headers()
if ua_headers:
existing = run_options.get("extra_headers")
if existing is None:
run_options["extra_headers"] = ua_headers
elif USER_AGENT_KEY not in existing:
run_options["extra_headers"] = {**existing, **ua_headers}

Comment on lines +678 to +683

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic only injects the agent-framework User-Agent when extra_headers is missing a User-Agent key. If a caller supplies their own User-Agent (via extra_headers or default_headers), agent-framework telemetry (and any user_agent_prefix) will be omitted or overridden compared to the previous prepend_agent_framework_to_user_agent() behavior. Consider composing/concatenating the runtime agent-framework UA with the existing UA value instead of skipping injection.

Suggested change
existing = run_options.get("extra_headers")
if existing is None:
run_options["extra_headers"] = ua_headers
elif USER_AGENT_KEY not in existing:
run_options["extra_headers"] = {**existing, **ua_headers}
existing_extra_headers = run_options.get("extra_headers")
existing_default_headers = run_options.get("default_headers")
merged_extra_headers = (
dict(existing_extra_headers) if existing_extra_headers is not None else {}
)
merged_extra_headers.update(ua_headers)
runtime_user_agent = ua_headers.get(USER_AGENT_KEY)
existing_user_agent = None
if existing_extra_headers is not None:
existing_user_agent = existing_extra_headers.get(USER_AGENT_KEY)
if existing_user_agent is None and existing_default_headers is not None:
existing_user_agent = existing_default_headers.get(USER_AGENT_KEY)
if runtime_user_agent and existing_user_agent:
merged_extra_headers[USER_AGENT_KEY] = f"{runtime_user_agent} {existing_user_agent}"
run_options["extra_headers"] = merged_extra_headers

Copilot uses AI. Check for mistakes.
return run_options

def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping[str, Any]) -> ChatResponse:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from agent_framework._clients import BaseEmbeddingClient
from agent_framework._settings import SecretString
from agent_framework._telemetry import USER_AGENT_KEY
from agent_framework._telemetry import USER_AGENT_KEY, get_user_agent_extra_headers
from agent_framework._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails
from agent_framework.observability import EmbeddingTelemetryLayer
from openai import AsyncAzureOpenAI, AsyncOpenAI
Expand Down Expand Up @@ -282,6 +282,13 @@ async def get_embeddings(
kwargs["encoding_format"] = encoding_format
if user := opts.get("user"):
kwargs["user"] = user
ua_headers = get_user_agent_extra_headers()
if ua_headers:
existing = kwargs.get("extra_headers")
if existing is None:
kwargs["extra_headers"] = ua_headers
elif USER_AGENT_KEY not in existing:
kwargs["extra_headers"] = {**existing, **ua_headers}
Comment on lines +285 to +291

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Injecting a per-request "User-Agent" via extra_headers will override any User-Agent that a caller provided in default_headers when constructing the OpenAI client. Previously prepend_agent_framework_to_user_agent() preserved the caller’s User-Agent by prepending the agent-framework value (including any active user_agent_prefix). Consider carrying forward the previous behavior by composing the runtime agent-framework user agent with any existing User-Agent value (from default_headers and/or extra_headers) rather than skipping injection or overriding it.

Copilot uses AI. Check for mistakes.

response = await self.client.embeddings.create(**kwargs) # type: ignore[union-attr]

Expand Down
3 changes: 1 addition & 2 deletions python/packages/openai/agent_framework_openai/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import TYPE_CHECKING, Any, Literal, Union

from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import APP_INFO, prepend_agent_framework_to_user_agent
from agent_framework._telemetry import APP_INFO
from agent_framework.exceptions import SettingNotFoundError
from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore
from openai.types import Completion
Expand Down Expand Up @@ -174,7 +174,6 @@ def load_openai_service_settings(
merged_headers = dict(copy(default_headers)) if default_headers else {}
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)

api_key_callable = api_key if callable(api_key) else None
api_key_str = api_key if not callable(api_key) else None
Expand Down
Loading