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