Stabilize LiteLLM provider fallback

Fall back to Chat Completions for provider/proxy Responses endpoint failures before any output is emitted. Preserve streamed Chat Completions tool-call deltas as structured LLMResult function-call items, so fallback providers can still drive tools. Document Docker host-gateway addressing for local model servers, where container localhost does not reach host loopback. Verified with: PYTHONPATH="/home/eclypso/a0/agent-zero" conda run -n a0 pytest tests/test_stream_tool_early_stop.py tests/test_responses_architecture.py -q; PYTHONPATH="/home/eclypso/a0/agent-zero" conda run -n a0 python -m py_compile helpers/litellm_transport.py helpers/llm_result.py; git diff --check.

Alessandro committed Jul 1, 2026 at 15:53 UTC 738949031b31a9e42388d528b5503accc6c14de9
6 files changed +472 -12
docs/setup/installation.md
+6
@@ -546,6 +546,12 @@ Use the naming format required by your selected provider:
546 > [!TIP]
547 > If you see "Invalid model ID," verify the provider and naming format on the provider website, or search the web for "<name-of-ai-model> model naming".
548
549 +#### Local Model Server Addresses From Docker
550 +
551 +When Agent Zero runs in Docker, `localhost` and `127.0.0.1` inside an API base URL mean the Agent Zero container, not your host machine. For a model server running on the host, use `http://host.docker.internal:<port>` when available, or the Docker host gateway address such as `http://172.17.0.1:<port>` on the default Linux bridge.
552 +
553 +If the model server only listens on host loopback, for example `127.0.0.1:<port>`, the container still cannot reach it through the gateway. Configure the local server to listen on a Docker-reachable address such as `0.0.0.0`, and keep that port limited to trusted clients.
554 +
555 #### Context Window & Memory Split
556
557 - Set the **total context window** (e.g., 100k) first.
helpers/litellm_transport.py
+203 -4
@@ -260,11 +260,17 @@ class LiteLLMTransport:
260 try:
261 if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
262 iterator = completion(**self._chat_request(stream=True))
263 + parser = ChatCompletionsStreamParser()
264 for chunk in iterator:
264 - parsed = ChatCompletionsTransport.parse(chunk)
265 + parsed = parser.parse(chunk)
266 if _has_chunk_delta(parsed):
267 got_any_chunk = True
268 yield parsed
269 + parsed = parser.flush()
270 + if _has_chunk_delta(parsed):
271 + got_any_chunk = True
272 + yield parsed
273 + self.last_result = self._stream_result_from_chat_parser(parser)
274 else:
275 request = self._responses_request(stream=True)
276 iterator = responses(**request)
@@ -295,11 +301,17 @@ class LiteLLMTransport:
301 try:
302 if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
303 iterator = await acompletion(**self._chat_request(stream=True))
304 + parser = ChatCompletionsStreamParser()
305 async for chunk in iterator: # type: ignore[union-attr]
299 - parsed = ChatCompletionsTransport.parse(chunk)
306 + parsed = parser.parse(chunk)
307 if _has_chunk_delta(parsed):
308 got_any_chunk = True
309 yield parsed
310 + parsed = parser.flush()
311 + if _has_chunk_delta(parsed):
312 + got_any_chunk = True
313 + yield parsed
314 + self.last_result = self._stream_result_from_chat_parser(parser)
315 else:
316 request = self._responses_request(stream=True)
317 iterator = await aresponses(**request)
@@ -393,6 +405,7 @@ class LiteLLMTransport:
405 response=parsed["response_delta"],
406 reasoning=parsed["reasoning_delta"],
407 input_items=ResponsesTransport.input_from_messages(self.messages),
408 + output_items=parsed.get("_output_items"),
409 provider_model_key=self.model,
410 capability=self._capability_metadata(),
411 )
@@ -417,6 +430,20 @@ class LiteLLMTransport:
430 return None
431 return self._llm_result_from_response(parser.completed_response, request)
432
433 + def _stream_result_from_chat_parser(
434 + self, parser: "ChatCompletionsStreamParser"
435 + ) -> LLMResult | None:
436 + output_items = parser.output_items()
437 + if not output_items:
438 + return None
439 + return LLMResult.from_chat(
440 + response=parser.function_calls_text(),
441 + input_items=ResponsesTransport.input_from_messages(self.messages),
442 + output_items=output_items,
443 + provider_model_key=self.model,
444 + capability=self._capability_metadata(),
445 + )
446 +
447 def _capability_metadata(self) -> dict[str, Any]:
448 return {
449 "mode": self.policy.mode.value,
@@ -489,7 +516,149 @@ class ChatCompletionsTransport:
516 reasoning_delta = _get_value(delta, "reasoning_content") or _get_value(
517 message, "reasoning_content"
518 ) or ""
492 - return {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
519 + parsed = {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
520 + if not response_delta:
521 + tool_calls = _as_list(_get_value(message, "tool_calls"))
522 + response_delta = ChatCompletionsTransport.tool_calls_text(tool_calls)
523 + if response_delta:
524 + parsed["response_delta"] = response_delta
525 + parsed["_output_items"] = ChatCompletionsTransport.output_items(
526 + tool_calls
527 + )
528 + return parsed
529 +
530 + @classmethod
531 + def tool_calls_text(cls, tool_calls: Any) -> str:
532 + calls = [cls.tool_call_object(call) for call in _as_list(tool_calls)]
533 + calls = [call for call in calls if call]
534 + if not calls:
535 + return ""
536 + if len(calls) == 1:
537 + return json.dumps(calls[0])
538 + return json.dumps(
539 + {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}}
540 + )
541 +
542 + @classmethod
543 + def output_items(cls, tool_calls: Any) -> list[dict[str, Any]]:
544 + items = []
545 + for index, tool_call in enumerate(_as_list(tool_calls)):
546 + item = cls.function_call_item(tool_call, fallback_index=index)
547 + if item:
548 + items.append(item)
549 + return items
550 +
551 + @classmethod
552 + def function_call_item(
553 + cls, tool_call: Any, *, fallback_index: int = 0
554 + ) -> dict[str, Any]:
555 + function = _get_value(tool_call, "function") or {}
556 + name = _get_value(function, "name") or _get_value(tool_call, "name")
557 + if not name:
558 + return {}
559 + raw_arguments = _get_value(function, "arguments")
560 + if raw_arguments is None:
561 + raw_arguments = _get_value(tool_call, "arguments") or "{}"
562 + call_id = str(_get_value(tool_call, "id") or f"call_{fallback_index}")
563 + return {
564 + "type": "function_call",
565 + "id": call_id,
566 + "call_id": call_id,
567 + "name": str(name),
568 + "arguments": raw_arguments
569 + if isinstance(raw_arguments, str)
570 + else json.dumps(raw_arguments),
571 + }
572 +
573 + @classmethod
574 + def tool_call_object(cls, tool_call: Any) -> dict[str, Any]:
575 + item = cls.function_call_item(tool_call)
576 + if not item:
577 + return {}
578 + return ResponsesTransport.function_call_object(item)
579 +
580 +
581 +class ChatCompletionsStreamParser:
582 + def __init__(self) -> None:
583 + self.tool_calls: dict[str, dict[str, Any]] = {}
584 + self.order: list[str] = []
585 + self.emitted = False
586 +
587 + def parse(self, chunk: Any) -> ChatChunk:
588 + parsed = ChatCompletionsTransport.parse(chunk)
589 + choice = _first_choice(chunk)
590 + delta = _get_value(choice, "delta") or {}
591 + self._append_tool_calls(_get_value(delta, "tool_calls"))
592 + self._append_legacy_function_call(_get_value(delta, "function_call"))
593 +
594 + if _get_value(choice, "finish_reason") in {"tool_calls", "function_call"}:
595 + text = self._emit()
596 + if text and not parsed["response_delta"]:
597 + parsed["response_delta"] = text
598 + return parsed
599 +
600 + def flush(self) -> ChatChunk:
601 + return {"reasoning_delta": "", "response_delta": self._emit()}
602 +
603 + def function_calls_text(self) -> str:
604 + return ChatCompletionsTransport.tool_calls_text(self._ordered_tool_calls())
605 +
606 + def output_items(self) -> list[dict[str, Any]]:
607 + return ChatCompletionsTransport.output_items(self._ordered_tool_calls())
608 +
609 + def _append_tool_calls(self, tool_calls: Any) -> None:
610 + for fallback_index, tool_call in enumerate(_as_list(tool_calls)):
611 + key = self._tool_call_key(tool_call, fallback_index)
612 + current = self._current_tool_call(key)
613 + if _get_value(tool_call, "id"):
614 + current["id"] = _get_value(tool_call, "id")
615 + if _get_value(tool_call, "type"):
616 + current["type"] = _get_value(tool_call, "type")
617 + self._append_function_delta(current, _get_value(tool_call, "function"))
618 +
619 + def _append_legacy_function_call(self, function_call: Any) -> None:
620 + if not function_call:
621 + return
622 + current = self._current_tool_call("0")
623 + current["type"] = "function"
624 + self._append_function_delta(current, function_call)
625 +
626 + def _append_function_delta(self, tool_call: dict[str, Any], delta: Any) -> None:
627 + if not delta:
628 + return
629 + function = tool_call.setdefault("function", {})
630 + if _get_value(delta, "name"):
631 + function["name"] = _get_value(delta, "name")
632 + if _get_value(delta, "arguments") is not None:
633 + function["arguments"] = str(function.get("arguments") or "") + str(
634 + _get_value(delta, "arguments") or ""
635 + )
636 +
637 + def _current_tool_call(self, key: str) -> dict[str, Any]:
638 + if key not in self.tool_calls:
639 + self.tool_calls[key] = {"type": "function", "function": {}}
640 + self.order.append(key)
641 + return self.tool_calls[key]
642 +
643 + def _ordered_tool_calls(self) -> list[dict[str, Any]]:
644 + return [self.tool_calls[key] for key in self.order]
645 +
646 + def _emit(self) -> str:
647 + if self.emitted:
648 + return ""
649 + text = self.function_calls_text()
650 + if text:
651 + self.emitted = True
652 + return text
653 +
654 + @staticmethod
655 + def _tool_call_key(tool_call: Any, fallback_index: int) -> str:
656 + index = _get_value(tool_call, "index")
657 + if index is not None:
658 + return str(index)
659 + if _get_value(tool_call, "id"):
660 + return str(_get_value(tool_call, "id"))
661 + return str(fallback_index)
662
663
664 class ResponsesTransport:
@@ -1548,6 +1717,8 @@ def _is_responses_not_supported_error(exc: Exception) -> bool:
1717 return False
1718 if _is_bad_request_error(exc) and _looks_like_responses_request_rejected(text):
1719 return True
1720 + if _is_server_error(exc) and _looks_like_responses_endpoint(text):
1721 + return True
1722 if _is_not_found_error(exc) and _looks_like_responses_endpoint_not_found(text):
1723 return True
1724 if "/v1/responses" in text and any(
@@ -1565,6 +1736,9 @@ def _is_responses_not_supported_error(exc: Exception) -> bool:
1736 "no 'tools' defined while 'tool_choice' is specified",
1737 "tools` must not be an empty array",
1738 "tools must not be an empty array",
1739 + "not available through this proxy",
1740 + "litellm[proxy]",
1741 + "no module named 'fastapi'",
1742 )
1743 )
1744
@@ -1585,6 +1759,24 @@ def _is_bad_request_error(exc: Exception) -> bool:
1759 return "400" in text and "bad request" in text
1760
1761
1762 +def _is_server_error(exc: Exception) -> bool:
1763 + status_code = _exception_status_code(exc)
1764 + if isinstance(status_code, int) and 500 <= status_code < 600:
1765 + return True
1766 + type_chain = _exception_type_chain(exc).lower()
1767 + if "internalservererror" in type_chain:
1768 + return True
1769 + text = _exception_text(exc).lower()
1770 + return any(
1771 + marker in text
1772 + for marker in (
1773 + "500 internal server error",
1774 + "server error '500",
1775 + "internalservererror",
1776 + )
1777 + )
1778 +
1779 +
1780 def _looks_like_responses_request_rejected(text: str) -> bool:
1781 if "/v1/responses" in text or "responses api" in text:
1782 return True
@@ -1613,6 +1805,10 @@ def _looks_like_responses_endpoint_not_found(text: str) -> bool:
1805 return "detail" in text and "not found" in text
1806
1807
1808 +def _looks_like_responses_endpoint(text: str) -> bool:
1809 + return "/responses" in text or "path /api/v1/responses" in text
1810 +
1811 +
1812 def _is_responses_state_unsupported_error(exc: Exception) -> bool:
1813 text = _exception_text(exc).lower()
1814 if any(marker in text for marker in ("429", "too many requests", "rate limit")):
@@ -1742,7 +1938,10 @@ def _first_choice(chunk: Any) -> Any:
1938 def _get_value(obj: Any, key: str) -> Any:
1939 if isinstance(obj, dict):
1940 return obj.get(key)
1745 - return getattr(obj, key, None)
1941 + value = getattr(obj, key, None)
1942 + if value is not None:
1943 + return value
1944 + return _object_to_dict(obj).get(key)
1945
1946
1947 def _as_list(value: Any) -> list[Any]:
helpers/litellm_transport.py.dox.md
+2
@@ -28,6 +28,8 @@
28 - Normalize function tool parameter schemas with an explicit object `properties` field before Responses requests so OpenAI-compatible chat backends reached through LiteLLM can validate them.
29 - Prefer Responses API when configured, but fallback to Chat Completions when the provider does not support Responses.
30 - Fall back to Chat Completions when a Responses request is rejected before any output by an endpoint-specific or shape-specific Bad Request indicating the provider cannot parse Responses payloads.
31 +- Fall back to Chat Completions when a Responses endpoint fails before output with an endpoint-specific server error, proxy path-unavailable error, or LiteLLM proxy-extra import error.
32 +- Preserve Chat Completions tool calls from both non-streaming responses and streaming deltas as canonical `LLMResult` function-call items.
33 - Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.
34 - Keep prompt-cache markers only for providers that accept them.
35
helpers/llm_result.py
+10 -6
@@ -125,12 +125,13 @@ class LLMResult:
125 response: str,
126 reasoning: str = "",
127 input_items: list[dict[str, Any]] | None = None,
128 + output_items: list[dict[str, Any]] | None = None,
129 provider_model_key: str = "",
130 capability: dict[str, Any] | None = None,
131 ) -> "LLMResult":
131 - output_items = []
132 - if response:
133 - output_items.append(
132 + items = [ResponseItem.from_any(item) for item in output_items or []]
133 + if response and not items:
134 + items.append(
135 ResponseItem(
136 type="message",
137 data={
@@ -141,7 +142,7 @@ class LLMResult:
142 )
143 )
144 if reasoning:
144 - output_items.insert(
145 + items.insert(
146 0,
147 ResponseItem(
148 type="reasoning",
@@ -151,16 +152,19 @@ class LLMResult:
152 },
153 ),
154 )
154 - return cls(
155 + result = cls(
156 response=response,
157 reasoning=reasoning,
158 input_items=list(input_items or []),
158 - output_items=output_items,
159 + output_items=items,
160 provider_model_key=provider_model_key,
161 mode="chat_completions",
162 state="off",
163 capability=dict(capability or {}),
164 )
165 + if not result.response and result.function_calls:
166 + result.response = result.function_calls_text()
167 + return result
168
169 @property
170 def function_calls(self) -> list[ResponseFunctionCall]:
helpers/llm_result.py.dox.md
+1 -1
@@ -19,7 +19,7 @@
19
20 - `LLMResult.metadata()` stores data under `RESPONSE_METADATA_KEY` so history can round-trip provider state.
21 - `from_response(...)` must preserve provider `response_id`, `previous_response_id`, raw output items, usage, and capability metadata.
22 -- `from_chat(...)` must produce an equivalent chat-completions result with `mode="chat_completions"` and `state="off"`.
22 +- `from_chat(...)` must produce an equivalent chat-completions result with `mode="chat_completions"` and `state="off"`, preserving optional function-call output items when the chat transport supplies them.
23 - Function-call output items must preserve `call_id` and optional acknowledged safety checks.
24 - Argument parsing must tolerate JSON strings, dictionaries, and malformed values without throwing.
25
tests/test_stream_tool_early_stop.py
+250 -1
@@ -62,6 +62,14 @@ class _FailingAsyncChunkStream:
62 self.closed = True
63
64
65 +class _DumpOnly:
66 + def __init__(self, **data):
67 + self._data = data
68 +
69 + def model_dump(self):
70 + return dict(self._data)
71 +
72 +
73 def test_extract_json_root_string_returns_canonical_snapshot():
74 text = (
75 'prefix {"tool_name":"response","tool_args":{"text":"brace } inside"}} '
@@ -553,6 +561,62 @@ async def test_unified_call_falls_back_when_litellm_hides_responses_404_url(
561 assert calls == ["responses", "chat"]
562
563
564 +@pytest.mark.parametrize(
565 + "responses_error",
566 + [
567 + "litellm.exceptions.APIError: Path /api/v1/responses is not "
568 + "available through this proxy.",
569 + "MaskedHTTPStatusError: Server error '500 Internal Server Error' "
570 + "for url 'https://api.venice.ai/api/v1/responses'",
571 + "InternalServerError: OpenAIException - '<=' not supported between "
572 + "instances of 'str' and 'int' for url 'http://192.168.200.52:4000/responses'",
573 + "ImportError Missing dependency No module named 'fastapi'. "
574 + "Run `pip install 'litellm[proxy]'`",
575 + ],
576 +)
577 +@pytest.mark.asyncio
578 +async def test_unified_call_falls_back_for_proxy_responses_failures(
579 + monkeypatch,
580 + responses_error,
581 +):
582 + calls: list[str] = []
583 +
584 + async def fake_aresponses(*args, **kwargs):
585 + calls.append("responses")
586 + raise RuntimeError(responses_error)
587 +
588 + async def fake_acompletion(*args, **kwargs):
589 + calls.append("chat")
590 + assert kwargs["stream"] is True
591 + assert kwargs["drop_params"] is True
592 + return _AsyncChunkStream([_chunk("fallback")])
593 +
594 + async def fake_rate_limiter(*args, **kwargs):
595 + return None
596 +
597 + monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
598 + monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
599 + monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
600 +
601 + wrapper = models.LiteLLMChatWrapper(
602 + model="test-model",
603 + provider="openai",
604 + model_config=None,
605 + )
606 +
607 + async def response_callback(chunk: str, full: str):
608 + return None
609 +
610 + response, reasoning = await wrapper.unified_call(
611 + messages=[],
612 + response_callback=response_callback,
613 + )
614 +
615 + assert response == "fallback"
616 + assert reasoning == ""
617 + assert calls == ["responses", "chat"]
618 +
619 +
620 @pytest.mark.asyncio
621 async def test_unified_call_falls_back_when_responses_bad_request_rejects_shape(
622 monkeypatch,
@@ -1169,7 +1233,7 @@ def test_cache_control_policy_keeps_native_responses_first():
1233 def test_responses_fallback_does_not_mask_rate_limits():
1234 exc = RuntimeError(
1235 "RateLimitError: 429 Too Many Requests for url "
1172 - "https://api.openai.com/v1/responses"
1236 + "https://provider.example/v1/responses"
1237 )
1238
1239 policy = litellm_transport.TransportPolicy(
@@ -1215,6 +1279,191 @@ def test_responses_response_parser_extracts_text_reasoning_and_function_calls():
1279 }
1280
1281
1282 +def test_chat_completions_response_parser_extracts_tool_calls():
1283 + parsed = litellm_transport.ChatCompletionsTransport.parse(
1284 + {
1285 + "choices": [
1286 + {
1287 + "message": {
1288 + "tool_calls": [
1289 + {
1290 + "id": "call_1",
1291 + "type": "function",
1292 + "function": {
1293 + "name": "lookup",
1294 + "arguments": '{"q":"a0"}',
1295 + },
1296 + }
1297 + ]
1298 + }
1299 + }
1300 + ]
1301 + }
1302 + )
1303 +
1304 + assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
1305 + "tool_name": "lookup",
1306 + "tool_args": {"q": "a0"},
1307 + }
1308 + assert parsed["_output_items"][0]["name"] == "lookup"
1309 +
1310 +
1311 +def test_chat_completions_stream_parser_accumulates_tool_call_arguments():
1312 + parser = litellm_transport.ChatCompletionsStreamParser()
1313 +
1314 + assert parser.parse(
1315 + {
1316 + "choices": [
1317 + {
1318 + "delta": {
1319 + "tool_calls": [
1320 + {
1321 + "index": 0,
1322 + "id": "call_1",
1323 + "type": "function",
1324 + "function": {
1325 + "name": "lookup",
1326 + "arguments": '{"q":',
1327 + },
1328 + }
1329 + ]
1330 + }
1331 + }
1332 + ]
1333 + }
1334 + ) == {"reasoning_delta": "", "response_delta": ""}
1335 + parsed = parser.parse(
1336 + {
1337 + "choices": [
1338 + {
1339 + "delta": {
1340 + "tool_calls": [
1341 + {
1342 + "index": 0,
1343 + "function": {"arguments": '"a0"}'},
1344 + }
1345 + ]
1346 + },
1347 + "finish_reason": "tool_calls",
1348 + }
1349 + ]
1350 + }
1351 + )
1352 +
1353 + assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
1354 + "tool_name": "lookup",
1355 + "tool_args": {"q": "a0"},
1356 + }
1357 + assert parser.output_items()[0]["name"] == "lookup"
1358 + assert parser.flush() == {"reasoning_delta": "", "response_delta": ""}
1359 +
1360 +
1361 +def test_chat_completions_stream_parser_reads_dumped_tool_calls():
1362 + parser = litellm_transport.ChatCompletionsStreamParser()
1363 +
1364 + assert parser.parse(
1365 + _DumpOnly(
1366 + choices=[
1367 + _DumpOnly(
1368 + delta=_DumpOnly(
1369 + tool_calls=[
1370 + {
1371 + "index": 0,
1372 + "id": "call_1",
1373 + "type": "function",
1374 + "function": _DumpOnly(
1375 + name="lookup",
1376 + arguments='{"q":"a0"}',
1377 + ),
1378 + }
1379 + ]
1380 + )
1381 + )
1382 + ]
1383 + )
1384 + ) == {"reasoning_delta": "", "response_delta": ""}
1385 +
1386 + parsed = parser.parse(
1387 + _DumpOnly(choices=[_DumpOnly(delta=_DumpOnly(), finish_reason="tool_calls")])
1388 + )
1389 +
1390 + assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
1391 + "tool_name": "lookup",
1392 + "tool_args": {"q": "a0"},
1393 + }
1394 +
1395 +
1396 +@pytest.mark.asyncio
1397 +async def test_unified_turn_preserves_chat_streaming_tool_calls(monkeypatch):
1398 + async def fake_acompletion(*args, **kwargs):
1399 + return _AsyncChunkStream(
1400 + [
1401 + {
1402 + "choices": [
1403 + {
1404 + "delta": {
1405 + "tool_calls": [
1406 + {
1407 + "index": 0,
1408 + "id": "call_1",
1409 + "type": "function",
1410 + "function": {
1411 + "name": "lookup",
1412 + "arguments": '{"q":',
1413 + },
1414 + }
1415 + ]
1416 + }
1417 + }
1418 + ]
1419 + },
1420 + {
1421 + "choices": [
1422 + {
1423 + "delta": {
1424 + "tool_calls": [
1425 + {
1426 + "index": 0,
1427 + "function": {"arguments": '"a0"}'},
1428 + }
1429 + ]
1430 + },
1431 + "finish_reason": "tool_calls",
1432 + }
1433 + ]
1434 + },
1435 + ]
1436 + )
1437 +
1438 + async def fake_rate_limiter(*args, **kwargs):
1439 + return None
1440 +
1441 + monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
1442 + monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
1443 +
1444 + wrapper = models.LiteLLMChatWrapper(
1445 + model="test-model",
1446 + provider="openai",
1447 + model_config=None,
1448 + )
1449 +
1450 + async def response_callback(chunk: str, full: str):
1451 + return None
1452 +
1453 + result = await wrapper.unified_turn(
1454 + messages=[],
1455 + response_callback=response_callback,
1456 + a0_api_mode="chat",
1457 + )
1458 +
1459 + assert extract_tools.json_parse_dirty(result.response) == {
1460 + "tool_name": "lookup",
1461 + "tool_args": {"q": "a0"},
1462 + }
1463 + assert result.function_calls[0].name == "lookup"
1464 + assert result.function_calls[0].arguments == {"q": "a0"}
1465 +
1466 +
1467 def test_responses_stream_parser_accumulates_function_call_arguments():
1468 parser = litellm_transport.ResponsesEventParser()
1469