Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/lfx/src/lfx/_assets/component_index.json

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions src/lfx/src/lfx/base/models/model_remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ class Remediation:

``markers`` are lowercase substrings; the error matches when ANY is present
(they are alternate phrasings of the same constraint). ``providers`` empty
means the remediation applies to any provider.
means the remediation applies to any provider. A missing provider is treated
as unknown so connected models can still attempt a narrowly matched repair;
a known provider outside the allowlist remains a mismatch.
"""

name: str
Expand All @@ -36,7 +38,7 @@ class Remediation:
providers: tuple[str, ...] = field(default_factory=tuple)

def matches(self, error_msg: str | None, provider: str | None) -> bool:
if self.providers and (provider or "") not in self.providers:
if self.providers and provider is not None and provider not in self.providers:
return False
lowered = (error_msg or "").lower()
return any(marker in lowered for marker in self.markers)
Expand All @@ -49,7 +51,7 @@ def matches(self, error_msg: str | None, provider: str | None) -> bool:
name="openai-responses-api-for-tools",
# gpt-5.6+ reasoning models reject tools + reasoning_effort on
# chat/completions and point at the Responses API in the 400 body.
markers=("/v1/responses",),
markers=("function tools with reasoning_effort are not supported",),
overrides={"use_responses_api": True},
providers=("OpenAI",),
),
Expand Down
38 changes: 30 additions & 8 deletions src/lfx/src/lfx/components/models_and_agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -741,20 +741,33 @@ def _build_initial_agent_message(self) -> Message:
session_id=session_id or uuid.uuid4(),
)

def _selected_provider_and_model(self) -> tuple[str | None, str | None]:
"""Provider/name of the selected model, for error-driven remediation."""
def _selected_model_remediation_context(self) -> tuple[str | None, str | None, Any | None]:
"""Return provider/name plus a connected model target, when present."""
try:
selected = self._resolve_selected_model()
if isinstance(selected, list) and selected and isinstance(selected[0], dict):
return selected[0].get("provider"), selected[0].get("name")
return selected[0].get("provider"), selected[0].get("name"), None

from langchain_core.language_models import BaseLanguageModel

if isinstance(selected, BaseLanguageModel):
model_name = next(
(
getattr(selected, attr, None)
for attr in ("model_name", "model", "model_id")
if getattr(selected, attr, None)
),
None,
)
return None, model_name if isinstance(model_name, str) else None, selected
except (AttributeError, TypeError, KeyError, ImportError):
pass
return None, None
return None, None, None

async def message_response(self) -> Message:
from lfx.base.models.model_remediation import find_remediation, remember
from lfx.base.models.model_remediation import apply_overrides_to_model, find_remediation, remember

provider, model_name = self._selected_provider_and_model()
provider, model_name, connected_model = self._selected_model_remediation_context()
applied: set[str] = set()
while True:
try:
Expand Down Expand Up @@ -782,14 +795,23 @@ async def message_response(self) -> Message:
label = type(e).__name__
await logger.aerror(f"{label}: {e!s}")
raise
if connected_model is not None and not apply_overrides_to_model(connected_model, remediation.overrides):
await logger.aerror(
f"model.remediation.unapplied name={remediation.name} provider={provider} model={model_name}"
)
raise
applied.add(remediation.name)
self._model_overrides = {**(getattr(self, "_model_overrides", None) or {}), **remediation.overrides}
if connected_model is None:
self._model_overrides = {
**(getattr(self, "_model_overrides", None) or {}),
**remediation.overrides,
}
await logger.awarning(
f"model.remediation.applied name={remediation.name} provider={provider} model={model_name}"
)
continue
else:
if getattr(self, "_model_overrides", None):
if connected_model is None and getattr(self, "_model_overrides", None):
remember(provider, model_name, self._model_overrides)
return result

Expand Down
11 changes: 10 additions & 1 deletion src/lfx/tests/unit/base/models/test_model_remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

GPT56_ERROR = (
"Error building Component Agent: Error code: 400 - {'error': {'message': "
'"Function tools with reasoning_effort are not supported for gpt-5.6 in '
'"Function tools with reasoning_effort are not supported for gpt-5.6-luna in '
"/v1/chat/completions. To use function tools, use /v1/responses or set "
"reasoning_effort to 'none'.\", 'type': 'invalid_request_error'}}"
)
Expand All @@ -36,12 +36,21 @@ def test_should_match_openai_responses_api_constraint(self):
assert rem is not None
assert rem.overrides == {"use_responses_api": True}

def test_should_match_openai_responses_api_constraint_when_provider_is_unknown(self):
rem = find_remediation(GPT56_ERROR, provider=None, already_applied=set())
assert rem is not None
assert rem.overrides == {"use_responses_api": True}

def test_should_not_match_for_a_different_provider(self):
assert find_remediation(GPT56_ERROR, provider="Anthropic", already_applied=set()) is None

def test_should_not_match_unrelated_errors(self):
assert find_remediation("rate limit exceeded", provider="OpenAI", already_applied=set()) is None

def test_should_not_match_an_unrelated_responses_api_suggestion(self):
error = "This feature is only available through /v1/responses."
assert find_remediation(error, provider=None, already_applied=set()) is None

def test_should_skip_a_remediation_already_applied(self):
rem = find_remediation(GPT56_ERROR, provider="OpenAI", already_applied=set())
assert rem is not None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from lfx.schema.message import Message

GPT56_RESPONSES_API_ERROR = RuntimeError(
"Error building Component Agent: Error code: 400 - Function tools with "
"reasoning_effort are not supported for gpt-5.6 in /v1/chat/completions. "
"reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. "
"To use function tools, use /v1/responses or set reasoning_effort to 'none'."
)


class ResponsesApiConnectedModel(FakeListChatModel):
use_responses_api: bool = False


@pytest.mark.asyncio
async def test_message_response_remediates_responses_api_error_and_remembers():
from lfx.base.models import model_remediation
Expand All @@ -36,7 +41,7 @@ async def test_message_response_remediates_responses_api_error_and_remembers():
patch.object(
AgentComponent,
"_resolve_selected_model",
return_value=[{"provider": "OpenAI", "name": "gpt-5.6"}],
return_value=[{"provider": "OpenAI", "name": "gpt-5.6-luna"}],
),
patch.object(
AgentComponent,
Expand All @@ -54,7 +59,86 @@ async def test_message_response_remediates_responses_api_error_and_remembers():
assert result.text == "ok"
assert run_agent.await_count == 2
assert agent._model_overrides == {"use_responses_api": True}
assert model_remediation.cached_overrides("OpenAI", "gpt-5.6") == {"use_responses_api": True}
assert model_remediation.cached_overrides("OpenAI", "gpt-5.6-luna") == {"use_responses_api": True}
finally:
model_remediation.reset_remediation_cache()


@pytest.mark.asyncio
async def test_message_response_remediates_a_connected_model_in_place_without_caching():
from lfx.base.models import model_remediation
from lfx.components.models_and_agents.agent import AgentComponent

model_remediation.reset_remediation_cache()
try:
connected_model = ResponsesApiConnectedModel(responses=["unused"])
agent = AgentComponent()
agent.model = connected_model
agent.input_value = "hi"
agent.system_prompt = ""
agent.tools = []
seen_values: list[bool] = []

async def run_agent(_agent):
seen_values.append(connected_model.use_responses_api)
if not connected_model.use_responses_api:
raise GPT56_RESPONSES_API_ERROR
return Message(text="ok")

mocked_run_agent = AsyncMock(side_effect=run_agent)
with (
patch.object(
AgentComponent,
"get_agent_requirements",
new=AsyncMock(return_value=(connected_model, [], [])),
),
patch.object(AgentComponent, "create_agent_runnable", return_value=MagicMock()),
patch.object(AgentComponent, "_inject_dynamic_prompt_values", return_value=""),
patch.object(AgentComponent, "set", new=MagicMock()),
patch.object(AgentComponent, "run_agent", new=mocked_run_agent),
):
result = await agent.message_response()

assert result.text == "ok"
assert seen_values == [False, True]
assert mocked_run_agent.await_count == 2
assert connected_model.use_responses_api is True
assert model_remediation.cached_overrides(None, None) == {}
finally:
model_remediation.reset_remediation_cache()


@pytest.mark.asyncio
async def test_message_response_does_not_retry_when_connected_model_cannot_apply_remediation():
from lfx.base.models import model_remediation
from lfx.components.models_and_agents.agent import AgentComponent

model_remediation.reset_remediation_cache()
try:
connected_model = FakeListChatModel(responses=["unused"])
agent = AgentComponent()
agent.model = connected_model
agent.input_value = "hi"
agent.system_prompt = ""
agent.tools = []
mocked_run_agent = AsyncMock(side_effect=GPT56_RESPONSES_API_ERROR)

with (
patch.object(
AgentComponent,
"get_agent_requirements",
new=AsyncMock(return_value=(connected_model, [], [])),
),
patch.object(AgentComponent, "create_agent_runnable", return_value=MagicMock()),
patch.object(AgentComponent, "_inject_dynamic_prompt_values", return_value=""),
patch.object(AgentComponent, "set", new=MagicMock()),
patch.object(AgentComponent, "run_agent", new=mocked_run_agent),
pytest.raises(RuntimeError, match=r"gpt-5\.6-luna"),
):
await agent.message_response()

assert mocked_run_agent.await_count == 1
assert model_remediation.cached_overrides(None, None) == {}
finally:
model_remediation.reset_remediation_cache()

Expand All @@ -77,7 +161,7 @@ async def test_message_response_does_not_retry_unrelated_errors():
patch.object(
AgentComponent,
"_resolve_selected_model",
return_value=[{"provider": "OpenAI", "name": "gpt-5.6"}],
return_value=[{"provider": "OpenAI", "name": "gpt-5.6-luna"}],
),
patch.object(
AgentComponent,
Expand All @@ -93,6 +177,6 @@ async def test_message_response_does_not_retry_unrelated_errors():
await agent.message_response()

assert run_agent.await_count == 1
assert model_remediation.cached_overrides("OpenAI", "gpt-5.6") == {}
assert model_remediation.cached_overrides("OpenAI", "gpt-5.6-luna") == {}
finally:
model_remediation.reset_remediation_cache()
Loading