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,