Skip to content
24 changes: 16 additions & 8 deletions sdk/ai/azure-ai-projects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ async with (
) as project_client,
):
```

### Performing Responses operations using OpenAI client

Use the `.get_openai_client()` method to obtain an authenticated [OpenAI](https://github.com/openai/openai-python) client and run Responses, Conversations, Evaluations, Files, and Fine-Tuning operations. See the **responses**, **agents**, **evaluations**, **files**, and **finetuning** folders in the [samples][samples] for complete working examples.
Expand Down Expand Up @@ -158,6 +159,7 @@ See the **responses** folder in the [samples][samples] for additional samples in
### Agents

See Foundry documentation:

* **[Microsoft Foundry Agents overview](https://learn.microsoft.com/azure/foundry/agents/overview)** — concepts, setup, and quick-starts.
* **[Runtime components](https://learn.microsoft.com/azure/foundry/agents/concepts/runtime-components?tabs=python)** — deep-dive into agent architecture.
* **[Tool catalog](https://learn.microsoft.com/azure/foundry/agents/concepts/tool-catalog)** — all available tools and agent capabilities.
Expand Down Expand Up @@ -234,25 +236,29 @@ To turn on client console logging define the environment variable `AZURE_AI_PROJ

#### Customizing your log

Instead of using the above-mentioned environment variable, you can configure logging yourself and control the log level, format and destination. To log to `stdout`, add the following at the top of your Python script:
Instead of using the above-mentioned environment variable, you can configure logging yourself and control the log level, format, and destination. You can optionally attach the same handler to the Azure SDK logger and, for `.get_openai_client()` scenarios, optionally attach it to the dedicated OpenAI transport logger as well:

```python
import sys
import logging

# Acquire the logger for this client library. Use 'azure' to affect both
# `azure.core` and `azure.ai.projects' libraries.
logger = logging.getLogger("azure")

# Set the desired logging level. logging.INFO or logging.DEBUG are good options.
logger.setLevel(logging.DEBUG)

# Direct logging output to stdout:
handler = logging.StreamHandler(stream=sys.stdout)
# Or direct logging output to a file:
# handler = logging.FileHandler(filename="sample.log")

# Optional: logger for azure-ai-projects and azure-core.
logger = logging.getLogger("azure")
logger.setLevel(logging.DEBUG)
logger.addHandler(handler)

# Optional: additional logger for an openai client generated from `.get_openai_client()`.
openai_logger = logging.getLogger("azure.ai.projects.openai_transport")
openai_logger.setLevel(logging.DEBUG)
openai_logger.propagate = False
openai_logger.addHandler(handler)


# Optional: change the default logging format. Here we add a timestamp.
#formatter = logging.Formatter("%(asctime)s:%(levelname)s:%(name)s:%(message)s")
#handler.setFormatter(formatter)
Expand All @@ -270,6 +276,8 @@ project_client = AIProjectClient(

Note that the log level must be set to `logging.DEBUG` (see above code). Logs will be redacted with any other log level.

See the logging samples in the `samples/logs/` folder for complete end-to-end examples, including console logging, file logging, and OpenAI transport logging.

Be sure to protect non-redacted logs to avoid compromising security.

For more information, see [Configure logging in the Azure libraries for Python](https://aka.ms/azsdk/python/logging)
Expand Down
162 changes: 130 additions & 32 deletions sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,19 @@
import os
import re
import logging
from typing import List, Any, Optional
from typing import List, Any, Optional, cast
import httpx # pylint: disable=networking-import-outside-azure-core-transport
from openai import OpenAI
from openai import OpenAI, DefaultHttpxClient
from azure.core.tracing.decorator import distributed_trace
from azure.core.credentials import TokenCredential
from azure.identity import get_bearer_token_provider
from ._client import AIProjectClient as AIProjectClientGenerated
from .operations import TelemetryOperations
from .models._patch import _BETA_OPERATION_FEATURE_HEADERS, _FOUNDRY_FEATURES_HEADER_NAME, _has_header_case_insensitive

_OPENAI_TRANSPORT_LOGGER_NAME = "azure.ai.projects.openai_transport"
logger = logging.getLogger(__name__)
_openai_transport_logger = logging.getLogger(_OPENAI_TRANSPORT_LOGGER_NAME)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -97,6 +99,61 @@ def _build_openai_user_agent(custom_user_agent: Optional[str], openai_default_us
return "-".join(ua for ua in [custom_user_agent, "AIProjectClient"] if ua) + " " + openai_default_user_agent


def _log_streaming_response_notice(logging_enabled: bool) -> bool:
if logging_enabled:
_openai_transport_logger.debug("Body: [Streaming response will be logged as consumed]")
return True

_openai_transport_logger.debug("Body: [Streaming content exists]")
return False


def _log_streaming_response_chunk(chunk: bytes) -> None:
if not chunk:
return

try:
_openai_transport_logger.debug("Body chunk:\n %s", chunk.decode("utf-8"))
except Exception: # pylint: disable=broad-exception-caught
_openai_transport_logger.debug("Body chunk (raw):\n %r", chunk)


class _LoggingSyncByteStream(httpx.SyncByteStream):
def __init__(self, stream: httpx.SyncByteStream) -> None:
self._stream = stream

def __iter__(self):
try:
for chunk in self._stream:
_log_streaming_response_chunk(chunk)
yield chunk
finally:
_openai_transport_logger.debug("Body: [Streaming response completed]")

def close(self) -> None:
close = getattr(self._stream, "close", None)
if close:
close()


class _LoggingAsyncByteStream(httpx.AsyncByteStream):
def __init__(self, stream: httpx.AsyncByteStream) -> None:
self._stream = stream

async def __aiter__(self):
try:
async for chunk in self._stream:
_log_streaming_response_chunk(chunk)
yield chunk
finally:
_openai_transport_logger.debug("Body: [Streaming response completed]")

async def aclose(self) -> None:
aclose = getattr(self._stream, "aclose", None)
if aclose:
await aclose()


class AIProjectClient(AIProjectClientGenerated): # pylint: disable=too-many-instance-attributes
"""AIProjectClient.

Expand Down Expand Up @@ -161,6 +218,7 @@ def __init__(
azure_logger.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler(stream=sys.stdout)
console_handler.addFilter(_AuthSecretsFilter())
console_handler.addFilter(_OpenAIAuthSecretsFilter())
azure_logger.addHandler(console_handler)
# Exclude detailed logs for network calls associated with getting Entra ID token.
logging.getLogger("azure.identity").setLevel(logging.ERROR)
Expand All @@ -169,6 +227,10 @@ def __init__(
# (which are implemented as a separate logging policy)
logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.ERROR)

openai_transport_logger = logging.getLogger(_OPENAI_TRANSPORT_LOGGER_NAME)
openai_transport_logger.setLevel(logging.DEBUG)
openai_transport_logger.propagate = False
openai_transport_logger.addHandler(console_handler)
kwargs.setdefault("logging_enable", self._console_logging_enabled)

self._kwargs = kwargs.copy()
Expand Down Expand Up @@ -203,9 +265,10 @@ def _get_openai_http_client(self, kwargs: dict):
"""
if "http_client" in kwargs:
return kwargs.pop("http_client")
if self._console_logging_enabled:
return httpx.Client(transport=_OpenAILoggingTransport())
return None

logging_kwargs = getattr(self, "_kwargs", {})
logging_enabled = bool(logging_kwargs.get("logging_enable", False))
return DefaultHttpxClient(transport=_OpenAILoggingTransport(logging_enabled=logging_enabled))

@distributed_trace
def get_openai_client(
Expand Down Expand Up @@ -242,7 +305,7 @@ def get_openai_client(
base_url = _resolve_openai_base_url(self._config, agent_name, kwargs)
default_query = _resolve_openai_query_params(self._config, agent_name, kwargs)

logger.debug( # pylint: disable=specify-parameter-names-in-call
_openai_transport_logger.debug( # pylint: disable=specify-parameter-names-in-call
"[get_openai_client] Creating OpenAI client using Entra ID authentication, base_url = `%s`", # pylint: disable=line-too-long
base_url,
)
Expand Down Expand Up @@ -300,24 +363,44 @@ def filter(self, record: logging.LogRecord) -> bool:
return True


class _OpenAIAuthSecretsFilter(logging.Filter):
"""Redact bearer tokens in OpenAI transport log messages before console emission."""

_AUTH_HEADER_LINE_PATTERN = re.compile(r"(?im)^(\s*authorization:\s*bearer\s+).+$")

def filter(self, record: logging.LogRecord) -> bool:
rendered = record.getMessage()
redacted = self._AUTH_HEADER_LINE_PATTERN.sub(r"\1<REDACTED>", rendered)
if redacted != rendered:
record.msg = redacted
record.args = ()
return True
Comment thread
howieleung marked this conversation as resolved.


class _OpenAILoggingTransport(httpx.HTTPTransport):
"""Custom HTTP transport that logs OpenAI API requests and responses to the console.
"""Custom HTTP transport that logs OpenAI API requests and responses.

This transport wraps httpx.HTTPTransport to intercept all HTTP traffic and print
detailed request/response information for debugging purposes. It automatically
This transport wraps httpx.HTTPTransport to intercept all HTTP traffic and emit
detailed request/response information through a dedicated logger. It automatically
redacts sensitive authorization headers and handles various content types including
multipart form data (file uploads).

Used internally by AIProjectClient when console logging is enabled via the
AZURE_AI_PROJECTS_CONSOLE_LOGGING environment variable.
"""

def __init__(self, *, logging_enabled: bool) -> None:
super().__init__()
self._logging_enabled = logging_enabled

def _sanitize_auth_header(self, headers) -> None:
"""Sanitize authorization and api-key headers by redacting sensitive information.

:param headers: Dictionary of HTTP headers to sanitize
:type headers: dict
"""
if self._logging_enabled:
return
Comment thread
howieleung marked this conversation as resolved.

if "authorization" in headers:
auth_value = headers["authorization"]
Expand All @@ -326,9 +409,14 @@ def _sanitize_auth_header(self, headers) -> None:
else:
headers["authorization"] = "<ERROR>"

@staticmethod
def _is_streaming_response(response: httpx.Response) -> bool:
content_type = response.headers.get("content-type", "").lower()
return "text/event-stream" in content_type

def handle_request(self, request: httpx.Request) -> httpx.Response:
"""
Log HTTP request and response details to console, in a nicely formatted way,
Log HTTP request and response details using the dedicated transport logger,
for OpenAI / Azure OpenAI clients.

:param request: The HTTP request to handle and log
Expand All @@ -338,31 +426,38 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
:rtype: httpx.Response
"""

print(f"\n==> Request:\n{request.method} {request.url}")
_openai_transport_logger.debug("\n==> Request:\n%s %s", request.method, request.url)
headers = dict(request.headers)
self._sanitize_auth_header(headers)
print("Headers:")
_openai_transport_logger.debug("Headers:")
for key, value in sorted(headers.items()):
print(f" {key}: {value}")
_openai_transport_logger.debug(" %s: %s", key, value)

self._log_request_body(request)

response = super().handle_request(request)

print(f"\n<== Response:\n{response.status_code} {response.reason_phrase}")
print("Headers:")
_openai_transport_logger.debug("\n<== Response:\n%s %s", response.status_code, response.reason_phrase)
_openai_transport_logger.debug("Headers:")
for key, value in sorted(dict(response.headers).items()):
print(f" {key}: {value}")
_openai_transport_logger.debug(" %s: %s", key, value)

content = response.read()
if content is None or content == b"":
print("Body: [No content]")
if self._is_streaming_response(response):
if _log_streaming_response_notice(self._logging_enabled):
response.stream = _LoggingSyncByteStream(cast(httpx.SyncByteStream, response.stream))
else:
try:
print(f"Body:\n {content.decode('utf-8')}")
except Exception: # pylint: disable=broad-exception-caught
print(f"Body (raw):\n {content!r}")
print("\n")
content = response.read()
Comment thread
howieleung marked this conversation as resolved.
if content is None or content == b"":
_openai_transport_logger.debug("Body: [No content]")
else:
if self._logging_enabled:
try:
_openai_transport_logger.debug("Body:\n %s", content.decode("utf-8"))
except Exception: # pylint: disable=broad-exception-caught
_openai_transport_logger.debug("Body (raw):\n %r", content)
else:
_openai_transport_logger.debug("Body: [Content exists]")
_openai_transport_logger.debug("\n")

return response

Expand All @@ -376,29 +471,32 @@ def _log_request_body(self, request: httpx.Request) -> None:
# Check content-type header to identify file uploads
content_type = request.headers.get("content-type", "").lower()
if "multipart/form-data" in content_type:
print("Body: [Multipart form data - file upload, not logged]")
_openai_transport_logger.debug("Body: [Multipart form data - file upload, not logged]")
return

# Safely check if content exists without accessing it
if not hasattr(request, "content"):
print("Body: [No content attribute]")
_openai_transport_logger.debug("Body: [No content attribute]")
return

# Very careful content access - wrap in try-catch immediately
try:
content = request.content
except Exception as access_error: # pylint: disable=broad-exception-caught
print(f"Body: [Cannot access content: {access_error}]")
_openai_transport_logger.debug("Body: [Cannot access content: %s]", access_error)
return

if content is None or content == b"":
print("Body: [No content]")
_openai_transport_logger.debug("Body: [No content]")
return

try:
print(f"Body:\n {content.decode('utf-8')}")
except Exception: # pylint: disable=broad-exception-caught
print(f"Body (raw):\n {content!r}")
if self._logging_enabled:
try:
_openai_transport_logger.debug("Body:\n %s", content.decode("utf-8"))
except Exception: # pylint: disable=broad-exception-caught
_openai_transport_logger.debug("Body (raw):\n %r", content)
else:
_openai_transport_logger.debug("Body: [Content exists]")


__all__: List[str] = [
Expand Down
13 changes: 13 additions & 0 deletions sdk/ai/azure-ai-projects/azure/ai/projects/_patch.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Azure-specific grader types in addition to the standard OpenAI graders.

import logging
from typing import Any, Iterable, List, Union, Optional
import httpx
from httpx import Timeout
from openai import NotGiven, Omit, OpenAI as OpenAIClient
from openai._types import Body, Query, Headers
Expand Down Expand Up @@ -107,11 +108,23 @@ class AIProjectClient(AIProjectClientGenerated):

# To make mypy happy... otherwise imports of the below result in mypy "attr-defined" error
class _AuthSecretsFilter(logging.Filter): ...
class _OpenAIAuthSecretsFilter(logging.Filter): ...

class _OpenAILoggingTransport:
def __init__(self, *, logging_enabled: bool) -> None: ...
def handle_request(self, request: httpx.Request) -> httpx.Response: ...

class _LoggingSyncByteStream(httpx.SyncByteStream):
def __init__(self, stream: httpx.SyncByteStream) -> None: ...

class _LoggingAsyncByteStream(httpx.AsyncByteStream):
def __init__(self, stream: httpx.AsyncByteStream) -> None: ...

def _resolve_openai_base_url(config: Any, agent_name: Optional[str], kwargs: dict) -> str: ...
def _resolve_openai_query_params(config: Any, agent_name: Optional[str], kwargs: dict) -> dict: ...
def _resolve_openai_default_headers(agent_name: Optional[str], kwargs: dict) -> dict: ...
def _build_openai_user_agent(custom_user_agent: Optional[str], openai_default_user_agent: str) -> str: ...
def _log_streaming_response_notice(logging_enabled: bool) -> bool: ...

__all__: List[str] = ["AIProjectClient"]

Expand Down
Loading
Loading