diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 216b36aabd..a084a761fc 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -833,6 +833,9 @@ def _idempotency_key(self) -> str: class _DefaultHttpxClient(httpx.Client): def __init__(self, **kwargs: Any) -> None: + from ._utils import sanitize_proxy_env_vars + + sanitize_proxy_env_vars() kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) @@ -1420,6 +1423,9 @@ def get_api_list( class _DefaultAsyncHttpxClient(httpx.AsyncClient): def __init__(self, **kwargs: Any) -> None: + from ._utils import sanitize_proxy_env_vars + + sanitize_proxy_env_vars() kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) @@ -1437,6 +1443,9 @@ def __init__(self, **_kwargs: Any) -> None: class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore def __init__(self, **kwargs: Any) -> None: + from ._utils import sanitize_proxy_env_vars + + sanitize_proxy_env_vars() kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) diff --git a/src/openai/_models.py b/src/openai/_models.py index ed4c1f82d6..e2877caad5 100644 --- a/src/openai/_models.py +++ b/src/openai/_models.py @@ -657,8 +657,11 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] if not is_mapping(value): return value - _, items_type = get_args(type_) # Dict[_, items_type] - return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} + dict_args = get_args(type_) + if len(dict_args) >= 2: + items_type = dict_args[1] + return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} + return dict(value) if ( not is_literal_type(type_) @@ -678,8 +681,10 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] if not is_list(value): return value - inner_type = args[0] # List[inner_type] - return [construct_type(value=entry, type_=inner_type) for entry in value] + if args: + inner_type = args[0] # List[inner_type] + return [construct_type(value=entry, type_=inner_type) for entry in value] + return list(value) if origin == float: if isinstance(value, int): diff --git a/src/openai/_utils/__init__.py b/src/openai/_utils/__init__.py index bbd79691fa..6e73dd5696 100644 --- a/src/openai/_utils/__init__.py +++ b/src/openai/_utils/__init__.py @@ -29,6 +29,7 @@ get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, + sanitize_proxy_env_vars as sanitize_proxy_env_vars, maybe_coerce_boolean as maybe_coerce_boolean, maybe_coerce_integer as maybe_coerce_integer, is_async_azure_client as is_async_azure_client, diff --git a/src/openai/_utils/_transform.py b/src/openai/_utils/_transform.py index 414f38c340..76079b5aa5 100644 --- a/src/openai/_utils/_transform.py +++ b/src/openai/_utils/_transform.py @@ -180,8 +180,11 @@ def _transform_recursive( return _transform_typeddict(data, stripped_type) if origin == dict and is_mapping(data): - items_type = get_args(stripped_type)[1] - return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + args = get_args(stripped_type) + if len(args) >= 2: + items_type = args[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + return dict(data) if ( # List[T] @@ -196,6 +199,12 @@ def _transform_recursive( if isinstance(data, dict): return cast(object, data) + # bare list/iterable/sequence with no type args - return as-is + if not get_args(stripped_type): + if is_list(data): + return data + return list(data) + inner_type = extract_type_arg(stripped_type, 0) if _no_transform_needed(inner_type): # for some types there is no need to transform anything, so we can get a small @@ -346,8 +355,11 @@ async def _async_transform_recursive( return await _async_transform_typeddict(data, stripped_type) if origin == dict and is_mapping(data): - items_type = get_args(stripped_type)[1] - return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + args = get_args(stripped_type) + if len(args) >= 2: + items_type = args[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + return dict(data) if ( # List[T] @@ -362,6 +374,12 @@ async def _async_transform_recursive( if isinstance(data, dict): return cast(object, data) + # bare list/iterable/sequence with no type args - return as-is + if not get_args(stripped_type): + if is_list(data): + return data + return list(data) + inner_type = extract_type_arg(stripped_type, 0) if _no_transform_needed(inner_type): # for some types there is no need to transform anything, so we can get a small diff --git a/src/openai/_utils/_utils.py b/src/openai/_utils/_utils.py index 9f7401ca83..b17a577e1c 100644 --- a/src/openai/_utils/_utils.py +++ b/src/openai/_utils/_utils.py @@ -447,3 +447,32 @@ def is_async_azure_client(client: object) -> TypeGuard[AsyncAzureOpenAI]: from ..lib.azure import AsyncAzureOpenAI return isinstance(client, AsyncAzureOpenAI) + + +_PROXY_ENV_VARS = ( + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", +) + + +def sanitize_proxy_env_vars() -> None: + """Sanitize proxy-related environment variables by removing newline characters. + + This works around an issue where httpx's proxy parsing only splits by comma + and fails with InvalidURL when newlines are present in the value. + """ + for var in _PROXY_ENV_VARS: + value = os.environ.get(var) + if value and ("\n" in value or "\r" in value): + sanitized = ",".join( + part.strip() + for part in value.replace("\n", ",").replace("\r", ",").split(",") + if part.strip() + ) + os.environ[var] = sanitized diff --git a/src/openai/types/responses/response_function_web_search.py b/src/openai/types/responses/response_function_web_search.py index 3584992b7d..51c0a1b50c 100644 --- a/src/openai/types/responses/response_function_web_search.py +++ b/src/openai/types/responses/response_function_web_search.py @@ -78,7 +78,7 @@ class ResponseFunctionWebSearch(BaseModel): id: str """The unique ID of the web search tool call.""" - action: Action + action: Optional[Action] = None """ An object describing the specific action taken in this web search call. Includes details on how the model used the web (search, open_page, find_in_page). diff --git a/tests/test_models.py b/tests/test_models.py index cc204bac1d..6e2770be11 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1015,3 +1015,25 @@ class Model(BaseModel): # falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"]) assert m.data["items"] == ["h", "e", "l", "l", "o"] assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"] + + +def test_bare_dict_annotation() -> None: + result = construct_type(value={"key": "value"}, type_=dict) + assert result == {"key": "value"} + + result = construct_type(value={"a": 1, "b": 2}, type_=dict) + assert result == {"a": 1, "b": 2} + + result = construct_type(value="not a dict", type_=dict) + assert result == "not a dict" + + +def test_bare_list_annotation() -> None: + result = construct_type(value=[1, 2, 3], type_=list) + assert result == [1, 2, 3] + + result = construct_type(value=["a", "b"], type_=list) + assert result == ["a", "b"] + + result = construct_type(value="not a list", type_=list) + assert result == "not a list" diff --git a/tests/test_response_function_web_search.py b/tests/test_response_function_web_search.py new file mode 100644 index 0000000000..2dbbb998ce --- /dev/null +++ b/tests/test_response_function_web_search.py @@ -0,0 +1,133 @@ +# Tests for ResponseFunctionWebSearch type +# Verifies that the action field correctly handles None values from the API +import pytest + +from openai.types.responses.response_function_web_search import ( + ActionSearch, + ActionOpenPage, + ActionFind, + ResponseFunctionWebSearch, +) + + +def test_response_function_web_search_with_search_action(): + """Test that a web search call with a search action works correctly.""" + data = { + "id": "ws_123", + "action": { + "type": "search", + "query": "test query", + "queries": ["test query"], + }, + "status": "completed", + "type": "web_search_call", + } + result = ResponseFunctionWebSearch(**data) + assert result.id == "ws_123" + assert result.action is not None + assert isinstance(result.action, ActionSearch) + assert result.action.type == "search" + assert result.action.query == "test query" + + +def test_response_function_web_search_with_open_page_action(): + """Test that a web search call with an open_page action works correctly.""" + data = { + "id": "ws_456", + "action": { + "type": "open_page", + "url": "https://example.com", + }, + "status": "completed", + "type": "web_search_call", + } + result = ResponseFunctionWebSearch(**data) + assert result.id == "ws_456" + assert result.action is not None + assert isinstance(result.action, ActionOpenPage) + assert result.action.type == "open_page" + assert result.action.url == "https://example.com" + + +def test_response_function_web_search_with_find_action(): + """Test that a web search call with a find_in_page action works correctly.""" + data = { + "id": "ws_789", + "action": { + "type": "find_in_page", + "pattern": "search term", + "url": "https://example.com", + }, + "status": "completed", + "type": "web_search_call", + } + result = ResponseFunctionWebSearch(**data) + assert result.id == "ws_789" + assert result.action is not None + assert isinstance(result.action, ActionFind) + assert result.action.type == "find_in_page" + assert result.action.pattern == "search term" + + +def test_response_function_web_search_with_none_action(): + """Test that a web search call with action=None works correctly. + + This is a regression test for GitHub issue #3179. + The API can return null for the action field in some cases + (e.g., when the search is still in progress or the action + hasn't been determined yet). + """ + data = { + "id": "ws_abc", + "action": None, + "status": "completed", + "type": "web_search_call", + } + result = ResponseFunctionWebSearch(**data) + assert result.id == "ws_abc" + assert result.action is None + assert result.status == "completed" + + +def test_response_function_web_search_without_action(): + """Test that a web search call without action field defaults to None.""" + data = { + "id": "ws_def", + "status": "in_progress", + "type": "web_search_call", + } + result = ResponseFunctionWebSearch(**data) + assert result.id == "ws_def" + assert result.action is None + assert result.status == "in_progress" + + +def test_response_function_web_search_action_none_safe_access(): + """Test that users can safely check action type with None action. + + This demonstrates the fix for issue #3179 - users can now safely + access action.type without AttributeError when action is None. + """ + data_with_action = { + "id": "ws_1", + "action": {"type": "search", "query": "test"}, + "status": "completed", + "type": "web_search_call", + } + data_without_action = { + "id": "ws_2", + "action": None, + "status": "completed", + "type": "web_search_call", + } + + result_with = ResponseFunctionWebSearch(**data_with_action) + result_without = ResponseFunctionWebSearch(**data_without_action) + + # This pattern should work without errors + search_count = 0 + for result in [result_with, result_without]: + if result.action is not None and result.action.type == "search": + search_count += 1 + + assert search_count == 1 diff --git a/tests/test_transform.py b/tests/test_transform.py index bece75dfc7..e4024aa3a5 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -458,3 +458,25 @@ async def test_strips_notgiven(use_async: bool) -> None: async def test_strips_omit(use_async: bool) -> None: assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} assert await transform({"foo_bar": omit}, Foo1, use_async) == {} + + +class BareDictParams(TypedDict, total=False): + metadata: dict + + +@parametrize +@pytest.mark.asyncio +async def test_bare_dict_in_typeddict(use_async: bool) -> None: + result = await transform({"metadata": {"key": "value"}}, BareDictParams, use_async) + assert result == {"metadata": {"key": "value"}} + + +class BareListParams(TypedDict, total=False): + items: list + + +@parametrize +@pytest.mark.asyncio +async def test_bare_list_in_typeddict(use_async: bool) -> None: + result = await transform({"items": [1, 2, 3]}, BareListParams, use_async) + assert result == {"items": [1, 2, 3]} diff --git a/tests/test_utils/test_sanitize_proxy_env.py b/tests/test_utils/test_sanitize_proxy_env.py new file mode 100644 index 0000000000..1112164574 --- /dev/null +++ b/tests/test_utils/test_sanitize_proxy_env.py @@ -0,0 +1,101 @@ +import os +import pytest +from unittest.mock import patch + +from openai._utils._utils import sanitize_proxy_env_vars + + +@pytest.fixture(autouse=True) +def clean_proxy_env(): + """Save and restore proxy env vars around each test.""" + proxy_vars = ( + "HTTP_PROXY", "http_proxy", + "HTTPS_PROXY", "https_proxy", + "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", + ) + saved = {var: os.environ.get(var) for var in proxy_vars} + yield + for var, val in saved.items(): + if val is None: + os.environ.pop(var, None) + else: + os.environ[var] = val + + +def test_sanitize_proxy_env_vars_no_newlines(): + os.environ["NO_PROXY"] = "localhost,192.168.1.1" + sanitize_proxy_env_vars() + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + + +def test_sanitize_proxy_env_vars_with_newline(): + os.environ["NO_PROXY"] = "localhost\n192.168.1.1" + sanitize_proxy_env_vars() + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + + +def test_sanitize_proxy_env_vars_with_crlf(): + os.environ["NO_PROXY"] = "localhost\r\n192.168.1.1" + sanitize_proxy_env_vars() + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + + +def test_sanitize_proxy_env_vars_with_multiple_newlines(): + os.environ["NO_PROXY"] = "localhost\n192.168.1.1\n10.0.0.1" + sanitize_proxy_env_vars() + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1,10.0.0.1" + + +def test_sanitize_proxy_env_vars_with_whitespace(): + os.environ["NO_PROXY"] = " localhost \n 192.168.1.1 " + sanitize_proxy_env_vars() + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + + +def test_sanitize_proxy_env_vars_with_empty_entries(): + os.environ["NO_PROXY"] = "localhost\n\n192.168.1.1" + sanitize_proxy_env_vars() + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + + +def test_sanitize_proxy_env_vars_all_proxy_vars(): + os.environ["HTTP_PROXY"] = "http://proxy:8080\n" + os.environ["HTTPS_PROXY"] = "https://proxy:8443\n" + os.environ["NO_PROXY"] = "localhost\n192.168.1.1" + + sanitize_proxy_env_vars() + + assert os.environ["HTTP_PROXY"] == "http://proxy:8080" + assert os.environ["HTTPS_PROXY"] == "https://proxy:8443" + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1" + + +def test_sanitize_proxy_env_vars_empty_value(): + os.environ["NO_PROXY"] = "" + sanitize_proxy_env_vars() + assert os.environ["NO_PROXY"] == "" + + +def test_sanitize_proxy_env_vars_not_set(): + os.environ.pop("NO_PROXY", None) + sanitize_proxy_env_vars() + assert "NO_PROXY" not in os.environ + + +def test_sanitize_proxy_env_vars_preserves_commas(): + os.environ["NO_PROXY"] = "localhost,192.168.1.1\n10.0.0.1" + sanitize_proxy_env_vars() + assert os.environ["NO_PROXY"] == "localhost,192.168.1.1,10.0.0.1" + + +def test_openai_client_with_newline_in_no_proxy(): + """Integration test: creating an OpenAI client with newlines in NO_PROXY should not raise.""" + import httpx + from openai._base_client import _DefaultHttpxClient + + os.environ["NO_PROXY"] = "localhost\n192.168.1.1" + + client = _DefaultHttpxClient() + assert client is not None + client.close()