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/_streaming.py b/src/openai/_streaming.py index 45c13cc11d..e5c203c2c1 100644 --- a/src/openai/_streaming.py +++ b/src/openai/_streaming.py @@ -63,11 +63,10 @@ def __stream__(self) -> Iterator[_T]: if sse.data.startswith("[DONE]"): break - # we have to special case the Assistants `thread.` events since we won't have an "event" key in the data - if sse.event and sse.event.startswith("thread."): + # Handle error events first - these can occur outside of thread.* events + if sse.event == "error": data = sse.json() - - if sse.event == "error" and is_mapping(data) and data.get("error"): + if is_mapping(data) and data.get("error"): message = None error = data.get("error") if is_mapping(error): @@ -80,7 +79,16 @@ def __stream__(self) -> Iterator[_T]: request=self.response.request, body=data["error"], ) + # If error event doesn't have expected structure, still raise + raise APIError( + message="An error occurred during streaming", + request=self.response.request, + body=data, + ) + # we have to special case the Assistants `thread.` events since we won't have an "event" key in the data + if sse.event and sse.event.startswith("thread."): + data = sse.json() yield process_data(data={"data": data, "event": sse.event}, cast_to=cast_to, response=response) else: data = sse.json() @@ -173,11 +181,10 @@ async def __stream__(self) -> AsyncIterator[_T]: if sse.data.startswith("[DONE]"): break - # we have to special case the Assistants `thread.` events since we won't have an "event" key in the data - if sse.event and sse.event.startswith("thread."): + # Handle error events first - these can occur outside of thread.* events + if sse.event == "error": data = sse.json() - - if sse.event == "error" and is_mapping(data) and data.get("error"): + if is_mapping(data) and data.get("error"): message = None error = data.get("error") if is_mapping(error): @@ -190,7 +197,16 @@ async def __stream__(self) -> AsyncIterator[_T]: request=self.response.request, body=data["error"], ) + # If error event doesn't have expected structure, still raise + raise APIError( + message="An error occurred during streaming", + request=self.response.request, + body=data, + ) + # we have to special case the Assistants `thread.` events since we won't have an "event" key in the data + if sse.event and sse.event.startswith("thread."): + data = sse.json() yield process_data(data={"data": data, "event": sse.event}, cast_to=cast_to, response=response) else: data = sse.json() 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/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 232718cef6..ad2297e249 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -58,7 +58,7 @@ def parse_response( ) -> ParsedResponse[TextFormatT]: output_list: List[ParsedResponseOutputItem[TextFormatT]] = [] - for output in response.output: + for output in (response.output or []): if output.type == "message": content_list: List[ParsedContent[TextFormatT]] = [] for item in output.content: diff --git a/src/openai/types/responses/response_function_web_search.py b/src/openai/types/responses/response_function_web_search.py index 3584992b7d..b037055bca 100644 --- a/src/openai/types/responses/response_function_web_search.py +++ b/src/openai/types/responses/response_function_web_search.py @@ -19,11 +19,15 @@ class ActionSearchSource(BaseModel): """A source used in the search.""" - type: Literal["url"] - """The type of source. Always `url`.""" + type: Literal["url", "api"] + """The type of source. `url` for a web page, `api` for a built-in OpenAI data source.""" - url: str - """The URL of the source.""" + url: Optional[str] = None + """The URL of the source. Present when type is `url`.""" + + name: Optional[str] = None + """The name of the built-in data source (e.g. `oai-weather`, `oai-sports`, `oai-finance`). + Present when type is `api`.""" class ActionSearch(BaseModel): @@ -78,7 +82,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/src/openai/types/responses/response_function_web_search_param.py b/src/openai/types/responses/response_function_web_search_param.py index 9e31a46be1..a18606f2b3 100644 --- a/src/openai/types/responses/response_function_web_search_param.py +++ b/src/openai/types/responses/response_function_web_search_param.py @@ -20,11 +20,15 @@ class ActionSearchSource(TypedDict, total=False): """A source used in the search.""" - type: Required[Literal["url"]] - """The type of source. Always `url`.""" + type: Required[Literal["url", "api"]] + """The type of source. `url` for a web page, `api` for a built-in OpenAI data source.""" - url: Required[str] - """The URL of the source.""" + url: str + """The URL of the source. Required when type is `url`.""" + + name: str + """The name of the built-in data source (e.g. `oai-weather`, `oai-sports`, `oai-finance`). + Required when type is `api`.""" class ActionSearch(TypedDict, total=False): diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 8e5f16df95..2fa4fadc6b 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -8,6 +8,9 @@ from openai import OpenAI, AsyncOpenAI from openai._utils import assert_signatures_in_sync +from openai._models import construct_type_unchecked +from openai.lib._parsing._responses import parse_response +from openai.types.responses import Response from ...conftest import base_url from ..snapshots import make_snapshot_request @@ -61,3 +64,24 @@ def test_parse_method_definition_in_sync(sync: bool, client: OpenAI, async_clien checking_client.responses.parse, exclude_params={"tools"}, ) + + +def test_parse_response_with_none_output() -> None: + response_data = { + "id": "resp_test123", + "created_at": 1754925861, + "model": "gpt-4o-mini", + "object": "response", + "output": None, + "parallel_tool_calls": True, + "tool_choice": "auto", + "tools": [], + "status": "completed", + } + + response = construct_type_unchecked(type_=Response, value=response_data) + parsed = parse_response(response=response, text_format=None, input_tools=None) + + assert parsed.id == "resp_test123" + assert parsed.output == [] + assert parsed.status == "completed" 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..c3b067a650 --- /dev/null +++ b/tests/test_response_function_web_search.py @@ -0,0 +1,194 @@ +# 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, + ActionSearchSource, + 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 + + +def test_action_search_source_url_type(): + """Test that ActionSearchSource with type='url' works correctly.""" + source = ActionSearchSource(type="url", url="https://example.com") + assert source.type == "url" + assert source.url == "https://example.com" + assert source.name is None + + +def test_action_search_source_api_type(): + """Test that ActionSearchSource with type='api' works correctly. + + This is a regression test for GitHub issue #2736. + The API returns specialized data sources with type='api' and a name field + (e.g., 'oai-weather', 'oai-sports', 'oai-finance') instead of a URL. + """ + source = ActionSearchSource(type="api", name="oai-weather") + assert source.type == "api" + assert source.name == "oai-weather" + assert source.url is None + + +def test_action_search_source_api_type_with_url(): + """Test that ActionSearchSource with type='api' can optionally have a URL.""" + source = ActionSearchSource(type="api", name="oai-sports", url="https://api.example.com") + assert source.type == "api" + assert source.name == "oai-sports" + assert source.url == "https://api.example.com" + + +def test_search_action_with_api_source(): + """Test that ActionSearch can contain API-type sources. + + This verifies the fix for issue #2736 - the API returns specialized + data sources (weather, sports, finance) with type='api'. + """ + data = { + "id": "ws_api", + "action": { + "type": "search", + "query": "weather in NYC", + "queries": ["weather in NYC"], + "sources": [ + {"type": "url", "url": "https://weather.com"}, + {"type": "api", "name": "oai-weather"}, + ], + }, + "status": "completed", + "type": "web_search_call", + } + result = ResponseFunctionWebSearch(**data) + assert result.action is not None + assert isinstance(result.action, ActionSearch) + assert result.action.sources is not None + assert len(result.action.sources) == 2 + assert result.action.sources[0].type == "url" + assert result.action.sources[0].url == "https://weather.com" + assert result.action.sources[1].type == "api" + assert result.action.sources[1].name == "oai-weather" diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 04f8e51abd..911b8651d6 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -6,6 +6,7 @@ import pytest from openai import OpenAI, AsyncOpenAI +from openai._exceptions import APIError from openai._streaming import Stream, AsyncStream, ServerSentEvent @@ -216,6 +217,40 @@ def body() -> Iterator[bytes]: assert sse.json() == {"content": "известни"} +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_error_event_raises_api_error(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None: + """Error events should raise APIError regardless of whether they occur in thread.* context.""" + + def body() -> Iterator[bytes]: + yield b'event: error\n' + yield b'data: {"error": {"message": "Something went wrong", "type": "invalid_request_error"}}\n' + yield b"\n" + + stream = make_stream(content=body(), sync=sync, client=client, async_client=async_client) + + with pytest.raises(APIError, match="Something went wrong"): + await consume_stream(stream, sync=sync) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_error_event_without_structure_raises_api_error( + sync: bool, client: OpenAI, async_client: AsyncOpenAI +) -> None: + """Error events without expected error structure should still raise APIError.""" + + def body() -> Iterator[bytes]: + yield b'event: error\n' + yield b'data: {"unexpected": "structure"}\n' + yield b"\n" + + stream = make_stream(content=body(), sync=sync, client=client, async_client=async_client) + + with pytest.raises(APIError, match="An error occurred during streaming"): + await consume_stream(stream, sync=sync) + + async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: for chunk in iter: yield chunk @@ -246,3 +281,28 @@ def make_event_iterator( return AsyncStream( cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) )._iter_events() + + +def make_stream( + content: Iterator[bytes], + *, + sync: bool, + client: OpenAI, + async_client: AsyncOpenAI, +) -> Stream[object] | AsyncStream[object]: + request = httpx.Request("POST", "https://api.openai.com/v1/chat/completions") + if sync: + return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content, request=request)) + + return AsyncStream( + cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content), request=request) + ) + + +async def consume_stream(stream: Stream[object] | AsyncStream[object], *, sync: bool) -> None: + if sync: + for _ in stream: + pass + else: + async for _ in stream: + pass 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()