Skip to content

Python: Inject user agent header at runtime#5435

Draft
TaoChenOSU wants to merge 1 commit intomainfrom
taochen/python-inject-user-agent-at-runtime
Draft

Python: Inject user agent header at runtime#5435
TaoChenOSU wants to merge 1 commit intomainfrom
taochen/python-inject-user-agent-at-runtime

Conversation

@TaoChenOSU
Copy link
Copy Markdown
Contributor

Motivation and Context

Description

Contribution Checklist

  • The code builds clean without any errors or warnings
  • The PR follows the Contribution Guidelines
  • All unit tests pass, and I have added new tests where possible
  • Is this a breaking change? If yes, add "[BREAKING]" prefix to the title of the PR.

Copilot AI review requested due to automatic review settings April 23, 2026 04:34
@github-actions github-actions Bot changed the title Inject user agent header at runtime Python: Inject user agent header at runtime Apr 23, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 (respecting user_agent_prefix).
  • Update OpenAI embedding/chat clients to inject runtime User-Agent via extra_headers on 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.

Comment on lines +947 to +953
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")
Copy link

Copilot AI Apr 23, 2026

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.
Comment on lines +285 to +291
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}
Copy link

Copilot AI Apr 23, 2026

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.
Comment on lines +678 to +683
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}

Copy link

Copilot AI Apr 23, 2026

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.
Comment on lines +485 to +491
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}
Copy link

Copilot AI Apr 23, 2026

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.
Comment on lines 532 to 536
stream_response = await client.responses.retrieve(
continuation_token["response_id"],
stream=True,
extra_headers=get_user_agent_extra_headers(),
)
Copy link

Copilot AI Apr 23, 2026

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.
Comment on lines +583 to +586
response = await client.responses.retrieve(
continuation_token["response_id"],
extra_headers=get_user_agent_extra_headers(),
)
Copy link

Copilot AI Apr 23, 2026

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.
Comment on lines +928 to +934
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")])])
Copy link

Copilot AI Apr 23, 2026

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.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

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 (via default_headers) to per-request time (via extra_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.py are covered. The merge logic for existing extra_headers handles three cases correctly: no existing headers, existing without UA, and existing with UA (preserved). The removal of prepend_agent_framework_to_user_agent from _shared.py is clean — the APP_INFO custom header is still set at client construction, while the User-Agent is 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_headers has 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 that extra_headers is correctly injected into run_options/kwargs, or that existing extra_headers are 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 AsyncOpenAI surface without Agent Framework telemetry headers.

Suggestions

  • Consider extracting the duplicated extra_headers merging 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants