Skip to content

Commit ef87280

Browse files
authored
Take a look the function tool sample. This sample code generate the… (Azure#44776)
* Take a look the function tool sample. This sample code generate the following as user agent for openai call MyApp/1.0-AIProjectClient-OpenAI/Python 2.6.1 * Refactor user agent handling in AIProjectClient and add tests for user agent patching * Refactor user agent handling in AIProjectClient and related tests * Refactor user agent construction in AIProjectClient for consistency and clarity * resolved comments * clean up
1 parent 5231877 commit ef87280

6 files changed

Lines changed: 258 additions & 46 deletions

File tree

sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"""
1010
import os
1111
import logging
12-
from typing import List, Any, Optional
12+
from typing import List, Any
1313
from openai import OpenAI
1414
from azure.core.tracing.decorator import distributed_trace
1515
from azure.core.credentials import TokenCredential
@@ -21,22 +21,6 @@
2121
logger = logging.getLogger(__name__)
2222

2323

24-
def _patch_user_agent(user_agent: Optional[str]) -> str:
25-
# All authenticated external clients exposed by this client will have this application id
26-
# set on their user-agent. For more info on user-agent HTTP header, see:
27-
# https://azure.github.io/azure-sdk/general_azurecore.html#telemetry-policy
28-
USER_AGENT_APP_ID = "AIProjectClient"
29-
30-
if user_agent:
31-
# If the calling application has set "user_agent" when constructing the AIProjectClient,
32-
# take that value and prepend it to USER_AGENT_APP_ID.
33-
patched_user_agent = f"{user_agent}-{USER_AGENT_APP_ID}"
34-
else:
35-
patched_user_agent = USER_AGENT_APP_ID
36-
37-
return patched_user_agent
38-
39-
4024
class AIProjectClient(AIProjectClientGenerated): # pylint: disable=too-many-instance-attributes
4125
"""AIProjectClient.
4226
@@ -78,6 +62,7 @@ class AIProjectClient(AIProjectClientGenerated): # pylint: disable=too-many-ins
7862
:paramtype api_version: str
7963
:keyword int polling_interval: Default waiting time between two polls for LRO operations if no
8064
Retry-After header is present.
65+
:keyword user_agent: Optional string identifying the caller. This string will show up at the front of the "User-Agent" HTTP request header in all network calls this client makes. If an OpenAI client was obtained by calling get_openai_client(), this string will also show up at the front of the "User-Agent" request header in network calls that OpenAI client makes.
8166
"""
8267

8368
def __init__(self, endpoint: str, credential: TokenCredential, **kwargs: Any) -> None:
@@ -103,7 +88,7 @@ def __init__(self, endpoint: str, credential: TokenCredential, **kwargs: Any) ->
10388
kwargs.setdefault("logging_enable", self._console_logging_enabled)
10489

10590
self._kwargs = kwargs.copy()
106-
self._patched_user_agent = _patch_user_agent(self._kwargs.pop("user_agent", None))
91+
self._custom_user_agent = self._kwargs.get("user_agent", None)
10792

10893
super().__init__(endpoint=endpoint, credential=credential, **kwargs)
10994

@@ -143,6 +128,8 @@ def get_openai_client(self, **kwargs: Any) -> "OpenAI": # type: ignore[name-def
143128

144129
http_client = None
145130

131+
kwargs = kwargs.copy() if kwargs else {}
132+
146133
if self._console_logging_enabled:
147134
try:
148135
import httpx
@@ -241,16 +228,38 @@ def _log_request_body(self, request: httpx.Request) -> None:
241228

242229
http_client = httpx.Client(transport=OpenAILoggingTransport())
243230

244-
client = OpenAI(
245-
# See https://learn.microsoft.com/python/api/azure-identity/azure.identity?view=azure-python#azure-identity-get-bearer-token-provider # pylint: disable=line-too-long
246-
api_key=get_bearer_token_provider(
247-
self._config.credential, # pylint: disable=protected-access
248-
"https://ai.azure.com/.default",
249-
),
250-
base_url=base_url,
251-
http_client=http_client,
252-
**kwargs,
253-
)
231+
default_headers = dict[str, str](kwargs.pop("default_headers", None) or {})
232+
233+
openai_custom_user_agent = default_headers.get("User-Agent", None)
234+
235+
def _create_openai_client(**kwargs) -> OpenAI:
236+
return OpenAI(
237+
# See https://learn.microsoft.com/python/api/azure-identity/azure.identity?view=azure-python#azure-identity-get-bearer-token-provider # pylint: disable=line-too-long
238+
api_key=get_bearer_token_provider(
239+
self._config.credential, # pylint: disable=protected-access
240+
"https://ai.azure.com/.default",
241+
),
242+
base_url=base_url,
243+
http_client=http_client,
244+
**kwargs,
245+
)
246+
247+
dummy_client = _create_openai_client()
248+
249+
openai_default_user_agent = dummy_client.user_agent
250+
251+
if openai_custom_user_agent:
252+
final_user_agent = openai_custom_user_agent
253+
else:
254+
final_user_agent = (
255+
"-".join(ua for ua in [self._custom_user_agent, "AIProjectClient"] if ua)
256+
+ " "
257+
+ openai_default_user_agent
258+
)
259+
260+
default_headers["User-Agent"] = final_user_agent
261+
262+
client = _create_openai_client(default_headers=default_headers, **kwargs)
254263

255264
return client
256265

sdk/ai/azure-ai-projects/azure/ai/projects/aio/_patch.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from azure.core.credentials_async import AsyncTokenCredential
1616
from azure.identity.aio import get_bearer_token_provider
1717
from ._client import AIProjectClient as AIProjectClientGenerated
18-
from .._patch import _patch_user_agent
1918
from .operations import TelemetryOperations
2019

2120

@@ -63,6 +62,8 @@ class AIProjectClient(AIProjectClientGenerated): # pylint: disable=too-many-ins
6362
:paramtype api_version: str
6463
:keyword int polling_interval: Default waiting time between two polls for LRO operations if no
6564
Retry-After header is present.
65+
:keyword user_agent: Optional string identifying the caller. This string will show up at the front of the "User-Agent" HTTP request header in all network calls this client makes. If an OpenAI client was obtained by calling get_openai_client(), this string will also show up at the front of the "User-Agent" request header in network calls that OpenAI client makes.
66+
:meth:`get_openai_client`.
6667
"""
6768

6869
def __init__(self, endpoint: str, credential: AsyncTokenCredential, **kwargs: Any) -> None:
@@ -88,7 +89,7 @@ def __init__(self, endpoint: str, credential: AsyncTokenCredential, **kwargs: An
8889
kwargs.setdefault("logging_enable", self._console_logging_enabled)
8990

9091
self._kwargs = kwargs.copy()
91-
self._patched_user_agent = _patch_user_agent(self._kwargs.pop("user_agent", None))
92+
self._custom_user_agent = self._kwargs.get("user_agent", None)
9293

9394
super().__init__(endpoint=endpoint, credential=credential, **kwargs)
9495

@@ -128,6 +129,8 @@ def get_openai_client(self, **kwargs: Any) -> "AsyncOpenAI": # type: ignore[nam
128129

129130
http_client = None
130131

132+
kwargs = kwargs.copy() if kwargs else {}
133+
131134
if self._console_logging_enabled:
132135
try:
133136
import httpx
@@ -226,16 +229,38 @@ def _log_request_body(self, request: httpx.Request) -> None:
226229

227230
http_client = httpx.AsyncClient(transport=OpenAILoggingTransport())
228231

229-
client = AsyncOpenAI(
230-
# See https://learn.microsoft.com/python/api/azure-identity/azure.identity?view=azure-python#azure-identity-get-bearer-token-provider # pylint: disable=line-too-long
231-
api_key=get_bearer_token_provider(
232-
self._config.credential, # pylint: disable=protected-access
233-
"https://ai.azure.com/.default",
234-
),
235-
base_url=base_url,
236-
http_client=http_client,
237-
**kwargs,
238-
)
232+
default_headers = dict[str, str](kwargs.pop("default_headers", None) or {})
233+
234+
openai_custom_user_agent = default_headers.get("User-Agent", None)
235+
236+
def _create_openai_client(**kwargs) -> AsyncOpenAI:
237+
return AsyncOpenAI(
238+
# See https://learn.microsoft.com/python/api/azure-identity/azure.identity?view=azure-python#azure-identity-get-bearer-token-provider # pylint: disable=line-too-long
239+
api_key=get_bearer_token_provider(
240+
self._config.credential, # pylint: disable=protected-access
241+
"https://ai.azure.com/.default",
242+
),
243+
base_url=base_url,
244+
http_client=http_client,
245+
**kwargs,
246+
)
247+
248+
dummy_client = _create_openai_client()
249+
250+
openai_default_user_agent = dummy_client.user_agent
251+
252+
if openai_custom_user_agent:
253+
final_user_agent = openai_custom_user_agent
254+
else:
255+
final_user_agent = (
256+
"-".join(ua for ua in [self._custom_user_agent, "AIProjectClient"] if ua)
257+
+ " "
258+
+ openai_default_user_agent
259+
)
260+
261+
default_headers["User-Agent"] = final_user_agent
262+
263+
client = _create_openai_client(default_headers=default_headers, **kwargs)
239264

240265
return client
241266

sdk/ai/azure-ai-projects/tests/conftest.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
add_general_regex_sanitizer,
2020
add_body_key_sanitizer,
2121
add_remove_header_sanitizer,
22-
add_body_regex_sanitizer,
2322
)
2423

2524
if not load_dotenv(find_dotenv(), override=True):
@@ -121,10 +120,7 @@ def sanitize_url_paths():
121120
add_general_regex_sanitizer(regex=r"ftchkpt-[a-f0-9]+", value="sanitized-checkpoint-id")
122121

123122
# Sanitize eval dataset names with timestamps (e.g., eval-data-2026-01-19_040648_UTC)
124-
add_general_regex_sanitizer(
125-
regex=r"eval-data-\d{4}-\d{2}-\d{2}_\d{6}_UTC",
126-
value="eval-data-sanitized-timestamp"
127-
)
123+
add_general_regex_sanitizer(regex=r"eval-data-\d{4}-\d{2}-\d{2}_\d{6}_UTC", value="eval-data-sanitized-timestamp")
128124

129125
# Sanitize API key from service response (this includes Application Insights connection string)
130126
add_body_key_sanitizer(json_path="credentials.key", value="sanitized-api-key")

sdk/ai/azure-ai-projects/tests/responses/test_responses.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,40 @@
55
# ------------------------------------
66
# cSpell:disable
77

8-
# import os
8+
import pytest
9+
import httpx
910
from devtools_testutils import recorded_by_proxy, RecordedTransport
1011
from test_base import TestBase, servicePreparer
12+
from typing import Any, Dict, Optional
13+
from openai import OpenAI
14+
from azure.core.credentials import TokenCredential
15+
from azure.ai.projects import AIProjectClient
16+
17+
BASE_OPENAI_UA = OpenAI(api_key="dummy").user_agent
18+
19+
20+
class DummyTokenCredential(TokenCredential):
21+
def get_token(self, *scopes: str, **kwargs: Any): # type: ignore[override]
22+
return None
23+
24+
25+
@pytest.fixture(autouse=True)
26+
def patch_openai(monkeypatch):
27+
# Ensure no real network/token calls are made during the test.
28+
monkeypatch.setattr("azure.ai.projects._patch.get_bearer_token_provider", lambda *_, **__: "token-provider")
29+
30+
31+
def _build_client(
32+
project_user_agent: Optional[str],
33+
default_headers: Optional[Dict[str, str]],
34+
):
35+
project_client = AIProjectClient(
36+
"https://example.com/api/projects/test",
37+
DummyTokenCredential(),
38+
user_agent=project_user_agent,
39+
)
40+
kwargs: Dict[str, Any] = {"default_headers": default_headers}
41+
return project_client.get_openai_client(**kwargs)
1142

1243

1344
class TestResponses(TestBase):
@@ -42,3 +73,64 @@ def test_responses(self, **kwargs):
4273
)
4374
print(f"Response id: {response2.id}, output text: {response2.output_text}")
4475
assert "1609" in response2.output_text or "1,609" in response2.output_text
76+
77+
@pytest.mark.parametrize(
78+
"project_ua,openai_default_header,expected_ua",
79+
[
80+
# 1) No user_agent anywhere
81+
(
82+
None,
83+
None,
84+
f"AIProjectClient {BASE_OPENAI_UA}",
85+
),
86+
# 2) user_agent at project client only
87+
(
88+
"custom-client-ua",
89+
None,
90+
f"custom-client-ua-AIProjectClient {BASE_OPENAI_UA}",
91+
),
92+
# 3) user_agent at openai client only
93+
(
94+
None,
95+
{"User-Agent": "custom-openai-ua"},
96+
"custom-openai-ua",
97+
),
98+
# 4) user_agent at both clients only
99+
(
100+
"custom-client-ua",
101+
{"User-Agent": "custom-openai-ua"},
102+
"custom-openai-ua",
103+
),
104+
],
105+
)
106+
def test_user_agent_patching_via_response_create(self, project_ua, openai_default_header, expected_ua):
107+
client = _build_client(project_ua, openai_default_header)
108+
109+
calls = []
110+
111+
def fake_send(request: httpx.Request, *args: Any, **kwargs: Any):
112+
# Capture headers that would be sent over the wire.
113+
calls.append(dict(request.headers))
114+
return httpx.Response(
115+
200,
116+
request=request,
117+
json={
118+
"id": "resp_123",
119+
"object": "response",
120+
"model": kwargs.get("model", ""),
121+
"status": "ok",
122+
"output": "",
123+
},
124+
)
125+
126+
# Monkeypatch the underlying httpx client used by the OpenAI client instance.
127+
client._client.send = fake_send # type: ignore[attr-defined]
128+
129+
# Act through the actual call surface
130+
client.responses.create(model="gpt-4o")
131+
132+
# Assert
133+
assert calls, "Expected a responses.create call to be captured"
134+
headers_used = {k.lower(): v for k, v in calls[0].items()}
135+
136+
assert headers_used["user-agent"] == expected_ua

0 commit comments

Comments
 (0)