main
py 2,053 lines 71.6 KB
Raw
1 from __future__ import annotations
2
3 from dataclasses import dataclass, field
4 from enum import Enum
5 import hashlib
6 import inspect
7 import json
8 from typing import Any, AsyncIterator, Iterator, Optional
9
10 from litellm import (
11 acompletion,
12 adelete_responses,
13 aresponses,
14 completion,
15 delete_responses,
16 responses,
17 )
18
19 from helpers import images
20 from helpers.llm_result import LLMResult
21
22
23 ChatChunk = dict[str, str]
24
25
26 class TransportMode(Enum):
27 RESPONSES = "responses"
28 CHAT_COMPLETIONS = "chat_completions"
29
30
31 class TransportRecovery(Enum):
32 RAISE = "raise"
33 RETRY_RESPONSES = "retry_responses"
34 RETRY_LOCAL_RESPONSES = "retry_local_responses"
35 FALLBACK_TO_CHAT = "fallback_to_chat"
36
37
38 RESPONSES_ALIASES = {"response", "responses", "responses_api"}
39 RESPONSES_REASONING_EFFORTS = {"minimal", "low", "medium", "high"}
40 RESPONSES_REASONING_FALLBACK_EFFORT = "high"
41 NO_REASONING_EFFORT_ALIASES = {"", "0", "false", "no", "none", "off", "disabled"}
42 RESPONSES_UNSUPPORTED_CACHE: set[str] = set()
43 RESPONSES_STATE_UNSUPPORTED_CACHE: set[str] = set()
44 RESPONSES_BUILTIN_UNSUPPORTED_CACHE: dict[str, set[str]] = {}
45 OPENAI_RESPONSES_EXTRA_BODY_PARAMS = {
46 "context_management",
47 "prompt_cache_retention",
48 }
49 CACHE_CONTROL_PROMPT_PROVIDERS = {
50 "anthropic",
51 "bedrock",
52 "databricks",
53 "dashscope",
54 "gemini",
55 "gemini_api_oauth",
56 "minimax",
57 "openrouter",
58 "vertex_ai",
59 "vertexai",
60 "z_ai",
61 "zai",
62 }
63 OPENAI_PROMPT_CACHE_PROVIDERS = {"openai", "azure"}
64 RESPONSES_STATE_PROVIDER = "provider"
65 RESPONSES_STATE_LOCAL = "local"
66 RESPONSES_STATE_OFF = "off"
67 RESPONSES_STATES = {
68 RESPONSES_STATE_PROVIDER,
69 RESPONSES_STATE_LOCAL,
70 RESPONSES_STATE_OFF,
71 }
72
73
74 @dataclass
75 class TransportPolicy:
76 mode: TransportMode
77 allow_fallback: bool = True
78 retried_reasoning: bool = False
79 fallback_error: Exception | None = None
80 state_fallback_error: Exception | None = None
81 cache_key: str = ""
82 state: str = RESPONSES_STATE_PROVIDER
83
84 @classmethod
85 def from_request(
86 cls,
87 model: str,
88 kwargs: dict[str, Any],
89 messages: list[dict[str, Any]] | None = None,
90 ) -> "TransportPolicy":
91 mode = cls._pop_mode(kwargs)
92 allow_fallback = _coerce_bool(
93 kwargs.pop("a0_responses_fallback", True), default=True
94 )
95 cache_key = _responses_cache_key(model, kwargs)
96 state = _normalize_responses_state(kwargs.get("responses_state"))
97
98 if mode is TransportMode.CHAT_COMPLETIONS:
99 _drop_responses_only_kwargs(kwargs)
100 return cls(
101 mode=mode,
102 allow_fallback=allow_fallback,
103 cache_key=cache_key,
104 state=RESPONSES_STATE_OFF,
105 )
106
107 if (
108 state == RESPONSES_STATE_PROVIDER
109 and cache_key in RESPONSES_STATE_UNSUPPORTED_CACHE
110 ):
111 kwargs["responses_state"] = RESPONSES_STATE_LOCAL
112 state = RESPONSES_STATE_LOCAL
113
114 _filter_unsupported_builtin_tools(kwargs, cache_key)
115
116 if _should_preserve_cache_control_on_chat(model, kwargs, messages or []):
117 return cls(
118 mode=TransportMode.CHAT_COMPLETIONS,
119 allow_fallback=allow_fallback,
120 cache_key=cache_key,
121 state=RESPONSES_STATE_OFF,
122 )
123
124 if cache_key in RESPONSES_UNSUPPORTED_CACHE:
125 return cls(
126 mode=TransportMode.CHAT_COMPLETIONS,
127 allow_fallback=allow_fallback,
128 fallback_error=RuntimeError("Responses API previously failed"),
129 cache_key=cache_key,
130 state=RESPONSES_STATE_OFF,
131 )
132
133 return cls(
134 mode=TransportMode.RESPONSES,
135 allow_fallback=allow_fallback,
136 cache_key=cache_key,
137 state=state,
138 )
139
140 @staticmethod
141 def _pop_mode(kwargs: dict[str, Any]) -> TransportMode:
142 value = str(kwargs.pop("a0_api_mode", "") or "").lower().strip()
143 if value in RESPONSES_ALIASES:
144 return TransportMode.RESPONSES
145 return TransportMode.CHAT_COMPLETIONS
146
147 @property
148 def using_responses(self) -> bool:
149 return self.mode is TransportMode.RESPONSES
150
151 def recover(self, exc: Exception, *, got_any_chunk: bool) -> TransportRecovery:
152 if not self.using_responses or got_any_chunk:
153 return TransportRecovery.RAISE
154 if not self.retried_reasoning and _is_responses_reasoning_effort_error(exc):
155 self.retried_reasoning = True
156 return TransportRecovery.RETRY_RESPONSES
157 if (
158 self.state == RESPONSES_STATE_PROVIDER
159 and _is_responses_state_unsupported_error(exc)
160 ):
161 self.state = RESPONSES_STATE_LOCAL
162 self.state_fallback_error = exc
163 if self.cache_key:
164 RESPONSES_STATE_UNSUPPORTED_CACHE.add(self.cache_key)
165 return TransportRecovery.RETRY_LOCAL_RESPONSES
166 if self.allow_fallback and _is_responses_not_supported_error(exc):
167 self.mode = TransportMode.CHAT_COMPLETIONS
168 self.fallback_error = exc
169 self.state = RESPONSES_STATE_OFF
170 if self.cache_key:
171 RESPONSES_UNSUPPORTED_CACHE.add(self.cache_key)
172 return TransportRecovery.FALLBACK_TO_CHAT
173 return TransportRecovery.RAISE
174
175
176 @dataclass
177 class LiteLLMTransport:
178 model: str
179 messages: list[dict[str, Any]]
180 kwargs: dict[str, Any]
181 stop: Optional[list[str]] = None
182 policy: TransportPolicy = field(init=False)
183 last_result: LLMResult | None = field(init=False, default=None)
184 last_request_state: str = field(init=False, default=RESPONSES_STATE_PROVIDER)
185 explicit_prompt_caching: bool = field(init=False, default=False)
186
187 def __post_init__(self) -> None:
188 self.kwargs = _without_stream_kwarg(dict(self.kwargs))
189 self.explicit_prompt_caching = _coerce_bool(
190 self.kwargs.pop("a0_explicit_prompt_caching", False), default=False
191 )
192 if self.explicit_prompt_caching:
193 self.messages = apply_chat_prompt_cache_markers(
194 self.messages,
195 model=self.model,
196 kwargs=self.kwargs,
197 )
198 self.policy = TransportPolicy.from_request(
199 self.model,
200 self.kwargs,
201 messages=self.messages,
202 )
203
204 def complete(self) -> ChatChunk:
205 while True:
206 try:
207 if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
208 raw_response = completion(**self._chat_request(stream=False))
209 parsed = ChatCompletionsTransport.parse(raw_response)
210 self.last_result = self._llm_result_from_chat(
211 parsed, raw_response
212 )
213 return parsed
214 request = self._responses_request(stream=False)
215 raw_response = responses(**request)
216 parsed = ResponsesTransport.parse_response(raw_response)
217 self.last_result = self._llm_result_from_response(
218 raw_response, request
219 )
220 return parsed
221 except Exception as exc:
222 if self._recover(exc, got_any_chunk=False):
223 continue
224 raise
225
226 async def acomplete(self) -> ChatChunk:
227 while True:
228 try:
229 if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
230 raw_response = await acompletion(
231 **self._chat_request(stream=False)
232 )
233 parsed = ChatCompletionsTransport.parse(raw_response)
234 self.last_result = self._llm_result_from_chat(
235 parsed, raw_response
236 )
237 return parsed
238 request = self._responses_request(stream=False)
239 raw_response = await aresponses(**request)
240 parsed = ResponsesTransport.parse_response(raw_response)
241 self.last_result = self._llm_result_from_response(
242 raw_response, request
243 )
244 return parsed
245 except Exception as exc:
246 if self._recover(exc, got_any_chunk=False):
247 continue
248 raise
249
250 def stream(self) -> Iterator[ChatChunk]:
251 while True:
252 iterator = None
253 exhausted = False
254 got_any_chunk = False
255 try:
256 if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
257 iterator = completion(**self._chat_request(stream=True))
258 parser = ChatCompletionsStreamParser()
259 for chunk in iterator:
260 parsed = parser.parse(chunk)
261 if _has_chunk_delta(parsed):
262 got_any_chunk = True
263 yield parsed
264 parsed = parser.flush()
265 if _has_chunk_delta(parsed):
266 got_any_chunk = True
267 yield parsed
268 self.last_result = self._stream_result_from_chat_parser(parser)
269 else:
270 request = self._responses_request(stream=True)
271 iterator = responses(**request)
272 parser = ResponsesEventParser()
273 for event in iterator:
274 parsed = parser.parse(event)
275 if _has_chunk_delta(parsed):
276 got_any_chunk = True
277 yield parsed
278 self.last_result = self._stream_result_from_parser(
279 parser, request
280 )
281 exhausted = True
282 return
283 except Exception as exc:
284 if self._recover(exc, got_any_chunk=got_any_chunk):
285 continue
286 raise
287 finally:
288 if iterator is not None and not exhausted:
289 _close_sync_stream(iterator)
290
291 async def astream(self) -> AsyncIterator[ChatChunk]:
292 while True:
293 iterator = None
294 exhausted = False
295 got_any_chunk = False
296 try:
297 if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
298 iterator = await acompletion(**self._chat_request(stream=True))
299 parser = ChatCompletionsStreamParser()
300 async for chunk in iterator: # type: ignore[union-attr]
301 parsed = parser.parse(chunk)
302 if _has_chunk_delta(parsed):
303 got_any_chunk = True
304 yield parsed
305 parsed = parser.flush()
306 if _has_chunk_delta(parsed):
307 got_any_chunk = True
308 yield parsed
309 self.last_result = self._stream_result_from_chat_parser(parser)
310 else:
311 request = self._responses_request(stream=True)
312 iterator = await aresponses(**request)
313 parser = ResponsesEventParser()
314 async for event in iterator: # type: ignore[union-attr]
315 parsed = parser.parse(event)
316 if _has_chunk_delta(parsed):
317 got_any_chunk = True
318 yield parsed
319 self.last_result = self._stream_result_from_parser(
320 parser, request
321 )
322 exhausted = True
323 return
324 except Exception as exc:
325 if self._recover(exc, got_any_chunk=got_any_chunk):
326 continue
327 raise
328 finally:
329 if iterator is not None and not exhausted:
330 await _close_async_stream(iterator)
331
332 def _recover(self, exc: Exception, *, got_any_chunk: bool) -> bool:
333 if (
334 self.policy.using_responses
335 and not got_any_chunk
336 and self.kwargs.get("responses_builtin_tools")
337 and _is_responses_builtin_tool_error(exc)
338 ):
339 downgraded = _builtin_tool_types(self.kwargs.get("responses_builtin_tools"))
340 if downgraded:
341 RESPONSES_BUILTIN_UNSUPPORTED_CACHE.setdefault(
342 self.policy.cache_key, set()
343 ).update(downgraded)
344 self.kwargs["_a0_responses_builtin_downgrades"] = sorted(downgraded)
345 self.kwargs["responses_builtin_tools"] = []
346 return True
347
348 recovery = self.policy.recover(exc, got_any_chunk=got_any_chunk)
349 if recovery is TransportRecovery.RETRY_RESPONSES:
350 self.kwargs["reasoning"] = {
351 "effort": RESPONSES_REASONING_FALLBACK_EFFORT
352 }
353 return True
354 if recovery is TransportRecovery.RETRY_LOCAL_RESPONSES:
355 self.kwargs["responses_state"] = RESPONSES_STATE_LOCAL
356 self.kwargs.pop("previous_response_id", None)
357 return True
358 return recovery is TransportRecovery.FALLBACK_TO_CHAT
359
360 def _chat_request(self, *, stream: bool) -> dict[str, Any]:
361 chat_kwargs = ChatCompletionsTransport.prepare_kwargs(
362 self.kwargs,
363 fallback_error=self.policy.fallback_error,
364 model=self.model,
365 messages=self.messages,
366 explicit_prompt_caching=self.explicit_prompt_caching,
367 )
368 request = {
369 "model": self.model,
370 "messages": ChatCompletionsTransport.prepare_messages(
371 self.messages,
372 model=self.model,
373 kwargs=chat_kwargs,
374 ),
375 "stream": stream,
376 **chat_kwargs,
377 }
378 if self.stop is not None:
379 request["stop"] = self.stop
380 return request
381
382 def _responses_request(self, *, stream: bool) -> dict[str, Any]:
383 response_kwargs = ResponsesTransport.from_chat(
384 self.messages,
385 self.kwargs,
386 stop=self.stop,
387 model=self.model,
388 )
389 self.last_request_state = _normalize_responses_state(
390 self.kwargs.get("responses_state")
391 )
392 return {
393 "model": self.model,
394 "stream": stream,
395 **response_kwargs,
396 }
397
398 def _llm_result_from_chat(
399 self, parsed: ChatChunk, response: Any = None
400 ) -> LLMResult:
401 return LLMResult.from_chat(
402 response=parsed["response_delta"],
403 reasoning=parsed["reasoning_delta"],
404 usage=_reported_usage(response),
405 input_items=ResponsesTransport.input_from_messages(self.messages),
406 output_items=parsed.get("_output_items"),
407 provider_model_key=self.model,
408 capability=self._capability_metadata(),
409 )
410
411 def _llm_result_from_response(
412 self, response: Any, request: dict[str, Any]
413 ) -> LLMResult:
414 result = LLMResult.from_response(
415 response,
416 input_items=_as_list(request.get("input")),
417 previous_response_id=str(request.get("previous_response_id") or ""),
418 provider_model_key=self.model,
419 mode=TransportMode.RESPONSES.value,
420 state=self.last_request_state,
421 capability=self._capability_metadata(),
422 )
423 result.usage = _reported_usage(response)
424 return result
425
426 def _stream_result_from_parser(
427 self, parser: "ResponsesEventParser", request: dict[str, Any]
428 ) -> LLMResult | None:
429 if parser.completed_response is None:
430 return None
431 response = _object_to_dict(parser.completed_response)
432 output = _as_list(response.get("output"))
433 if parser.function_calls and not any(
434 _get_value(item, "type") == "function_call" for item in output
435 ):
436 response["output"] = [*output, *parser.function_calls.values()]
437 return self._llm_result_from_response(response, request)
438
439 def _stream_result_from_chat_parser(
440 self, parser: "ChatCompletionsStreamParser"
441 ) -> LLMResult | None:
442 output_items = parser.output_items()
443 if not output_items and not parser.usage:
444 return None
445 return LLMResult.from_chat(
446 response=parser.function_calls_text(),
447 usage=parser.usage,
448 input_items=ResponsesTransport.input_from_messages(self.messages),
449 output_items=output_items,
450 provider_model_key=self.model,
451 capability=self._capability_metadata(),
452 )
453
454 def _capability_metadata(self) -> dict[str, Any]:
455 return {
456 "mode": self.policy.mode.value,
457 "state": self.policy.state,
458 "cache_key": self.policy.cache_key,
459 "fallback_error": _exception_text(self.policy.fallback_error)
460 if self.policy.fallback_error
461 else "",
462 "state_fallback_error": _exception_text(self.policy.state_fallback_error)
463 if self.policy.state_fallback_error
464 else "",
465 "builtin_tool_downgrades": list(
466 self.kwargs.get("_a0_responses_builtin_downgrades") or []
467 ),
468 }
469
470
471 class ChatCompletionsTransport:
472 @staticmethod
473 def prepare_messages(
474 messages: list[dict[str, Any]],
475 *,
476 model: str = "",
477 kwargs: dict[str, Any] | None = None,
478 ) -> list[dict[str, Any]]:
479 if _is_openai_prompt_cache_provider(model, kwargs or {}):
480 stripped = _without_cache_control(messages)
481 return stripped if isinstance(stripped, list) else messages
482 return messages
483
484 @staticmethod
485 def prepare_kwargs(
486 kwargs: dict[str, Any],
487 fallback_error: Exception | None = None,
488 *,
489 model: str = "",
490 messages: list[dict[str, Any]] | None = None,
491 explicit_prompt_caching: bool = False,
492 ) -> dict[str, Any]:
493 chat_kwargs = dict(kwargs)
494 _drop_internal_transport_kwargs(chat_kwargs)
495 if not _has_tools(chat_kwargs.get("tools")):
496 chat_kwargs.pop("tools", None)
497 chat_kwargs.pop("tool_choice", None)
498 chat_kwargs.pop("parallel_tool_calls", None)
499 if _is_openai_prompt_cache_provider(model, chat_kwargs):
500 _prepare_openai_prompt_cache_params(
501 chat_kwargs,
502 messages or [],
503 model=model,
504 )
505 elif _supports_cache_control_markers(model, chat_kwargs) and (
506 explicit_prompt_caching or _has_cache_control(messages or [])
507 ):
508 _apply_tool_cache_control(chat_kwargs)
509 if fallback_error is not None:
510 chat_kwargs.setdefault("drop_params", True)
511 return {key: value for key, value in chat_kwargs.items() if value is not None}
512
513 @staticmethod
514 def parse(chunk: Any) -> ChatChunk:
515 choice = _first_choice(chunk)
516 delta = _get_value(choice, "delta") or {}
517 message = _get_value(choice, "message") or _get_value(
518 _get_value(choice, "model_extra") or {}, "message"
519 ) or {}
520 response_delta = _get_value(delta, "content") or _get_value(
521 message, "content"
522 ) or ""
523 reasoning_delta = _get_value(delta, "reasoning_content") or _get_value(
524 message, "reasoning_content"
525 ) or ""
526 parsed = {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
527 if not response_delta:
528 tool_calls = _as_list(_get_value(message, "tool_calls"))
529 response_delta = ChatCompletionsTransport.tool_calls_text(tool_calls)
530 if response_delta:
531 parsed["response_delta"] = response_delta
532 parsed["_output_items"] = ChatCompletionsTransport.output_items(
533 tool_calls
534 )
535 return parsed
536
537 @classmethod
538 def tool_calls_text(cls, tool_calls: Any) -> str:
539 calls = [cls.tool_call_object(call) for call in _as_list(tool_calls)]
540 calls = [call for call in calls if call]
541 if not calls:
542 return ""
543 if len(calls) == 1:
544 return json.dumps(calls[0], ensure_ascii=False)
545 return json.dumps(
546 {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}},
547 ensure_ascii=False,
548 )
549
550 @classmethod
551 def output_items(cls, tool_calls: Any) -> list[dict[str, Any]]:
552 items = []
553 for index, tool_call in enumerate(_as_list(tool_calls)):
554 item = cls.function_call_item(tool_call, fallback_index=index)
555 if item:
556 items.append(item)
557 return items
558
559 @classmethod
560 def function_call_item(
561 cls, tool_call: Any, *, fallback_index: int = 0
562 ) -> dict[str, Any]:
563 function = _get_value(tool_call, "function") or {}
564 name = _get_value(function, "name") or _get_value(tool_call, "name")
565 if not name:
566 return {}
567 raw_arguments = _get_value(function, "arguments")
568 if raw_arguments is None:
569 raw_arguments = _get_value(tool_call, "arguments") or "{}"
570 call_id = str(_get_value(tool_call, "id") or f"call_{fallback_index}")
571 return {
572 "type": "function_call",
573 "id": call_id,
574 "call_id": call_id,
575 "name": str(name),
576 "arguments": raw_arguments
577 if isinstance(raw_arguments, str)
578 else json.dumps(raw_arguments),
579 }
580
581 @classmethod
582 def tool_call_object(cls, tool_call: Any) -> dict[str, Any]:
583 item = cls.function_call_item(tool_call)
584 if not item:
585 return {}
586 return ResponsesTransport.function_call_object(item)
587
588
589 class ChatCompletionsStreamParser:
590 def __init__(self) -> None:
591 self.tool_calls: dict[str, dict[str, Any]] = {}
592 self.order: list[str] = []
593 self.emitted = False
594 self.usage: dict[str, Any] = {}
595
596 def parse(self, chunk: Any) -> ChatChunk:
597 if usage := _reported_usage(chunk):
598 self.usage.update(usage)
599 parsed = ChatCompletionsTransport.parse(chunk)
600 choice = _first_choice(chunk)
601 delta = _get_value(choice, "delta") or {}
602 self._append_tool_calls(_get_value(delta, "tool_calls"))
603 self._append_legacy_function_call(_get_value(delta, "function_call"))
604
605 if _get_value(choice, "finish_reason") in {"tool_calls", "function_call"}:
606 text = self._emit()
607 if text and not parsed["response_delta"]:
608 parsed["response_delta"] = text
609 return parsed
610
611 def flush(self) -> ChatChunk:
612 return {"reasoning_delta": "", "response_delta": self._emit()}
613
614 def function_calls_text(self) -> str:
615 return ChatCompletionsTransport.tool_calls_text(self._ordered_tool_calls())
616
617 def output_items(self) -> list[dict[str, Any]]:
618 return ChatCompletionsTransport.output_items(self._ordered_tool_calls())
619
620 def _append_tool_calls(self, tool_calls: Any) -> None:
621 for fallback_index, tool_call in enumerate(_as_list(tool_calls)):
622 key = self._tool_call_key(tool_call, fallback_index)
623 current = self._current_tool_call(key)
624 if _get_value(tool_call, "id"):
625 current["id"] = _get_value(tool_call, "id")
626 if _get_value(tool_call, "type"):
627 current["type"] = _get_value(tool_call, "type")
628 self._append_function_delta(current, _get_value(tool_call, "function"))
629
630 def _append_legacy_function_call(self, function_call: Any) -> None:
631 if not function_call:
632 return
633 current = self._current_tool_call("0")
634 current["type"] = "function"
635 self._append_function_delta(current, function_call)
636
637 def _append_function_delta(self, tool_call: dict[str, Any], delta: Any) -> None:
638 if not delta:
639 return
640 function = tool_call.setdefault("function", {})
641 if _get_value(delta, "name"):
642 function["name"] = _get_value(delta, "name")
643 if _get_value(delta, "arguments") is not None:
644 function["arguments"] = str(function.get("arguments") or "") + str(
645 _get_value(delta, "arguments") or ""
646 )
647
648 def _current_tool_call(self, key: str) -> dict[str, Any]:
649 if key not in self.tool_calls:
650 self.tool_calls[key] = {"type": "function", "function": {}}
651 self.order.append(key)
652 return self.tool_calls[key]
653
654 def _ordered_tool_calls(self) -> list[dict[str, Any]]:
655 return [self.tool_calls[key] for key in self.order]
656
657 def _emit(self) -> str:
658 if self.emitted:
659 return ""
660 text = self.function_calls_text()
661 if text:
662 self.emitted = True
663 return text
664
665 @staticmethod
666 def _tool_call_key(tool_call: Any, fallback_index: int) -> str:
667 index = _get_value(tool_call, "index")
668 if index is not None:
669 return str(index)
670 if _get_value(tool_call, "id"):
671 return str(_get_value(tool_call, "id"))
672 return str(fallback_index)
673
674
675 class ResponsesTransport:
676 @classmethod
677 def from_chat(
678 cls,
679 messages: list[dict[str, Any]],
680 kwargs: dict[str, Any],
681 stop: Optional[list[str]] = None,
682 model: str = "",
683 ) -> dict[str, Any]:
684 request = cls.prepare_kwargs(kwargs, stop=stop, model=model, messages=messages)
685 state = _normalize_responses_state(kwargs.get("responses_state"))
686 input_items = cls._select_input_items(kwargs, messages, state)
687 request["input"] = input_items or ""
688 cls.apply_state(request, kwargs, state=state)
689 return request
690
691 @classmethod
692 def from_input(
693 cls,
694 input_items: list[dict[str, Any]],
695 kwargs: dict[str, Any],
696 stop: Optional[list[str]] = None,
697 model: str = "",
698 messages: list[dict[str, Any]] | None = None,
699 ) -> dict[str, Any]:
700 request = cls.prepare_kwargs(kwargs, stop=stop, model=model, messages=messages)
701 state = _normalize_responses_state(kwargs.get("responses_state"))
702 request["input"] = list(input_items or []) or ""
703 cls.apply_state(request, kwargs, state=state)
704 return request
705
706 @classmethod
707 def prepare_kwargs(
708 cls,
709 kwargs: dict[str, Any],
710 stop: Optional[list[str]] = None,
711 model: str = "",
712 messages: list[dict[str, Any]] | None = None,
713 ) -> dict[str, Any]:
714 request = dict(kwargs)
715 response_function_tools = request.pop("a0_responses_function_tools", None)
716 response_builtin_tools = request.pop("responses_builtin_tools", None)
717 _drop_responses_only_kwargs(request)
718 _drop_legacy_transport_kwargs(request)
719 request.pop("stop", None)
720
721 max_completion_tokens = request.pop("max_completion_tokens", None)
722 max_tokens = request.pop("max_tokens", None)
723 if "max_output_tokens" not in request:
724 request["max_output_tokens"] = max_completion_tokens or max_tokens
725
726 reasoning_effort = request.pop("reasoning_effort", None)
727 if "reasoning" in request:
728 request["reasoning"] = cls.normalize_reasoning(request["reasoning"])
729 elif reasoning_effort is not None:
730 request["reasoning"] = cls.normalize_reasoning(reasoning_effort)
731
732 response_format = request.pop("response_format", None)
733 if response_format is not None:
734 text_param, text_format = cls.text_from_response_format(response_format)
735 if text_param is not None and "text" not in request:
736 request["text"] = text_param
737 if text_format is not None and "text_format" not in request:
738 request["text_format"] = text_format
739
740 functions = request.pop("functions", None)
741 if functions and "tools" not in request:
742 request["tools"] = [
743 {"type": "function", **function}
744 for function in functions
745 if isinstance(function, dict)
746 ]
747
748 tools = cls.tools_from_chat(request.pop("tools", None))
749 tools = cls.merge_response_tools(
750 tools,
751 response_function_tools=response_function_tools,
752 response_builtin_tools=response_builtin_tools,
753 )
754 if _has_tools(tools):
755 request["tools"] = tools
756 else:
757 request.pop("tools", None)
758
759 function_call = request.pop("function_call", None)
760 if function_call is not None and "tool_choice" not in request:
761 request["tool_choice"] = cls.tool_choice_from_function_call(function_call)
762 elif "tool_choice" in request:
763 request["tool_choice"] = cls.tool_choice_from_chat(request["tool_choice"])
764
765 if _has_tools(response_function_tools):
766 request.setdefault("tool_choice", "required")
767 request.setdefault("parallel_tool_calls", False)
768
769 if not _has_tools(request.get("tools")):
770 request.pop("tool_choice", None)
771 request.pop("parallel_tool_calls", None)
772
773 cls.prepare_prompt_caching(request, messages or [], model=model)
774
775 _ = stop
776 return {key: value for key, value in request.items() if value is not None}
777
778 @classmethod
779 def _select_input_items(
780 cls,
781 kwargs: dict[str, Any],
782 messages: list[dict[str, Any]],
783 state: str,
784 ) -> list[dict[str, Any]]:
785 provider_items = kwargs.get("responses_input_items")
786 local_items = kwargs.get("responses_local_input_items")
787 previous_response_id = kwargs.get("previous_response_id")
788
789 if (
790 state == RESPONSES_STATE_PROVIDER
791 and previous_response_id
792 and isinstance(provider_items, list)
793 ):
794 return [dict(item) for item in provider_items if isinstance(item, dict)]
795
796 if state == RESPONSES_STATE_LOCAL and isinstance(local_items, list):
797 return [dict(item) for item in local_items if isinstance(item, dict)]
798
799 return cls.input_from_messages(messages)
800
801 @staticmethod
802 def apply_state(
803 request: dict[str, Any],
804 kwargs: dict[str, Any],
805 *,
806 state: str,
807 ) -> None:
808 if state == RESPONSES_STATE_PROVIDER:
809 request.setdefault("store", True)
810 previous_response_id = str(kwargs.get("previous_response_id") or "")
811 if previous_response_id:
812 request["previous_response_id"] = previous_response_id
813 elif state == RESPONSES_STATE_LOCAL:
814 request.setdefault("store", False)
815 else:
816 request.setdefault("store", False)
817
818 @classmethod
819 def merge_response_tools(
820 cls,
821 tools: Any,
822 *,
823 response_function_tools: Any = None,
824 response_builtin_tools: Any = None,
825 ) -> list[Any]:
826 merged: list[Any] = []
827 for source in (tools, response_function_tools, response_builtin_tools):
828 source_tools = (
829 source if isinstance(source, list) else [source] if source else []
830 )
831 for tool in source_tools:
832 normalized = cls.normalize_response_tool(tool)
833 if normalized:
834 merged.append(normalized)
835 return merged
836
837 @staticmethod
838 def normalize_response_tool(tool: Any) -> dict[str, Any] | None:
839 if isinstance(tool, str):
840 tool = {"type": tool}
841 if not isinstance(tool, dict):
842 return None
843 normalized = dict(tool)
844 if normalized.get("type") == "function":
845 normalized["parameters"] = _normalize_function_parameters(
846 normalized.get("parameters")
847 )
848 return normalized
849
850 @staticmethod
851 def prepare_prompt_caching(
852 request: dict[str, Any],
853 messages: list[dict[str, Any]],
854 model: str = "",
855 ) -> None:
856 if not _is_openai_prompt_cache_provider(model, request):
857 return
858
859 _prepare_openai_prompt_cache_params(request, messages, model=model)
860
861 for key in OPENAI_RESPONSES_EXTRA_BODY_PARAMS:
862 if key not in request:
863 continue
864 extra_body = request.get("extra_body")
865 if not isinstance(extra_body, dict):
866 extra_body = {}
867 extra_body.setdefault(key, request.pop(key))
868 request["extra_body"] = extra_body
869
870 @classmethod
871 def input_from_messages(
872 cls, messages: list[dict[str, Any]]
873 ) -> list[dict[str, Any]]:
874 response_input: list[dict[str, Any]] = []
875
876 for message in messages:
877 role = str(message.get("role") or "user")
878 content = message.get("content", "")
879
880 if role == "tool":
881 response_input.append(
882 {
883 "type": "function_call_output",
884 "call_id": str(message.get("tool_call_id") or ""),
885 "output": _content_to_text(content),
886 }
887 )
888 continue
889
890 tool_calls = message.get("tool_calls")
891 if role == "assistant" and isinstance(tool_calls, list) and tool_calls:
892 if _has_real_content(content):
893 response_input.append(
894 {
895 "role": "assistant",
896 "content": cls.content_from_chat(content, role=role),
897 }
898 )
899 response_input.extend(cls.tool_calls_from_chat(tool_calls))
900 continue
901
902 response_input.append(
903 {
904 "role": role
905 if role in {"user", "assistant", "system", "developer"}
906 else "user",
907 "content": cls.content_from_chat(content, role=role),
908 }
909 )
910
911 return response_input
912
913 @classmethod
914 def content_from_chat(cls, content: Any, role: str = "user") -> Any:
915 content = images.prepare_content(content)
916 if not isinstance(content, list):
917 return content
918 return [
919 converted
920 for item in content
921 if (converted := cls.content_part_from_chat(item, role=role)) is not None
922 ]
923
924 @staticmethod
925 def content_part_from_chat(item: Any, role: str = "user") -> Any:
926 if not isinstance(item, dict):
927 return item
928
929 item_type = item.get("type")
930 if item_type in {"input_text", "output_text", "input_image", "input_file"}:
931 return dict(item)
932 if item_type == "text":
933 return {
934 "type": "output_text" if role == "assistant" else "input_text",
935 "text": item.get("text", ""),
936 }
937 if item_type == "image_url":
938 image_url = item.get("image_url")
939 if isinstance(image_url, dict):
940 url = image_url.get("url", "")
941 detail = image_url.get("detail")
942 else:
943 url = image_url or ""
944 detail = item.get("detail")
945 result = {"type": "input_image", "image_url": url}
946 if detail:
947 result["detail"] = detail
948 return result
949
950 return dict(item)
951
952 @staticmethod
953 def tool_calls_from_chat(tool_calls: list[Any]) -> list[dict[str, Any]]:
954 response_input: list[dict[str, Any]] = []
955 for tool_call in tool_calls:
956 if not isinstance(tool_call, dict):
957 continue
958 function = tool_call.get("function") or {}
959 if not isinstance(function, dict):
960 function = {}
961 response_input.append(
962 {
963 "type": "function_call",
964 "call_id": str(tool_call.get("id") or ""),
965 "id": str(tool_call.get("id") or ""),
966 "name": str(function.get("name") or tool_call.get("name") or ""),
967 "arguments": str(function.get("arguments") or ""),
968 "status": "completed",
969 }
970 )
971 return response_input
972
973 @staticmethod
974 def tools_from_chat(tools: Any) -> Any:
975 if not isinstance(tools, list):
976 return tools
977 response_tools: list[Any] = []
978 for tool in tools:
979 if not isinstance(tool, dict):
980 response_tools.append(tool)
981 continue
982 if tool.get("type") == "function" and isinstance(
983 tool.get("function"), dict
984 ):
985 function = tool["function"]
986 response_tool = {
987 "type": "function",
988 "name": function.get("name", ""),
989 "description": function.get("description", ""),
990 "parameters": _normalize_function_parameters(
991 function.get("parameters")
992 ),
993 }
994 if "strict" in function:
995 response_tool["strict"] = function["strict"]
996 response_tools.append(response_tool)
997 else:
998 response_tools.append(dict(tool))
999 return response_tools
1000
1001 @staticmethod
1002 def tool_choice_from_function_call(function_call: Any) -> Any:
1003 if isinstance(function_call, str):
1004 return function_call
1005 if isinstance(function_call, dict) and function_call.get("name"):
1006 return {"type": "function", "name": function_call["name"]}
1007 return function_call
1008
1009 @staticmethod
1010 def tool_choice_from_chat(tool_choice: Any) -> Any:
1011 if (
1012 isinstance(tool_choice, dict)
1013 and tool_choice.get("type") == "function"
1014 and isinstance(tool_choice.get("function"), dict)
1015 ):
1016 return {"type": "function", "name": tool_choice["function"].get("name", "")}
1017 return tool_choice
1018
1019 @staticmethod
1020 def text_from_response_format(response_format: Any) -> tuple[Any, Any]:
1021 if isinstance(response_format, type):
1022 return None, response_format
1023 if not isinstance(response_format, dict):
1024 return response_format, None
1025
1026 format_type = response_format.get("type")
1027 if format_type == "json_schema":
1028 schema = response_format.get("json_schema") or {}
1029 return (
1030 {
1031 "format": {
1032 "type": "json_schema",
1033 "name": schema.get("name", "response_schema"),
1034 "schema": schema.get("schema", {}),
1035 "strict": schema.get("strict", False),
1036 }
1037 },
1038 None,
1039 )
1040 if format_type:
1041 return {"format": {"type": format_type}}, None
1042 return response_format, None
1043
1044 @staticmethod
1045 def normalize_reasoning(reasoning: Any) -> Any:
1046 if isinstance(reasoning, dict):
1047 normalized = dict(reasoning)
1048 if "effort" in normalized:
1049 effort = _normalize_reasoning_effort(normalized.get("effort"))
1050 if effort is None:
1051 normalized.pop("effort", None)
1052 else:
1053 normalized["effort"] = effort
1054 return normalized or None
1055 if reasoning is None:
1056 return None
1057 effort = _normalize_reasoning_effort(reasoning)
1058 return {"effort": effort} if effort is not None else None
1059
1060 @classmethod
1061 def parse_response(cls, response: Any) -> ChatChunk:
1062 response_delta = cls.output_text(response)
1063 reasoning_delta = cls.reasoning_text(response)
1064 if not response_delta:
1065 response_delta = cls.function_calls_text(response)
1066 return {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
1067
1068 @classmethod
1069 def parse_event(cls, event: Any) -> ChatChunk:
1070 return ResponsesEventParser().parse(event)
1071
1072 @classmethod
1073 def output_text(cls, response: Any) -> str:
1074 output_text = _get_value(response, "output_text")
1075 if isinstance(output_text, str):
1076 return output_text
1077
1078 pieces: list[str] = []
1079 for item in _as_list(_get_value(response, "output")):
1080 if _get_value(item, "type") != "message":
1081 continue
1082 for block in _as_list(_get_value(item, "content")):
1083 block_type = _get_value(block, "type")
1084 if block_type in {"output_text", "text"}:
1085 text = _get_value(block, "text")
1086 if isinstance(text, str):
1087 pieces.append(text)
1088 elif block_type == "refusal":
1089 refusal = _get_value(block, "refusal")
1090 if isinstance(refusal, str):
1091 pieces.append(refusal)
1092 return "".join(pieces)
1093
1094 @staticmethod
1095 def reasoning_text(response: Any) -> str:
1096 pieces: list[str] = []
1097 for item in _as_list(_get_value(response, "output")):
1098 if _get_value(item, "type") != "reasoning":
1099 continue
1100 for block in _as_list(_get_value(item, "summary")):
1101 text = _get_value(block, "text") or _get_value(block, "reasoning")
1102 if isinstance(text, str):
1103 pieces.append(text)
1104 return "".join(pieces)
1105
1106 @classmethod
1107 def function_calls_text(cls, response: Any) -> str:
1108 calls = [
1109 cls.function_call_object(item)
1110 for item in _as_list(_get_value(response, "output"))
1111 ]
1112 calls = [call for call in calls if call]
1113 if not calls:
1114 return ""
1115 if len(calls) == 1:
1116 return json.dumps(calls[0])
1117 return json.dumps(
1118 {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}}
1119 )
1120
1121 @classmethod
1122 def function_call_text(cls, item: Any) -> str:
1123 call = cls.function_call_object(item)
1124 if not call:
1125 return ""
1126 return json.dumps(call, ensure_ascii=False)
1127
1128 @staticmethod
1129 def function_call_object(item: Any) -> dict[str, Any]:
1130 if _get_value(item, "type") != "function_call":
1131 return {}
1132 name = _get_value(item, "name")
1133 if not name:
1134 return {}
1135 raw_arguments = _get_value(item, "arguments") or "{}"
1136 if isinstance(raw_arguments, str):
1137 try:
1138 args = json.loads(raw_arguments or "{}")
1139 except Exception:
1140 args = {"arguments": raw_arguments}
1141 elif isinstance(raw_arguments, dict):
1142 args = raw_arguments
1143 else:
1144 args = {"arguments": raw_arguments}
1145 if not isinstance(args, dict):
1146 args = {"arguments": args}
1147 return {
1148 "tool_name": str(name),
1149 "tool_args": args,
1150 }
1151
1152
1153 class ResponsesEventParser:
1154 """Stateful parser for Responses streaming events."""
1155
1156 def __init__(self) -> None:
1157 self.function_calls: dict[str, dict[str, Any]] = {}
1158 self.output_index_keys: dict[str, str] = {}
1159 self.emitted_function_calls: set[str] = set()
1160 self.streamed_response_calls: dict[str, str] = {}
1161 self.seen_response_delta = False
1162 self.seen_reasoning_delta = False
1163 self.completed_response: Any = None
1164
1165 def parse(self, event: Any) -> ChatChunk:
1166 event_type = _get_value(event, "type") or ""
1167 response_delta = ""
1168 reasoning_delta = ""
1169
1170 if event_type in {
1171 "response.output_text.delta",
1172 "response.refusal.delta",
1173 "response.text.delta",
1174 }:
1175 response_delta = str(_get_value(event, "delta") or "")
1176 elif event_type in {
1177 "response.reasoning_summary_text.delta",
1178 "response.reasoning_text.delta",
1179 }:
1180 reasoning_delta = str(_get_value(event, "delta") or "")
1181 elif event_type == "response.output_item.added":
1182 self._remember_function_call(_get_value(event, "item"), event)
1183 elif event_type == "response.function_call_arguments.delta":
1184 response_delta = self._append_function_call_arguments(event)
1185 elif event_type == "response.function_call_arguments.done":
1186 response_delta = self._complete_function_call(event)
1187 elif event_type == "response.output_item.done":
1188 response_delta = self._complete_output_item(_get_value(event, "item"), event)
1189 elif event_type == "response.completed":
1190 response_delta, reasoning_delta = self._complete_response(event)
1191 elif event_type == "response.failed":
1192 raise RuntimeError(self._response_error_message(event))
1193 elif event_type == "error":
1194 error = _get_value(event, "error")
1195 message = _get_value(error, "message") or error
1196 raise RuntimeError(str(message))
1197
1198 if response_delta:
1199 self.seen_response_delta = True
1200 if reasoning_delta:
1201 self.seen_reasoning_delta = True
1202
1203 return {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
1204
1205 def _remember_function_call(self, item: Any, event: Any) -> str:
1206 if _get_value(item, "type") != "function_call":
1207 return ""
1208 key = self._event_key(event, item)
1209 if not key:
1210 return ""
1211 current = self.function_calls.get(key, {})
1212 merged = {**current, **_object_to_dict(item)}
1213 self.function_calls[key] = merged
1214 output_index = _get_value(event, "output_index")
1215 if output_index is not None:
1216 self.output_index_keys[str(output_index)] = key
1217 return key
1218
1219 def _append_function_call_arguments(self, event: Any) -> str:
1220 key = self._event_key(event)
1221 if not key:
1222 return ""
1223 current = self.function_calls.setdefault(key, {"type": "function_call"})
1224 delta = str(_get_value(event, "delta") or "")
1225 current["arguments"] = str(current.get("arguments") or "") + delta
1226 if current.get("name") != "response":
1227 return ""
1228 if key not in self.streamed_response_calls:
1229 self.streamed_response_calls[key] = str(current["arguments"])
1230 return '{"tool_name":"response","tool_args":' + str(
1231 current["arguments"]
1232 )
1233 self.streamed_response_calls[key] += delta
1234 return delta
1235
1236 def _complete_function_call(self, event: Any) -> str:
1237 key = self._event_key(event)
1238 if not key:
1239 return ""
1240 current = self.function_calls.setdefault(key, {"type": "function_call"})
1241 if _get_value(event, "arguments") is not None:
1242 current["arguments"] = _get_value(event, "arguments")
1243 if _get_value(event, "name"):
1244 current["name"] = _get_value(event, "name")
1245 if key in self.streamed_response_calls:
1246 return self._finish_response_call(key, current)
1247 return self._emit_function_call(key, current)
1248
1249 def _complete_output_item(self, item: Any, event: Any) -> str:
1250 key = self._remember_function_call(item, event)
1251 if not key:
1252 return ""
1253 if key in self.streamed_response_calls:
1254 return self._finish_response_call(key, self.function_calls[key])
1255 return self._emit_function_call(key, self.function_calls[key])
1256
1257 def _finish_response_call(self, key: str, item: Any) -> str:
1258 streamed = self.streamed_response_calls.pop(key)
1259 arguments = str(_get_value(item, "arguments") or "")
1260 self.emitted_function_calls.add(key)
1261 tail = arguments[len(streamed) :] if arguments.startswith(streamed) else ""
1262 return tail + "}"
1263
1264 def _complete_response(self, event: Any) -> tuple[str, str]:
1265 self.completed_response = _get_value(event, "response")
1266 if self.seen_response_delta or self.emitted_function_calls:
1267 return "", ""
1268 parsed = ResponsesTransport.parse_response(self.completed_response)
1269 if self.seen_reasoning_delta:
1270 parsed["reasoning_delta"] = ""
1271 return parsed["response_delta"], parsed["reasoning_delta"]
1272
1273 def _emit_function_call(self, key: str, item: Any) -> str:
1274 if key in self.emitted_function_calls:
1275 return ""
1276 text = ResponsesTransport.function_call_text(item)
1277 if text:
1278 self.emitted_function_calls.add(key)
1279 return text
1280
1281 def _event_key(self, event: Any, item: Any = None) -> str:
1282 key = _get_value(event, "item_id") or _get_value(item, "id")
1283 if key:
1284 return str(key)
1285 output_index = _get_value(event, "output_index")
1286 if output_index is not None:
1287 output_key = self.output_index_keys.get(str(output_index))
1288 if output_key:
1289 return output_key
1290 return f"output:{output_index}"
1291 return ""
1292
1293 def _response_error_message(self, event: Any) -> str:
1294 response = _get_value(event, "response") or {}
1295 error = _get_value(response, "error") or _get_value(event, "error")
1296 message = _get_value(error, "message") or error
1297 return str(message or "Responses API request failed")
1298
1299
1300 def clear_transport_capability_cache() -> None:
1301 RESPONSES_UNSUPPORTED_CACHE.clear()
1302 RESPONSES_STATE_UNSUPPORTED_CACHE.clear()
1303 RESPONSES_BUILTIN_UNSUPPORTED_CACHE.clear()
1304
1305
1306 def delete_stored_response_ids(
1307 response_ids: list[str], **kwargs: Any
1308 ) -> list[tuple[str, str]]:
1309 errors: list[tuple[str, str]] = []
1310 for response_id in response_ids:
1311 try:
1312 delete_responses(response_id=response_id, **kwargs)
1313 except Exception as exc:
1314 errors.append((response_id, _exception_text(exc)))
1315 return errors
1316
1317
1318 async def adelete_stored_response_ids(
1319 response_ids: list[str], **kwargs: Any
1320 ) -> list[tuple[str, str]]:
1321 errors: list[tuple[str, str]] = []
1322 for response_id in response_ids:
1323 try:
1324 await adelete_responses(response_id=response_id, **kwargs)
1325 except Exception as exc:
1326 errors.append((response_id, _exception_text(exc)))
1327 return errors
1328
1329
1330 def _coerce_bool(value: Any, default: bool = False) -> bool:
1331 if value is None:
1332 return default
1333 if isinstance(value, bool):
1334 return value
1335 if isinstance(value, str):
1336 normalized = value.strip().lower()
1337 if normalized in {"1", "true", "yes", "on"}:
1338 return True
1339 if normalized in {"0", "false", "no", "off", "none"}:
1340 return False
1341 return bool(value)
1342
1343
1344 def _responses_cache_key(model: str, kwargs: dict[str, Any]) -> str:
1345 api_base = (
1346 kwargs.get("api_base")
1347 or kwargs.get("base_url")
1348 or kwargs.get("api_base_url")
1349 or ""
1350 )
1351 custom_provider = kwargs.get("custom_llm_provider") or ""
1352 return "|".join(str(part) for part in (model, custom_provider, api_base))
1353
1354
1355 def _drop_legacy_transport_kwargs(kwargs: dict[str, Any]) -> None:
1356 kwargs.pop("a0_api_mode", None)
1357 kwargs.pop("a0_responses_fallback", None)
1358
1359
1360 def _drop_responses_only_kwargs(kwargs: dict[str, Any]) -> None:
1361 kwargs.pop("responses_state", None)
1362 kwargs.pop("responses_delete_on_chat_delete", None)
1363 kwargs.pop("responses_input_items", None)
1364 kwargs.pop("responses_local_input_items", None)
1365 kwargs.pop("previous_response_id", None)
1366 kwargs.pop("_a0_responses_builtin_downgrades", None)
1367
1368
1369 def _drop_internal_transport_kwargs(kwargs: dict[str, Any]) -> None:
1370 _drop_legacy_transport_kwargs(kwargs)
1371 kwargs.pop("a0_explicit_prompt_caching", None)
1372 kwargs.pop("a0_responses_function_tools", None)
1373 kwargs.pop("responses_builtin_tools", None)
1374 _drop_responses_only_kwargs(kwargs)
1375
1376
1377 def _normalize_responses_state(value: Any) -> str:
1378 normalized = str(value or RESPONSES_STATE_PROVIDER).strip().lower()
1379 return normalized if normalized in RESPONSES_STATES else RESPONSES_STATE_PROVIDER
1380
1381
1382 def _filter_unsupported_builtin_tools(kwargs: dict[str, Any], cache_key: str) -> None:
1383 unsupported = RESPONSES_BUILTIN_UNSUPPORTED_CACHE.get(cache_key)
1384 if not unsupported:
1385 return
1386 tools = kwargs.get("responses_builtin_tools")
1387 if not isinstance(tools, list):
1388 return
1389 filtered = []
1390 downgraded = []
1391 for tool in tools:
1392 tool_type = _response_tool_type(tool)
1393 if tool_type in unsupported:
1394 downgraded.append(tool_type)
1395 continue
1396 filtered.append(tool)
1397 if downgraded:
1398 kwargs["responses_builtin_tools"] = filtered
1399 kwargs["_a0_responses_builtin_downgrades"] = sorted(set(downgraded))
1400
1401
1402 def _builtin_tool_types(tools: Any) -> set[str]:
1403 return {
1404 tool_type
1405 for tool in _as_list(tools)
1406 if (tool_type := _response_tool_type(tool))
1407 }
1408
1409
1410 def _response_tool_type(tool: Any) -> str:
1411 if isinstance(tool, str):
1412 return tool
1413 if isinstance(tool, dict):
1414 return str(tool.get("type") or "")
1415 return ""
1416
1417
1418 def _normalize_function_parameters(parameters: Any) -> dict[str, Any]:
1419 if not isinstance(parameters, dict):
1420 return _permissive_function_parameters()
1421
1422 normalized = dict(parameters)
1423 normalized.setdefault("type", "object")
1424 if normalized.get("type") == "object" and not isinstance(
1425 normalized.get("properties"), dict
1426 ):
1427 normalized["properties"] = {}
1428 return normalized or _permissive_function_parameters()
1429
1430
1431 def _permissive_function_parameters() -> dict[str, Any]:
1432 return {"type": "object", "properties": {}, "additionalProperties": True}
1433
1434
1435 def apply_chat_prompt_cache_markers(
1436 messages: list[dict[str, Any]],
1437 *,
1438 model: str = "",
1439 kwargs: dict[str, Any] | None = None,
1440 ) -> list[dict[str, Any]]:
1441 if not _supports_cache_control_markers(model, kwargs or {}):
1442 return [dict(message) for message in messages]
1443
1444 prepared = [_strip_message_cache_control(message) for message in messages]
1445 for index in _prompt_cache_message_indexes(prepared):
1446 prepared[index] = _message_with_cache_control(prepared[index])
1447 return prepared
1448
1449
1450 def _prompt_cache_message_indexes(messages: list[dict[str, Any]]) -> list[int]:
1451 indexes: list[int] = []
1452
1453 leading_context: list[int] = []
1454 for index, message in enumerate(messages):
1455 role = str(message.get("role") or "")
1456 if role in {"system", "developer"}:
1457 leading_context.append(index)
1458 continue
1459 break
1460 if leading_context:
1461 indexes.append(leading_context[-1])
1462
1463 user_indexes = [
1464 index
1465 for index, message in enumerate(messages)
1466 if str(message.get("role") or "") == "user"
1467 ]
1468 indexes.extend(user_indexes[-2:])
1469
1470 deduplicated: list[int] = []
1471 for index in indexes:
1472 if index not in deduplicated:
1473 deduplicated.append(index)
1474 return deduplicated[:3]
1475
1476
1477 def _strip_message_cache_control(message: dict[str, Any]) -> dict[str, Any]:
1478 result = dict(message)
1479 result.pop("cache_control", None)
1480 return result
1481
1482
1483 def _message_with_cache_control(message: dict[str, Any]) -> dict[str, Any]:
1484 result = dict(message)
1485 result["content"] = _content_with_cache_control(result.get("content", ""))
1486 return result
1487
1488
1489 def _content_with_cache_control(content: Any) -> Any:
1490 marker = _cache_control_marker()
1491 if isinstance(content, list):
1492 blocks = [_copy_content_block(block) for block in content]
1493 if not blocks:
1494 return [{"type": "text", "text": "", "cache_control": marker}]
1495 for index in range(len(blocks) - 1, -1, -1):
1496 block = blocks[index]
1497 if isinstance(block, dict):
1498 block["cache_control"] = marker
1499 return blocks
1500 if isinstance(block, str):
1501 blocks[index] = {
1502 "type": "text",
1503 "text": block,
1504 "cache_control": marker,
1505 }
1506 return blocks
1507 return blocks
1508
1509 if isinstance(content, dict):
1510 block = dict(content)
1511 block["cache_control"] = marker
1512 return block
1513
1514 return [
1515 {
1516 "type": "text",
1517 "text": _content_to_text(content),
1518 "cache_control": marker,
1519 }
1520 ]
1521
1522
1523 def _copy_content_block(block: Any) -> Any:
1524 if isinstance(block, dict):
1525 return dict(block)
1526 if isinstance(block, list):
1527 return [_copy_content_block(item) for item in block]
1528 return block
1529
1530
1531 def _apply_tool_cache_control(kwargs: dict[str, Any]) -> None:
1532 tools = kwargs.get("tools")
1533 if not isinstance(tools, list) or not tools or _has_cache_control(tools):
1534 return
1535
1536 prepared = [dict(tool) if isinstance(tool, dict) else tool for tool in tools]
1537 for index in range(len(prepared) - 1, -1, -1):
1538 tool = prepared[index]
1539 if not isinstance(tool, dict):
1540 continue
1541 if tool.get("type") == "function" and isinstance(tool.get("function"), dict):
1542 function = dict(tool["function"])
1543 function["cache_control"] = _cache_control_marker()
1544 tool = dict(tool)
1545 tool["function"] = function
1546 prepared[index] = tool
1547 else:
1548 tool = dict(tool)
1549 tool["cache_control"] = _cache_control_marker()
1550 prepared[index] = tool
1551 kwargs["tools"] = prepared
1552 return
1553
1554
1555 def _cache_control_marker() -> dict[str, str]:
1556 return {"type": "ephemeral"}
1557
1558
1559 def _should_preserve_cache_control_on_chat(
1560 model: str,
1561 kwargs: dict[str, Any],
1562 messages: list[dict[str, Any]],
1563 ) -> bool:
1564 if not (_has_cache_control(messages) or _has_cache_control(kwargs.get("tools"))):
1565 return False
1566 return not _is_native_responses_provider(model, kwargs)
1567
1568
1569 def _is_native_responses_provider(model: str, kwargs: dict[str, Any]) -> bool:
1570 api_base = _api_base(kwargs)
1571 if "openrouter.ai" in api_base or "anthropic.com" in api_base:
1572 return False
1573
1574 provider = _normalized_provider(model, kwargs)
1575 return provider in {"openai", "azure", "azure_ai", "xai"}
1576
1577
1578 def _supports_cache_control_markers(
1579 model: str,
1580 kwargs: dict[str, Any],
1581 ) -> bool:
1582 api_base = _api_base(kwargs)
1583 if "openrouter.ai" in api_base or "anthropic.com" in api_base:
1584 return True
1585 provider = _normalized_provider(model, kwargs)
1586 return provider in CACHE_CONTROL_PROMPT_PROVIDERS
1587
1588
1589 def _is_openai_prompt_cache_provider(model: str, kwargs: dict[str, Any]) -> bool:
1590 api_base = _api_base(kwargs)
1591 provider = _normalized_provider(model, kwargs)
1592
1593 if api_base:
1594 if "api.openai.com" in api_base:
1595 return provider in {"", "openai"}
1596 if "openai.azure.com" in api_base:
1597 return provider in {"", "azure", "openai"}
1598 return False
1599
1600 if not provider and str(model):
1601 provider = "openai"
1602 return provider in OPENAI_PROMPT_CACHE_PROVIDERS
1603
1604
1605 def _api_base(kwargs: dict[str, Any]) -> str:
1606 return str(
1607 kwargs.get("api_base")
1608 or kwargs.get("base_url")
1609 or kwargs.get("api_base_url")
1610 or ""
1611 ).lower()
1612
1613
1614 def _normalized_provider(model: str, kwargs: dict[str, Any]) -> str:
1615 provider = str(kwargs.get("custom_llm_provider") or "").strip().lower()
1616 if not provider and "/" in str(model):
1617 provider = str(model).split("/", 1)[0].strip().lower()
1618 return provider.replace("-", "_")
1619
1620
1621 def _prepare_openai_prompt_cache_params(
1622 request: dict[str, Any],
1623 messages: list[dict[str, Any]],
1624 *,
1625 model: str = "",
1626 ) -> None:
1627 if "prompt_cache_key" in request:
1628 return
1629
1630 sanitized_messages = _without_cache_control(messages)
1631 sanitized_request = _without_cache_control(request)
1632 prompt_cache_key = _default_prompt_cache_key(
1633 model,
1634 sanitized_messages if isinstance(sanitized_messages, list) else messages,
1635 sanitized_request if isinstance(sanitized_request, dict) else request,
1636 )
1637 if prompt_cache_key:
1638 request["prompt_cache_key"] = prompt_cache_key
1639
1640
1641 def _default_prompt_cache_key(
1642 model: str,
1643 messages: list[dict[str, Any]],
1644 request: dict[str, Any],
1645 ) -> str:
1646 material = _prompt_cache_key_material(messages, request)
1647 if not material:
1648 return ""
1649 digest = hashlib.sha256(
1650 json.dumps(
1651 {
1652 "model": model,
1653 "material": material,
1654 },
1655 sort_keys=True,
1656 default=str,
1657 separators=(",", ":"),
1658 ).encode("utf-8")
1659 ).hexdigest()[:32]
1660 return f"a0-{digest}"
1661
1662
1663 def _prompt_cache_key_material(
1664 messages: list[dict[str, Any]],
1665 request: dict[str, Any],
1666 ) -> dict[str, Any]:
1667 material: dict[str, Any] = {}
1668
1669 leading_messages: list[dict[str, Any]] = []
1670 for message in messages:
1671 role = str(message.get("role") or "")
1672 if role not in {"system", "developer"}:
1673 break
1674 leading_messages.append(
1675 {
1676 "role": role,
1677 "content": message.get("content"),
1678 }
1679 )
1680 if leading_messages:
1681 material["messages"] = leading_messages
1682
1683 if request.get("instructions"):
1684 material["instructions"] = request["instructions"]
1685 if request.get("prompt"):
1686 material["prompt"] = request["prompt"]
1687 if request.get("tools"):
1688 material["tools"] = request["tools"]
1689
1690 return material
1691
1692
1693 def _has_cache_control(value: Any) -> bool:
1694 if isinstance(value, dict):
1695 if value.get("cache_control") is not None:
1696 return True
1697 return any(_has_cache_control(item) for item in value.values())
1698 if isinstance(value, list):
1699 return any(_has_cache_control(item) for item in value)
1700 return False
1701
1702
1703 def _without_cache_control(value: Any) -> Any:
1704 if isinstance(value, dict):
1705 return {
1706 key: _without_cache_control(item)
1707 for key, item in value.items()
1708 if key != "cache_control"
1709 }
1710 if isinstance(value, list):
1711 return [_without_cache_control(item) for item in value]
1712 return value
1713
1714
1715 def _object_to_dict(obj: Any) -> dict[str, Any]:
1716 if isinstance(obj, dict):
1717 return dict(obj)
1718 if hasattr(obj, "model_dump"):
1719 dumped = obj.model_dump()
1720 return dict(dumped) if isinstance(dumped, dict) else {}
1721 if hasattr(obj, "dict"):
1722 dumped = obj.dict()
1723 return dict(dumped) if isinstance(dumped, dict) else {}
1724 return {}
1725
1726
1727 def _reported_usage(response: Any) -> dict[str, Any]:
1728 usage = _object_to_dict(_get_value(response, "usage"))
1729 hidden = _object_to_dict(_get_value(response, "_hidden_params"))
1730 if usage.get("cost") is None:
1731 usage.pop("cost", None)
1732 if hidden.get("response_cost") is not None:
1733 usage["cost"] = hidden["response_cost"]
1734 return usage
1735
1736
1737 def _normalize_reasoning_effort(effort: Any) -> str | None:
1738 if isinstance(effort, str):
1739 normalized = effort.strip().lower()
1740 else:
1741 normalized = str(effort).strip().lower() if effort is not None else ""
1742 if normalized in RESPONSES_REASONING_EFFORTS:
1743 return normalized
1744 if normalized in NO_REASONING_EFFORT_ALIASES:
1745 return None
1746 return RESPONSES_REASONING_FALLBACK_EFFORT
1747
1748
1749 def _is_responses_reasoning_effort_error(exc: Exception) -> bool:
1750 text = _exception_text(exc).lower()
1751 return (
1752 "response.reasoning.effort" in text
1753 and "minimal" in text
1754 and "high" in text
1755 and "none" in text
1756 )
1757
1758
1759 def _is_responses_not_supported_error(exc: Exception) -> bool:
1760 text = _exception_text(exc).lower()
1761 if any(marker in text for marker in ("429", "too many requests", "rate limit")):
1762 return False
1763 if _is_sse_json_decode_error(exc):
1764 return True
1765 if _is_bad_request_error(exc) and _looks_like_responses_request_rejected(text):
1766 return True
1767 if _is_server_error(exc) and _looks_like_responses_endpoint(text):
1768 return True
1769 if _is_not_found_error(exc) and _looks_like_responses_endpoint_not_found(text):
1770 return True
1771 if "/v1/responses" in text and any(
1772 marker in text for marker in ("404", "not found")
1773 ):
1774 return True
1775 return any(
1776 marker in text
1777 for marker in (
1778 "responses api",
1779 "does not support responses",
1780 "not support responses",
1781 "unsupportedparamserror",
1782 "does not support parameters",
1783 "no 'tools' defined while 'tool_choice' is specified",
1784 "tools` must not be an empty array",
1785 "tools must not be an empty array",
1786 "not available through this proxy",
1787 "litellm[proxy]",
1788 "no module named 'fastapi'",
1789 )
1790 )
1791
1792
1793 def _is_not_found_error(exc: Exception) -> bool:
1794 if _exception_status_code(exc) == 404:
1795 return True
1796 return "notfounderror" in _exception_type_chain(exc).lower()
1797
1798
1799 def _is_bad_request_error(exc: Exception) -> bool:
1800 if _exception_status_code(exc) == 400:
1801 return True
1802 type_chain = _exception_type_chain(exc).lower()
1803 if "badrequesterror" in type_chain:
1804 return True
1805 text = _exception_text(exc).lower()
1806 return "400" in text and "bad request" in text
1807
1808
1809 def _is_server_error(exc: Exception) -> bool:
1810 status_code = _exception_status_code(exc)
1811 if isinstance(status_code, int) and 500 <= status_code < 600:
1812 return True
1813 type_chain = _exception_type_chain(exc).lower()
1814 if "internalservererror" in type_chain:
1815 return True
1816 text = _exception_text(exc).lower()
1817 return any(
1818 marker in text
1819 for marker in (
1820 "500 internal server error",
1821 "server error '500",
1822 "internalservererror",
1823 )
1824 )
1825
1826
1827 def _is_sse_json_decode_error(exc: Exception) -> bool:
1828 current: BaseException | None = exc
1829 while current is not None:
1830 if isinstance(current, json.JSONDecodeError) and _looks_like_sse_payload(
1831 current.doc
1832 ):
1833 return True
1834 current = current.__cause__ or (
1835 current.__context__ if current.__context__ is not current.__cause__ else None
1836 )
1837 return _looks_like_sse_payload(_exception_text(exc))
1838
1839
1840 def _looks_like_sse_payload(text: Any) -> bool:
1841 if not isinstance(text, str):
1842 return False
1843 lowered = text.lstrip().lower()
1844 return lowered.startswith("event:") and "\ndata:" in lowered
1845
1846
1847 def _looks_like_responses_request_rejected(text: str) -> bool:
1848 if "/v1/responses" in text or "responses api" in text:
1849 return True
1850 return any(
1851 marker in text
1852 for marker in (
1853 "input_image",
1854 "response.input",
1855 "expected object, received string",
1856 "expected string, received array",
1857 "zod",
1858 "failed to deserialize input",
1859 "failed to deserialize response",
1860 "failed to deserialize responses",
1861 "cannot determine type",
1862 )
1863 )
1864
1865
1866 def _looks_like_responses_endpoint_not_found(text: str) -> bool:
1867 if "/v1/responses" in text:
1868 return True
1869 if "not found" not in text:
1870 return False
1871 if "openaiexception" in text:
1872 return True
1873 return "detail" in text and "not found" in text
1874
1875
1876 def _looks_like_responses_endpoint(text: str) -> bool:
1877 return "/responses" in text or "path /api/v1/responses" in text
1878
1879
1880 def _is_responses_state_unsupported_error(exc: Exception) -> bool:
1881 text = _exception_text(exc).lower()
1882 if any(marker in text for marker in ("429", "too many requests", "rate limit")):
1883 return False
1884 if "404" in text and "/v1/responses/" in text:
1885 return True
1886 return any(
1887 marker in text
1888 for marker in (
1889 "previous_response_id",
1890 "store",
1891 "stored response",
1892 "response not found",
1893 "no response found",
1894 "does not support response storage",
1895 "doesn't support response storage",
1896 "response storage is not supported",
1897 "state is not supported",
1898 )
1899 )
1900
1901
1902 def _is_responses_builtin_tool_error(exc: Exception) -> bool:
1903 text = _exception_text(exc).lower()
1904 if any(marker in text for marker in ("429", "too many requests", "rate limit")):
1905 return False
1906 return any(
1907 marker in text
1908 for marker in (
1909 "unsupported tool",
1910 "unsupported tools",
1911 "invalid tool",
1912 "tool type",
1913 "tools[",
1914 "web_search",
1915 "file_search",
1916 "code_interpreter",
1917 "image_generation",
1918 "computer_use_preview",
1919 "mcp",
1920 )
1921 )
1922
1923
1924 def _exception_text(exc: Exception | None) -> str:
1925 if exc is None:
1926 return ""
1927 parts = [exc.__class__.__name__, str(exc)]
1928 for attr in ("status_code", "code", "message", "body"):
1929 value = getattr(exc, attr, None)
1930 if value not in (None, ""):
1931 parts.append(f"{attr}={value}")
1932 response = getattr(exc, "response", None)
1933 if response is not None:
1934 response_text = getattr(response, "text", None)
1935 if response_text:
1936 parts.append(str(response_text))
1937 response_url = getattr(response, "url", None)
1938 if response_url:
1939 parts.append(str(response_url))
1940 cause = getattr(exc, "__cause__", None)
1941 context = getattr(exc, "__context__", None)
1942 if cause is not None:
1943 parts.append(str(cause))
1944 if context is not None and context is not cause:
1945 parts.append(str(context))
1946 return "\n".join(parts)
1947
1948
1949 def _exception_status_code(exc: Exception | None) -> int | None:
1950 if exc is None:
1951 return None
1952 for attr in ("status_code", "code"):
1953 value = getattr(exc, attr, None)
1954 if isinstance(value, int):
1955 return value
1956 if isinstance(value, str) and value.isdigit():
1957 return int(value)
1958 response = getattr(exc, "response", None)
1959 value = getattr(response, "status_code", None)
1960 return value if isinstance(value, int) else None
1961
1962
1963 def _exception_type_chain(exc: Exception | None) -> str:
1964 names: list[str] = []
1965 current = exc
1966 while current is not None:
1967 names.append(current.__class__.__name__)
1968 cause = getattr(current, "__cause__", None)
1969 context = getattr(current, "__context__", None)
1970 current = cause or (context if context is not cause else None)
1971 return "\n".join(names)
1972
1973
1974 def _close_sync_stream(stream: Any) -> None:
1975 for method_name in ("close", "aclose"):
1976 close = getattr(stream, method_name, None)
1977 if close is None:
1978 continue
1979 result = close()
1980 if inspect.isawaitable(result):
1981 result.close()
1982 return
1983
1984
1985 async def _close_async_stream(stream: Any) -> None:
1986 for method_name in ("aclose", "close"):
1987 close = getattr(stream, method_name, None)
1988 if close is None:
1989 continue
1990 result = close()
1991 if inspect.isawaitable(result):
1992 await result
1993 return
1994
1995
1996 def _without_stream_kwarg(kwargs: dict[str, Any]) -> dict[str, Any]:
1997 kwargs.pop("stream", None)
1998 return kwargs
1999
2000
2001 def _first_choice(chunk: Any) -> Any:
2002 choices = _get_value(chunk, "choices") or []
2003 return choices[0] if choices else {}
2004
2005
2006 def _get_value(obj: Any, key: str) -> Any:
2007 if isinstance(obj, dict):
2008 return obj.get(key)
2009 value = getattr(obj, key, None)
2010 if value is not None:
2011 return value
2012 return _object_to_dict(obj).get(key)
2013
2014
2015 def _as_list(value: Any) -> list[Any]:
2016 return value if isinstance(value, list) else []
2017
2018
2019 def _has_tools(tools: Any) -> bool:
2020 if isinstance(tools, list):
2021 return bool(tools)
2022 return bool(tools)
2023
2024
2025 def _has_chunk_delta(chunk: ChatChunk) -> bool:
2026 return bool(chunk.get("response_delta") or chunk.get("reasoning_delta"))
2027
2028
2029 def _has_real_content(content: Any) -> bool:
2030 if content == "empty":
2031 return False
2032 if isinstance(content, str):
2033 return bool(content.strip())
2034 if isinstance(content, list):
2035 return len(content) > 0
2036 return content is not None
2037
2038
2039 def _content_to_text(content: Any) -> str:
2040 content = images.prepare_content(content)
2041 if isinstance(content, str):
2042 return content
2043 if isinstance(content, list):
2044 pieces: list[str] = []
2045 for item in content:
2046 if isinstance(item, str):
2047 pieces.append(item)
2048 elif isinstance(item, dict):
2049 text = item.get("text")
2050 if isinstance(text, str):
2051 pieces.append(text)
2052 return "\n".join(pieces)
2053 return "" if content is None else str(content)