main
py 1,869 lines 55.7 KB
Raw
1 import json
2 import sys
3 from pathlib import Path
4
5 import pytest
6 from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
7
8
9 PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 if str(PROJECT_ROOT) not in sys.path:
11 sys.path.insert(0, str(PROJECT_ROOT))
12
13 import models
14 from helpers import extract_tools
15 from helpers import litellm_transport
16 from helpers.dirty_json import DirtyJson
17
18
19 @pytest.fixture(autouse=True)
20 def _clear_transport_capability_cache():
21 litellm_transport.clear_transport_capability_cache()
22
23
24 def _chunk(content: str) -> dict:
25 return {"choices": [{"delta": {"content": content}, "message": {}}]}
26
27
28 def _response_event(delta: str) -> dict:
29 return {"type": "response.output_text.delta", "delta": delta}
30
31
32 class _AsyncChunkStream:
33 def __init__(self, chunks: list[dict]):
34 self._chunks = chunks
35 self.index = 0
36 self.closed = False
37
38 def __aiter__(self):
39 return self
40
41 async def __anext__(self):
42 if self.index >= len(self._chunks):
43 raise StopAsyncIteration
44 chunk = self._chunks[self.index]
45 self.index += 1
46 return chunk
47
48 async def aclose(self):
49 self.closed = True
50
51
52 class _FailingAsyncChunkStream:
53 def __init__(self, exc: Exception):
54 self.exc = exc
55 self.closed = False
56
57 def __aiter__(self):
58 return self
59
60 async def __anext__(self):
61 raise self.exc
62
63 async def aclose(self):
64 self.closed = True
65
66
67 class _DumpOnly:
68 def __init__(self, **data):
69 self._data = data
70
71 def model_dump(self):
72 return dict(self._data)
73
74
75 def test_extract_json_root_string_returns_canonical_snapshot():
76 text = (
77 'prefix {"tool_name":"response","tool_args":{"text":"brace } inside"}} '
78 "trailing noise"
79 )
80
81 root = extract_tools.extract_json_root_string(text)
82
83 assert root == '{"tool_name":"response","tool_args":{"text":"brace } inside"}}'
84 assert extract_tools.json_parse_dirty(root)["tool_args"]["text"] == "brace } inside"
85 assert extract_tools.extract_json_root_string(
86 '{"tool_name":"response","tool_args":{"text":"missing"'
87 ) is None
88 assert extract_tools.extract_json_root_string('[{"tool_name":"response"}]') is None
89
90
91 @pytest.mark.parametrize(
92 "content",
93 [
94 '{"tool_name":"response","tool_args":{"text":"partial"',
95 'prefix {"tool_name":"response","tool_args":{}}',
96 '[{"tool_name":"response","tool_args":{}}]',
97 '```json\n{"tool_name":"response","tool_args":{}}\n```',
98 ],
99 )
100 def test_extract_tool_request_skips_noncanonical_boundaries(monkeypatch, content):
101 monkeypatch.setattr(
102 extract_tools,
103 "extract_json_root_string",
104 lambda _content: pytest.fail("noncanonical content reached the root scanner"),
105 )
106
107 assert extract_tools.extract_tool_request(content) is None
108
109
110 def test_json_parse_dirty_prefers_valid_tool_request_after_preamble_object():
111 text = (
112 'I will call the tool after this note {"note":"not the tool"}.\n'
113 '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text'
114 )
115
116 assert extract_tools.json_parse_dirty(text) == {
117 "tool_name": "response",
118 "tool_args": {"text": "ok"},
119 }
120
121
122 def test_extract_json_root_string_prefers_valid_tool_request():
123 text = (
124 'I will call the tool after this note {"note":"not the tool"}.\n'
125 '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text'
126 )
127
128 assert extract_tools.extract_json_root_string(text) == (
129 '{"tool_name":"response","tool_args":{"text":"ok"}}'
130 )
131 assert extract_tools.extract_json_root_string(
132 'Only a note {"note":"not the tool"}'
133 ) == '{"note":"not the tool"}'
134
135
136 def test_extract_json_root_string_waits_for_complete_parallel_parent():
137 partial = (
138 '{"tool_name":"parallel","tool_args":{"tool_calls":['
139 '{"tool_name":"code_execution_tool","tool_args":{"code":"first"}}'
140 )
141
142 assert extract_tools.extract_json_root_string(partial) is None
143
144 full = (
145 partial
146 + ',{"tool_name":"code_execution_tool","tool_args":{"code":"second"}}'
147 '],"wait":true}} trailing text'
148 )
149
150 root = extract_tools.extract_json_root_string(full)
151 assert root == (
152 '{"tool_name":"parallel","tool_args":{"tool_calls":['
153 '{"tool_name":"code_execution_tool","tool_args":{"code":"first"}},'
154 '{"tool_name":"code_execution_tool","tool_args":{"code":"second"}}'
155 '],"wait":true}}'
156 )
157 parsed = extract_tools.json_parse_dirty(root)
158 assert parsed["tool_name"] == "parallel"
159 assert len(parsed["tool_args"]["tool_calls"]) == 2
160
161
162 def test_litellm_global_kwargs_merge_defaults_and_config(monkeypatch):
163 monkeypatch.setattr(
164 models.settings,
165 "get_settings",
166 lambda: {"litellm_global_kwargs": {}},
167 )
168
169 assert models._merge_litellm_call_kwargs({})["drop_params"] is True
170 assert models._merge_litellm_call_kwargs({"temperature": 0}) == {
171 "drop_params": True,
172 "temperature": 0,
173 }
174
175 monkeypatch.setattr(
176 models.settings,
177 "get_settings",
178 lambda: {
179 "litellm_global_kwargs": {
180 "drop_params": "false",
181 "timeout": "30",
182 "additional_drop_params": ["response_format"],
183 }
184 },
185 )
186
187 assert models._merge_litellm_call_kwargs({}) == {
188 "drop_params": False,
189 "timeout": 30,
190 "additional_drop_params": ["response_format"],
191 }
192
193 original_drop_params = getattr(models.litellm, "drop_params", None)
194 had_timeout = hasattr(models.litellm, "timeout")
195 original_timeout = getattr(models.litellm, "timeout", None)
196 had_additional_drop_params = hasattr(models.litellm, "additional_drop_params")
197 original_additional_drop_params = getattr(
198 models.litellm, "additional_drop_params", None
199 )
200 try:
201 assert models.set_litellm_params() == {
202 "drop_params": False,
203 "timeout": 30,
204 "additional_drop_params": ["response_format"],
205 }
206 assert models.litellm.drop_params is False
207 if had_timeout:
208 assert models.litellm.timeout == original_timeout
209 else:
210 assert not hasattr(models.litellm, "timeout")
211 if had_additional_drop_params:
212 assert (
213 models.litellm.additional_drop_params
214 == original_additional_drop_params
215 )
216 else:
217 assert not hasattr(models.litellm, "additional_drop_params")
218 finally:
219 setattr(models.litellm, "drop_params", original_drop_params)
220 if had_timeout:
221 setattr(models.litellm, "timeout", original_timeout)
222 elif hasattr(models.litellm, "timeout"):
223 delattr(models.litellm, "timeout")
224 if had_additional_drop_params:
225 setattr(
226 models.litellm,
227 "additional_drop_params",
228 original_additional_drop_params,
229 )
230 elif hasattr(models.litellm, "additional_drop_params"):
231 delattr(models.litellm, "additional_drop_params")
232
233
234 def test_provider_defaults_do_not_freeze_litellm_global_kwargs(monkeypatch):
235 monkeypatch.setattr(models, "get_provider_config", lambda *args, **kwargs: None)
236 monkeypatch.setattr(models, "get_api_key", lambda *_args, **_kwargs: None)
237 monkeypatch.setattr(
238 models.settings,
239 "get_settings",
240 lambda: {"litellm_global_kwargs": {"drop_params": "true"}},
241 )
242
243 _, provider_kwargs = models._merge_provider_defaults("chat", "openai", {})
244
245 assert "drop_params" not in provider_kwargs
246 assert models._merge_litellm_call_kwargs(provider_kwargs)["drop_params"] is True
247
248 monkeypatch.setattr(
249 models.settings,
250 "get_settings",
251 lambda: {"litellm_global_kwargs": {"drop_params": "false"}},
252 )
253
254 assert models._merge_litellm_call_kwargs(provider_kwargs)["drop_params"] is False
255
256
257 @pytest.mark.asyncio
258 async def test_unified_call_stops_chat_after_canonical_root_snapshot(monkeypatch):
259 stream = _AsyncChunkStream(
260 [
261 _chunk('{"tool_name":"response","tool_args":{"text":"hello"}}'),
262 _chunk(" unreachable"),
263 ]
264 )
265
266 async def fake_acompletion(*args, **kwargs):
267 assert kwargs["stream"] is True
268 return stream
269
270 async def fake_rate_limiter(*args, **kwargs):
271 return None
272
273 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
274 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
275 monkeypatch.setattr(
276 models.settings,
277 "get_settings",
278 lambda: {"litellm_global_kwargs": {}},
279 )
280
281 wrapper = models.LiteLLMChatWrapper(
282 model="test-model",
283 provider="openai",
284 model_config=None,
285 a0_api_mode="chat",
286 )
287
288 seen: list[tuple[str, str]] = []
289
290 async def response_callback(chunk: str, full: str):
291 seen.append((chunk, full))
292 return full.strip() if extract_tools.extract_tool_request(full) else None
293
294 response, reasoning = await wrapper.unified_call(
295 messages=[],
296 response_callback=response_callback,
297 )
298
299 assert response == '{"tool_name":"response","tool_args":{"text":"hello"}}'
300 assert reasoning == ""
301 assert stream.index == 1
302 assert stream.closed is True
303 assert len(seen) == 1
304 assert seen[0][1] == '{"tool_name":"response","tool_args":{"text":"hello"}}'
305
306
307 @pytest.mark.asyncio
308 async def test_unified_call_does_not_stop_for_embedded_tool_json(monkeypatch):
309 stream = _AsyncChunkStream(
310 [
311 _chunk('Preamble {"note":"not the tool"}.\n'),
312 _chunk(
313 '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text'
314 ),
315 _chunk(" unreachable"),
316 ]
317 )
318
319 async def fake_acompletion(*args, **kwargs):
320 assert kwargs["stream"] is True
321 return stream
322
323 async def fake_rate_limiter(*args, **kwargs):
324 return None
325
326 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
327 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
328 monkeypatch.setattr(
329 models.settings,
330 "get_settings",
331 lambda: {"litellm_global_kwargs": {}},
332 )
333
334 wrapper = models.LiteLLMChatWrapper(
335 model="test-model",
336 provider="openai",
337 model_config=None,
338 a0_api_mode="chat",
339 )
340
341 seen: list[tuple[str, str]] = []
342
343 async def response_callback(chunk: str, full: str):
344 seen.append((chunk, full))
345 return full.strip() if extract_tools.extract_tool_request(full) else None
346
347 response, reasoning = await wrapper.unified_call(
348 messages=[],
349 response_callback=response_callback,
350 )
351
352 assert response == (
353 'Preamble {"note":"not the tool"}.\n'
354 '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text unreachable'
355 )
356 assert reasoning == ""
357 assert stream.index == 3
358 assert stream.closed is False
359 assert len(seen) == 3
360 assert seen[0][1] == 'Preamble {"note":"not the tool"}.\n'
361 assert (
362 seen[1][1]
363 == 'Preamble {"note":"not the tool"}.\n'
364 '{"tool_name":"response","tool_args":{"text":"ok"}} trailing text'
365 )
366
367
368 @pytest.mark.asyncio
369 async def test_unified_call_closes_responses_stream_when_callback_raises(monkeypatch):
370 stream = _AsyncChunkStream([_response_event("interrupt me")])
371
372 class ExpectedIntervention(Exception):
373 pass
374
375 async def fake_aresponses(*args, **kwargs):
376 assert kwargs["stream"] is True
377 return stream
378
379 async def fake_rate_limiter(*args, **kwargs):
380 return None
381
382 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
383 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
384
385 wrapper = models.LiteLLMChatWrapper(
386 model="test-model",
387 provider="openai",
388 model_config=None,
389 a0_api_mode="responses",
390 )
391
392 async def response_callback(chunk: str, full: str):
393 raise ExpectedIntervention()
394
395 with pytest.raises(ExpectedIntervention):
396 await wrapper.unified_call(
397 messages=[],
398 response_callback=response_callback,
399 )
400
401 assert stream.closed is True
402
403
404 @pytest.mark.asyncio
405 async def test_chat_completions_default_uses_acompletion(monkeypatch):
406 stream = _AsyncChunkStream([_chunk("hello")])
407 calls: list[str] = []
408
409 async def fake_acompletion(*args, **kwargs):
410 calls.append("chat")
411 assert kwargs["stream"] is True
412 assert "a0_api_mode" not in kwargs
413 return stream
414
415 async def fake_aresponses(*args, **kwargs):
416 raise AssertionError("Responses path should not be used")
417
418 async def fake_rate_limiter(*args, **kwargs):
419 return None
420
421 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
422 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
423 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
424
425 wrapper = models.LiteLLMChatWrapper(
426 model="test-model",
427 provider="openai",
428 model_config=None,
429 )
430
431 async def response_callback(chunk: str, full: str):
432 return None
433
434 response, reasoning = await wrapper.unified_call(
435 messages=[],
436 response_callback=response_callback,
437 )
438
439 assert response == "hello"
440 assert reasoning == ""
441 assert calls == ["chat"]
442
443
444 @pytest.mark.asyncio
445 async def test_unified_turn_stops_chat_stream_after_text_tool_request(monkeypatch):
446 message = (
447 '{"thoughts":["test"],"actions":['
448 '{"tool_name":"response","tool_args":{"text":"ok"}}]}'
449 )
450 stream = _AsyncChunkStream([_chunk(message), _chunk(" unreachable")])
451
452 async def fake_acompletion(*args, **kwargs):
453 assert kwargs["stream"] is True
454 return stream
455
456 async def fake_rate_limiter(*args, **kwargs):
457 return None
458
459 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
460 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
461
462 wrapper = models.LiteLLMChatWrapper(
463 model="test-model",
464 provider="openai",
465 model_config=None,
466 a0_api_mode="chat",
467 )
468
469 async def response_callback(chunk: str, full: str):
470 return full if extract_tools.extract_tool_request(full) else None
471
472 result = await wrapper.unified_turn.__wrapped__(
473 wrapper,
474 messages=[],
475 response_callback=response_callback,
476 )
477
478 assert result.response == message
479 assert stream.index == 1
480 assert stream.closed is True
481
482
483 @pytest.mark.asyncio
484 async def test_unified_call_retries_responses_with_high_reasoning(monkeypatch):
485 validation_error = ValueError(
486 "1 validation error for ResponseCreatedEvent\n"
487 "response.reasoning.effort\n"
488 "Input should be 'minimal', 'low', 'medium' or 'high' "
489 "[type=literal_error, input_value='none', input_type=str]"
490 )
491 failing_stream = _FailingAsyncChunkStream(validation_error)
492 working_stream = _AsyncChunkStream([_response_event("ok")])
493 calls: list[dict] = []
494
495 async def fake_aresponses(*args, **kwargs):
496 calls.append(kwargs)
497 if len(calls) == 1:
498 return failing_stream
499 return working_stream
500
501 async def fake_rate_limiter(*args, **kwargs):
502 return None
503
504 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
505 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
506
507 wrapper = models.LiteLLMChatWrapper(
508 model="gpt-5.4",
509 provider="openai",
510 model_config=None,
511 a0_api_mode="responses",
512 )
513
514 async def response_callback(chunk: str, full: str):
515 return None
516
517 response, reasoning = await wrapper.unified_call(
518 messages=[],
519 response_callback=response_callback,
520 )
521
522 assert response == "ok"
523 assert reasoning == ""
524 assert failing_stream.closed is True
525 assert len(calls) == 2
526 assert "reasoning" not in calls[0]
527 assert calls[1]["reasoning"] == {"effort": "high"}
528
529
530 @pytest.mark.asyncio
531 async def test_unified_call_falls_back_to_chat_when_responses_endpoint_missing(
532 monkeypatch,
533 ):
534 calls: list[str] = []
535
536 async def fake_aresponses(*args, **kwargs):
537 calls.append("responses")
538 raise RuntimeError(
539 "Client error '404 Not Found' for url "
540 "'https://llm.agent-zero.ai/v1/responses'"
541 )
542
543 async def fake_acompletion(*args, **kwargs):
544 calls.append("chat")
545 assert kwargs["stream"] is True
546 assert kwargs["drop_params"] is True
547 assert "tool_choice" not in kwargs
548 assert "parallel_tool_calls" not in kwargs
549 return _AsyncChunkStream([_chunk("fallback")])
550
551 async def fake_rate_limiter(*args, **kwargs):
552 return None
553
554 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
555 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
556 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
557
558 wrapper = models.LiteLLMChatWrapper(
559 model="claude-opus-4.7",
560 provider="openai",
561 model_config=None,
562 a0_api_mode="responses",
563 tool_choice="auto",
564 parallel_tool_calls=True,
565 )
566
567 async def response_callback(chunk: str, full: str):
568 return None
569
570 response, reasoning = await wrapper.unified_call(
571 messages=[],
572 response_callback=response_callback,
573 )
574
575 assert response == "fallback"
576 assert reasoning == ""
577 assert calls == ["responses", "chat"]
578
579 response, reasoning = await wrapper.unified_call(
580 messages=[],
581 response_callback=response_callback,
582 )
583
584 assert response == "fallback"
585 assert reasoning == ""
586 assert calls == ["responses", "chat", "chat"]
587
588
589 @pytest.mark.asyncio
590 async def test_unified_call_falls_back_when_litellm_hides_responses_404_url(
591 monkeypatch,
592 ):
593 class NotFoundError(Exception):
594 status_code = 404
595
596 calls: list[str] = []
597
598 async def fake_aresponses(*args, **kwargs):
599 calls.append("responses")
600 raise NotFoundError(
601 'litellm.NotFoundError: NotFoundError: OpenAIException - {"detail":"Not Found"}'
602 )
603
604 async def fake_acompletion(*args, **kwargs):
605 calls.append("chat")
606 assert kwargs["stream"] is True
607 assert kwargs["drop_params"] is True
608 return _AsyncChunkStream([_chunk("fallback")])
609
610 async def fake_rate_limiter(*args, **kwargs):
611 return None
612
613 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
614 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
615 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
616
617 wrapper = models.LiteLLMChatWrapper(
618 model="claude-opus-4.7",
619 provider="openai",
620 model_config=None,
621 a0_api_mode="responses",
622 )
623
624 async def response_callback(chunk: str, full: str):
625 return None
626
627 response, reasoning = await wrapper.unified_call(
628 messages=[],
629 response_callback=response_callback,
630 )
631
632 assert response == "fallback"
633 assert reasoning == ""
634 assert calls == ["responses", "chat"]
635
636
637 @pytest.mark.parametrize(
638 "responses_error",
639 [
640 "litellm.exceptions.APIError: Path /api/v1/responses is not "
641 "available through this proxy.",
642 "MaskedHTTPStatusError: Server error '500 Internal Server Error' "
643 "for url 'https://api.venice.ai/api/v1/responses'",
644 "InternalServerError: OpenAIException - '<=' not supported between "
645 "instances of 'str' and 'int' for url 'http://192.168.200.52:4000/responses'",
646 "ImportError Missing dependency No module named 'fastapi'. "
647 "Run `pip install 'litellm[proxy]'`",
648 ],
649 )
650 @pytest.mark.asyncio
651 async def test_unified_call_falls_back_for_proxy_responses_failures(
652 monkeypatch,
653 responses_error,
654 ):
655 calls: list[str] = []
656
657 async def fake_aresponses(*args, **kwargs):
658 calls.append("responses")
659 raise RuntimeError(responses_error)
660
661 async def fake_acompletion(*args, **kwargs):
662 calls.append("chat")
663 assert kwargs["stream"] is True
664 assert kwargs["drop_params"] is True
665 return _AsyncChunkStream([_chunk("fallback")])
666
667 async def fake_rate_limiter(*args, **kwargs):
668 return None
669
670 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
671 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
672 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
673
674 wrapper = models.LiteLLMChatWrapper(
675 model="test-model",
676 provider="openai",
677 model_config=None,
678 a0_api_mode="responses",
679 )
680
681 async def response_callback(chunk: str, full: str):
682 return None
683
684 response, reasoning = await wrapper.unified_call(
685 messages=[],
686 response_callback=response_callback,
687 )
688
689 assert response == "fallback"
690 assert reasoning == ""
691 assert calls == ["responses", "chat"]
692
693
694 @pytest.mark.asyncio
695 async def test_unified_call_falls_back_when_responses_mock_reads_sse_as_json(
696 monkeypatch,
697 ):
698 calls: list[str] = []
699 sse_error = json.JSONDecodeError(
700 "Expecting value",
701 'event: response.output_text.delta\ndata: {"delta":"hello"}\n\n',
702 0,
703 )
704 failing_stream = _FailingAsyncChunkStream(sse_error)
705
706 async def fake_aresponses(*args, **kwargs):
707 calls.append("responses")
708 return failing_stream
709
710 async def fake_acompletion(*args, **kwargs):
711 calls.append("chat")
712 assert kwargs["stream"] is True
713 assert kwargs["drop_params"] is True
714 return _AsyncChunkStream([_chunk("fallback")])
715
716 async def fake_rate_limiter(*args, **kwargs):
717 return None
718
719 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
720 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
721 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
722
723 wrapper = models.LiteLLMChatWrapper(
724 model="omniroute/test-model",
725 provider="openai",
726 model_config=None,
727 a0_api_mode="responses",
728 )
729
730 async def response_callback(chunk: str, full: str):
731 return None
732
733 response, reasoning = await wrapper.unified_call(
734 messages=[],
735 response_callback=response_callback,
736 )
737
738 assert response == "fallback"
739 assert reasoning == ""
740 assert calls == ["responses", "chat"]
741 assert failing_stream.closed is True
742
743
744 @pytest.mark.asyncio
745 async def test_unified_call_falls_back_when_responses_bad_request_rejects_shape(
746 monkeypatch,
747 ):
748 class BadRequestError(Exception):
749 status_code = 400
750
751 calls: list[str] = []
752
753 async def fake_aresponses(*args, **kwargs):
754 calls.append("responses")
755 raise BadRequestError(
756 'BadRequestError: Zod validation error: input_image Expected object, '
757 'received string; Expected string, received array'
758 )
759
760 async def fake_acompletion(*args, **kwargs):
761 calls.append("chat")
762 assert kwargs["stream"] is True
763 assert kwargs["drop_params"] is True
764 return _AsyncChunkStream([_chunk("fallback")])
765
766 async def fake_rate_limiter(*args, **kwargs):
767 return None
768
769 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
770 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
771 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
772
773 wrapper = models.LiteLLMChatWrapper(
774 model="venice-model",
775 provider="openai",
776 model_config=None,
777 a0_api_mode="responses",
778 )
779
780 async def response_callback(chunk: str, full: str):
781 return None
782
783 response, reasoning = await wrapper.unified_call(
784 messages=[
785 HumanMessage(
786 content=[
787 {"type": "text", "text": "describe it"},
788 {
789 "type": "image_url",
790 "image_url": {"url": "https://example.test/a.png"},
791 },
792 ]
793 )
794 ],
795 response_callback=response_callback,
796 )
797
798 assert response == "fallback"
799 assert reasoning == ""
800 assert calls == ["responses", "chat"]
801
802
803 @pytest.mark.asyncio
804 async def test_unified_call_raises_generic_responses_bad_request(monkeypatch):
805 class BadRequestError(Exception):
806 status_code = 400
807
808 calls: list[str] = []
809
810 async def fake_aresponses(*args, **kwargs):
811 calls.append("responses")
812 raise BadRequestError(
813 "BadRequestError: validation error: invalid request: max_tokens is too high"
814 )
815
816 async def fake_acompletion(*args, **kwargs):
817 calls.append("chat")
818 raise AssertionError("generic 400 should not fallback to chat")
819
820 async def fake_rate_limiter(*args, **kwargs):
821 return None
822
823 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
824 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
825 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
826
827 wrapper = models.LiteLLMChatWrapper(
828 model="test-model",
829 provider="openai",
830 model_config=None,
831 a0_api_mode="responses",
832 )
833
834 async def response_callback(chunk: str, full: str):
835 return None
836
837 with pytest.raises(BadRequestError):
838 await wrapper.unified_call(
839 messages=[],
840 response_callback=response_callback,
841 )
842
843 assert calls == ["responses"]
844
845
846 @pytest.mark.asyncio
847 async def test_unified_call_preserves_cache_control_with_chat_for_non_native_responses(
848 monkeypatch,
849 ):
850 calls: list[str] = []
851
852 async def fake_aresponses(*args, **kwargs):
853 raise AssertionError("cache_control should keep Anthropic-family calls on chat")
854
855 async def fake_acompletion(*args, **kwargs):
856 calls.append("chat")
857 assert kwargs["stream"] is True
858 messages = kwargs["messages"]
859 assert "cache_control" not in messages[0]
860 assert messages[0]["content"][-1]["cache_control"] == {
861 "type": "ephemeral"
862 }
863 assert messages[1]["content"][-1]["cache_control"] == {
864 "type": "ephemeral"
865 }
866 assert "cache_control" not in messages[2]
867 assert messages[3]["content"][-1]["cache_control"] == {
868 "type": "ephemeral"
869 }
870 return _AsyncChunkStream([_chunk("cached")])
871
872 async def fake_rate_limiter(*args, **kwargs):
873 return None
874
875 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
876 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
877 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
878
879 wrapper = models.LiteLLMChatWrapper(
880 model="claude-sonnet-4-5",
881 provider="anthropic",
882 model_config=None,
883 a0_api_mode="responses",
884 )
885
886 async def response_callback(chunk: str, full: str):
887 return None
888
889 response, reasoning = await wrapper.unified_call(
890 messages=[
891 SystemMessage(content="static instructions"),
892 HumanMessage(content="question"),
893 AIMessage(content="previous answer"),
894 HumanMessage(content="follow up"),
895 ],
896 response_callback=response_callback,
897 explicit_caching=True,
898 )
899
900 assert response == "cached"
901 assert reasoning == ""
902 assert calls == ["chat"]
903
904
905 def test_responses_request_translates_messages_and_params():
906 messages = [
907 {"role": "system", "content": "You are precise."},
908 {
909 "role": "user",
910 "content": [
911 {"type": "text", "text": "Inspect this."},
912 {
913 "type": "image_url",
914 "image_url": {"url": "https://example.test/a.png"},
915 },
916 ],
917 },
918 {
919 "role": "assistant",
920 "content": "empty",
921 "tool_calls": [
922 {
923 "id": "call_1",
924 "type": "function",
925 "function": {"name": "lookup", "arguments": '{"q":"a0"}'},
926 }
927 ],
928 },
929 {"role": "tool", "tool_call_id": "call_1", "content": "done"},
930 ]
931 kwargs = {
932 "max_tokens": 42,
933 "reasoning_effort": "high",
934 "response_format": {
935 "type": "json_schema",
936 "json_schema": {
937 "name": "answer",
938 "schema": {"type": "object"},
939 "strict": True,
940 },
941 },
942 "tools": [
943 {
944 "type": "function",
945 "function": {
946 "name": "lookup",
947 "description": "Search",
948 "parameters": {"type": "object"},
949 "strict": True,
950 },
951 }
952 ],
953 }
954
955 request = litellm_transport.ResponsesTransport.from_chat(messages, kwargs)
956
957 assert "instructions" not in request
958 assert request["store"] is True
959 assert request["max_output_tokens"] == 42
960 assert request["reasoning"] == {"effort": "high"}
961 assert request["text"] == {
962 "format": {
963 "type": "json_schema",
964 "name": "answer",
965 "schema": {"type": "object"},
966 "strict": True,
967 }
968 }
969 assert request["tools"] == [
970 {
971 "type": "function",
972 "name": "lookup",
973 "description": "Search",
974 "parameters": {"type": "object", "properties": {}},
975 "strict": True,
976 }
977 ]
978 assert request["input"] == [
979 {"role": "system", "content": "You are precise."},
980 {
981 "role": "user",
982 "content": [
983 {"type": "input_text", "text": "Inspect this."},
984 {
985 "type": "input_image",
986 "image_url": "https://example.test/a.png",
987 },
988 ],
989 },
990 {
991 "type": "function_call",
992 "call_id": "call_1",
993 "id": "call_1",
994 "name": "lookup",
995 "arguments": '{"q":"a0"}',
996 "status": "completed",
997 },
998 {"type": "function_call_output", "call_id": "call_1", "output": "done"},
999 ]
1000
1001
1002 def test_responses_request_normalizes_reasoning_and_orphan_tool_choice():
1003 request = litellm_transport.ResponsesTransport.from_chat(
1004 [],
1005 {
1006 "reasoning_effort": "none",
1007 "tools": [],
1008 "tool_choice": "auto",
1009 "parallel_tool_calls": True,
1010 },
1011 )
1012
1013 assert "reasoning" not in request
1014 assert "tools" not in request
1015 assert "tool_choice" not in request
1016 assert "parallel_tool_calls" not in request
1017
1018 request = litellm_transport.ResponsesTransport.from_chat(
1019 [],
1020 {"reasoning": {"effort": "xhigh"}},
1021 )
1022
1023 assert request["reasoning"] == {"effort": "high"}
1024
1025 request = litellm_transport.ResponsesTransport.from_chat(
1026 [],
1027 {"reasoning_effort": "off"},
1028 )
1029
1030 assert "reasoning" not in request
1031
1032
1033 def test_responses_request_normalizes_function_tool_parameter_shapes():
1034 request = litellm_transport.ResponsesTransport.from_chat(
1035 [],
1036 {
1037 "functions": [
1038 {
1039 "name": "legacy_noop",
1040 "description": "Legacy function",
1041 "parameters": {},
1042 }
1043 ],
1044 },
1045 )
1046
1047 assert request["tools"] == [
1048 {
1049 "type": "function",
1050 "name": "legacy_noop",
1051 "description": "Legacy function",
1052 "parameters": {
1053 "type": "object",
1054 "properties": {},
1055 },
1056 }
1057 ]
1058
1059 request = litellm_transport.ResponsesTransport.from_chat(
1060 [],
1061 {
1062 "a0_responses_function_tools": [
1063 {
1064 "type": "function",
1065 "name": "native_noop",
1066 "description": "Native function",
1067 "parameters": {"type": "object"},
1068 }
1069 ],
1070 "responses_builtin_tools": [{"type": "web_search"}],
1071 },
1072 )
1073
1074 assert request["tools"] == [
1075 {
1076 "type": "function",
1077 "name": "native_noop",
1078 "description": "Native function",
1079 "parameters": {
1080 "type": "object",
1081 "properties": {},
1082 },
1083 },
1084 {"type": "web_search"},
1085 ]
1086 assert request["tool_choice"] == "required"
1087 assert request["parallel_tool_calls"] is False
1088
1089
1090 def test_responses_request_preserves_explicit_a0_tool_controls():
1091 request = litellm_transport.ResponsesTransport.from_chat(
1092 [],
1093 {
1094 "a0_responses_function_tools": [
1095 {
1096 "type": "function",
1097 "name": "native_noop",
1098 "parameters": {"type": "object"},
1099 }
1100 ],
1101 "tool_choice": "auto",
1102 "parallel_tool_calls": True,
1103 },
1104 )
1105
1106 assert request["tool_choice"] == "auto"
1107 assert request["parallel_tool_calls"] is True
1108
1109
1110 def test_chat_completions_kwargs_omit_empty_tools():
1111 kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
1112 {
1113 "tools": [],
1114 "tool_choice": "auto",
1115 "parallel_tool_calls": True,
1116 "max_tokens": 8,
1117 }
1118 )
1119
1120 assert kwargs == {"max_tokens": 8}
1121
1122 kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
1123 {
1124 "tools": [
1125 {
1126 "type": "function",
1127 "function": {
1128 "name": "lookup",
1129 "parameters": {"type": "object"},
1130 },
1131 }
1132 ],
1133 "tool_choice": "auto",
1134 }
1135 )
1136
1137 assert kwargs["tools"][0]["function"]["name"] == "lookup"
1138 assert kwargs["tool_choice"] == "auto"
1139
1140
1141 def test_complete_falls_back_to_chat_when_responses_shim_sends_empty_tools(
1142 monkeypatch,
1143 ):
1144 calls: list[str] = []
1145
1146 def fake_responses(*args, **kwargs):
1147 calls.append("responses")
1148 raise RuntimeError(
1149 "Value error, `tools` must not be an empty array. "
1150 "Either provide at least one tool or omit the field entirely."
1151 )
1152
1153 def fake_completion(*args, **kwargs):
1154 calls.append("chat")
1155 assert kwargs["drop_params"] is True
1156 assert "tools" not in kwargs
1157 assert "tool_choice" not in kwargs
1158 assert "parallel_tool_calls" not in kwargs
1159 return {"choices": [{"message": {"content": "ok"}}]}
1160
1161 monkeypatch.setattr(litellm_transport, "responses", fake_responses)
1162 monkeypatch.setattr(litellm_transport, "completion", fake_completion)
1163
1164 transport = litellm_transport.LiteLLMTransport(
1165 model="hosted_vllm/qwen",
1166 messages=[{"role": "user", "content": "hi"}],
1167 kwargs={
1168 "a0_api_mode": "responses",
1169 "tools": [],
1170 "tool_choice": "auto",
1171 "parallel_tool_calls": True,
1172 "max_tokens": 8,
1173 },
1174 )
1175
1176 parsed = transport.complete()
1177
1178 assert parsed["response_delta"] == "ok"
1179 assert calls == ["responses", "chat"]
1180
1181
1182 def test_responses_request_adds_openai_prompt_cache_key_for_static_prefix():
1183 request = litellm_transport.ResponsesTransport.from_chat(
1184 [
1185 {"role": "system", "content": "stable system prompt"},
1186 {"role": "user", "content": "dynamic question"},
1187 ],
1188 {
1189 "tools": [
1190 {
1191 "type": "function",
1192 "function": {
1193 "name": "lookup",
1194 "description": "Search",
1195 "parameters": {"type": "object"},
1196 },
1197 }
1198 ],
1199 },
1200 model="openai/gpt-5.4",
1201 )
1202
1203 assert request["prompt_cache_key"].startswith("a0-")
1204 assert len(request["prompt_cache_key"]) == 35
1205 assert "stable system prompt" not in request["prompt_cache_key"]
1206
1207 request_again = litellm_transport.ResponsesTransport.from_chat(
1208 [
1209 {"role": "system", "content": "stable system prompt"},
1210 {"role": "user", "content": "different dynamic question"},
1211 ],
1212 {
1213 "tools": [
1214 {
1215 "type": "function",
1216 "function": {
1217 "name": "lookup",
1218 "description": "Search",
1219 "parameters": {"type": "object"},
1220 },
1221 }
1222 ],
1223 },
1224 model="openai/gpt-5.4",
1225 )
1226
1227 assert request_again["prompt_cache_key"] == request["prompt_cache_key"]
1228
1229
1230 def test_responses_request_respects_explicit_prompt_cache_and_retention():
1231 request = litellm_transport.ResponsesTransport.from_chat(
1232 [{"role": "system", "content": "stable system prompt"}],
1233 {
1234 "prompt_cache_key": "user-provided-key",
1235 "prompt_cache_retention": "24h",
1236 "extra_body": {"prompt_cache_retention": "in_memory"},
1237 },
1238 model="openai/gpt-5.4",
1239 )
1240
1241 assert request["prompt_cache_key"] == "user-provided-key"
1242 assert "prompt_cache_retention" not in request
1243 assert request["extra_body"]["prompt_cache_retention"] == "in_memory"
1244
1245
1246 def test_responses_request_adds_azure_prompt_cache_params():
1247 request = litellm_transport.ResponsesTransport.from_chat(
1248 [{"role": "system", "content": "stable system prompt"}],
1249 {"prompt_cache_retention": "24h"},
1250 model="azure/gpt-4.1",
1251 )
1252
1253 assert request["prompt_cache_key"].startswith("a0-")
1254 assert "prompt_cache_retention" not in request
1255 assert request["extra_body"]["prompt_cache_retention"] == "24h"
1256
1257
1258 def test_responses_request_does_not_add_openai_cache_key_to_custom_api_base():
1259 request = litellm_transport.ResponsesTransport.from_chat(
1260 [{"role": "system", "content": "stable system prompt"}],
1261 {"api_base": "https://llm.agent-zero.ai/v1"},
1262 model="openai/gpt-5.4",
1263 )
1264
1265 assert "prompt_cache_key" not in request
1266
1267
1268 def test_chat_kwargs_add_openai_prompt_cache_key_for_chat_completions():
1269 kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
1270 {"max_tokens": 10},
1271 model="openai/gpt-5.4",
1272 messages=[
1273 {"role": "system", "content": "stable system prompt"},
1274 {"role": "user", "content": "dynamic question"},
1275 ],
1276 )
1277
1278 assert kwargs["prompt_cache_key"].startswith("a0-")
1279 assert kwargs["max_tokens"] == 10
1280
1281
1282 def test_chat_messages_strip_cache_control_for_openai_prompt_cache():
1283 messages = [
1284 {
1285 "role": "system",
1286 "cache_control": {"type": "ephemeral"},
1287 "content": [
1288 {
1289 "type": "text",
1290 "text": "stable system prompt",
1291 "cache_control": {"type": "ephemeral"},
1292 }
1293 ],
1294 }
1295 ]
1296
1297 prepared = litellm_transport.ChatCompletionsTransport.prepare_messages(
1298 messages,
1299 model="openai/gpt-5.4",
1300 kwargs={},
1301 )
1302
1303 assert "cache_control" not in prepared[0]
1304 assert "cache_control" not in prepared[0]["content"][0]
1305 assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
1306
1307
1308 def test_chat_kwargs_mark_cached_tools_for_cache_control_providers():
1309 kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
1310 {
1311 "tools": [
1312 {
1313 "type": "function",
1314 "function": {
1315 "name": "lookup",
1316 "description": "Search",
1317 "parameters": {"type": "object"},
1318 },
1319 }
1320 ],
1321 },
1322 model="anthropic/claude-sonnet-4-5",
1323 messages=[
1324 {
1325 "role": "system",
1326 "content": [
1327 {
1328 "type": "text",
1329 "text": "static instructions",
1330 "cache_control": {"type": "ephemeral"},
1331 }
1332 ],
1333 }
1334 ],
1335 explicit_prompt_caching=True,
1336 )
1337
1338 assert kwargs["tools"][0]["function"]["cache_control"] == {
1339 "type": "ephemeral"
1340 }
1341
1342
1343 def test_chat_kwargs_strip_orphan_tool_choice_and_enable_fallback_drop_params():
1344 kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
1345 {
1346 "tool_choice": "auto",
1347 "parallel_tool_calls": True,
1348 "max_tokens": 10,
1349 },
1350 fallback_error=RuntimeError("This model does not support Responses API"),
1351 )
1352
1353 assert kwargs["max_tokens"] == 10
1354 assert kwargs["drop_params"] is True
1355 assert "tool_choice" not in kwargs
1356 assert "parallel_tool_calls" not in kwargs
1357
1358
1359 def test_cache_control_policy_keeps_native_responses_first():
1360 messages = [
1361 {
1362 "role": "system",
1363 "content": "static instructions",
1364 "cache_control": {"type": "ephemeral"},
1365 }
1366 ]
1367
1368 openai_policy = litellm_transport.TransportPolicy.from_request(
1369 "openai/gpt-5.4",
1370 {"a0_api_mode": "responses"},
1371 messages=messages,
1372 )
1373 anthropic_policy = litellm_transport.TransportPolicy.from_request(
1374 "anthropic/claude-sonnet-4-5",
1375 {"a0_api_mode": "responses"},
1376 messages=messages,
1377 )
1378
1379 assert openai_policy.mode is litellm_transport.TransportMode.RESPONSES
1380 assert anthropic_policy.mode is litellm_transport.TransportMode.CHAT_COMPLETIONS
1381
1382
1383 def test_responses_fallback_does_not_mask_rate_limits():
1384 exc = RuntimeError(
1385 "RateLimitError: 429 Too Many Requests for url "
1386 "https://provider.example/v1/responses"
1387 )
1388
1389 policy = litellm_transport.TransportPolicy(
1390 mode=litellm_transport.TransportMode.RESPONSES
1391 )
1392
1393 assert (
1394 policy.recover(exc, got_any_chunk=False)
1395 is litellm_transport.TransportRecovery.RAISE
1396 )
1397
1398
1399 def test_responses_fallback_on_untyped_input_item_rejection():
1400 class BadRequestError(RuntimeError):
1401 status_code = 400
1402
1403 policy = litellm_transport.TransportPolicy(
1404 mode=litellm_transport.TransportMode.RESPONSES
1405 )
1406
1407 assert policy.recover(
1408 BadRequestError("Cannot determine type of item"), got_any_chunk=False
1409 ) is litellm_transport.TransportRecovery.FALLBACK_TO_CHAT
1410 assert policy.mode is litellm_transport.TransportMode.CHAT_COMPLETIONS
1411
1412
1413 def test_responses_response_parser_extracts_text_reasoning_and_function_calls():
1414 text_response = {
1415 "output": [
1416 {"type": "reasoning", "summary": [{"text": "because"}]},
1417 {
1418 "type": "message",
1419 "content": [{"type": "output_text", "text": "answer"}],
1420 },
1421 ]
1422 }
1423
1424 parsed = litellm_transport.ResponsesTransport.parse_response(text_response)
1425
1426 assert parsed == {"response_delta": "answer", "reasoning_delta": "because"}
1427
1428 tool_response = {
1429 "output": [
1430 {
1431 "type": "function_call",
1432 "name": "lookup",
1433 "arguments": '{"q":"a0"}',
1434 }
1435 ]
1436 }
1437
1438 parsed_tool = litellm_transport.ResponsesTransport.parse_response(tool_response)
1439
1440 assert extract_tools.json_parse_dirty(parsed_tool["response_delta"]) == {
1441 "tool_name": "lookup",
1442 "tool_args": {"q": "a0"},
1443 }
1444
1445
1446 def test_chat_completions_response_parser_extracts_tool_calls():
1447 parsed = litellm_transport.ChatCompletionsTransport.parse(
1448 {
1449 "choices": [
1450 {
1451 "message": {
1452 "tool_calls": [
1453 {
1454 "id": "call_1",
1455 "type": "function",
1456 "function": {
1457 "name": "lookup",
1458 "arguments": '{"q":"a0"}',
1459 },
1460 }
1461 ]
1462 }
1463 }
1464 ]
1465 }
1466 )
1467
1468 assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
1469 "tool_name": "lookup",
1470 "tool_args": {"q": "a0"},
1471 }
1472 assert parsed["_output_items"][0]["name"] == "lookup"
1473
1474
1475 def test_chat_completions_stream_parser_accumulates_tool_call_arguments():
1476 parser = litellm_transport.ChatCompletionsStreamParser()
1477
1478 assert parser.parse(
1479 {
1480 "choices": [
1481 {
1482 "delta": {
1483 "tool_calls": [
1484 {
1485 "index": 0,
1486 "id": "call_1",
1487 "type": "function",
1488 "function": {
1489 "name": "lookup",
1490 "arguments": '{"q":',
1491 },
1492 }
1493 ]
1494 }
1495 }
1496 ]
1497 }
1498 ) == {"reasoning_delta": "", "response_delta": ""}
1499 parsed = parser.parse(
1500 {
1501 "choices": [
1502 {
1503 "delta": {
1504 "tool_calls": [
1505 {
1506 "index": 0,
1507 "function": {"arguments": '"a0"}'},
1508 }
1509 ]
1510 },
1511 "finish_reason": "tool_calls",
1512 }
1513 ]
1514 }
1515 )
1516
1517 assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
1518 "tool_name": "lookup",
1519 "tool_args": {"q": "a0"},
1520 }
1521 assert parser.output_items()[0]["name"] == "lookup"
1522 assert parser.flush() == {"reasoning_delta": "", "response_delta": ""}
1523
1524
1525 def test_chat_completions_stream_parser_reads_dumped_tool_calls():
1526 parser = litellm_transport.ChatCompletionsStreamParser()
1527
1528 assert parser.parse(
1529 _DumpOnly(
1530 choices=[
1531 _DumpOnly(
1532 delta=_DumpOnly(
1533 tool_calls=[
1534 {
1535 "index": 0,
1536 "id": "call_1",
1537 "type": "function",
1538 "function": _DumpOnly(
1539 name="lookup",
1540 arguments='{"q":"a0"}',
1541 ),
1542 }
1543 ]
1544 )
1545 )
1546 ]
1547 )
1548 ) == {"reasoning_delta": "", "response_delta": ""}
1549
1550 parsed = parser.parse(
1551 _DumpOnly(choices=[_DumpOnly(delta=_DumpOnly(), finish_reason="tool_calls")])
1552 )
1553
1554 assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
1555 "tool_name": "lookup",
1556 "tool_args": {"q": "a0"},
1557 }
1558
1559
1560 def test_chat_completions_stream_parser_preserves_optional_usage():
1561 parser = litellm_transport.ChatCompletionsStreamParser()
1562 parser.parse(
1563 {
1564 "choices": [],
1565 "usage": {"prompt_tokens": 240},
1566 "_hidden_params": {"response_cost": 0.0084},
1567 }
1568 )
1569 parser.parse(
1570 {
1571 "choices": [],
1572 "usage": {"completion_tokens": 16, "total_tokens": 256},
1573 }
1574 )
1575 transport = litellm_transport.LiteLLMTransport(
1576 model="custom/model",
1577 messages=[{"role": "user", "content": "question"}],
1578 kwargs={"a0_api_mode": "chat_completions"},
1579 )
1580
1581 result = transport._stream_result_from_chat_parser(parser)
1582
1583 assert result is not None
1584 assert result.usage == {
1585 "prompt_tokens": 240,
1586 "completion_tokens": 16,
1587 "total_tokens": 256,
1588 "cost": 0.0084,
1589 }
1590
1591
1592 @pytest.mark.asyncio
1593 async def test_unified_turn_preserves_chat_streaming_tool_calls(monkeypatch):
1594 async def fake_acompletion(*args, **kwargs):
1595 return _AsyncChunkStream(
1596 [
1597 {
1598 "choices": [
1599 {
1600 "delta": {
1601 "tool_calls": [
1602 {
1603 "index": 0,
1604 "id": "call_1",
1605 "type": "function",
1606 "function": {
1607 "name": "lookup",
1608 "arguments": '{"q":',
1609 },
1610 }
1611 ]
1612 }
1613 }
1614 ]
1615 },
1616 {
1617 "choices": [
1618 {
1619 "delta": {
1620 "tool_calls": [
1621 {
1622 "index": 0,
1623 "function": {"arguments": '"a0"}'},
1624 }
1625 ]
1626 },
1627 "finish_reason": "tool_calls",
1628 }
1629 ]
1630 },
1631 ]
1632 )
1633
1634 async def fake_rate_limiter(*args, **kwargs):
1635 return None
1636
1637 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
1638 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
1639
1640 wrapper = models.LiteLLMChatWrapper(
1641 model="test-model",
1642 provider="openai",
1643 model_config=None,
1644 )
1645
1646 async def response_callback(chunk: str, full: str):
1647 return None
1648
1649 result = await wrapper.unified_turn(
1650 messages=[],
1651 response_callback=response_callback,
1652 a0_api_mode="chat",
1653 )
1654
1655 assert extract_tools.json_parse_dirty(result.response) == {
1656 "tool_name": "lookup",
1657 "tool_args": {"q": "a0"},
1658 }
1659 assert result.function_calls[0].name == "lookup"
1660 assert result.function_calls[0].arguments == {"q": "a0"}
1661
1662
1663 def test_responses_stream_parser_accumulates_function_call_arguments():
1664 parser = litellm_transport.ResponsesEventParser()
1665
1666 assert parser.parse(
1667 {
1668 "type": "response.output_item.added",
1669 "output_index": 0,
1670 "item": {
1671 "type": "function_call",
1672 "id": "fc_1",
1673 "call_id": "call_1",
1674 "name": "lookup",
1675 "arguments": "",
1676 },
1677 }
1678 ) == {"reasoning_delta": "", "response_delta": ""}
1679 assert parser.parse(
1680 {
1681 "type": "response.function_call_arguments.delta",
1682 "item_id": "fc_1",
1683 "output_index": 0,
1684 "delta": '{"q":',
1685 }
1686 ) == {"reasoning_delta": "", "response_delta": ""}
1687
1688 parsed = parser.parse(
1689 {
1690 "type": "response.function_call_arguments.done",
1691 "item_id": "fc_1",
1692 "output_index": 0,
1693 "name": "lookup",
1694 "arguments": '{"q":"a0"}',
1695 }
1696 )
1697
1698 assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
1699 "tool_name": "lookup",
1700 "tool_args": {"q": "a0"},
1701 }
1702 assert parser.parse(
1703 {
1704 "type": "response.output_item.done",
1705 "output_index": 0,
1706 "item": {
1707 "type": "function_call",
1708 "id": "fc_1",
1709 "call_id": "call_1",
1710 "name": "lookup",
1711 "arguments": '{"q":"a0"}',
1712 },
1713 }
1714 ) == {"reasoning_delta": "", "response_delta": ""}
1715
1716
1717 def test_responses_stream_parser_streams_response_function_arguments():
1718 parser = litellm_transport.ResponsesEventParser()
1719
1720 parser.parse(
1721 {
1722 "type": "response.output_item.added",
1723 "output_index": 0,
1724 "item": {
1725 "type": "function_call",
1726 "id": "fc_1",
1727 "name": "response",
1728 "arguments": "",
1729 },
1730 }
1731 )
1732 chunks = [
1733 parser.parse(
1734 {
1735 "type": "response.function_call_arguments.delta",
1736 "item_id": "fc_1",
1737 "delta": '{"text":"Hello',
1738 }
1739 )["response_delta"],
1740 parser.parse(
1741 {
1742 "type": "response.function_call_arguments.delta",
1743 "item_id": "fc_1",
1744 "delta": ' world"}',
1745 }
1746 )["response_delta"],
1747 parser.parse(
1748 {
1749 "type": "response.function_call_arguments.done",
1750 "item_id": "fc_1",
1751 "name": "response",
1752 "arguments": '{"text":"Hello world"}',
1753 }
1754 )["response_delta"],
1755 ]
1756
1757 assert chunks[0] == '{"tool_name":"response","tool_args":{"text":"Hello'
1758 assert DirtyJson.parse_string(chunks[0]) == {
1759 "tool_name": "response",
1760 "tool_args": {"text": "Hello"},
1761 }
1762 assert extract_tools.json_parse_dirty("".join(chunks)) == {
1763 "tool_name": "response",
1764 "tool_args": {"text": "Hello world"},
1765 }
1766 assert chunks[-1] == "}"
1767 assert parser.parse(
1768 {
1769 "type": "response.output_item.done",
1770 "item": {
1771 "type": "function_call",
1772 "id": "fc_1",
1773 "name": "response",
1774 "arguments": '{"text":"Hello world"}',
1775 },
1776 }
1777 ) == {"reasoning_delta": "", "response_delta": ""}
1778
1779
1780 def test_responses_stream_parser_uses_completed_response_when_no_deltas_arrive():
1781 parser = litellm_transport.ResponsesEventParser()
1782
1783 parsed = parser.parse(
1784 {
1785 "type": "response.completed",
1786 "response": {
1787 "output": [
1788 {
1789 "type": "message",
1790 "content": [{"type": "output_text", "text": "done"}],
1791 }
1792 ]
1793 },
1794 }
1795 )
1796
1797 assert parsed == {"reasoning_delta": "", "response_delta": "done"}
1798
1799
1800 def test_responses_stream_parser_handles_refusal_and_failed_events():
1801 parser = litellm_transport.ResponsesEventParser()
1802
1803 assert parser.parse(
1804 {"type": "response.refusal.delta", "delta": "no"}
1805 ) == {"reasoning_delta": "", "response_delta": "no"}
1806
1807 with pytest.raises(RuntimeError, match="policy"):
1808 parser.parse(
1809 {
1810 "type": "response.failed",
1811 "response": {"error": {"message": "policy"}},
1812 }
1813 )
1814
1815
1816 def test_responses_response_parser_groups_parallel_function_calls():
1817 response = {
1818 "output": [
1819 {
1820 "type": "function_call",
1821 "name": "lookup",
1822 "arguments": '{"q":"a0"}',
1823 },
1824 {
1825 "type": "function_call",
1826 "name": "rank",
1827 "arguments": '{"limit":2}',
1828 },
1829 ]
1830 }
1831
1832 parsed = litellm_transport.ResponsesTransport.parse_response(response)
1833
1834 assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
1835 "tool_name": "parallel_tool_calls",
1836 "tool_args": {
1837 "calls": [
1838 {"tool_name": "lookup", "tool_args": {"q": "a0"}},
1839 {"tool_name": "rank", "tool_args": {"limit": 2}},
1840 ]
1841 },
1842 }
1843
1844
1845 def test_responses_stream_parser_preserves_non_ascii_function_call_arguments():
1846 parser = litellm_transport.ResponsesEventParser()
1847
1848 parser.parse(
1849 {
1850 "type": "response.output_item.added",
1851 "output_index": 0,
1852 "item": {
1853 "type": "function_call",
1854 "id": "fc_1",
1855 "name": "response",
1856 "arguments": "",
1857 },
1858 }
1859 )
1860 parsed = parser.parse(
1861 {
1862 "type": "response.function_call_arguments.done",
1863 "item_id": "fc_1",
1864 "name": "response",
1865 "arguments": '{"text":"привет"}',
1866 }
1867 )
1868
1869 assert parsed["response_delta"] == '{"tool_name": "response", "tool_args": {"text": "привет"}}'