diff --git a/helpers/extract_tools.py b/helpers/extract_tools.py index 39e837855..188bf7fef 100644 --- a/helpers/extract_tools.py +++ b/helpers/extract_tools.py @@ -8,15 +8,22 @@ def json_parse_dirty(json: str) -> dict[str, Any] | None: if not json or not isinstance(json, str): return None - ext_json = extract_json_object_string(json.strip()) - if ext_json: + parsed_candidates: list[dict[str, Any]] = [] + for ext_json in extract_json_root_strings(json.strip()): try: data = DirtyJson.parse_string(ext_json) if isinstance(data, dict): - return data + parsed_candidates.append(data) except Exception: - # If parsing fails, return None instead of crashing - return None + continue + for data in parsed_candidates: + try: + normalize_tool_request(data) + return data + except ValueError: + continue + if parsed_candidates: + return parsed_candidates[0] return None @@ -46,26 +53,34 @@ def normalize_tool_request(tool_request: Any) -> tuple[str, dict]: def extract_json_root_string(content: str) -> str | None: + for root in extract_json_root_strings(content): + return root + return None + + +def extract_json_root_strings(content: str) -> list[str]: if not content or not isinstance(content, str): - return None + return [] - start = content.find("{") - if start == -1: - return None - first_array = content.find("[") - if first_array != -1 and first_array < start: - return None + if content.lstrip().startswith("["): + return [] - parser = DirtyJson() - try: - parser.parse(content[start:]) - except Exception: - return None + roots: list[str] = [] + for start, char in enumerate(content): + if char != "{": + continue - if not parser.completed: - return None + parser = DirtyJson() + try: + parser.parse(content[start:]) + except Exception: + continue - return content[start : start + parser.index] + if not parser.completed: + continue + + roots.append(content[start : start + parser.index]) + return roots def extract_json_object_string(content): diff --git a/helpers/extract_tools.py.dox.md b/helpers/extract_tools.py.dox.md index 9c65b8ced..6efb0de7f 100644 --- a/helpers/extract_tools.py.dox.md +++ b/helpers/extract_tools.py.dox.md @@ -14,6 +14,7 @@ - `json_parse_dirty(json: str) -> dict[str, Any] | None` - `normalize_tool_request(tool_request: Any) -> tuple[str, dict]` - `extract_json_root_string(content: str) -> str | None` +- `extract_json_root_strings(content: str) -> list[str]` - `extract_json_object_string(content)` - `extract_json_string(content)` - `fix_json_string(json_string)` @@ -23,6 +24,7 @@ - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together. - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - Observed side-effect areas: settings/state persistence. +- Dirty parsing scans complete JSON object roots in prose and prefers the first object that normalizes as a valid tool request, so a leading text preamble or incidental non-tool object does not force a misformat warning when a valid tool call follows. - Imported dependency areas include: `dirty_json`, `helpers.modules`, `re`, `regex`, `typing`. ## Key Concepts diff --git a/helpers/litellm_transport.py b/helpers/litellm_transport.py index 05b9c6b2a..9298ede59 100644 --- a/helpers/litellm_transport.py +++ b/helpers/litellm_transport.py @@ -1524,6 +1524,8 @@ def _is_responses_not_supported_error(exc: Exception) -> bool: text = _exception_text(exc).lower() if any(marker in text for marker in ("429", "too many requests", "rate limit")): return False + if _is_bad_request_error(exc) and _looks_like_responses_request_rejected(text): + return True if _is_not_found_error(exc) and _looks_like_responses_endpoint_not_found(text): return True if "/v1/responses" in text and any( @@ -1551,6 +1553,35 @@ def _is_not_found_error(exc: Exception) -> bool: return "notfounderror" in _exception_type_chain(exc).lower() +def _is_bad_request_error(exc: Exception) -> bool: + if _exception_status_code(exc) == 400: + return True + type_chain = _exception_type_chain(exc).lower() + if "badrequesterror" in type_chain: + return True + text = _exception_text(exc).lower() + return "400" in text and "bad request" in text + + +def _looks_like_responses_request_rejected(text: str) -> bool: + if "/v1/responses" in text or "responses api" in text: + return True + return any( + marker in text + for marker in ( + "input_image", + "response.input", + "expected object, received string", + "expected string, received array", + "zod", + "invalid request", + "invalid type", + "failed to deserialize", + "validation error", + ) + ) + + def _looks_like_responses_endpoint_not_found(text: str) -> bool: if "/v1/responses" in text: return True diff --git a/helpers/litellm_transport.py.dox.md b/helpers/litellm_transport.py.dox.md index 4105d7ba0..da8836150 100644 --- a/helpers/litellm_transport.py.dox.md +++ b/helpers/litellm_transport.py.dox.md @@ -26,6 +26,7 @@ - Strip Agent Zero internal kwargs before sending requests to LiteLLM. - Do not send orphan tool controls when no tools are present; strict OpenAI-compatible servers can reject empty `tools` arrays. - Prefer Responses API when configured, but fallback to Chat Completions when the provider does not support Responses. +- Fall back to Chat Completions when a Responses request is rejected before any output by a Bad Request/validation error that indicates the provider cannot parse the Responses request shape. - Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported. - Keep prompt-cache markers only for providers that accept them. diff --git a/tests/test_responses_architecture.py b/tests/test_responses_architecture.py index 03c0ae54e..056e912a6 100644 --- a/tests/test_responses_architecture.py +++ b/tests/test_responses_architecture.py @@ -10,7 +10,7 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) import models -from agent import Agent, AgentConfig, LoopData +from agent import Agent, AgentConfig, AgentContextType, LoopData from helpers import history, litellm_transport from helpers.log import Log from helpers.llm_result import LLMResult, result_from_metadata @@ -316,6 +316,7 @@ async def test_agent_executes_native_responses_function_call_and_records_output( class DummyContext: paused = False log = Log() + type = AgentContextType.USER def get_data(self, key, recursive=True): return None diff --git a/tests/test_stream_tool_early_stop.py b/tests/test_stream_tool_early_stop.py index 759079ae6..0cb7a526d 100644 --- a/tests/test_stream_tool_early_stop.py +++ b/tests/test_stream_tool_early_stop.py @@ -78,6 +78,18 @@ def test_extract_json_root_string_returns_canonical_snapshot(): assert extract_tools.extract_json_root_string('[{"tool_name":"response"}]') is None +def test_json_parse_dirty_prefers_valid_tool_request_after_preamble_object(): + text = ( + 'I will call the tool after this note {"note":"not the tool"}.\n' + '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text' + ) + + assert extract_tools.json_parse_dirty(text) == { + "tool_name": "response", + "tool_args": {"text": "ok"}, + } + + def test_litellm_global_kwargs_merge_defaults_and_config(monkeypatch): monkeypatch.setattr( models.settings, @@ -457,6 +469,64 @@ async def test_unified_call_falls_back_when_litellm_hides_responses_404_url( assert calls == ["responses", "chat"] +@pytest.mark.asyncio +async def test_unified_call_falls_back_when_responses_bad_request_rejects_shape( + monkeypatch, +): + class BadRequestError(Exception): + status_code = 400 + + calls: list[str] = [] + + async def fake_aresponses(*args, **kwargs): + calls.append("responses") + raise BadRequestError( + 'BadRequestError: Zod validation error: input_image Expected object, ' + 'received string; Expected string, received array' + ) + + async def fake_acompletion(*args, **kwargs): + calls.append("chat") + assert kwargs["stream"] is True + assert kwargs["drop_params"] is True + return _AsyncChunkStream([_chunk("fallback")]) + + async def fake_rate_limiter(*args, **kwargs): + return None + + monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses) + monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion) + monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter) + + wrapper = models.LiteLLMChatWrapper( + model="venice-model", + provider="openai", + model_config=None, + ) + + async def response_callback(chunk: str, full: str): + return None + + response, reasoning = await wrapper.unified_call( + messages=[ + HumanMessage( + content=[ + {"type": "text", "text": "describe it"}, + { + "type": "image_url", + "image_url": {"url": "https://example.test/a.png"}, + }, + ] + ) + ], + response_callback=response_callback, + ) + + assert response == "fallback" + assert reasoning == "" + assert calls == ["responses", "chat"] + + @pytest.mark.asyncio async def test_unified_call_preserves_cache_control_with_chat_for_non_native_responses( monkeypatch,