Skip to content
Open
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 src/openai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
)
from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient
from ._utils._logs import setup_logging as _setup_logging
from ._utils._logs import set_log_level as set_log_level
from ._legacy_response import HttpxBinaryResponseContent as HttpxBinaryResponseContent
from .types.websocket_reconnection import ReconnectingEvent, ReconnectingOverrides

Expand Down Expand Up @@ -80,6 +81,7 @@
"OpenAI",
"AsyncOpenAI",
"file_from_path",
"set_log_level",
"BaseModel",
"DEFAULT_TIMEOUT",
"DEFAULT_MAX_RETRIES",
Expand Down
1 change: 1 addition & 0 deletions src/openai/_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from ._logs import SensitiveHeadersFilter as SensitiveHeadersFilter
from ._logs import set_log_level as set_log_level
from ._path import path_template as path_template
from ._sync import asyncify as asyncify
from ._proxy import LazyProxy as LazyProxy
Expand Down
70 changes: 61 additions & 9 deletions src/openai/_utils/_logs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import logging
from typing import Union
from typing_extensions import override

from ._utils import is_dict
Expand All @@ -10,6 +11,16 @@

SENSITIVE_HEADERS = {"api-key", "authorization"}

_LOG_LEVEL_MAP: dict[str, int] = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"warn": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
"fatal": logging.CRITICAL,
}


def _basic_config() -> None:
# e.g. [2023-10-05 14:12:26 - openai._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK"
Expand All @@ -19,16 +30,57 @@ def _basic_config() -> None:
)


def _parse_log_level(value: str) -> int | None:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use 3.9-compatible union syntax in _parse_log_level

openai._utils._logs is imported during import openai, and this annotation uses int | None without from __future__ import annotations. Because the package declares Python 3.9 support (pyproject.toml has requires-python = ">= 3.9"), this expression will raise TypeError at import time on 3.9, preventing the SDK from loading for all 3.9 users. Please switch this annotation to a 3.9-safe form (for example Optional[int]/Union[int, None]) or enable postponed evaluation in this module.

Useful? React with 👍 / 👎.

"""Parse a log level string into a logging level constant.

Accepts standard level names (case-insensitive) and numeric values.
Returns None if the value cannot be parsed.
"""
level = _LOG_LEVEL_MAP.get(value.lower())
if level is not None:
return level

# Try numeric values
try:
numeric = int(value)
if 0 <= numeric <= 100:
return numeric
except ValueError:
pass

return None


def set_log_level(level: Union[int, str]) -> None:
"""Set the log level for the OpenAI SDK loggers.

Args:
level: A log level as a string (e.g. "debug", "info", "warning", "error", "critical")
or a numeric logging level (e.g. logging.DEBUG, logging.WARNING).
"""
if isinstance(level, str):
parsed = _parse_log_level(level)
if parsed is None:
raise ValueError(
f"Invalid log level: {level!r}. "
f"Expected one of: {', '.join(sorted(_LOG_LEVEL_MAP.keys()))} or a numeric level."
)
level = parsed

_basic_config()
logger.setLevel(level)
httpx_logger.setLevel(level)


def setup_logging() -> None:
env = os.environ.get("OPENAI_LOG")
if env == "debug":
_basic_config()
logger.setLevel(logging.DEBUG)
httpx_logger.setLevel(logging.DEBUG)
elif env == "info":
_basic_config()
logger.setLevel(logging.INFO)
httpx_logger.setLevel(logging.INFO)
# OPENAI_LOG_LEVEL takes precedence over the legacy OPENAI_LOG env var
env = os.environ.get("OPENAI_LOG_LEVEL") or os.environ.get("OPENAI_LOG")
if env:
parsed = _parse_log_level(env)
if parsed is not None:
_basic_config()
logger.setLevel(parsed)
httpx_logger.setLevel(parsed)


class SensitiveHeadersFilter(logging.Filter):
Expand Down
134 changes: 134 additions & 0 deletions tests/test_utils/test_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,137 @@ def test_standard_debug_msg(logger_with_filter: logging.Logger, caplog: pytest.L
with caplog.at_level(logging.DEBUG):
logger_with_filter.debug("Sending HTTP Request: %s %s", "POST", "chat/completions")
assert caplog.messages[0] == "Sending HTTP Request: POST chat/completions"


class TestSetLogLevel:
"""Tests for set_log_level and setup_logging."""

def test_set_log_level_string_debug(self) -> None:
from openai._utils._logs import set_log_level, logger, httpx_logger

set_log_level("debug")
assert logger.level == logging.DEBUG
assert httpx_logger.level == logging.DEBUG

def test_set_log_level_string_info(self) -> None:
from openai._utils._logs import set_log_level, logger, httpx_logger

set_log_level("info")
assert logger.level == logging.INFO
assert httpx_logger.level == logging.INFO

def test_set_log_level_string_warning(self) -> None:
from openai._utils._logs import set_log_level, logger, httpx_logger

set_log_level("warning")
assert logger.level == logging.WARNING
assert httpx_logger.level == logging.WARNING

def test_set_log_level_string_warn(self) -> None:
from openai._utils._logs import set_log_level, logger, httpx_logger

set_log_level("warn")
assert logger.level == logging.WARNING
assert httpx_logger.level == logging.WARNING

def test_set_log_level_string_error(self) -> None:
from openai._utils._logs import set_log_level, logger, httpx_logger

set_log_level("error")
assert logger.level == logging.ERROR
assert httpx_logger.level == logging.ERROR

def test_set_log_level_string_critical(self) -> None:
from openai._utils._logs import set_log_level, logger, httpx_logger

set_log_level("critical")
assert logger.level == logging.CRITICAL
assert httpx_logger.level == logging.CRITICAL

def test_set_log_level_case_insensitive(self) -> None:
from openai._utils._logs import set_log_level, logger, httpx_logger

set_log_level("WARNING")
assert logger.level == logging.WARNING
assert httpx_logger.level == logging.WARNING

def test_set_log_level_numeric(self) -> None:
from openai._utils._logs import set_log_level, logger, httpx_logger

set_log_level(logging.WARNING)
assert logger.level == logging.WARNING
assert httpx_logger.level == logging.WARNING

def test_set_log_level_invalid_string_raises(self) -> None:
from openai._utils._logs import set_log_level

with pytest.raises(ValueError, match="Invalid log level"):
set_log_level("not_a_level")

def test_setup_logging_respects_openai_log_level_env(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from openai._utils._logs import setup_logging, logger, httpx_logger

monkeypatch.setenv("OPENAI_LOG_LEVEL", "warning")
monkeypatch.delenv("OPENAI_LOG", raising=False)
setup_logging()
assert logger.level == logging.WARNING
assert httpx_logger.level == logging.WARNING

def test_setup_logging_openai_log_level_takes_precedence(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from openai._utils._logs import setup_logging, logger, httpx_logger

monkeypatch.setenv("OPENAI_LOG_LEVEL", "error")
monkeypatch.setenv("OPENAI_LOG", "debug")
setup_logging()
assert logger.level == logging.ERROR
assert httpx_logger.level == logging.ERROR

def test_setup_logging_legacy_openai_log_still_works(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from openai._utils._logs import setup_logging, logger, httpx_logger

monkeypatch.delenv("OPENAI_LOG_LEVEL", raising=False)
monkeypatch.setenv("OPENAI_LOG", "info")
setup_logging()
assert logger.level == logging.INFO
assert httpx_logger.level == logging.INFO

def test_setup_logging_legacy_openai_log_warning(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from openai._utils._logs import setup_logging, logger, httpx_logger

monkeypatch.delenv("OPENAI_LOG_LEVEL", raising=False)
monkeypatch.setenv("OPENAI_LOG", "warning")
setup_logging()
assert logger.level == logging.WARNING
assert httpx_logger.level == logging.WARNING

def test_setup_logging_no_env_does_not_set_level(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from openai._utils._logs import setup_logging, logger, httpx_logger

monkeypatch.delenv("OPENAI_LOG_LEVEL", raising=False)
monkeypatch.delenv("OPENAI_LOG", raising=False)

# Save original levels
orig_logger_level = logger.level
orig_httpx_level = httpx_logger.level

setup_logging()

# Levels should not have changed
assert logger.level == orig_logger_level
assert httpx_logger.level == orig_httpx_level

def test_set_log_level_importable_from_openai(self) -> None:
import openai

assert hasattr(openai, "set_log_level")
assert callable(openai.set_log_level)