Skip to content
Merged
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
5 changes: 5 additions & 0 deletions dana/common/llm/providers/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class AzureProvider(OpenAICompatibleProvider):
# Valid values: "minimal" | "low" | "medium" | "high".
_REASONING_EFFORT_ENV_VAR = "AZURE_THINKING_EFFORT"

# Env var to force/disable the Responses API for this provider.
# Truthy: 1/true/on/yes — falsy: 0/false/off/no. Overrides the config flag
# and the api-version gate (forcing on an old api-version will likely 400).
_RESPONSES_API_ENV_VAR = "AZURE_USE_RESPONSES_API"

@property
def name(self) -> str:
return "azure"
Expand Down
4 changes: 4 additions & 0 deletions dana/common/llm/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class OpenAIProvider(OpenAICompatibleProvider):
# Valid values: "minimal" | "low" | "medium" | "high".
_REASONING_EFFORT_ENV_VAR = "OPENAI_THINKING_EFFORT"

# Env var to force/disable the Responses API for this provider.
# Truthy: 1/true/on/yes — falsy: 0/false/off/no. Overrides the config flag.
_RESPONSES_API_ENV_VAR = "OPENAI_USE_RESPONSES_API"

@property
def name(self) -> str:
return "openai"
Expand Down
63 changes: 60 additions & 3 deletions dana/common/llm/providers/openai_compatible_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ def _extract_audio_format(media_type: str) -> str:

DEFAULT_REASONING_EFFORT = "low"

# Generic fallback env var to force/disable Responses API across any provider.
# Provider-specific vars (OPENAI_USE_RESPONSES_API / AZURE_USE_RESPONSES_API) win.
GENERIC_USE_RESPONSES_API_ENV = "LLM_USE_RESPONSES_API"
_RESPONSES_API_ENABLE_VALUES = frozenset({"1", "true", "on", "yes"})
_RESPONSES_API_DISABLE_VALUES = frozenset({"0", "false", "off", "no"})


def _serialize_output_item(item: Any) -> dict:
"""Convert a Responses API output item to a JSON-serializable dict that's
Expand Down Expand Up @@ -177,6 +183,37 @@ def _resolve_reasoning_effort(provider_env_var: str | None) -> str:
return DEFAULT_REASONING_EFFORT


def _resolve_use_responses_api_env(provider_env_var: str | None) -> bool | None:
"""Resolve an explicit Responses-API override from environment.

Precedence:
1. provider-specific env var (e.g. ``OPENAI_USE_RESPONSES_API``)
2. generic ``LLM_USE_RESPONSES_API``

Returns ``True``/``False`` when set, or ``None`` when unset so the caller
falls back to the config flag and then model/endpoint auto-detection.
Invalid values are logged and ignored so a typo doesn't silently flip routing.
"""
for env_name in (provider_env_var, GENERIC_USE_RESPONSES_API_ENV):
if not env_name:
continue
raw = os.getenv(env_name)
if not raw:
continue
normalized = raw.strip().lower()
if normalized in _RESPONSES_API_ENABLE_VALUES:
return True
if normalized in _RESPONSES_API_DISABLE_VALUES:
return False
logger.warning(
"ignoring invalid use_responses_api env var",
env_var=env_name,
value=raw,
valid=sorted(_RESPONSES_API_ENABLE_VALUES | _RESPONSES_API_DISABLE_VALUES),
)
return None


def make_logging_http_client(timeout_seconds: int) -> httpx.AsyncClient:
"""Build an ``httpx.AsyncClient`` with request/response hooks.

Expand Down Expand Up @@ -213,6 +250,10 @@ class OpenAICompatibleProvider(LLMProvider):
# ``AZURE_THINKING_EFFORT`` or ``OPENAI_THINKING_EFFORT``. Resolved at call
# time so env changes take effect without process restart in tests.
_REASONING_EFFORT_ENV_VAR: str | None = None
# Provider-specific env var to force/disable Responses API, e.g.
# ``OPENAI_USE_RESPONSES_API`` / ``AZURE_USE_RESPONSES_API``. Resolved at
# call time; takes precedence over the ``use_responses_api`` config flag.
_RESPONSES_API_ENV_VAR: str | None = None

# Sticky flag — set after the first ``include=["reasoning.encrypted_content"]``
# rejection so we stop paying the round-trip cost of retry on every call.
Expand Down Expand Up @@ -984,10 +1025,26 @@ def _responses_api_supported(self) -> bool:
def _should_use_responses_api(self) -> bool:
"""Determine whether to use Responses API or Chat Completions.

Priority: explicit config flag > endpoint capability + model prefix.
Priority: env override > config flag > endpoint capability + model prefix.

An explicit override (env or config) takes the caller's word and bypasses
the endpoint-capability gate — useful for forcing the path under test or
against a custom-configured resource. When forcing it on against an
endpoint that reports no support (e.g. an Azure api-version older than
2025-03-01), we log a warning since the request will likely 400.
"""
if self._use_responses_api is not None:
return self._use_responses_api
override = _resolve_use_responses_api_env(self._RESPONSES_API_ENV_VAR)
if override is None:
override = self._use_responses_api

if override is not None:
if override and not self._responses_api_supported():
logger.warning(
"Responses API forced on but endpoint reports no support; request may fail",
provider=self.name,
model=self.model,
)
return override

if not self._responses_api_supported():
return False
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/llm/test_responses_api_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,54 @@ def test_reasoning_model_prefixes_match(self, model):
assert p._should_use_responses_api() is True


class TestEnvVarOverride:
"""Env var forces/disables Responses API, taking precedence over config flag."""

def test_provider_env_forces_on_over_version_gate(self, monkeypatch):
monkeypatch.setenv("AZURE_USE_RESPONSES_API", "true")
p = _make_azure(api_version="2024-12-01-preview", model="gpt-4o")
assert p._should_use_responses_api() is True

def test_provider_env_forces_off_over_prefix_match(self, monkeypatch):
monkeypatch.setenv("AZURE_USE_RESPONSES_API", "0")
p = _make_azure(api_version="2025-04-01-preview", model="gpt-5.2")
assert p._should_use_responses_api() is False

def test_provider_env_overrides_config_flag(self, monkeypatch):
monkeypatch.setenv("AZURE_USE_RESPONSES_API", "off")
p = _make_azure(api_version="2025-04-01-preview", model="gpt-5.2", use_responses_api=True)
assert p._should_use_responses_api() is False

@pytest.mark.parametrize("raw", ["1", "true", "on", "yes", "TRUE", " Yes "])
def test_truthy_values(self, monkeypatch, raw):
monkeypatch.setenv("AZURE_USE_RESPONSES_API", raw)
p = _make_azure(api_version="2024-12-01-preview", model="gpt-4o")
assert p._should_use_responses_api() is True

@pytest.mark.parametrize("raw", ["0", "false", "off", "no", "FALSE"])
def test_falsy_values(self, monkeypatch, raw):
monkeypatch.setenv("AZURE_USE_RESPONSES_API", raw)
p = _make_azure(api_version="2025-04-01-preview", model="gpt-5.2")
assert p._should_use_responses_api() is False

def test_invalid_value_ignored_falls_back_to_auto_detect(self, monkeypatch):
monkeypatch.setenv("AZURE_USE_RESPONSES_API", "maybe")
p = _make_azure(api_version="2025-04-01-preview", model="gpt-5.2")
assert p._should_use_responses_api() is True # prefix match still applies

def test_generic_env_applies_when_provider_var_unset(self, monkeypatch):
monkeypatch.delenv("AZURE_USE_RESPONSES_API", raising=False)
monkeypatch.setenv("LLM_USE_RESPONSES_API", "true")
p = _make_azure(api_version="2024-12-01-preview", model="gpt-4o")
assert p._should_use_responses_api() is True

def test_provider_var_wins_over_generic(self, monkeypatch):
monkeypatch.setenv("AZURE_USE_RESPONSES_API", "false")
monkeypatch.setenv("LLM_USE_RESPONSES_API", "true")
p = _make_azure(api_version="2025-04-01-preview", model="gpt-5.2")
assert p._should_use_responses_api() is False


class TestOpenAIBaseDefaultSupported:
"""Non-Azure OpenAI-compatible providers support Responses API unconditionally."""

Expand Down
Loading