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.
This commit is contained in:
Alessandro 2026-06-25 09:40:37 +02:00
parent 855694faf4
commit 72cc78f8a5
6 changed files with 141 additions and 21 deletions

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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,