Fallback Responses bad requests

Treat pre-output Responses API 400 validation failures as unsupported so OpenAI-compatible providers that reject /v1/responses payloads can retry through Chat Completions. Also prefer a valid tool-call JSON object after leading prose or incidental JSON to reduce false misformat warnings.

Alessandro committed Jun 25, 2026 at 09:40 UTC 72cc78f8a580a097aec65ae14914740a2bc94f6f
6 files changed +141 -21
helpers/extract_tools.py
+35 -20
@@ -8,15 +8,22 @@ def json_parse_dirty(json: str) -> dict[str, Any] | None:
8 if not json or not isinstance(json, str):
9 return None
10
11 - ext_json = extract_json_object_string(json.strip())
12 - if ext_json:
11 + parsed_candidates: list[dict[str, Any]] = []
12 + for ext_json in extract_json_root_strings(json.strip()):
13 try:
14 data = DirtyJson.parse_string(ext_json)
15 if isinstance(data, dict):
16 - return data
16 + parsed_candidates.append(data)
17 except Exception:
18 - # If parsing fails, return None instead of crashing
19 - return None
18 + continue
19 + for data in parsed_candidates:
20 + try:
21 + normalize_tool_request(data)
22 + return data
23 + except ValueError:
24 + continue
25 + if parsed_candidates:
26 + return parsed_candidates[0]
27 return None
28
29
@@ -46,26 +53,34 @@ def normalize_tool_request(tool_request: Any) -> tuple[str, dict]:
53
54
55 def extract_json_root_string(content: str) -> str | None:
56 + for root in extract_json_root_strings(content):
57 + return root
58 + return None
59 +
60 +
61 +def extract_json_root_strings(content: str) -> list[str]:
62 if not content or not isinstance(content, str):
50 - return None
63 + return []
64
52 - start = content.find("{")
53 - if start == -1:
54 - return None
55 - first_array = content.find("[")
56 - if first_array != -1 and first_array < start:
57 - return None
65 + if content.lstrip().startswith("["):
66 + return []
67
59 - parser = DirtyJson()
60 - try:
61 - parser.parse(content[start:])
62 - except Exception:
63 - return None
68 + roots: list[str] = []
69 + for start, char in enumerate(content):
70 + if char != "{":
71 + continue
72
65 - if not parser.completed:
66 - return None
73 + parser = DirtyJson()
74 + try:
75 + parser.parse(content[start:])
76 + except Exception:
77 + continue
78 +
79 + if not parser.completed:
80 + continue
81
68 - return content[start : start + parser.index]
82 + roots.append(content[start : start + parser.index])
83 + return roots
84
85
86 def extract_json_object_string(content):
helpers/extract_tools.py.dox.md
+2
@@ -14,6 +14,7 @@
14 - `json_parse_dirty(json: str) -> dict[str, Any] | None`
15 - `normalize_tool_request(tool_request: Any) -> tuple[str, dict]`
16 - `extract_json_root_string(content: str) -> str | None`
17 +- `extract_json_root_strings(content: str) -> list[str]`
18 - `extract_json_object_string(content)`
19 - `extract_json_string(content)`
20 - `fix_json_string(json_string)`
@@ -23,6 +24,7 @@
24 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
25 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
26 - Observed side-effect areas: settings/state persistence.
27 +- 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.
28 - Imported dependency areas include: `dirty_json`, `helpers.modules`, `re`, `regex`, `typing`.
29
30 ## Key Concepts
helpers/litellm_transport.py
+31
@@ -1524,6 +1524,8 @@ def _is_responses_not_supported_error(exc: Exception) -> bool:
1524 text = _exception_text(exc).lower()
1525 if any(marker in text for marker in ("429", "too many requests", "rate limit")):
1526 return False
1527 + if _is_bad_request_error(exc) and _looks_like_responses_request_rejected(text):
1528 + return True
1529 if _is_not_found_error(exc) and _looks_like_responses_endpoint_not_found(text):
1530 return True
1531 if "/v1/responses" in text and any(
@@ -1551,6 +1553,35 @@ def _is_not_found_error(exc: Exception) -> bool:
1553 return "notfounderror" in _exception_type_chain(exc).lower()
1554
1555
1556 +def _is_bad_request_error(exc: Exception) -> bool:
1557 + if _exception_status_code(exc) == 400:
1558 + return True
1559 + type_chain = _exception_type_chain(exc).lower()
1560 + if "badrequesterror" in type_chain:
1561 + return True
1562 + text = _exception_text(exc).lower()
1563 + return "400" in text and "bad request" in text
1564 +
1565 +
1566 +def _looks_like_responses_request_rejected(text: str) -> bool:
1567 + if "/v1/responses" in text or "responses api" in text:
1568 + return True
1569 + return any(
1570 + marker in text
1571 + for marker in (
1572 + "input_image",
1573 + "response.input",
1574 + "expected object, received string",
1575 + "expected string, received array",
1576 + "zod",
1577 + "invalid request",
1578 + "invalid type",
1579 + "failed to deserialize",
1580 + "validation error",
1581 + )
1582 + )
1583 +
1584 +
1585 def _looks_like_responses_endpoint_not_found(text: str) -> bool:
1586 if "/v1/responses" in text:
1587 return True
helpers/litellm_transport.py.dox.md
+1
@@ -26,6 +26,7 @@
26 - Strip Agent Zero internal kwargs before sending requests to LiteLLM.
27 - Do not send orphan tool controls when no tools are present; strict OpenAI-compatible servers can reject empty `tools` arrays.
28 - Prefer Responses API when configured, but fallback to Chat Completions when the provider does not support Responses.
29 +- 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.
30 - Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.
31 - Keep prompt-cache markers only for providers that accept them.
32
tests/test_responses_architecture.py
+2 -1
@@ -10,7 +10,7 @@ if str(PROJECT_ROOT) not in sys.path:
10 sys.path.insert(0, str(PROJECT_ROOT))
11
12 import models
13 -from agent import Agent, AgentConfig, LoopData
13 +from agent import Agent, AgentConfig, AgentContextType, LoopData
14 from helpers import history, litellm_transport
15 from helpers.log import Log
16 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(
316 class DummyContext:
317 paused = False
318 log = Log()
319 + type = AgentContextType.USER
320
321 def get_data(self, key, recursive=True):
322 return None
tests/test_stream_tool_early_stop.py
+70
@@ -78,6 +78,18 @@ def test_extract_json_root_string_returns_canonical_snapshot():
78 assert extract_tools.extract_json_root_string('[{"tool_name":"response"}]') is None
79
80
81 +def test_json_parse_dirty_prefers_valid_tool_request_after_preamble_object():
82 + text = (
83 + 'I will call the tool after this note {"note":"not the tool"}.\n'
84 + '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text'
85 + )
86 +
87 + assert extract_tools.json_parse_dirty(text) == {
88 + "tool_name": "response",
89 + "tool_args": {"text": "ok"},
90 + }
91 +
92 +
93 def test_litellm_global_kwargs_merge_defaults_and_config(monkeypatch):
94 monkeypatch.setattr(
95 models.settings,
@@ -457,6 +469,64 @@ async def test_unified_call_falls_back_when_litellm_hides_responses_404_url(
469 assert calls == ["responses", "chat"]
470
471
472 +@pytest.mark.asyncio
473 +async def test_unified_call_falls_back_when_responses_bad_request_rejects_shape(
474 + monkeypatch,
475 +):
476 + class BadRequestError(Exception):
477 + status_code = 400
478 +
479 + calls: list[str] = []
480 +
481 + async def fake_aresponses(*args, **kwargs):
482 + calls.append("responses")
483 + raise BadRequestError(
484 + 'BadRequestError: Zod validation error: input_image Expected object, '
485 + 'received string; Expected string, received array'
486 + )
487 +
488 + async def fake_acompletion(*args, **kwargs):
489 + calls.append("chat")
490 + assert kwargs["stream"] is True
491 + assert kwargs["drop_params"] is True
492 + return _AsyncChunkStream([_chunk("fallback")])
493 +
494 + async def fake_rate_limiter(*args, **kwargs):
495 + return None
496 +
497 + monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
498 + monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
499 + monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
500 +
501 + wrapper = models.LiteLLMChatWrapper(
502 + model="venice-model",
503 + provider="openai",
504 + model_config=None,
505 + )
506 +
507 + async def response_callback(chunk: str, full: str):
508 + return None
509 +
510 + response, reasoning = await wrapper.unified_call(
511 + messages=[
512 + HumanMessage(
513 + content=[
514 + {"type": "text", "text": "describe it"},
515 + {
516 + "type": "image_url",
517 + "image_url": {"url": "https://example.test/a.png"},
518 + },
519 + ]
520 + )
521 + ],
522 + response_callback=response_callback,
523 + )
524 +
525 + assert response == "fallback"
526 + assert reasoning == ""
527 + assert calls == ["responses", "chat"]
528 +
529 +
530 @pytest.mark.asyncio
531 async def test_unified_call_preserves_cache_control_with_chat_for_non_native_responses(
532 monkeypatch,