Python: Inject user agent header at runtime#5435
Conversation
There was a problem hiding this comment.
Pull request overview
This PR shifts User-Agent telemetry from being set on OpenAI client construction to being injected per-request at runtime, so the value can reflect active user_agent_prefix(...) contexts (e.g., foundry-hosting) during execution.
Changes:
- Add
get_user_agent_extra_headers()to compute a runtime User-Agent header (respectinguser_agent_prefix). - Update OpenAI embedding/chat clients to inject runtime User-Agent via
extra_headerson each API call (including Responses API continuation retrieval). - Add/extend tests to validate runtime prefix behavior and the new helper.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| python/packages/core/agent_framework/_telemetry.py | Adds get_user_agent_extra_headers() helper to compute runtime User-Agent headers. |
| python/packages/core/agent_framework/init.py | Exposes get_user_agent_extra_headers as part of the public core API surface. |
| python/packages/core/tests/core/test_telemetry.py | Adds unit tests for get_user_agent_extra_headers() and prefix behavior. |
| python/packages/openai/agent_framework_openai/_shared.py | Stops prepending agent-framework UA into OpenAI client default_headers at construction time. |
| python/packages/openai/agent_framework_openai/_embedding_client.py | Injects runtime User-Agent via extra_headers for embeddings calls. |
| python/packages/openai/agent_framework_openai/_chat_completion_client.py | Injects runtime User-Agent via extra_headers for chat completion calls. |
| python/packages/openai/agent_framework_openai/_chat_client.py | Injects runtime User-Agent via extra_headers for Responses API calls, including continuation retrieval. |
| python/packages/foundry_hosting/tests/test_responses.py | Adds integration-style tests asserting user_agent_prefix is active during request handling. |
| 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") |
There was a problem hiding this comment.
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.
| 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} |
There was a problem hiding this comment.
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.
| 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} | ||
|
|
There was a problem hiding this comment.
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.
| 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 |
| 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} |
There was a problem hiding this comment.
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.
| stream_response = await client.responses.retrieve( | ||
| continuation_token["response_id"], | ||
| stream=True, | ||
| extra_headers=get_user_agent_extra_headers(), | ||
| ) |
There was a problem hiding this comment.
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.
| response = await client.responses.retrieve( | ||
| continuation_token["response_id"], | ||
| extra_headers=get_user_agent_extra_headers(), | ||
| ) |
There was a problem hiding this comment.
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.
| 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")])]) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Automated Code Review
Reviewers: 4 | Confidence: 92%
✓ Correctness
This PR adds a new
get_user_agent_extra_headers()function and shifts User-Agent injection from client construction time (viadefault_headers) to per-request time (viaextra_headers). This correctly enables context-scoped user agent prefixes (e.g.foundry-hosting) to appear in per-request headers. All API call sites in_chat_client.py,_chat_completion_client.py, and_embedding_client.pyare covered. The merge logic for existingextra_headershandles three cases correctly: no existing headers, existing without UA, and existing with UA (preserved). The removal ofprepend_agent_framework_to_user_agentfrom_shared.pyis clean — theAPP_INFOcustom header is still set at client construction, while theUser-Agentis now dynamically evaluated per-request. Tests adequately cover the new function including prefix and nesting scenarios. No correctness issues found.
✓ Security Reliability
This PR moves User-Agent header injection from client-construction time to per-request time, enabling context-scoped prefixes (e.g., 'foundry-hosting') to appear in outgoing API calls. The new get_user_agent_extra_headers() function is cleanly integrated at all API call sites in the chat client, chat completion client, and embedding client. The header merging logic correctly handles None, empty, and pre-populated extra_headers without overwriting user-provided User-Agent values. ContextVar usage for prefix tracking is asyncio-safe. No security or reliability issues identified.
✗ Test Coverage
The PR adds
get_user_agent_extra_headers()and moves user-agent injection from client construction time to per-request time. The core function has good tests (basic, with prefix, nested prefix). The foundry_hosting tests verify the prefix is active during agent execution. However, there are two notable test coverage gaps: (1)get_user_agent_extra_headershas no test for the telemetry-disabled path (IS_TELEMETRY_ENABLED = False), and (2) none of the OpenAI client classes (_chat_client.py,_chat_completion_client.py,_embedding_client.py) have tests verifying thatextra_headersis correctly injected into run_options/kwargs, or that existingextra_headersare preserved when merging. The duplicated header-merging logic across three clients is also not tested.
✗ Design Approach
The change improves per-request prefixing for the main chat/embedding calls, but it does so by removing the baseline User-Agent from the shared OpenAI client and re-injecting it only on a few hand-picked methods. That is a narrower fix than the underlying problem and leaves the rest of the exposed
AsyncOpenAIsurface without Agent Framework telemetry headers.
Suggestions
- Consider extracting the duplicated
extra_headersmerging pattern (check UA headers, check existing, merge or skip) into a shared helper to reduce repetition and make it easier to test in one place.
Automated review by TaoChenOSU's agents
Motivation and Context
Description
Contribution Checklist