diff --git a/src/openai/lib/__init__.py b/src/openai/lib/__init__.py index 5c6cb782c0..7ed2893aaa 100644 --- a/src/openai/lib/__init__.py +++ b/src/openai/lib/__init__.py @@ -1,2 +1,4 @@ from ._tools import pydantic_function_tool as pydantic_function_tool from ._parsing import ResponseFormatT as ResponseFormatT +from ._reasoning import get_default_reasoning as get_default_reasoning +from ._reasoning import get_reasoning_effort_from_env as get_reasoning_effort_from_env diff --git a/src/openai/lib/_reasoning.py b/src/openai/lib/_reasoning.py new file mode 100644 index 0000000000..c4107ce159 --- /dev/null +++ b/src/openai/lib/_reasoning.py @@ -0,0 +1,145 @@ +"""Utilities for reasoning configuration. + +This module provides utilities for configuring reasoning behavior, +including support for the OPENAI_REASONING_EFFORT environment variable. + +Example usage: + from openai import OpenAI + from openai.lib import get_default_reasoning + + client = OpenAI() + + # Uses OPENAI_REASONING_EFFORT env var if set, otherwise returns None + reasoning = get_default_reasoning() + + response = client.responses.create( + model="gpt-5", + input="Hello", + reasoning=reasoning, + ) +""" + +from __future__ import annotations + +import os +import warnings +from typing import Optional, Literal + +from ..types.shared_params.reasoning import Reasoning +from ..types.shared.reasoning_effort import ReasoningEffort + + +__all__ = [ + "get_default_reasoning", + "get_reasoning_effort_from_env", +] + +# Valid reasoning effort values +VALID_REASONING_EFFORTS: tuple[str, ...] = ( + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +) + +# Environment variable name +REASONING_EFFORT_ENV_VAR = "OPENAI_REASONING_EFFORT" + + +def get_reasoning_effort_from_env() -> Optional[ReasoningEffort]: + """Get reasoning effort from the OPENAI_REASONING_EFFORT environment variable. + + Returns: + The reasoning effort value if set and valid, None otherwise. + + Valid values are: none, minimal, low, medium, high, xhigh + + If an invalid value is set, a warning is emitted and None is returned. + + Example: + >>> import os + >>> os.environ["OPENAI_REASONING_EFFORT"] = "low" + >>> get_reasoning_effort_from_env() + 'low' + """ + value = os.environ.get(REASONING_EFFORT_ENV_VAR) + if value is None: + return None + + # Normalize to lowercase + value_lower = value.lower().strip() + + if value_lower not in VALID_REASONING_EFFORTS: + warnings.warn( + f"Invalid {REASONING_EFFORT_ENV_VAR} value: '{value}'. " + f"Valid values are: {', '.join(VALID_REASONING_EFFORTS)}. " + "Ignoring environment variable.", + UserWarning, + stacklevel=2, + ) + return None + + return value_lower # type: ignore[return-value] + + +def get_default_reasoning( + effort: Optional[ReasoningEffort] = None, + summary: Optional[Literal["auto", "concise", "detailed"]] = None, +) -> Optional[Reasoning]: + """Get a Reasoning configuration, using environment variable as default. + + This function allows you to easily configure reasoning with support for + the OPENAI_REASONING_EFFORT environment variable. + + Args: + effort: Override the reasoning effort. If None, uses OPENAI_REASONING_EFFORT + environment variable if set. + summary: Optional summary configuration for reasoning output. + + Returns: + A Reasoning TypedDict if effort is configured (either explicitly or via + environment variable), None otherwise. + + Precedence: + 1. Explicit `effort` parameter (if provided) + 2. OPENAI_REASONING_EFFORT environment variable (if set) + 3. None (SDK default behavior) + + Example: + >>> # With environment variable set: + >>> import os + >>> os.environ["OPENAI_REASONING_EFFORT"] = "low" + >>> get_default_reasoning() + {'effort': 'low'} + + >>> # With explicit override: + >>> get_default_reasoning(effort="high") + {'effort': 'high'} + + >>> # With no configuration: + >>> del os.environ["OPENAI_REASONING_EFFORT"] + >>> get_default_reasoning() + None + """ + # Determine the effort value + final_effort: Optional[ReasoningEffort] = None + + if effort is not None: + final_effort = effort + else: + final_effort = get_reasoning_effort_from_env() + + # If no effort is configured, return None + if final_effort is None and summary is None: + return None + + # Build the Reasoning config + result: Reasoning = {} + if final_effort is not None: + result["effort"] = final_effort + if summary is not None: + result["summary"] = summary + + return result diff --git a/tests/lib/test_reasoning.py b/tests/lib/test_reasoning.py new file mode 100644 index 0000000000..4f7aab9bdc --- /dev/null +++ b/tests/lib/test_reasoning.py @@ -0,0 +1,114 @@ +"""Tests for reasoning utilities. + +Tests the OPENAI_REASONING_EFFORT environment variable support. +Relates to issue #2686: Allow setting reasoning effort via environment variable +""" + +from __future__ import annotations + +import os +import warnings + +import pytest + +from openai.lib import get_default_reasoning, get_reasoning_effort_from_env + + +class TestGetReasoningEffortFromEnv: + """Tests for get_reasoning_effort_from_env function.""" + + def setup_method(self) -> None: + """Clean up env var before each test.""" + if "OPENAI_REASONING_EFFORT" in os.environ: + del os.environ["OPENAI_REASONING_EFFORT"] + + def teardown_method(self) -> None: + """Clean up env var after each test.""" + if "OPENAI_REASONING_EFFORT" in os.environ: + del os.environ["OPENAI_REASONING_EFFORT"] + + def test_returns_none_when_not_set(self) -> None: + """Returns None when env var is not set.""" + result = get_reasoning_effort_from_env() + assert result is None + + @pytest.mark.parametrize( + "value", + ["none", "minimal", "low", "medium", "high", "xhigh"], + ) + def test_returns_valid_values(self, value: str) -> None: + """Returns the value when it's valid.""" + os.environ["OPENAI_REASONING_EFFORT"] = value + result = get_reasoning_effort_from_env() + assert result == value + + def test_case_insensitive(self) -> None: + """Accepts case-insensitive values.""" + os.environ["OPENAI_REASONING_EFFORT"] = "HIGH" + result = get_reasoning_effort_from_env() + assert result == "high" + + def test_strips_whitespace(self) -> None: + """Strips leading/trailing whitespace.""" + os.environ["OPENAI_REASONING_EFFORT"] = " low " + result = get_reasoning_effort_from_env() + assert result == "low" + + def test_warns_on_invalid_value(self) -> None: + """Warns and returns None for invalid values.""" + os.environ["OPENAI_REASONING_EFFORT"] = "invalid" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = get_reasoning_effort_from_env() + assert result is None + assert len(w) == 1 + assert "Invalid" in str(w[0].message) + assert "OPENAI_REASONING_EFFORT" in str(w[0].message) + + +class TestGetDefaultReasoning: + """Tests for get_default_reasoning function.""" + + def setup_method(self) -> None: + """Clean up env var before each test.""" + if "OPENAI_REASONING_EFFORT" in os.environ: + del os.environ["OPENAI_REASONING_EFFORT"] + + def teardown_method(self) -> None: + """Clean up env var after each test.""" + if "OPENAI_REASONING_EFFORT" in os.environ: + del os.environ["OPENAI_REASONING_EFFORT"] + + def test_returns_none_when_no_config(self) -> None: + """Returns None when no effort configured.""" + result = get_default_reasoning() + assert result is None + + def test_uses_env_var(self) -> None: + """Uses env var when no explicit effort provided.""" + os.environ["OPENAI_REASONING_EFFORT"] = "low" + result = get_default_reasoning() + assert result == {"effort": "low"} + + def test_explicit_effort_overrides_env(self) -> None: + """Explicit effort parameter overrides env var.""" + os.environ["OPENAI_REASONING_EFFORT"] = "low" + result = get_default_reasoning(effort="high") + assert result == {"effort": "high"} + + def test_with_summary(self) -> None: + """Can configure summary alongside effort.""" + os.environ["OPENAI_REASONING_EFFORT"] = "medium" + result = get_default_reasoning(summary="concise") + assert result == {"effort": "medium", "summary": "concise"} + + def test_summary_only(self) -> None: + """Can configure summary without effort.""" + result = get_default_reasoning(summary="detailed") + assert result == {"summary": "detailed"} + + def test_explicit_none_effort_uses_env(self) -> None: + """Explicit None for effort still uses env var.""" + os.environ["OPENAI_REASONING_EFFORT"] = "high" + result = get_default_reasoning(effort=None) + assert result == {"effort": "high"}