mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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:
parent
855694faf4
commit
72cc78f8a5
6 changed files with 141 additions and 21 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue