Add native Responses API transport
Route Agent Zero turns through a LiteLLM transport layer that prefers the Responses API while preserving chat-completions fallback for providers without compatible endpoints. Persist Responses metadata in history and agent state so provider-state continuation, local replay, native function-call execution, and stored-response cleanup survive normal chat workflows. Normalize prompt caching by provider: OpenAI and Azure use prompt_cache_key and prompt_cache_retention, while Anthropic, Gemini, Bedrock, OpenRouter, and compatible chat providers keep block-level cache_control breakpoints and cached tool definitions.
Alessandro committed
Jun 1, 2026 at 01:32 UTC
b04443be1a23926b08c803d230ecd746ee559b45
9 files changed
+4300
-158
agent.py
+539
-10
@@ -1,4 +1,4 @@
1
-import asyncio, random, string, threading
1
+import asyncio, json, random, re, string, threading
2
3
from collections import OrderedDict
4
from dataclasses import dataclass, field
@@ -32,6 +32,15 @@ from typing import Callable
32
from helpers.localization import Localization
33
from helpers import extension
34
from helpers.errors import RepairableException, InterventionException, HandledException
35
+from helpers.llm_result import (
36
+ LLMResult,
37
+ RESPONSE_METADATA_KEY,
38
+ function_call_output_item,
39
+ metadata_from_llm_result,
40
+ result_from_metadata,
41
+)
42
+from helpers.litellm_transport import ResponsesTransport
43
+from helpers.responses_tools import build_responses_function_tools, original_tool_name
44
45
class AgentContextType(Enum):
46
USER = "user"
@@ -346,6 +355,9 @@ class Agent:
355
DATA_NAME_SUPERIOR = "_superior"
356
DATA_NAME_SUBORDINATE = "_subordinate"
357
DATA_NAME_CTX_WINDOW = "ctx_window"
358
+ DATA_NAME_RESPONSES_STATE = "responses_state"
359
+ DATA_NAME_RESPONSES_TOOL_NAME_MAP = "responses_tool_name_map"
360
+ DATA_NAME_RESPONSES_COMPUTER_SESSION = "responses_computer_session_id"
361
362
@extension.extensible
363
def __init__(
@@ -468,11 +480,12 @@ class Agent:
480
return stop_response
481
482
# call main LLM
471
- agent_response, _reasoning = await self.call_chat_model(
483
+ llm_result = await self.call_chat_model_turn(
484
messages=prompt,
485
response_callback=stream_callback,
486
reasoning_callback=reasoning_callback,
487
)
488
+ agent_response = llm_result.response
489
await self.handle_intervention(agent_response)
490
491
# Notify extensions to finalize their stream filters
@@ -492,7 +505,12 @@ class Agent:
505
): # if assistant_response is the same as last message in history, let him know
506
# Append the assistant's response to the history
507
log_item = self.loop_data.params_temporary.get("log_item_generating")
495
- self.hist_add_ai_response(agent_response, id=log_item.id if log_item else "")
508
+ assistant_message = self.hist_add_ai_response(
509
+ agent_response,
510
+ id=log_item.id if log_item else "",
511
+ llm_result=llm_result,
512
+ )
513
+ self._remember_llm_result_state(llm_result, assistant_message)
514
# Append warning message to the history
515
warning_msg = self.read_prompt("fw.msg_repeat.md")
516
wmsg = self.hist_add_warning(message=warning_msg)
@@ -504,9 +522,16 @@ class Agent:
522
else: # otherwise proceed with tool
523
# Append the assistant's response to the history
524
log_item = self.loop_data.params_temporary.get("log_item_generating")
507
- self.hist_add_ai_response(agent_response, id=log_item.id if log_item else "")
525
+ assistant_message = self.hist_add_ai_response(
526
+ agent_response,
527
+ id=log_item.id if log_item else "",
528
+ llm_result=llm_result,
529
+ )
530
+ self._remember_llm_result_state(llm_result, assistant_message)
531
# process tools requested in agent message
509
- tools_result = await self.process_tools(agent_response)
532
+ tools_result = await self.process_llm_result_tools(
533
+ llm_result
534
+ )
535
if tools_result: # final response of message loop available
536
return tools_result # break the execution if the task is done
537
@@ -664,7 +689,12 @@ class Agent:
689
690
@extension.extensible
691
def hist_add_message(
667
- self, ai: bool, content: history.MessageContent, tokens: int = 0, id: str = ""
692
+ self,
693
+ ai: bool,
694
+ content: history.MessageContent,
695
+ tokens: int = 0,
696
+ id: str = "",
697
+ metadata: dict[str, Any] | None = None,
698
):
699
self.last_message = Localization.get().now()
700
# Allow extensions to process content before adding to history
@@ -673,7 +703,11 @@ class Agent:
703
"hist_add_before", self, content_data=content_data, ai=ai
704
)
705
return self.history.add_message(
676
- ai=ai, content=content_data["content"], tokens=tokens, id=id
706
+ ai=ai,
707
+ content=content_data["content"],
708
+ tokens=tokens,
709
+ id=id,
710
+ metadata=metadata,
711
)
712
713
@extension.extensible
@@ -706,10 +740,17 @@ class Agent:
740
return msg
741
742
@extension.extensible
709
- def hist_add_ai_response(self, message: str, id: str = ""):
743
+ def hist_add_ai_response(
744
+ self, message: str, id: str = "", llm_result: LLMResult | None = None
745
+ ):
746
self.loop_data.last_response = message
747
content = self.parse_prompt("fw.ai_response.md", message=message)
712
- return self.hist_add_message(True, content=content, id=id)
748
+ return self.hist_add_message(
749
+ True,
750
+ content=content,
751
+ id=id,
752
+ metadata=metadata_from_llm_result(llm_result),
753
+ )
754
755
@extension.extensible
756
def hist_add_warning(self, message: history.MessageContent, id: str = ""):
@@ -719,13 +760,28 @@ class Agent:
760
@extension.extensible
761
def hist_add_tool_result(self, tool_name: str, tool_result: str, **kwargs):
762
msg_id = kwargs.pop("id", "")
763
+ responses_item = kwargs.pop("_responses_output_item", None) or kwargs.pop(
764
+ "responses_item", None
765
+ )
766
+ metadata = (
767
+ {
768
+ RESPONSE_METADATA_KEY: {
769
+ "input_items": [responses_item],
770
+ "output_items": [],
771
+ "mode": "responses",
772
+ "state": "provider",
773
+ }
774
+ }
775
+ if isinstance(responses_item, dict)
776
+ else None
777
+ )
778
data = {
779
"tool_name": tool_name,
780
"tool_result": tool_result,
781
**kwargs,
782
}
783
extension.call_extensions_sync("hist_add_tool_result", self, data=data)
728
- return self.hist_add_message(False, content=data, id=msg_id)
784
+ return self.hist_add_message(False, content=data, id=msg_id, metadata=metadata)
785
786
def concat_messages(
787
self, messages
@@ -830,6 +886,170 @@ class Agent:
886
887
return response, reasoning
888
889
+ @extension.extensible
890
+ async def call_chat_model_turn(
891
+ self,
892
+ messages: list[BaseMessage],
893
+ response_callback: Callable[[str, str], Awaitable[str | None]] | None = None,
894
+ reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None,
895
+ background: bool = False,
896
+ explicit_caching: bool = True,
897
+ ) -> LLMResult:
898
+ model = self.get_chat_model()
899
+ model_kwargs = getattr(model, "kwargs", {}) if model else {}
900
+ if isinstance(model_kwargs, dict) and model_kwargs.get("responses_delete_on_chat_delete") is False:
901
+ self.set_data("responses_delete_on_chat_delete", False)
902
+ response_tools, name_map = build_responses_function_tools(self)
903
+ self.set_data(Agent.DATA_NAME_RESPONSES_TOOL_NAME_MAP, name_map)
904
+
905
+ call_data = {
906
+ "model": model,
907
+ "messages": messages,
908
+ "response_callback": response_callback,
909
+ "reasoning_callback": reasoning_callback,
910
+ "background": background,
911
+ "explicit_caching": explicit_caching,
912
+ "a0_responses_function_tools": response_tools,
913
+ }
914
+
915
+ previous_state = self._responses_state_for_model(model)
916
+ if previous_state:
917
+ history_counter = int(previous_state.get("history_counter", 0) or 0)
918
+ call_data["previous_response_id"] = previous_state.get("response_id", "")
919
+ call_data["responses_input_items"] = self._responses_input_items_since(
920
+ model,
921
+ history_counter,
922
+ )
923
+ call_data["responses_local_input_items"] = (
924
+ self._responses_static_prefix_items(model, messages)
925
+ + self._responses_input_items_since(model, 0)
926
+ )
927
+
928
+ await extension.call_extensions_async(
929
+ "chat_model_call_before", self, call_data=call_data
930
+ )
931
+
932
+ turn_kwargs = {
933
+ "a0_responses_function_tools": call_data.get(
934
+ "a0_responses_function_tools"
935
+ ),
936
+ "responses_local_input_items": call_data.get(
937
+ "responses_local_input_items"
938
+ ),
939
+ }
940
+ for key in (
941
+ "responses_builtin_tools",
942
+ "responses_state",
943
+ "previous_response_id",
944
+ "responses_input_items",
945
+ ):
946
+ if call_data.get(key) is not None:
947
+ turn_kwargs[key] = call_data.get(key)
948
+
949
+ llm_result = await call_data["model"].unified_turn(
950
+ messages=call_data["messages"],
951
+ reasoning_callback=call_data["reasoning_callback"],
952
+ response_callback=call_data["response_callback"],
953
+ rate_limiter_callback=(
954
+ self.rate_limiter_callback if not call_data["background"] else None
955
+ ),
956
+ explicit_caching=call_data["explicit_caching"],
957
+ **turn_kwargs,
958
+ )
959
+
960
+ downgraded = llm_result.capability.get("builtin_tool_downgrades")
961
+ if downgraded:
962
+ self.context.log.log(
963
+ type="info",
964
+ heading="Responses capability downgrade",
965
+ content=(
966
+ "Provider rejected Responses built-in tool(s); omitted: "
967
+ + ", ".join(str(item) for item in downgraded)
968
+ ),
969
+ )
970
+
971
+ await extension.call_extensions_async(
972
+ "chat_model_call_after",
973
+ self,
974
+ call_data=call_data,
975
+ response=llm_result.response,
976
+ reasoning=llm_result.reasoning,
977
+ )
978
+
979
+ return llm_result
980
+
981
+ def _responses_state_for_model(self, model: Any) -> dict[str, Any]:
982
+ state = self.get_data(Agent.DATA_NAME_RESPONSES_STATE)
983
+ if not isinstance(state, dict):
984
+ return {}
985
+ provider_model_key = str(getattr(model, "model_name", "") or "")
986
+ if state.get("provider_model_key") != provider_model_key:
987
+ return {}
988
+ if not state.get("response_id"):
989
+ return {}
990
+ return state
991
+
992
+ def _responses_input_items_since(
993
+ self, model: Any, sequence: int
994
+ ) -> list[dict[str, Any]]:
995
+ items: list[dict[str, Any]] = []
996
+ for message in self.history.messages_since(sequence):
997
+ items.extend(self._responses_input_items_for_message(model, message))
998
+ return items
999
+
1000
+ def _responses_input_items_for_message(
1001
+ self, model: Any, message: history.Message
1002
+ ) -> list[dict[str, Any]]:
1003
+ result = result_from_metadata(message.metadata)
1004
+ if result:
1005
+ if message.ai and result.output_items:
1006
+ return [item.to_dict() for item in result.output_items]
1007
+ if not message.ai and result.input_items:
1008
+ return [dict(item) for item in result.input_items]
1009
+
1010
+ output = message.output()
1011
+ langchain_messages = history.output_langchain(output)
1012
+ if hasattr(model, "_convert_messages"):
1013
+ converted = model._convert_messages(langchain_messages)
1014
+ return ResponsesTransport.input_from_messages(converted)
1015
+ return []
1016
+
1017
+ def _responses_static_prefix_items(
1018
+ self, model: Any, messages: list[BaseMessage]
1019
+ ) -> list[dict[str, Any]]:
1020
+ prefix: list[BaseMessage] = []
1021
+ for message in messages:
1022
+ if isinstance(message, SystemMessage):
1023
+ prefix.append(message)
1024
+ continue
1025
+ break
1026
+ if not prefix or not hasattr(model, "_convert_messages"):
1027
+ return []
1028
+ converted = model._convert_messages(prefix)
1029
+ return ResponsesTransport.input_from_messages(converted)
1030
+
1031
+ def _remember_llm_result_state(
1032
+ self, llm_result: LLMResult, history_message: history.Message
1033
+ ) -> None:
1034
+ if not llm_result.response_id:
1035
+ return
1036
+ current = self.get_data(Agent.DATA_NAME_RESPONSES_STATE)
1037
+ response_ids = []
1038
+ if isinstance(current, dict) and isinstance(current.get("response_ids"), list):
1039
+ response_ids = [str(item) for item in current["response_ids"] if item]
1040
+ if llm_result.response_id not in response_ids:
1041
+ response_ids.append(llm_result.response_id)
1042
+ self.set_data(
1043
+ Agent.DATA_NAME_RESPONSES_STATE,
1044
+ {
1045
+ "response_id": llm_result.response_id,
1046
+ "previous_response_id": llm_result.previous_response_id,
1047
+ "provider_model_key": llm_result.provider_model_key,
1048
+ "history_counter": history_message.sequence,
1049
+ "response_ids": response_ids,
1050
+ },
1051
+ )
1052
+
1053
@extension.extensible
1054
async def rate_limiter_callback(
1055
self, message: str, key: str, total: int, limit: int
@@ -863,6 +1083,310 @@ class Agent:
1083
while self.context.paused:
1084
await asyncio.sleep(0.1)
1085
1086
+ async def process_llm_result_tools(self, llm_result: LLMResult):
1087
+ await self._log_response_builtin_items(llm_result)
1088
+ if llm_result.function_calls:
1089
+ for function_call in llm_result.function_calls:
1090
+ name_map = self.get_data(Agent.DATA_NAME_RESPONSES_TOOL_NAME_MAP)
1091
+ tool_name = original_tool_name(function_call.name, name_map)
1092
+ response_item_factory = lambda response, call=function_call: function_call_output_item(
1093
+ call.call_id,
1094
+ response.message,
1095
+ )
1096
+ result = await self._execute_tool_request(
1097
+ tool_name=tool_name,
1098
+ tool_args=function_call.arguments,
1099
+ message=llm_result.response,
1100
+ raw_tool_name=tool_name,
1101
+ responses_item_factory=response_item_factory,
1102
+ )
1103
+ if result:
1104
+ return result
1105
+ return None
1106
+ if llm_result.builtin_items and not llm_result.response:
1107
+ return None
1108
+ if (
1109
+ llm_result.mode == "responses"
1110
+ and llm_result.response
1111
+ and extract_tools.json_parse_dirty(llm_result.response) is None
1112
+ ):
1113
+ return llm_result.response
1114
+ return await self.process_tools(llm_result.response)
1115
+
1116
+ async def _execute_tool_request(
1117
+ self,
1118
+ tool_name: str,
1119
+ tool_args: dict,
1120
+ message: str,
1121
+ raw_tool_name: str = "",
1122
+ responses_item_factory: Callable[[Any], dict[str, Any]] | None = None,
1123
+ ):
1124
+ raw_tool_name = raw_tool_name or tool_name
1125
+ tool_method = None
1126
+ tool = None
1127
+
1128
+ try:
1129
+ import helpers.mcp_handler as mcp_helper
1130
+
1131
+ mcp_tool_candidate = mcp_helper.MCPConfig.get_instance().get_tool(
1132
+ self, tool_name
1133
+ )
1134
+ if mcp_tool_candidate:
1135
+ tool = mcp_tool_candidate
1136
+ except ImportError:
1137
+ PrintStyle(
1138
+ background_color="black", font_color="yellow", padding=True
1139
+ ).print("MCP helper module not found. Skipping MCP tool lookup.")
1140
+ except Exception as e:
1141
+ PrintStyle(background_color="black", font_color="red", padding=True).print(
1142
+ f"Failed to get MCP tool '{tool_name}': {e}"
1143
+ )
1144
+
1145
+ if not tool:
1146
+ tool = self.get_tool(
1147
+ name=tool_name,
1148
+ method=tool_method,
1149
+ args=tool_args,
1150
+ message=message,
1151
+ loop_data=self.loop_data,
1152
+ )
1153
+
1154
+ if not tool:
1155
+ error_detail = (
1156
+ f"Tool '{raw_tool_name}' not found or could not be initialized."
1157
+ )
1158
+ wmsg = self.hist_add_warning(error_detail)
1159
+ PrintStyle(font_color="red", padding=True).print(error_detail)
1160
+ self.context.log.log(
1161
+ type="warning",
1162
+ content=f"{self.agent_name}: {error_detail}",
1163
+ id=wmsg.id,
1164
+ )
1165
+ return None
1166
+
1167
+ self.loop_data.current_tool = tool # type: ignore
1168
+ try:
1169
+ await self.handle_intervention()
1170
+
1171
+ await tool.before_execution(**tool_args)
1172
+ await self.handle_intervention()
1173
+
1174
+ await extension.call_extensions_async(
1175
+ "tool_execute_before",
1176
+ self,
1177
+ tool_args=tool_args or {},
1178
+ tool_name=tool_name,
1179
+ )
1180
+
1181
+ response = await tool.execute(**tool_args)
1182
+ await self.handle_intervention()
1183
+
1184
+ await extension.call_extensions_async(
1185
+ "tool_execute_after",
1186
+ self,
1187
+ response=response,
1188
+ tool_name=tool_name,
1189
+ )
1190
+
1191
+ if responses_item_factory:
1192
+ response.additional = {
1193
+ **(response.additional or {}),
1194
+ "_responses_output_item": responses_item_factory(response),
1195
+ }
1196
+
1197
+ await tool.after_execution(response)
1198
+ await self.handle_intervention()
1199
+
1200
+ if response.break_loop:
1201
+ self._clear_responses_pending_state()
1202
+ return response.message
1203
+ finally:
1204
+ self.loop_data.current_tool = None
1205
+ return None
1206
+
1207
+ async def _log_response_builtin_items(self, llm_result: LLMResult) -> None:
1208
+ for item in llm_result.builtin_items:
1209
+ if item.type == "computer_call":
1210
+ await self._handle_responses_computer_call(item.data)
1211
+ continue
1212
+ if item.type == "mcp_approval_request":
1213
+ self._handle_responses_mcp_approval_request(item.data)
1214
+ continue
1215
+ self.context.log.log(
1216
+ type="info",
1217
+ heading=f"Responses tool item: {item.type}",
1218
+ content=json.dumps(item.data, ensure_ascii=False, default=str),
1219
+ )
1220
+
1221
+ async def _handle_responses_computer_call(self, item: dict[str, Any]) -> None:
1222
+ safety_checks = item.get("pending_safety_checks") or item.get("safety_checks")
1223
+ if safety_checks:
1224
+ message = (
1225
+ "Responses computer_call requested safety-check acknowledgement. "
1226
+ "Agent Zero requires explicit user acknowledgement before executing it."
1227
+ )
1228
+ output_item = {
1229
+ "type": "computer_call_output",
1230
+ "call_id": str(item.get("call_id") or item.get("id") or ""),
1231
+ "output": {"type": "input_text", "text": message},
1232
+ }
1233
+ self.hist_add_tool_result(
1234
+ "computer_call",
1235
+ message,
1236
+ responses_item=output_item,
1237
+ )
1238
+ self.context.log.log(type="warning", content=message)
1239
+ return
1240
+
1241
+ args = self._computer_call_args(item)
1242
+ if not args:
1243
+ message = "Responses computer_call action is unsupported by Agent Zero."
1244
+ output_item = {
1245
+ "type": "computer_call_output",
1246
+ "call_id": str(item.get("call_id") or item.get("id") or ""),
1247
+ "output": {"type": "input_text", "text": message},
1248
+ }
1249
+ self.hist_add_tool_result(
1250
+ "computer_call",
1251
+ message,
1252
+ responses_item=output_item,
1253
+ )
1254
+ self.context.log.log(type="warning", content=message)
1255
+ return
1256
+
1257
+ if args.get("action") != "start_session" and not args.get("session_id"):
1258
+ session_id = str(
1259
+ self.get_data(Agent.DATA_NAME_RESPONSES_COMPUTER_SESSION) or ""
1260
+ )
1261
+ if session_id:
1262
+ args["session_id"] = session_id
1263
+
1264
+ response_item_factory = lambda response: self._computer_call_output_item(
1265
+ item,
1266
+ response,
1267
+ )
1268
+ result = await self._execute_tool_request(
1269
+ tool_name="computer_use_remote",
1270
+ tool_args=args,
1271
+ message=json.dumps(item, ensure_ascii=False, default=str),
1272
+ raw_tool_name="computer_call",
1273
+ responses_item_factory=response_item_factory,
1274
+ )
1275
+ _ = result
1276
+
1277
+ def _handle_responses_mcp_approval_request(self, item: dict[str, Any]) -> None:
1278
+ request_id = str(
1279
+ item.get("approval_request_id") or item.get("id") or item.get("call_id") or ""
1280
+ )
1281
+ message = (
1282
+ "Responses MCP approval request received. Agent Zero denied it because "
1283
+ "provider-hosted MCP approval requires explicit user approval."
1284
+ )
1285
+ output_item = {
1286
+ "type": "mcp_approval_response",
1287
+ "approval_request_id": request_id,
1288
+ "approve": False,
1289
+ }
1290
+ self.hist_add_tool_result(
1291
+ "mcp_approval_request",
1292
+ message,
1293
+ responses_item=output_item,
1294
+ )
1295
+ self.context.log.log(
1296
+ type="warning",
1297
+ heading="Responses MCP approval required",
1298
+ content=message,
1299
+ )
1300
+
1301
+ def _computer_call_args(self, item: dict[str, Any]) -> dict[str, Any]:
1302
+ action = item.get("action")
1303
+ action_data = dict(action) if isinstance(action, dict) else {}
1304
+ action_type = str(
1305
+ action_data.get("type")
1306
+ or action_data.get("action")
1307
+ or item.get("action_type")
1308
+ or ""
1309
+ ).strip().lower()
1310
+ args: dict[str, Any] = {}
1311
+
1312
+ if action_type in {"screenshot", "capture"}:
1313
+ args["action"] = "capture"
1314
+ elif action_type in {"move", "mousemove"}:
1315
+ args.update({"action": "move", "x": action_data.get("x"), "y": action_data.get("y")})
1316
+ elif action_type in {"click", "double_click"}:
1317
+ args.update(
1318
+ {
1319
+ "action": "click",
1320
+ "x": action_data.get("x"),
1321
+ "y": action_data.get("y"),
1322
+ "button": action_data.get("button", "left"),
1323
+ "count": 2 if action_type == "double_click" else action_data.get("count", 1),
1324
+ }
1325
+ )
1326
+ elif action_type == "scroll":
1327
+ args.update(
1328
+ {
1329
+ "action": "scroll",
1330
+ "dx": action_data.get("dx", action_data.get("scroll_x", 0)),
1331
+ "dy": action_data.get("dy", action_data.get("scroll_y", 0)),
1332
+ }
1333
+ )
1334
+ elif action_type in {"keypress", "key"}:
1335
+ args.update(
1336
+ {
1337
+ "action": "key",
1338
+ "keys": action_data.get("keys") or action_data.get("key"),
1339
+ }
1340
+ )
1341
+ elif action_type in {"type", "input_text"}:
1342
+ args.update({"action": "type", "text": action_data.get("text", "")})
1343
+ else:
1344
+ return {}
1345
+
1346
+ session_id = item.get("session_id") or action_data.get("session_id")
1347
+ if session_id:
1348
+ args["session_id"] = session_id
1349
+ return args
1350
+
1351
+ def _computer_call_output_item(
1352
+ self, source_item: dict[str, Any], response: Any
1353
+ ) -> dict[str, Any]:
1354
+ output: dict[str, Any] = {
1355
+ "type": "input_text",
1356
+ "text": str(getattr(response, "message", "") or ""),
1357
+ }
1358
+ additional = getattr(response, "additional", None)
1359
+ raw_content = additional.get("raw_content") if isinstance(additional, dict) else None
1360
+ if isinstance(raw_content, list):
1361
+ for content in raw_content:
1362
+ if not isinstance(content, dict):
1363
+ continue
1364
+ if content.get("type") != "image_url":
1365
+ continue
1366
+ image_url = content.get("image_url")
1367
+ url = image_url.get("url") if isinstance(image_url, dict) else image_url
1368
+ if url:
1369
+ output = {"type": "input_image", "image_url": url}
1370
+ break
1371
+
1372
+ session_id_match = re_search_session_id(str(getattr(response, "message", "") or ""))
1373
+ if session_id_match:
1374
+ self.set_data(Agent.DATA_NAME_RESPONSES_COMPUTER_SESSION, session_id_match)
1375
+
1376
+ return {
1377
+ "type": "computer_call_output",
1378
+ "call_id": str(source_item.get("call_id") or source_item.get("id") or ""),
1379
+ "output": output,
1380
+ }
1381
+
1382
+ def _clear_responses_pending_state(self) -> None:
1383
+ state = self.get_data(Agent.DATA_NAME_RESPONSES_STATE)
1384
+ if isinstance(state, dict):
1385
+ state = dict(state)
1386
+ state.pop("response_id", None)
1387
+ state.pop("previous_response_id", None)
1388
+ self.set_data(Agent.DATA_NAME_RESPONSES_STATE, state)
1389
+
1390
@extension.extensible
1391
async def process_tools(self, msg: str):
1392
# search for tool usage requests in agent message
@@ -1037,3 +1561,8 @@ class Agent:
1561
loop_data=loop_data,
1562
**kwargs,
1563
)
1564
+
1565
+
1566
+def re_search_session_id(text: str) -> str:
1567
+ match = re.search(r"session_id=([A-Za-z0-9_.:-]+)", text or "")
1568
+ return match.group(1) if match else ""
helpers/history.py
+104
-8
@@ -40,9 +40,12 @@ MessageContent = Union[
40
]
41
42
43
-class OutputMessage(TypedDict):
43
+class OutputMessage(TypedDict, total=False):
44
ai: bool
45
content: MessageContent
46
+ metadata: dict[str, Any]
47
+ id: str
48
+ sequence: int
49
50
51
class Record:
@@ -82,10 +85,20 @@ class Record:
85
86
87
class Message(Record):
85
- def __init__(self, ai: bool, content: MessageContent, tokens: int = 0, id: str = ""):
88
+ def __init__(
89
+ self,
90
+ ai: bool,
91
+ content: MessageContent,
92
+ tokens: int = 0,
93
+ id: str = "",
94
+ metadata: dict[str, Any] | None = None,
95
+ sequence: int = 0,
96
+ ):
97
self.id = id or str(uuid.uuid4())
98
self.ai = ai
99
self.content = content
100
+ self.metadata = metadata or {}
101
+ self.sequence = sequence
102
self.summary: str = ""
103
self.tokens: int = tokens or self.calculate_tokens()
104
@@ -106,7 +119,15 @@ class Message(Record):
119
return False
120
121
def output(self):
109
- return [OutputMessage(ai=self.ai, content=self.summary or self.content)]
122
+ return [
123
+ OutputMessage(
124
+ ai=self.ai,
125
+ content=self.summary or self.content,
126
+ metadata=self.metadata,
127
+ id=self.id,
128
+ sequence=self.sequence,
129
+ )
130
+ ]
131
132
def output_langchain(self):
133
return output_langchain(self.output())
@@ -120,6 +141,8 @@ class Message(Record):
141
"id": self.id,
142
"ai": self.ai,
143
"content": self.content,
144
+ "metadata": self.metadata,
145
+ "sequence": self.sequence,
146
"summary": self.summary,
147
"tokens": self.tokens,
148
}
@@ -127,7 +150,13 @@ class Message(Record):
150
@staticmethod
151
def from_dict(data: dict, history: "History"):
152
content = data.get("content", "Content lost")
130
- msg = Message(ai=data["ai"], content=content, id=data.get("id", ""))
153
+ msg = Message(
154
+ ai=data["ai"],
155
+ content=content,
156
+ id=data.get("id", ""),
157
+ metadata=data.get("metadata", {}) if isinstance(data.get("metadata"), dict) else {},
158
+ sequence=int(data.get("sequence", 0) or 0),
159
+ )
160
msg.summary = data.get("summary", "")
161
msg.tokens = data.get("tokens", 0)
162
return msg
@@ -146,9 +175,22 @@ class Topic(Record):
175
return sum(msg.get_tokens() for msg in self.messages)
176
177
def add_message(
149
- self, ai: bool, content: MessageContent, tokens: int = 0, id: str = ""
178
+ self,
179
+ ai: bool,
180
+ content: MessageContent,
181
+ tokens: int = 0,
182
+ id: str = "",
183
+ metadata: dict[str, Any] | None = None,
184
+ sequence: int = 0,
185
) -> Message:
151
- msg = Message(ai=ai, content=content, tokens=tokens, id=id)
186
+ msg = Message(
187
+ ai=ai,
188
+ content=content,
189
+ tokens=tokens,
190
+ id=id,
191
+ metadata=metadata,
192
+ sequence=sequence,
193
+ )
194
self.messages.append(msg)
195
return msg
196
@@ -335,10 +377,22 @@ class History(Record):
377
return self.current.get_tokens()
378
379
def add_message(
338
- self, ai: bool, content: MessageContent, tokens: int = 0, id: str = ""
380
+ self,
381
+ ai: bool,
382
+ content: MessageContent,
383
+ tokens: int = 0,
384
+ id: str = "",
385
+ metadata: dict[str, Any] | None = None,
386
) -> Message:
387
self.counter += 1
341
- return self.current.add_message(ai, content=content, tokens=tokens, id=id)
388
+ return self.current.add_message(
389
+ ai,
390
+ content=content,
391
+ tokens=tokens,
392
+ id=id,
393
+ metadata=metadata,
394
+ sequence=self.counter,
395
+ )
396
397
def new_topic(self):
398
if self.current.messages:
@@ -353,6 +407,35 @@ class History(Record):
407
result += self.current.output()
408
return result
409
410
+ def messages_since(self, sequence: int) -> list[Message]:
411
+ return [
412
+ message
413
+ for message in self.all_messages()
414
+ if int(message.sequence or 0) > int(sequence or 0)
415
+ ]
416
+
417
+ def all_messages(self) -> list[Message]:
418
+ messages: list[Message] = []
419
+ for bulk in self.bulks:
420
+ messages.extend(_messages_from_record(bulk))
421
+ for topic in self.topics:
422
+ messages.extend(topic.messages)
423
+ messages.extend(self.current.messages)
424
+ return messages
425
+
426
+ def latest_llm_result_for_model(self, provider_model_key: str):
427
+ from helpers.llm_result import result_from_metadata
428
+
429
+ for message in reversed(self.all_messages()):
430
+ if not message.ai:
431
+ continue
432
+ result = result_from_metadata(message.metadata)
433
+ if not result:
434
+ continue
435
+ if result.provider_model_key == provider_model_key and result.response_id:
436
+ return result
437
+ return None
438
+
439
def trim_embeds(self, max_embeds: int) -> int:
440
if max_embeds == -1:
441
return 0
@@ -679,6 +762,19 @@ def _is_embedded_data(obj: object) -> bool:
762
return isinstance(obj, Mapping) and obj.get("type") == "image_url"
763
764
765
+def _messages_from_record(record: Record) -> list[Message]:
766
+ if isinstance(record, Message):
767
+ return [record]
768
+ if isinstance(record, Topic):
769
+ return list(record.messages)
770
+ if isinstance(record, Bulk):
771
+ messages: list[Message] = []
772
+ for nested in record.records:
773
+ messages.extend(_messages_from_record(nested))
774
+ return messages
775
+ return []
776
+
777
+
778
def _json_dumps(obj):
779
return json.dumps(obj, ensure_ascii=False)
780
helpers/litellm_transport.py
new
+1676
@@ -0,0 +1,1676 @@
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
+CHAT_COMPLETIONS_ALIASES = {
39
+ "chat",
40
+ "chat_completion",
41
+ "chat_completions",
42
+ "completion",
43
+ "completions",
44
+}
45
+RESPONSES_ALIASES = {"", "auto", "default", "response", "responses", "responses_api"}
46
+RESPONSES_REASONING_EFFORTS = {"minimal", "low", "medium", "high"}
47
+RESPONSES_REASONING_FALLBACK_EFFORT = "high"
48
+NO_REASONING_EFFORT_ALIASES = {"", "0", "false", "no", "none", "off", "disabled"}
49
+RESPONSES_UNSUPPORTED_CACHE: set[str] = set()
50
+RESPONSES_STATE_UNSUPPORTED_CACHE: set[str] = set()
51
+RESPONSES_BUILTIN_UNSUPPORTED_CACHE: dict[str, set[str]] = {}
52
+OPENAI_RESPONSES_EXTRA_BODY_PARAMS = {
53
+ "context_management",
54
+ "prompt_cache_retention",
55
+}
56
+CACHE_CONTROL_PROMPT_PROVIDERS = {
57
+ "anthropic",
58
+ "bedrock",
59
+ "databricks",
60
+ "dashscope",
61
+ "gemini",
62
+ "gemini_api_oauth",
63
+ "minimax",
64
+ "openrouter",
65
+ "vertex_ai",
66
+ "vertexai",
67
+ "z_ai",
68
+ "zai",
69
+}
70
+OPENAI_PROMPT_CACHE_PROVIDERS = {"openai", "azure"}
71
+RESPONSES_STATE_PROVIDER = "provider"
72
+RESPONSES_STATE_LOCAL = "local"
73
+RESPONSES_STATE_OFF = "off"
74
+RESPONSES_STATES = {
75
+ RESPONSES_STATE_PROVIDER,
76
+ RESPONSES_STATE_LOCAL,
77
+ RESPONSES_STATE_OFF,
78
+}
79
+
80
+
81
+@dataclass
82
+class TransportPolicy:
83
+ mode: TransportMode
84
+ allow_fallback: bool = True
85
+ retried_reasoning: bool = False
86
+ fallback_error: Exception | None = None
87
+ state_fallback_error: Exception | None = None
88
+ cache_key: str = ""
89
+ state: str = RESPONSES_STATE_PROVIDER
90
+
91
+ @classmethod
92
+ def from_request(
93
+ cls,
94
+ model: str,
95
+ kwargs: dict[str, Any],
96
+ messages: list[dict[str, Any]] | None = None,
97
+ ) -> "TransportPolicy":
98
+ mode = cls._pop_mode(kwargs)
99
+ allow_fallback = _coerce_bool(
100
+ kwargs.pop("a0_responses_fallback", True), default=True
101
+ )
102
+ cache_key = _responses_cache_key(model, kwargs)
103
+ state = _normalize_responses_state(kwargs.get("responses_state"))
104
+
105
+ if mode is TransportMode.CHAT_COMPLETIONS:
106
+ _drop_responses_only_kwargs(kwargs)
107
+ return cls(
108
+ mode=mode,
109
+ allow_fallback=allow_fallback,
110
+ cache_key=cache_key,
111
+ state=RESPONSES_STATE_OFF,
112
+ )
113
+
114
+ if (
115
+ state == RESPONSES_STATE_PROVIDER
116
+ and cache_key in RESPONSES_STATE_UNSUPPORTED_CACHE
117
+ ):
118
+ kwargs["responses_state"] = RESPONSES_STATE_LOCAL
119
+ state = RESPONSES_STATE_LOCAL
120
+
121
+ _filter_unsupported_builtin_tools(kwargs, cache_key)
122
+
123
+ if _should_preserve_cache_control_on_chat(model, kwargs, messages or []):
124
+ return cls(
125
+ mode=TransportMode.CHAT_COMPLETIONS,
126
+ allow_fallback=allow_fallback,
127
+ cache_key=cache_key,
128
+ state=RESPONSES_STATE_OFF,
129
+ )
130
+
131
+ if cache_key in RESPONSES_UNSUPPORTED_CACHE:
132
+ return cls(
133
+ mode=TransportMode.CHAT_COMPLETIONS,
134
+ allow_fallback=allow_fallback,
135
+ fallback_error=RuntimeError("Responses API previously failed"),
136
+ cache_key=cache_key,
137
+ state=RESPONSES_STATE_OFF,
138
+ )
139
+
140
+ return cls(
141
+ mode=TransportMode.RESPONSES,
142
+ allow_fallback=allow_fallback,
143
+ cache_key=cache_key,
144
+ state=state,
145
+ )
146
+
147
+ @staticmethod
148
+ def _pop_mode(kwargs: dict[str, Any]) -> TransportMode:
149
+ value = str(kwargs.pop("a0_api_mode", "responses") or "").lower().strip()
150
+ if value in CHAT_COMPLETIONS_ALIASES:
151
+ return TransportMode.CHAT_COMPLETIONS
152
+ if value in RESPONSES_ALIASES:
153
+ return TransportMode.RESPONSES
154
+ return TransportMode.RESPONSES
155
+
156
+ @property
157
+ def using_responses(self) -> bool:
158
+ return self.mode is TransportMode.RESPONSES
159
+
160
+ def recover(self, exc: Exception, *, got_any_chunk: bool) -> TransportRecovery:
161
+ if not self.using_responses or got_any_chunk:
162
+ return TransportRecovery.RAISE
163
+ if not self.retried_reasoning and _is_responses_reasoning_effort_error(exc):
164
+ self.retried_reasoning = True
165
+ return TransportRecovery.RETRY_RESPONSES
166
+ if (
167
+ self.state == RESPONSES_STATE_PROVIDER
168
+ and _is_responses_state_unsupported_error(exc)
169
+ ):
170
+ self.state = RESPONSES_STATE_LOCAL
171
+ self.state_fallback_error = exc
172
+ if self.cache_key:
173
+ RESPONSES_STATE_UNSUPPORTED_CACHE.add(self.cache_key)
174
+ return TransportRecovery.RETRY_LOCAL_RESPONSES
175
+ if self.allow_fallback and _is_responses_not_supported_error(exc):
176
+ self.mode = TransportMode.CHAT_COMPLETIONS
177
+ self.fallback_error = exc
178
+ self.state = RESPONSES_STATE_OFF
179
+ if self.cache_key:
180
+ RESPONSES_UNSUPPORTED_CACHE.add(self.cache_key)
181
+ return TransportRecovery.FALLBACK_TO_CHAT
182
+ return TransportRecovery.RAISE
183
+
184
+
185
+@dataclass
186
+class LiteLLMTransport:
187
+ model: str
188
+ messages: list[dict[str, Any]]
189
+ kwargs: dict[str, Any]
190
+ stop: Optional[list[str]] = None
191
+ policy: TransportPolicy = field(init=False)
192
+ last_result: LLMResult | None = field(init=False, default=None)
193
+ last_request_state: str = field(init=False, default=RESPONSES_STATE_PROVIDER)
194
+ explicit_prompt_caching: bool = field(init=False, default=False)
195
+
196
+ def __post_init__(self) -> None:
197
+ self.kwargs = _without_stream_kwarg(dict(self.kwargs))
198
+ self.explicit_prompt_caching = _coerce_bool(
199
+ self.kwargs.pop("a0_explicit_prompt_caching", False), default=False
200
+ )
201
+ if self.explicit_prompt_caching:
202
+ self.messages = apply_chat_prompt_cache_markers(
203
+ self.messages,
204
+ model=self.model,
205
+ kwargs=self.kwargs,
206
+ )
207
+ self.policy = TransportPolicy.from_request(
208
+ self.model,
209
+ self.kwargs,
210
+ messages=self.messages,
211
+ )
212
+
213
+ def complete(self) -> ChatChunk:
214
+ while True:
215
+ try:
216
+ if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
217
+ parsed = ChatCompletionsTransport.parse(
218
+ completion(**self._chat_request(stream=False))
219
+ )
220
+ self.last_result = self._llm_result_from_chat(parsed)
221
+ return parsed
222
+ request = self._responses_request(stream=False)
223
+ raw_response = responses(**request)
224
+ parsed = ResponsesTransport.parse_response(raw_response)
225
+ self.last_result = self._llm_result_from_response(
226
+ raw_response, request
227
+ )
228
+ return parsed
229
+ except Exception as exc:
230
+ if self._recover(exc, got_any_chunk=False):
231
+ continue
232
+ raise
233
+
234
+ async def acomplete(self) -> ChatChunk:
235
+ while True:
236
+ try:
237
+ if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
238
+ parsed = ChatCompletionsTransport.parse(
239
+ await acompletion(**self._chat_request(stream=False))
240
+ )
241
+ self.last_result = self._llm_result_from_chat(parsed)
242
+ return parsed
243
+ request = self._responses_request(stream=False)
244
+ raw_response = await aresponses(**request)
245
+ parsed = ResponsesTransport.parse_response(raw_response)
246
+ self.last_result = self._llm_result_from_response(
247
+ raw_response, request
248
+ )
249
+ return parsed
250
+ except Exception as exc:
251
+ if self._recover(exc, got_any_chunk=False):
252
+ continue
253
+ raise
254
+
255
+ def stream(self) -> Iterator[ChatChunk]:
256
+ while True:
257
+ iterator = None
258
+ exhausted = False
259
+ got_any_chunk = False
260
+ try:
261
+ if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
262
+ iterator = completion(**self._chat_request(stream=True))
263
+ for chunk in iterator:
264
+ parsed = ChatCompletionsTransport.parse(chunk)
265
+ if _has_chunk_delta(parsed):
266
+ got_any_chunk = True
267
+ yield parsed
268
+ else:
269
+ request = self._responses_request(stream=True)
270
+ iterator = responses(**request)
271
+ parser = ResponsesEventParser()
272
+ for event in iterator:
273
+ parsed = parser.parse(event)
274
+ if _has_chunk_delta(parsed):
275
+ got_any_chunk = True
276
+ yield parsed
277
+ self.last_result = self._stream_result_from_parser(
278
+ parser, request
279
+ )
280
+ exhausted = True
281
+ return
282
+ except Exception as exc:
283
+ if self._recover(exc, got_any_chunk=got_any_chunk):
284
+ continue
285
+ raise
286
+ finally:
287
+ if iterator is not None and not exhausted:
288
+ _close_sync_stream(iterator)
289
+
290
+ async def astream(self) -> AsyncIterator[ChatChunk]:
291
+ while True:
292
+ iterator = None
293
+ exhausted = False
294
+ got_any_chunk = False
295
+ try:
296
+ if self.policy.mode is TransportMode.CHAT_COMPLETIONS:
297
+ iterator = await acompletion(**self._chat_request(stream=True))
298
+ async for chunk in iterator: # type: ignore[union-attr]
299
+ parsed = ChatCompletionsTransport.parse(chunk)
300
+ if _has_chunk_delta(parsed):
301
+ got_any_chunk = True
302
+ yield parsed
303
+ else:
304
+ request = self._responses_request(stream=True)
305
+ iterator = await aresponses(**request)
306
+ parser = ResponsesEventParser()
307
+ async for event in iterator: # type: ignore[union-attr]
308
+ parsed = parser.parse(event)
309
+ if _has_chunk_delta(parsed):
310
+ got_any_chunk = True
311
+ yield parsed
312
+ self.last_result = self._stream_result_from_parser(
313
+ parser, request
314
+ )
315
+ exhausted = True
316
+ return
317
+ except Exception as exc:
318
+ if self._recover(exc, got_any_chunk=got_any_chunk):
319
+ continue
320
+ raise
321
+ finally:
322
+ if iterator is not None and not exhausted:
323
+ await _close_async_stream(iterator)
324
+
325
+ def _recover(self, exc: Exception, *, got_any_chunk: bool) -> bool:
326
+ if (
327
+ self.policy.using_responses
328
+ and not got_any_chunk
329
+ and self.kwargs.get("responses_builtin_tools")
330
+ and _is_responses_builtin_tool_error(exc)
331
+ ):
332
+ downgraded = _builtin_tool_types(self.kwargs.get("responses_builtin_tools"))
333
+ if downgraded:
334
+ RESPONSES_BUILTIN_UNSUPPORTED_CACHE.setdefault(
335
+ self.policy.cache_key, set()
336
+ ).update(downgraded)
337
+ self.kwargs["_a0_responses_builtin_downgrades"] = sorted(downgraded)
338
+ self.kwargs["responses_builtin_tools"] = []
339
+ return True
340
+
341
+ recovery = self.policy.recover(exc, got_any_chunk=got_any_chunk)
342
+ if recovery is TransportRecovery.RETRY_RESPONSES:
343
+ self.kwargs["reasoning"] = {
344
+ "effort": RESPONSES_REASONING_FALLBACK_EFFORT
345
+ }
346
+ return True
347
+ if recovery is TransportRecovery.RETRY_LOCAL_RESPONSES:
348
+ self.kwargs["responses_state"] = RESPONSES_STATE_LOCAL
349
+ self.kwargs.pop("previous_response_id", None)
350
+ return True
351
+ return recovery is TransportRecovery.FALLBACK_TO_CHAT
352
+
353
+ def _chat_request(self, *, stream: bool) -> dict[str, Any]:
354
+ chat_kwargs = ChatCompletionsTransport.prepare_kwargs(
355
+ self.kwargs,
356
+ fallback_error=self.policy.fallback_error,
357
+ model=self.model,
358
+ messages=self.messages,
359
+ explicit_prompt_caching=self.explicit_prompt_caching,
360
+ )
361
+ request = {
362
+ "model": self.model,
363
+ "messages": ChatCompletionsTransport.prepare_messages(
364
+ self.messages,
365
+ model=self.model,
366
+ kwargs=chat_kwargs,
367
+ ),
368
+ "stream": stream,
369
+ **chat_kwargs,
370
+ }
371
+ if self.stop is not None:
372
+ request["stop"] = self.stop
373
+ return request
374
+
375
+ def _responses_request(self, *, stream: bool) -> dict[str, Any]:
376
+ response_kwargs = ResponsesTransport.from_chat(
377
+ self.messages,
378
+ self.kwargs,
379
+ stop=self.stop,
380
+ model=self.model,
381
+ )
382
+ self.last_request_state = _normalize_responses_state(
383
+ self.kwargs.get("responses_state")
384
+ )
385
+ return {
386
+ "model": self.model,
387
+ "stream": stream,
388
+ **response_kwargs,
389
+ }
390
+
391
+ def _llm_result_from_chat(self, parsed: ChatChunk) -> LLMResult:
392
+ return LLMResult.from_chat(
393
+ response=parsed["response_delta"],
394
+ reasoning=parsed["reasoning_delta"],
395
+ input_items=ResponsesTransport.input_from_messages(self.messages),
396
+ provider_model_key=self.model,
397
+ capability=self._capability_metadata(),
398
+ )
399
+
400
+ def _llm_result_from_response(
401
+ self, response: Any, request: dict[str, Any]
402
+ ) -> LLMResult:
403
+ return LLMResult.from_response(
404
+ response,
405
+ input_items=_as_list(request.get("input")),
406
+ previous_response_id=str(request.get("previous_response_id") or ""),
407
+ provider_model_key=self.model,
408
+ mode=TransportMode.RESPONSES.value,
409
+ state=self.last_request_state,
410
+ capability=self._capability_metadata(),
411
+ )
412
+
413
+ def _stream_result_from_parser(
414
+ self, parser: "ResponsesEventParser", request: dict[str, Any]
415
+ ) -> LLMResult | None:
416
+ if parser.completed_response is None:
417
+ return None
418
+ return self._llm_result_from_response(parser.completed_response, request)
419
+
420
+ def _capability_metadata(self) -> dict[str, Any]:
421
+ return {
422
+ "mode": self.policy.mode.value,
423
+ "state": self.policy.state,
424
+ "cache_key": self.policy.cache_key,
425
+ "fallback_error": _exception_text(self.policy.fallback_error)
426
+ if self.policy.fallback_error
427
+ else "",
428
+ "state_fallback_error": _exception_text(self.policy.state_fallback_error)
429
+ if self.policy.state_fallback_error
430
+ else "",
431
+ "builtin_tool_downgrades": list(
432
+ self.kwargs.get("_a0_responses_builtin_downgrades") or []
433
+ ),
434
+ }
435
+
436
+
437
+class ChatCompletionsTransport:
438
+ @staticmethod
439
+ def prepare_messages(
440
+ messages: list[dict[str, Any]],
441
+ *,
442
+ model: str = "",
443
+ kwargs: dict[str, Any] | None = None,
444
+ ) -> list[dict[str, Any]]:
445
+ if _is_openai_prompt_cache_provider(model, kwargs or {}):
446
+ stripped = _without_cache_control(messages)
447
+ return stripped if isinstance(stripped, list) else messages
448
+ return messages
449
+
450
+ @staticmethod
451
+ def prepare_kwargs(
452
+ kwargs: dict[str, Any],
453
+ fallback_error: Exception | None = None,
454
+ *,
455
+ model: str = "",
456
+ messages: list[dict[str, Any]] | None = None,
457
+ explicit_prompt_caching: bool = False,
458
+ ) -> dict[str, Any]:
459
+ chat_kwargs = dict(kwargs)
460
+ _drop_internal_transport_kwargs(chat_kwargs)
461
+ if not _has_tools(chat_kwargs.get("tools")):
462
+ chat_kwargs.pop("tool_choice", None)
463
+ chat_kwargs.pop("parallel_tool_calls", None)
464
+ if _is_openai_prompt_cache_provider(model, chat_kwargs):
465
+ _prepare_openai_prompt_cache_params(
466
+ chat_kwargs,
467
+ messages or [],
468
+ model=model,
469
+ )
470
+ elif _supports_cache_control_markers(model, chat_kwargs) and (
471
+ explicit_prompt_caching or _has_cache_control(messages or [])
472
+ ):
473
+ _apply_tool_cache_control(chat_kwargs)
474
+ if fallback_error is not None:
475
+ chat_kwargs.setdefault("drop_params", True)
476
+ return {key: value for key, value in chat_kwargs.items() if value is not None}
477
+
478
+ @staticmethod
479
+ def parse(chunk: Any) -> ChatChunk:
480
+ choice = _first_choice(chunk)
481
+ delta = _get_value(choice, "delta") or {}
482
+ message = _get_value(choice, "message") or _get_value(
483
+ _get_value(choice, "model_extra") or {}, "message"
484
+ ) or {}
485
+ response_delta = _get_value(delta, "content") or _get_value(
486
+ message, "content"
487
+ ) or ""
488
+ reasoning_delta = _get_value(delta, "reasoning_content") or _get_value(
489
+ message, "reasoning_content"
490
+ ) or ""
491
+ return {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
492
+
493
+
494
+class ResponsesTransport:
495
+ @classmethod
496
+ def from_chat(
497
+ cls,
498
+ messages: list[dict[str, Any]],
499
+ kwargs: dict[str, Any],
500
+ stop: Optional[list[str]] = None,
501
+ model: str = "",
502
+ ) -> dict[str, Any]:
503
+ request = cls.prepare_kwargs(kwargs, stop=stop, model=model, messages=messages)
504
+ state = _normalize_responses_state(kwargs.get("responses_state"))
505
+ input_items = cls._select_input_items(kwargs, messages, state)
506
+ request["input"] = input_items or ""
507
+ cls.apply_state(request, kwargs, state=state)
508
+ return request
509
+
510
+ @classmethod
511
+ def from_input(
512
+ cls,
513
+ input_items: list[dict[str, Any]],
514
+ kwargs: dict[str, Any],
515
+ stop: Optional[list[str]] = None,
516
+ model: str = "",
517
+ messages: list[dict[str, Any]] | None = None,
518
+ ) -> dict[str, Any]:
519
+ request = cls.prepare_kwargs(kwargs, stop=stop, model=model, messages=messages)
520
+ state = _normalize_responses_state(kwargs.get("responses_state"))
521
+ request["input"] = list(input_items or []) or ""
522
+ cls.apply_state(request, kwargs, state=state)
523
+ return request
524
+
525
+ @classmethod
526
+ def prepare_kwargs(
527
+ cls,
528
+ kwargs: dict[str, Any],
529
+ stop: Optional[list[str]] = None,
530
+ model: str = "",
531
+ messages: list[dict[str, Any]] | None = None,
532
+ ) -> dict[str, Any]:
533
+ request = dict(kwargs)
534
+ response_function_tools = request.pop("a0_responses_function_tools", None)
535
+ response_builtin_tools = request.pop("responses_builtin_tools", None)
536
+ _drop_responses_only_kwargs(request)
537
+ _drop_legacy_transport_kwargs(request)
538
+ request.pop("stop", None)
539
+
540
+ max_completion_tokens = request.pop("max_completion_tokens", None)
541
+ max_tokens = request.pop("max_tokens", None)
542
+ if "max_output_tokens" not in request:
543
+ request["max_output_tokens"] = max_completion_tokens or max_tokens
544
+
545
+ reasoning_effort = request.pop("reasoning_effort", None)
546
+ if "reasoning" in request:
547
+ request["reasoning"] = cls.normalize_reasoning(request["reasoning"])
548
+ elif reasoning_effort is not None:
549
+ request["reasoning"] = cls.normalize_reasoning(reasoning_effort)
550
+
551
+ response_format = request.pop("response_format", None)
552
+ if response_format is not None:
553
+ text_param, text_format = cls.text_from_response_format(response_format)
554
+ if text_param is not None and "text" not in request:
555
+ request["text"] = text_param
556
+ if text_format is not None and "text_format" not in request:
557
+ request["text_format"] = text_format
558
+
559
+ functions = request.pop("functions", None)
560
+ if functions and "tools" not in request:
561
+ request["tools"] = [
562
+ {"type": "function", **function}
563
+ for function in functions
564
+ if isinstance(function, dict)
565
+ ]
566
+
567
+ tools = cls.tools_from_chat(request.pop("tools", None))
568
+ tools = cls.merge_response_tools(
569
+ tools,
570
+ response_function_tools=response_function_tools,
571
+ response_builtin_tools=response_builtin_tools,
572
+ )
573
+ if _has_tools(tools):
574
+ request["tools"] = tools
575
+ else:
576
+ request.pop("tools", None)
577
+
578
+ function_call = request.pop("function_call", None)
579
+ if function_call is not None and "tool_choice" not in request:
580
+ request["tool_choice"] = cls.tool_choice_from_function_call(function_call)
581
+ elif "tool_choice" in request:
582
+ request["tool_choice"] = cls.tool_choice_from_chat(request["tool_choice"])
583
+
584
+ if not _has_tools(request.get("tools")):
585
+ request.pop("tool_choice", None)
586
+ request.pop("parallel_tool_calls", None)
587
+
588
+ cls.prepare_prompt_caching(request, messages or [], model=model)
589
+
590
+ _ = stop
591
+ return {key: value for key, value in request.items() if value is not None}
592
+
593
+ @classmethod
594
+ def _select_input_items(
595
+ cls,
596
+ kwargs: dict[str, Any],
597
+ messages: list[dict[str, Any]],
598
+ state: str,
599
+ ) -> list[dict[str, Any]]:
600
+ provider_items = kwargs.get("responses_input_items")
601
+ local_items = kwargs.get("responses_local_input_items")
602
+ previous_response_id = kwargs.get("previous_response_id")
603
+
604
+ if (
605
+ state == RESPONSES_STATE_PROVIDER
606
+ and previous_response_id
607
+ and isinstance(provider_items, list)
608
+ ):
609
+ return [dict(item) for item in provider_items if isinstance(item, dict)]
610
+
611
+ if state == RESPONSES_STATE_LOCAL and isinstance(local_items, list):
612
+ return [dict(item) for item in local_items if isinstance(item, dict)]
613
+
614
+ return cls.input_from_messages(messages)
615
+
616
+ @staticmethod
617
+ def apply_state(
618
+ request: dict[str, Any],
619
+ kwargs: dict[str, Any],
620
+ *,
621
+ state: str,
622
+ ) -> None:
623
+ if state == RESPONSES_STATE_PROVIDER:
624
+ request.setdefault("store", True)
625
+ previous_response_id = str(kwargs.get("previous_response_id") or "")
626
+ if previous_response_id:
627
+ request["previous_response_id"] = previous_response_id
628
+ elif state == RESPONSES_STATE_LOCAL:
629
+ request.setdefault("store", False)
630
+ else:
631
+ request.setdefault("store", False)
632
+
633
+ @classmethod
634
+ def merge_response_tools(
635
+ cls,
636
+ tools: Any,
637
+ *,
638
+ response_function_tools: Any = None,
639
+ response_builtin_tools: Any = None,
640
+ ) -> list[Any]:
641
+ merged: list[Any] = []
642
+ if isinstance(tools, list):
643
+ merged.extend(tools)
644
+ elif tools:
645
+ merged.append(tools)
646
+
647
+ for source in (response_function_tools, response_builtin_tools):
648
+ for tool in _as_list(source):
649
+ normalized = cls.normalize_response_tool(tool)
650
+ if normalized:
651
+ merged.append(normalized)
652
+ return merged
653
+
654
+ @staticmethod
655
+ def normalize_response_tool(tool: Any) -> dict[str, Any] | None:
656
+ if isinstance(tool, str):
657
+ tool = {"type": tool}
658
+ if not isinstance(tool, dict):
659
+ return None
660
+ return dict(tool)
661
+
662
+ @staticmethod
663
+ def prepare_prompt_caching(
664
+ request: dict[str, Any],
665
+ messages: list[dict[str, Any]],
666
+ model: str = "",
667
+ ) -> None:
668
+ if not _is_openai_prompt_cache_provider(model, request):
669
+ return
670
+
671
+ _prepare_openai_prompt_cache_params(request, messages, model=model)
672
+
673
+ for key in OPENAI_RESPONSES_EXTRA_BODY_PARAMS:
674
+ if key not in request:
675
+ continue
676
+ extra_body = request.get("extra_body")
677
+ if not isinstance(extra_body, dict):
678
+ extra_body = {}
679
+ extra_body.setdefault(key, request.pop(key))
680
+ request["extra_body"] = extra_body
681
+
682
+ @classmethod
683
+ def input_from_messages(
684
+ cls, messages: list[dict[str, Any]]
685
+ ) -> list[dict[str, Any]]:
686
+ response_input: list[dict[str, Any]] = []
687
+
688
+ for message in messages:
689
+ role = str(message.get("role") or "user")
690
+ content = message.get("content", "")
691
+
692
+ if role == "tool":
693
+ response_input.append(
694
+ {
695
+ "type": "function_call_output",
696
+ "call_id": str(message.get("tool_call_id") or ""),
697
+ "output": _content_to_text(content),
698
+ }
699
+ )
700
+ continue
701
+
702
+ tool_calls = message.get("tool_calls")
703
+ if role == "assistant" and isinstance(tool_calls, list) and tool_calls:
704
+ if _has_real_content(content):
705
+ response_input.append(
706
+ {
707
+ "role": "assistant",
708
+ "content": cls.content_from_chat(content, role=role),
709
+ }
710
+ )
711
+ response_input.extend(cls.tool_calls_from_chat(tool_calls))
712
+ continue
713
+
714
+ response_input.append(
715
+ {
716
+ "role": role
717
+ if role in {"user", "assistant", "system", "developer"}
718
+ else "user",
719
+ "content": cls.content_from_chat(content, role=role),
720
+ }
721
+ )
722
+
723
+ return response_input
724
+
725
+ @classmethod
726
+ def content_from_chat(cls, content: Any, role: str = "user") -> Any:
727
+ content = images.prepare_content(content)
728
+ if not isinstance(content, list):
729
+ return content
730
+ return [
731
+ converted
732
+ for item in content
733
+ if (converted := cls.content_part_from_chat(item, role=role)) is not None
734
+ ]
735
+
736
+ @staticmethod
737
+ def content_part_from_chat(item: Any, role: str = "user") -> Any:
738
+ if not isinstance(item, dict):
739
+ return item
740
+
741
+ item_type = item.get("type")
742
+ if item_type in {"input_text", "output_text", "input_image", "input_file"}:
743
+ return dict(item)
744
+ if item_type == "text":
745
+ return {
746
+ "type": "output_text" if role == "assistant" else "input_text",
747
+ "text": item.get("text", ""),
748
+ }
749
+ if item_type == "image_url":
750
+ image_url = item.get("image_url")
751
+ if isinstance(image_url, dict):
752
+ url = image_url.get("url", "")
753
+ detail = image_url.get("detail")
754
+ else:
755
+ url = image_url or ""
756
+ detail = item.get("detail")
757
+ result = {"type": "input_image", "image_url": url}
758
+ if detail:
759
+ result["detail"] = detail
760
+ return result
761
+
762
+ return dict(item)
763
+
764
+ @staticmethod
765
+ def tool_calls_from_chat(tool_calls: list[Any]) -> list[dict[str, Any]]:
766
+ response_input: list[dict[str, Any]] = []
767
+ for tool_call in tool_calls:
768
+ if not isinstance(tool_call, dict):
769
+ continue
770
+ function = tool_call.get("function") or {}
771
+ if not isinstance(function, dict):
772
+ function = {}
773
+ response_input.append(
774
+ {
775
+ "type": "function_call",
776
+ "call_id": str(tool_call.get("id") or ""),
777
+ "id": str(tool_call.get("id") or ""),
778
+ "name": str(function.get("name") or tool_call.get("name") or ""),
779
+ "arguments": str(function.get("arguments") or ""),
780
+ "status": "completed",
781
+ }
782
+ )
783
+ return response_input
784
+
785
+ @staticmethod
786
+ def tools_from_chat(tools: Any) -> Any:
787
+ if not isinstance(tools, list):
788
+ return tools
789
+ response_tools: list[Any] = []
790
+ for tool in tools:
791
+ if not isinstance(tool, dict):
792
+ response_tools.append(tool)
793
+ continue
794
+ if tool.get("type") == "function" and isinstance(
795
+ tool.get("function"), dict
796
+ ):
797
+ function = tool["function"]
798
+ response_tool = {
799
+ "type": "function",
800
+ "name": function.get("name", ""),
801
+ "description": function.get("description", ""),
802
+ "parameters": function.get("parameters", {}),
803
+ }
804
+ if "strict" in function:
805
+ response_tool["strict"] = function["strict"]
806
+ response_tools.append(response_tool)
807
+ else:
808
+ response_tools.append(dict(tool))
809
+ return response_tools
810
+
811
+ @staticmethod
812
+ def tool_choice_from_function_call(function_call: Any) -> Any:
813
+ if isinstance(function_call, str):
814
+ return function_call
815
+ if isinstance(function_call, dict) and function_call.get("name"):
816
+ return {"type": "function", "name": function_call["name"]}
817
+ return function_call
818
+
819
+ @staticmethod
820
+ def tool_choice_from_chat(tool_choice: Any) -> Any:
821
+ if (
822
+ isinstance(tool_choice, dict)
823
+ and tool_choice.get("type") == "function"
824
+ and isinstance(tool_choice.get("function"), dict)
825
+ ):
826
+ return {"type": "function", "name": tool_choice["function"].get("name", "")}
827
+ return tool_choice
828
+
829
+ @staticmethod
830
+ def text_from_response_format(response_format: Any) -> tuple[Any, Any]:
831
+ if isinstance(response_format, type):
832
+ return None, response_format
833
+ if not isinstance(response_format, dict):
834
+ return response_format, None
835
+
836
+ format_type = response_format.get("type")
837
+ if format_type == "json_schema":
838
+ schema = response_format.get("json_schema") or {}
839
+ return (
840
+ {
841
+ "format": {
842
+ "type": "json_schema",
843
+ "name": schema.get("name", "response_schema"),
844
+ "schema": schema.get("schema", {}),
845
+ "strict": schema.get("strict", False),
846
+ }
847
+ },
848
+ None,
849
+ )
850
+ if format_type:
851
+ return {"format": {"type": format_type}}, None
852
+ return response_format, None
853
+
854
+ @staticmethod
855
+ def normalize_reasoning(reasoning: Any) -> Any:
856
+ if isinstance(reasoning, dict):
857
+ normalized = dict(reasoning)
858
+ if "effort" in normalized:
859
+ effort = _normalize_reasoning_effort(normalized.get("effort"))
860
+ if effort is None:
861
+ normalized.pop("effort", None)
862
+ else:
863
+ normalized["effort"] = effort
864
+ return normalized or None
865
+ if reasoning is None:
866
+ return None
867
+ effort = _normalize_reasoning_effort(reasoning)
868
+ return {"effort": effort} if effort is not None else None
869
+
870
+ @classmethod
871
+ def parse_response(cls, response: Any) -> ChatChunk:
872
+ response_delta = cls.output_text(response)
873
+ reasoning_delta = cls.reasoning_text(response)
874
+ if not response_delta:
875
+ response_delta = cls.function_calls_text(response)
876
+ return {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
877
+
878
+ @classmethod
879
+ def parse_event(cls, event: Any) -> ChatChunk:
880
+ return ResponsesEventParser().parse(event)
881
+
882
+ @classmethod
883
+ def output_text(cls, response: Any) -> str:
884
+ output_text = _get_value(response, "output_text")
885
+ if isinstance(output_text, str):
886
+ return output_text
887
+
888
+ pieces: list[str] = []
889
+ for item in _as_list(_get_value(response, "output")):
890
+ if _get_value(item, "type") != "message":
891
+ continue
892
+ for block in _as_list(_get_value(item, "content")):
893
+ block_type = _get_value(block, "type")
894
+ if block_type in {"output_text", "text"}:
895
+ text = _get_value(block, "text")
896
+ if isinstance(text, str):
897
+ pieces.append(text)
898
+ elif block_type == "refusal":
899
+ refusal = _get_value(block, "refusal")
900
+ if isinstance(refusal, str):
901
+ pieces.append(refusal)
902
+ return "".join(pieces)
903
+
904
+ @staticmethod
905
+ def reasoning_text(response: Any) -> str:
906
+ pieces: list[str] = []
907
+ for item in _as_list(_get_value(response, "output")):
908
+ if _get_value(item, "type") != "reasoning":
909
+ continue
910
+ for block in _as_list(_get_value(item, "summary")):
911
+ text = _get_value(block, "text") or _get_value(block, "reasoning")
912
+ if isinstance(text, str):
913
+ pieces.append(text)
914
+ return "".join(pieces)
915
+
916
+ @classmethod
917
+ def function_calls_text(cls, response: Any) -> str:
918
+ calls = [
919
+ cls.function_call_object(item)
920
+ for item in _as_list(_get_value(response, "output"))
921
+ ]
922
+ calls = [call for call in calls if call]
923
+ if not calls:
924
+ return ""
925
+ if len(calls) == 1:
926
+ return json.dumps(calls[0])
927
+ return json.dumps(
928
+ {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}}
929
+ )
930
+
931
+ @classmethod
932
+ def function_call_text(cls, item: Any) -> str:
933
+ call = cls.function_call_object(item)
934
+ if not call:
935
+ return ""
936
+ return json.dumps(call)
937
+
938
+ @staticmethod
939
+ def function_call_object(item: Any) -> dict[str, Any]:
940
+ if _get_value(item, "type") != "function_call":
941
+ return {}
942
+ name = _get_value(item, "name")
943
+ if not name:
944
+ return {}
945
+ raw_arguments = _get_value(item, "arguments") or "{}"
946
+ if isinstance(raw_arguments, str):
947
+ try:
948
+ args = json.loads(raw_arguments or "{}")
949
+ except Exception:
950
+ args = {"arguments": raw_arguments}
951
+ elif isinstance(raw_arguments, dict):
952
+ args = raw_arguments
953
+ else:
954
+ args = {"arguments": raw_arguments}
955
+ if not isinstance(args, dict):
956
+ args = {"arguments": args}
957
+ return {
958
+ "tool_name": str(name),
959
+ "tool_args": args,
960
+ }
961
+
962
+
963
+class ResponsesEventParser:
964
+ """Stateful parser for Responses streaming events."""
965
+
966
+ def __init__(self) -> None:
967
+ self.function_calls: dict[str, dict[str, Any]] = {}
968
+ self.output_index_keys: dict[str, str] = {}
969
+ self.emitted_function_calls: set[str] = set()
970
+ self.seen_response_delta = False
971
+ self.seen_reasoning_delta = False
972
+ self.completed_response: Any = None
973
+
974
+ def parse(self, event: Any) -> ChatChunk:
975
+ event_type = _get_value(event, "type") or ""
976
+ response_delta = ""
977
+ reasoning_delta = ""
978
+
979
+ if event_type in {
980
+ "response.output_text.delta",
981
+ "response.refusal.delta",
982
+ "response.text.delta",
983
+ }:
984
+ response_delta = str(_get_value(event, "delta") or "")
985
+ elif event_type in {
986
+ "response.reasoning_summary_text.delta",
987
+ "response.reasoning_text.delta",
988
+ }:
989
+ reasoning_delta = str(_get_value(event, "delta") or "")
990
+ elif event_type == "response.output_item.added":
991
+ self._remember_function_call(_get_value(event, "item"), event)
992
+ elif event_type == "response.function_call_arguments.delta":
993
+ self._append_function_call_arguments(event)
994
+ elif event_type == "response.function_call_arguments.done":
995
+ response_delta = self._complete_function_call(event)
996
+ elif event_type == "response.output_item.done":
997
+ response_delta = self._complete_output_item(_get_value(event, "item"), event)
998
+ elif event_type == "response.completed":
999
+ response_delta, reasoning_delta = self._complete_response(event)
1000
+ elif event_type == "response.failed":
1001
+ raise RuntimeError(self._response_error_message(event))
1002
+ elif event_type == "error":
1003
+ error = _get_value(event, "error")
1004
+ message = _get_value(error, "message") or error
1005
+ raise RuntimeError(str(message))
1006
+
1007
+ if response_delta:
1008
+ self.seen_response_delta = True
1009
+ if reasoning_delta:
1010
+ self.seen_reasoning_delta = True
1011
+
1012
+ return {"reasoning_delta": reasoning_delta, "response_delta": response_delta}
1013
+
1014
+ def _remember_function_call(self, item: Any, event: Any) -> str:
1015
+ if _get_value(item, "type") != "function_call":
1016
+ return ""
1017
+ key = self._event_key(event, item)
1018
+ if not key:
1019
+ return ""
1020
+ current = self.function_calls.get(key, {})
1021
+ merged = {**current, **_object_to_dict(item)}
1022
+ self.function_calls[key] = merged
1023
+ output_index = _get_value(event, "output_index")
1024
+ if output_index is not None:
1025
+ self.output_index_keys[str(output_index)] = key
1026
+ return key
1027
+
1028
+ def _append_function_call_arguments(self, event: Any) -> None:
1029
+ key = self._event_key(event)
1030
+ if not key:
1031
+ return
1032
+ current = self.function_calls.setdefault(key, {"type": "function_call"})
1033
+ current["arguments"] = str(current.get("arguments") or "") + str(
1034
+ _get_value(event, "delta") or ""
1035
+ )
1036
+
1037
+ def _complete_function_call(self, event: Any) -> str:
1038
+ key = self._event_key(event)
1039
+ if not key:
1040
+ return ""
1041
+ current = self.function_calls.setdefault(key, {"type": "function_call"})
1042
+ if _get_value(event, "arguments") is not None:
1043
+ current["arguments"] = _get_value(event, "arguments")
1044
+ if _get_value(event, "name"):
1045
+ current["name"] = _get_value(event, "name")
1046
+ return self._emit_function_call(key, current)
1047
+
1048
+ def _complete_output_item(self, item: Any, event: Any) -> str:
1049
+ key = self._remember_function_call(item, event)
1050
+ if not key:
1051
+ return ""
1052
+ return self._emit_function_call(key, self.function_calls[key])
1053
+
1054
+ def _complete_response(self, event: Any) -> tuple[str, str]:
1055
+ self.completed_response = _get_value(event, "response")
1056
+ if self.seen_response_delta or self.emitted_function_calls:
1057
+ return "", ""
1058
+ parsed = ResponsesTransport.parse_response(self.completed_response)
1059
+ if self.seen_reasoning_delta:
1060
+ parsed["reasoning_delta"] = ""
1061
+ return parsed["response_delta"], parsed["reasoning_delta"]
1062
+
1063
+ def _emit_function_call(self, key: str, item: Any) -> str:
1064
+ if key in self.emitted_function_calls:
1065
+ return ""
1066
+ text = ResponsesTransport.function_call_text(item)
1067
+ if text:
1068
+ self.emitted_function_calls.add(key)
1069
+ return text
1070
+
1071
+ def _event_key(self, event: Any, item: Any = None) -> str:
1072
+ key = _get_value(event, "item_id") or _get_value(item, "id")
1073
+ if key:
1074
+ return str(key)
1075
+ output_index = _get_value(event, "output_index")
1076
+ if output_index is not None:
1077
+ output_key = self.output_index_keys.get(str(output_index))
1078
+ if output_key:
1079
+ return output_key
1080
+ return f"output:{output_index}"
1081
+ return ""
1082
+
1083
+ def _response_error_message(self, event: Any) -> str:
1084
+ response = _get_value(event, "response") or {}
1085
+ error = _get_value(response, "error") or _get_value(event, "error")
1086
+ message = _get_value(error, "message") or error
1087
+ return str(message or "Responses API request failed")
1088
+
1089
+
1090
+def clear_transport_capability_cache() -> None:
1091
+ RESPONSES_UNSUPPORTED_CACHE.clear()
1092
+ RESPONSES_STATE_UNSUPPORTED_CACHE.clear()
1093
+ RESPONSES_BUILTIN_UNSUPPORTED_CACHE.clear()
1094
+
1095
+
1096
+def delete_stored_response_ids(
1097
+ response_ids: list[str], **kwargs: Any
1098
+) -> list[tuple[str, str]]:
1099
+ errors: list[tuple[str, str]] = []
1100
+ for response_id in response_ids:
1101
+ try:
1102
+ delete_responses(response_id=response_id, **kwargs)
1103
+ except Exception as exc:
1104
+ errors.append((response_id, _exception_text(exc)))
1105
+ return errors
1106
+
1107
+
1108
+async def adelete_stored_response_ids(
1109
+ response_ids: list[str], **kwargs: Any
1110
+) -> list[tuple[str, str]]:
1111
+ errors: list[tuple[str, str]] = []
1112
+ for response_id in response_ids:
1113
+ try:
1114
+ await adelete_responses(response_id=response_id, **kwargs)
1115
+ except Exception as exc:
1116
+ errors.append((response_id, _exception_text(exc)))
1117
+ return errors
1118
+
1119
+
1120
+def _coerce_bool(value: Any, default: bool = False) -> bool:
1121
+ if value is None:
1122
+ return default
1123
+ if isinstance(value, bool):
1124
+ return value
1125
+ if isinstance(value, str):
1126
+ normalized = value.strip().lower()
1127
+ if normalized in {"1", "true", "yes", "on"}:
1128
+ return True
1129
+ if normalized in {"0", "false", "no", "off", "none"}:
1130
+ return False
1131
+ return bool(value)
1132
+
1133
+
1134
+def _responses_cache_key(model: str, kwargs: dict[str, Any]) -> str:
1135
+ api_base = (
1136
+ kwargs.get("api_base")
1137
+ or kwargs.get("base_url")
1138
+ or kwargs.get("api_base_url")
1139
+ or ""
1140
+ )
1141
+ custom_provider = kwargs.get("custom_llm_provider") or ""
1142
+ return "|".join(str(part) for part in (model, custom_provider, api_base))
1143
+
1144
+
1145
+def _drop_legacy_transport_kwargs(kwargs: dict[str, Any]) -> None:
1146
+ kwargs.pop("a0_api_mode", None)
1147
+ kwargs.pop("a0_responses_fallback", None)
1148
+
1149
+
1150
+def _drop_responses_only_kwargs(kwargs: dict[str, Any]) -> None:
1151
+ kwargs.pop("responses_state", None)
1152
+ kwargs.pop("responses_delete_on_chat_delete", None)
1153
+ kwargs.pop("responses_input_items", None)
1154
+ kwargs.pop("responses_local_input_items", None)
1155
+ kwargs.pop("previous_response_id", None)
1156
+ kwargs.pop("_a0_responses_builtin_downgrades", None)
1157
+
1158
+
1159
+def _drop_internal_transport_kwargs(kwargs: dict[str, Any]) -> None:
1160
+ _drop_legacy_transport_kwargs(kwargs)
1161
+ kwargs.pop("a0_explicit_prompt_caching", None)
1162
+ kwargs.pop("a0_responses_function_tools", None)
1163
+ kwargs.pop("responses_builtin_tools", None)
1164
+ _drop_responses_only_kwargs(kwargs)
1165
+
1166
+
1167
+def _normalize_responses_state(value: Any) -> str:
1168
+ normalized = str(value or RESPONSES_STATE_PROVIDER).strip().lower()
1169
+ return normalized if normalized in RESPONSES_STATES else RESPONSES_STATE_PROVIDER
1170
+
1171
+
1172
+def _filter_unsupported_builtin_tools(kwargs: dict[str, Any], cache_key: str) -> None:
1173
+ unsupported = RESPONSES_BUILTIN_UNSUPPORTED_CACHE.get(cache_key)
1174
+ if not unsupported:
1175
+ return
1176
+ tools = kwargs.get("responses_builtin_tools")
1177
+ if not isinstance(tools, list):
1178
+ return
1179
+ filtered = []
1180
+ downgraded = []
1181
+ for tool in tools:
1182
+ tool_type = _response_tool_type(tool)
1183
+ if tool_type in unsupported:
1184
+ downgraded.append(tool_type)
1185
+ continue
1186
+ filtered.append(tool)
1187
+ if downgraded:
1188
+ kwargs["responses_builtin_tools"] = filtered
1189
+ kwargs["_a0_responses_builtin_downgrades"] = sorted(set(downgraded))
1190
+
1191
+
1192
+def _builtin_tool_types(tools: Any) -> set[str]:
1193
+ return {
1194
+ tool_type
1195
+ for tool in _as_list(tools)
1196
+ if (tool_type := _response_tool_type(tool))
1197
+ }
1198
+
1199
+
1200
+def _response_tool_type(tool: Any) -> str:
1201
+ if isinstance(tool, str):
1202
+ return tool
1203
+ if isinstance(tool, dict):
1204
+ return str(tool.get("type") or "")
1205
+ return ""
1206
+
1207
+
1208
+def apply_chat_prompt_cache_markers(
1209
+ messages: list[dict[str, Any]],
1210
+ *,
1211
+ model: str = "",
1212
+ kwargs: dict[str, Any] | None = None,
1213
+) -> list[dict[str, Any]]:
1214
+ if not _supports_cache_control_markers(model, kwargs or {}):
1215
+ return [dict(message) for message in messages]
1216
+
1217
+ prepared = [_strip_message_cache_control(message) for message in messages]
1218
+ for index in _prompt_cache_message_indexes(prepared):
1219
+ prepared[index] = _message_with_cache_control(prepared[index])
1220
+ return prepared
1221
+
1222
+
1223
+def _prompt_cache_message_indexes(messages: list[dict[str, Any]]) -> list[int]:
1224
+ indexes: list[int] = []
1225
+
1226
+ leading_context: list[int] = []
1227
+ for index, message in enumerate(messages):
1228
+ role = str(message.get("role") or "")
1229
+ if role in {"system", "developer"}:
1230
+ leading_context.append(index)
1231
+ continue
1232
+ break
1233
+ if leading_context:
1234
+ indexes.append(leading_context[-1])
1235
+
1236
+ user_indexes = [
1237
+ index
1238
+ for index, message in enumerate(messages)
1239
+ if str(message.get("role") or "") == "user"
1240
+ ]
1241
+ indexes.extend(user_indexes[-2:])
1242
+
1243
+ deduplicated: list[int] = []
1244
+ for index in indexes:
1245
+ if index not in deduplicated:
1246
+ deduplicated.append(index)
1247
+ return deduplicated[:3]
1248
+
1249
+
1250
+def _strip_message_cache_control(message: dict[str, Any]) -> dict[str, Any]:
1251
+ result = dict(message)
1252
+ result.pop("cache_control", None)
1253
+ return result
1254
+
1255
+
1256
+def _message_with_cache_control(message: dict[str, Any]) -> dict[str, Any]:
1257
+ result = dict(message)
1258
+ result["content"] = _content_with_cache_control(result.get("content", ""))
1259
+ return result
1260
+
1261
+
1262
+def _content_with_cache_control(content: Any) -> Any:
1263
+ marker = _cache_control_marker()
1264
+ if isinstance(content, list):
1265
+ blocks = [_copy_content_block(block) for block in content]
1266
+ if not blocks:
1267
+ return [{"type": "text", "text": "", "cache_control": marker}]
1268
+ for index in range(len(blocks) - 1, -1, -1):
1269
+ block = blocks[index]
1270
+ if isinstance(block, dict):
1271
+ block["cache_control"] = marker
1272
+ return blocks
1273
+ if isinstance(block, str):
1274
+ blocks[index] = {
1275
+ "type": "text",
1276
+ "text": block,
1277
+ "cache_control": marker,
1278
+ }
1279
+ return blocks
1280
+ return blocks
1281
+
1282
+ if isinstance(content, dict):
1283
+ block = dict(content)
1284
+ block["cache_control"] = marker
1285
+ return block
1286
+
1287
+ return [
1288
+ {
1289
+ "type": "text",
1290
+ "text": _content_to_text(content),
1291
+ "cache_control": marker,
1292
+ }
1293
+ ]
1294
+
1295
+
1296
+def _copy_content_block(block: Any) -> Any:
1297
+ if isinstance(block, dict):
1298
+ return dict(block)
1299
+ if isinstance(block, list):
1300
+ return [_copy_content_block(item) for item in block]
1301
+ return block
1302
+
1303
+
1304
+def _apply_tool_cache_control(kwargs: dict[str, Any]) -> None:
1305
+ tools = kwargs.get("tools")
1306
+ if not isinstance(tools, list) or not tools or _has_cache_control(tools):
1307
+ return
1308
+
1309
+ prepared = [dict(tool) if isinstance(tool, dict) else tool for tool in tools]
1310
+ for index in range(len(prepared) - 1, -1, -1):
1311
+ tool = prepared[index]
1312
+ if not isinstance(tool, dict):
1313
+ continue
1314
+ if tool.get("type") == "function" and isinstance(tool.get("function"), dict):
1315
+ function = dict(tool["function"])
1316
+ function["cache_control"] = _cache_control_marker()
1317
+ tool = dict(tool)
1318
+ tool["function"] = function
1319
+ prepared[index] = tool
1320
+ else:
1321
+ tool = dict(tool)
1322
+ tool["cache_control"] = _cache_control_marker()
1323
+ prepared[index] = tool
1324
+ kwargs["tools"] = prepared
1325
+ return
1326
+
1327
+
1328
+def _cache_control_marker() -> dict[str, str]:
1329
+ return {"type": "ephemeral"}
1330
+
1331
+
1332
+def _should_preserve_cache_control_on_chat(
1333
+ model: str,
1334
+ kwargs: dict[str, Any],
1335
+ messages: list[dict[str, Any]],
1336
+) -> bool:
1337
+ if not (_has_cache_control(messages) or _has_cache_control(kwargs.get("tools"))):
1338
+ return False
1339
+ return not _is_native_responses_provider(model, kwargs)
1340
+
1341
+
1342
+def _is_native_responses_provider(model: str, kwargs: dict[str, Any]) -> bool:
1343
+ api_base = _api_base(kwargs)
1344
+ if "openrouter.ai" in api_base or "anthropic.com" in api_base:
1345
+ return False
1346
+
1347
+ provider = _normalized_provider(model, kwargs)
1348
+ return provider in {"openai", "azure", "azure_ai", "xai"}
1349
+
1350
+
1351
+def _supports_cache_control_markers(
1352
+ model: str,
1353
+ kwargs: dict[str, Any],
1354
+) -> bool:
1355
+ api_base = _api_base(kwargs)
1356
+ if "openrouter.ai" in api_base or "anthropic.com" in api_base:
1357
+ return True
1358
+ provider = _normalized_provider(model, kwargs)
1359
+ return provider in CACHE_CONTROL_PROMPT_PROVIDERS
1360
+
1361
+
1362
+def _is_openai_prompt_cache_provider(model: str, kwargs: dict[str, Any]) -> bool:
1363
+ api_base = _api_base(kwargs)
1364
+ provider = _normalized_provider(model, kwargs)
1365
+
1366
+ if api_base:
1367
+ if "api.openai.com" in api_base:
1368
+ return provider in {"", "openai"}
1369
+ if "openai.azure.com" in api_base:
1370
+ return provider in {"", "azure", "openai"}
1371
+ return False
1372
+
1373
+ if not provider and str(model):
1374
+ provider = "openai"
1375
+ return provider in OPENAI_PROMPT_CACHE_PROVIDERS
1376
+
1377
+
1378
+def _api_base(kwargs: dict[str, Any]) -> str:
1379
+ return str(
1380
+ kwargs.get("api_base")
1381
+ or kwargs.get("base_url")
1382
+ or kwargs.get("api_base_url")
1383
+ or ""
1384
+ ).lower()
1385
+
1386
+
1387
+def _normalized_provider(model: str, kwargs: dict[str, Any]) -> str:
1388
+ provider = str(kwargs.get("custom_llm_provider") or "").strip().lower()
1389
+ if not provider and "/" in str(model):
1390
+ provider = str(model).split("/", 1)[0].strip().lower()
1391
+ return provider.replace("-", "_")
1392
+
1393
+
1394
+def _prepare_openai_prompt_cache_params(
1395
+ request: dict[str, Any],
1396
+ messages: list[dict[str, Any]],
1397
+ *,
1398
+ model: str = "",
1399
+) -> None:
1400
+ if "prompt_cache_key" in request:
1401
+ return
1402
+
1403
+ sanitized_messages = _without_cache_control(messages)
1404
+ sanitized_request = _without_cache_control(request)
1405
+ prompt_cache_key = _default_prompt_cache_key(
1406
+ model,
1407
+ sanitized_messages if isinstance(sanitized_messages, list) else messages,
1408
+ sanitized_request if isinstance(sanitized_request, dict) else request,
1409
+ )
1410
+ if prompt_cache_key:
1411
+ request["prompt_cache_key"] = prompt_cache_key
1412
+
1413
+
1414
+def _default_prompt_cache_key(
1415
+ model: str,
1416
+ messages: list[dict[str, Any]],
1417
+ request: dict[str, Any],
1418
+) -> str:
1419
+ material = _prompt_cache_key_material(messages, request)
1420
+ if not material:
1421
+ return ""
1422
+ digest = hashlib.sha256(
1423
+ json.dumps(
1424
+ {
1425
+ "model": model,
1426
+ "material": material,
1427
+ },
1428
+ sort_keys=True,
1429
+ default=str,
1430
+ separators=(",", ":"),
1431
+ ).encode("utf-8")
1432
+ ).hexdigest()[:32]
1433
+ return f"a0-{digest}"
1434
+
1435
+
1436
+def _prompt_cache_key_material(
1437
+ messages: list[dict[str, Any]],
1438
+ request: dict[str, Any],
1439
+) -> dict[str, Any]:
1440
+ material: dict[str, Any] = {}
1441
+
1442
+ leading_messages: list[dict[str, Any]] = []
1443
+ for message in messages:
1444
+ role = str(message.get("role") or "")
1445
+ if role not in {"system", "developer"}:
1446
+ break
1447
+ leading_messages.append(
1448
+ {
1449
+ "role": role,
1450
+ "content": message.get("content"),
1451
+ }
1452
+ )
1453
+ if leading_messages:
1454
+ material["messages"] = leading_messages
1455
+
1456
+ if request.get("instructions"):
1457
+ material["instructions"] = request["instructions"]
1458
+ if request.get("prompt"):
1459
+ material["prompt"] = request["prompt"]
1460
+ if request.get("tools"):
1461
+ material["tools"] = request["tools"]
1462
+
1463
+ return material
1464
+
1465
+
1466
+def _has_cache_control(value: Any) -> bool:
1467
+ if isinstance(value, dict):
1468
+ if value.get("cache_control") is not None:
1469
+ return True
1470
+ return any(_has_cache_control(item) for item in value.values())
1471
+ if isinstance(value, list):
1472
+ return any(_has_cache_control(item) for item in value)
1473
+ return False
1474
+
1475
+
1476
+def _without_cache_control(value: Any) -> Any:
1477
+ if isinstance(value, dict):
1478
+ return {
1479
+ key: _without_cache_control(item)
1480
+ for key, item in value.items()
1481
+ if key != "cache_control"
1482
+ }
1483
+ if isinstance(value, list):
1484
+ return [_without_cache_control(item) for item in value]
1485
+ return value
1486
+
1487
+
1488
+def _object_to_dict(obj: Any) -> dict[str, Any]:
1489
+ if isinstance(obj, dict):
1490
+ return dict(obj)
1491
+ if hasattr(obj, "model_dump"):
1492
+ dumped = obj.model_dump()
1493
+ return dict(dumped) if isinstance(dumped, dict) else {}
1494
+ if hasattr(obj, "dict"):
1495
+ dumped = obj.dict()
1496
+ return dict(dumped) if isinstance(dumped, dict) else {}
1497
+ return {}
1498
+
1499
+
1500
+def _normalize_reasoning_effort(effort: Any) -> str | None:
1501
+ if isinstance(effort, str):
1502
+ normalized = effort.strip().lower()
1503
+ else:
1504
+ normalized = str(effort).strip().lower() if effort is not None else ""
1505
+ if normalized in RESPONSES_REASONING_EFFORTS:
1506
+ return normalized
1507
+ if normalized in NO_REASONING_EFFORT_ALIASES:
1508
+ return None
1509
+ return RESPONSES_REASONING_FALLBACK_EFFORT
1510
+
1511
+
1512
+def _is_responses_reasoning_effort_error(exc: Exception) -> bool:
1513
+ text = _exception_text(exc).lower()
1514
+ return (
1515
+ "response.reasoning.effort" in text
1516
+ and "minimal" in text
1517
+ and "high" in text
1518
+ and "none" in text
1519
+ )
1520
+
1521
+
1522
+def _is_responses_not_supported_error(exc: Exception) -> bool:
1523
+ text = _exception_text(exc).lower()
1524
+ if any(marker in text for marker in ("429", "too many requests", "rate limit")):
1525
+ return False
1526
+ if "/v1/responses" in text and any(
1527
+ marker in text for marker in ("404", "not found")
1528
+ ):
1529
+ return True
1530
+ return any(
1531
+ marker in text
1532
+ for marker in (
1533
+ "responses api",
1534
+ "does not support responses",
1535
+ "not support responses",
1536
+ "unsupportedparamserror",
1537
+ "does not support parameters",
1538
+ "no 'tools' defined while 'tool_choice' is specified",
1539
+ )
1540
+ )
1541
+
1542
+
1543
+def _is_responses_state_unsupported_error(exc: Exception) -> bool:
1544
+ text = _exception_text(exc).lower()
1545
+ if any(marker in text for marker in ("429", "too many requests", "rate limit")):
1546
+ return False
1547
+ if "404" in text and "/v1/responses/" in text:
1548
+ return True
1549
+ return any(
1550
+ marker in text
1551
+ for marker in (
1552
+ "previous_response_id",
1553
+ "store",
1554
+ "stored response",
1555
+ "response not found",
1556
+ "no response found",
1557
+ "does not support response storage",
1558
+ "doesn't support response storage",
1559
+ "response storage is not supported",
1560
+ "state is not supported",
1561
+ )
1562
+ )
1563
+
1564
+
1565
+def _is_responses_builtin_tool_error(exc: Exception) -> bool:
1566
+ text = _exception_text(exc).lower()
1567
+ if any(marker in text for marker in ("429", "too many requests", "rate limit")):
1568
+ return False
1569
+ return any(
1570
+ marker in text
1571
+ for marker in (
1572
+ "unsupported tool",
1573
+ "unsupported tools",
1574
+ "invalid tool",
1575
+ "tool type",
1576
+ "tools[",
1577
+ "web_search",
1578
+ "file_search",
1579
+ "code_interpreter",
1580
+ "image_generation",
1581
+ "computer_use_preview",
1582
+ "mcp",
1583
+ )
1584
+ )
1585
+
1586
+
1587
+def _exception_text(exc: Exception | None) -> str:
1588
+ if exc is None:
1589
+ return ""
1590
+ parts = [exc.__class__.__name__, str(exc)]
1591
+ cause = getattr(exc, "__cause__", None)
1592
+ context = getattr(exc, "__context__", None)
1593
+ if cause is not None:
1594
+ parts.append(str(cause))
1595
+ if context is not None and context is not cause:
1596
+ parts.append(str(context))
1597
+ return "\n".join(parts)
1598
+
1599
+
1600
+def _close_sync_stream(stream: Any) -> None:
1601
+ for method_name in ("close", "aclose"):
1602
+ close = getattr(stream, method_name, None)
1603
+ if close is None:
1604
+ continue
1605
+ result = close()
1606
+ if inspect.isawaitable(result):
1607
+ result.close()
1608
+ return
1609
+
1610
+
1611
+async def _close_async_stream(stream: Any) -> None:
1612
+ for method_name in ("aclose", "close"):
1613
+ close = getattr(stream, method_name, None)
1614
+ if close is None:
1615
+ continue
1616
+ result = close()
1617
+ if inspect.isawaitable(result):
1618
+ await result
1619
+ return
1620
+
1621
+
1622
+def _without_stream_kwarg(kwargs: dict[str, Any]) -> dict[str, Any]:
1623
+ kwargs.pop("stream", None)
1624
+ return kwargs
1625
+
1626
+
1627
+def _first_choice(chunk: Any) -> Any:
1628
+ choices = _get_value(chunk, "choices") or []
1629
+ return choices[0] if choices else {}
1630
+
1631
+
1632
+def _get_value(obj: Any, key: str) -> Any:
1633
+ if isinstance(obj, dict):
1634
+ return obj.get(key)
1635
+ return getattr(obj, key, None)
1636
+
1637
+
1638
+def _as_list(value: Any) -> list[Any]:
1639
+ return value if isinstance(value, list) else []
1640
+
1641
+
1642
+def _has_tools(tools: Any) -> bool:
1643
+ if isinstance(tools, list):
1644
+ return bool(tools)
1645
+ return bool(tools)
1646
+
1647
+
1648
+def _has_chunk_delta(chunk: ChatChunk) -> bool:
1649
+ return bool(chunk.get("response_delta") or chunk.get("reasoning_delta"))
1650
+
1651
+
1652
+def _has_real_content(content: Any) -> bool:
1653
+ if content == "empty":
1654
+ return False
1655
+ if isinstance(content, str):
1656
+ return bool(content.strip())
1657
+ if isinstance(content, list):
1658
+ return len(content) > 0
1659
+ return content is not None
1660
+
1661
+
1662
+def _content_to_text(content: Any) -> str:
1663
+ content = images.prepare_content(content)
1664
+ if isinstance(content, str):
1665
+ return content
1666
+ if isinstance(content, list):
1667
+ pieces: list[str] = []
1668
+ for item in content:
1669
+ if isinstance(item, str):
1670
+ pieces.append(item)
1671
+ elif isinstance(item, dict):
1672
+ text = item.get("text")
1673
+ if isinstance(text, str):
1674
+ pieces.append(text)
1675
+ return "\n".join(pieces)
1676
+ return "" if content is None else str(content)
helpers/llm_result.py
new
+311
@@ -0,0 +1,311 @@
1
+from __future__ import annotations
2
+
3
+from dataclasses import dataclass, field
4
+import json
5
+from typing import Any
6
+
7
+
8
+RESPONSE_METADATA_KEY = "responses"
9
+LOCAL_FUNCTION_TOOL_TYPES = {"function_call"}
10
+TEXT_OUTPUT_TYPES = {"message"}
11
+REASONING_OUTPUT_TYPES = {"reasoning"}
12
+
13
+
14
+@dataclass
15
+class ResponseItem:
16
+ type: str
17
+ data: dict[str, Any] = field(default_factory=dict)
18
+
19
+ @classmethod
20
+ def from_any(cls, item: Any) -> "ResponseItem":
21
+ data = object_to_dict(item)
22
+ return cls(type=str(data.get("type") or ""), data=data)
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ return dict(self.data)
26
+
27
+
28
+@dataclass
29
+class ResponseFunctionCall:
30
+ name: str
31
+ arguments: dict[str, Any]
32
+ call_id: str
33
+ item_id: str = ""
34
+ raw: dict[str, Any] = field(default_factory=dict)
35
+
36
+ @classmethod
37
+ def from_item(cls, item: ResponseItem) -> "ResponseFunctionCall | None":
38
+ if item.type != "function_call":
39
+ return None
40
+ name = str(item.data.get("name") or "")
41
+ if not name:
42
+ return None
43
+ return cls(
44
+ name=name,
45
+ arguments=parse_arguments(item.data.get("arguments")),
46
+ call_id=str(item.data.get("call_id") or item.data.get("id") or ""),
47
+ item_id=str(item.data.get("id") or ""),
48
+ raw=dict(item.data),
49
+ )
50
+
51
+
52
+@dataclass
53
+class LLMResult:
54
+ response: str = ""
55
+ reasoning: str = ""
56
+ response_id: str = ""
57
+ previous_response_id: str = ""
58
+ input_items: list[dict[str, Any]] = field(default_factory=list)
59
+ output_items: list[ResponseItem] = field(default_factory=list)
60
+ provider_model_key: str = ""
61
+ mode: str = "responses"
62
+ state: str = "provider"
63
+ usage: dict[str, Any] = field(default_factory=dict)
64
+ raw: dict[str, Any] = field(default_factory=dict)
65
+ capability: dict[str, Any] = field(default_factory=dict)
66
+
67
+ @classmethod
68
+ def from_dict(cls, data: dict[str, Any] | None) -> "LLMResult":
69
+ data = data or {}
70
+ return cls(
71
+ response=str(data.get("response") or ""),
72
+ reasoning=str(data.get("reasoning") or ""),
73
+ response_id=str(data.get("response_id") or ""),
74
+ previous_response_id=str(data.get("previous_response_id") or ""),
75
+ input_items=list(data.get("input_items") or []),
76
+ output_items=[
77
+ ResponseItem.from_any(item) for item in data.get("output_items") or []
78
+ ],
79
+ provider_model_key=str(data.get("provider_model_key") or ""),
80
+ mode=str(data.get("mode") or "responses"),
81
+ state=str(data.get("state") or "provider"),
82
+ usage=object_to_dict(data.get("usage") or {}),
83
+ raw=object_to_dict(data.get("raw") or {}),
84
+ capability=object_to_dict(data.get("capability") or {}),
85
+ )
86
+
87
+ @classmethod
88
+ def from_response(
89
+ cls,
90
+ response: Any,
91
+ *,
92
+ input_items: list[dict[str, Any]] | None = None,
93
+ previous_response_id: str = "",
94
+ provider_model_key: str = "",
95
+ mode: str = "responses",
96
+ state: str = "provider",
97
+ capability: dict[str, Any] | None = None,
98
+ ) -> "LLMResult":
99
+ raw = object_to_dict(response)
100
+ output_items = [ResponseItem.from_any(item) for item in as_list(raw.get("output"))]
101
+ result = cls(
102
+ response_id=str(raw.get("id") or ""),
103
+ previous_response_id=str(
104
+ raw.get("previous_response_id") or previous_response_id or ""
105
+ ),
106
+ input_items=list(input_items or []),
107
+ output_items=output_items,
108
+ provider_model_key=provider_model_key,
109
+ mode=mode,
110
+ state=state,
111
+ usage=object_to_dict(raw.get("usage") or {}),
112
+ raw=raw,
113
+ capability=dict(capability or {}),
114
+ )
115
+ result.response = output_text(raw, output_items)
116
+ result.reasoning = reasoning_text(output_items)
117
+ if not result.response and result.function_calls:
118
+ result.response = result.function_calls_text()
119
+ return result
120
+
121
+ @classmethod
122
+ def from_chat(
123
+ cls,
124
+ *,
125
+ response: str,
126
+ reasoning: str = "",
127
+ input_items: list[dict[str, Any]] | None = None,
128
+ provider_model_key: str = "",
129
+ capability: dict[str, Any] | None = None,
130
+ ) -> "LLMResult":
131
+ output_items = []
132
+ if response:
133
+ output_items.append(
134
+ ResponseItem(
135
+ type="message",
136
+ data={
137
+ "type": "message",
138
+ "role": "assistant",
139
+ "content": [{"type": "output_text", "text": response}],
140
+ },
141
+ )
142
+ )
143
+ if reasoning:
144
+ output_items.insert(
145
+ 0,
146
+ ResponseItem(
147
+ type="reasoning",
148
+ data={
149
+ "type": "reasoning",
150
+ "summary": [{"type": "summary_text", "text": reasoning}],
151
+ },
152
+ ),
153
+ )
154
+ return cls(
155
+ response=response,
156
+ reasoning=reasoning,
157
+ input_items=list(input_items or []),
158
+ output_items=output_items,
159
+ provider_model_key=provider_model_key,
160
+ mode="chat_completions",
161
+ state="off",
162
+ capability=dict(capability or {}),
163
+ )
164
+
165
+ @property
166
+ def function_calls(self) -> list[ResponseFunctionCall]:
167
+ calls: list[ResponseFunctionCall] = []
168
+ for item in self.output_items:
169
+ call = ResponseFunctionCall.from_item(item)
170
+ if call:
171
+ calls.append(call)
172
+ return calls
173
+
174
+ @property
175
+ def builtin_items(self) -> list[ResponseItem]:
176
+ return [
177
+ item
178
+ for item in self.output_items
179
+ if item.type
180
+ and item.type not in TEXT_OUTPUT_TYPES
181
+ and item.type not in REASONING_OUTPUT_TYPES
182
+ and item.type not in LOCAL_FUNCTION_TOOL_TYPES
183
+ ]
184
+
185
+ def function_calls_text(self) -> str:
186
+ calls = [
187
+ {"tool_name": call.name, "tool_args": call.arguments}
188
+ for call in self.function_calls
189
+ ]
190
+ if not calls:
191
+ return ""
192
+ if len(calls) == 1:
193
+ return json.dumps(calls[0])
194
+ return json.dumps(
195
+ {"tool_name": "parallel_tool_calls", "tool_args": {"calls": calls}}
196
+ )
197
+
198
+ def to_dict(self) -> dict[str, Any]:
199
+ return {
200
+ "response": self.response,
201
+ "reasoning": self.reasoning,
202
+ "response_id": self.response_id,
203
+ "previous_response_id": self.previous_response_id,
204
+ "input_items": self.input_items,
205
+ "output_items": [item.to_dict() for item in self.output_items],
206
+ "provider_model_key": self.provider_model_key,
207
+ "mode": self.mode,
208
+ "state": self.state,
209
+ "usage": self.usage,
210
+ "raw": self.raw,
211
+ "capability": self.capability,
212
+ }
213
+
214
+ def metadata(self) -> dict[str, Any]:
215
+ return {RESPONSE_METADATA_KEY: self.to_dict()}
216
+
217
+
218
+def function_call_output_item(
219
+ call_id: str,
220
+ output: str,
221
+ *,
222
+ acknowledged_safety_checks: list[dict[str, Any]] | None = None,
223
+) -> dict[str, Any]:
224
+ item: dict[str, Any] = {
225
+ "type": "function_call_output",
226
+ "call_id": str(call_id or ""),
227
+ "output": output,
228
+ }
229
+ if acknowledged_safety_checks:
230
+ item["acknowledged_safety_checks"] = acknowledged_safety_checks
231
+ return item
232
+
233
+
234
+def metadata_from_llm_result(result: LLMResult | None) -> dict[str, Any]:
235
+ return result.metadata() if result else {}
236
+
237
+
238
+def result_from_metadata(metadata: dict[str, Any] | None) -> LLMResult | None:
239
+ if not isinstance(metadata, dict):
240
+ return None
241
+ data = metadata.get(RESPONSE_METADATA_KEY)
242
+ if not isinstance(data, dict):
243
+ return None
244
+ return LLMResult.from_dict(data)
245
+
246
+
247
+def object_to_dict(obj: Any) -> dict[str, Any]:
248
+ if isinstance(obj, dict):
249
+ return dict(obj)
250
+ if hasattr(obj, "model_dump"):
251
+ dumped = obj.model_dump()
252
+ return dict(dumped) if isinstance(dumped, dict) else {}
253
+ if hasattr(obj, "dict"):
254
+ dumped = obj.dict()
255
+ return dict(dumped) if isinstance(dumped, dict) else {}
256
+ return {}
257
+
258
+
259
+def as_list(value: Any) -> list[Any]:
260
+ return value if isinstance(value, list) else []
261
+
262
+
263
+def output_text(raw: dict[str, Any], output_items: list[ResponseItem]) -> str:
264
+ direct = raw.get("output_text")
265
+ if isinstance(direct, str):
266
+ return direct
267
+ pieces: list[str] = []
268
+ for item in output_items:
269
+ if item.type != "message":
270
+ continue
271
+ for block in as_list(item.data.get("content")):
272
+ if not isinstance(block, dict):
273
+ continue
274
+ block_type = block.get("type")
275
+ if block_type in {"output_text", "text", "input_text"}:
276
+ text = block.get("text")
277
+ if isinstance(text, str):
278
+ pieces.append(text)
279
+ elif block_type == "refusal":
280
+ refusal = block.get("refusal")
281
+ if isinstance(refusal, str):
282
+ pieces.append(refusal)
283
+ return "".join(pieces)
284
+
285
+
286
+def reasoning_text(output_items: list[ResponseItem]) -> str:
287
+ pieces: list[str] = []
288
+ for item in output_items:
289
+ if item.type != "reasoning":
290
+ continue
291
+ for block in as_list(item.data.get("summary")):
292
+ if isinstance(block, dict):
293
+ text = block.get("text") or block.get("reasoning")
294
+ if isinstance(text, str):
295
+ pieces.append(text)
296
+ elif isinstance(block, str):
297
+ pieces.append(block)
298
+ return "".join(pieces)
299
+
300
+
301
+def parse_arguments(raw_arguments: Any) -> dict[str, Any]:
302
+ if isinstance(raw_arguments, dict):
303
+ return raw_arguments
304
+ if isinstance(raw_arguments, str):
305
+ try:
306
+ parsed = json.loads(raw_arguments or "{}")
307
+ except Exception:
308
+ parsed = {"arguments": raw_arguments}
309
+ else:
310
+ parsed = {"arguments": raw_arguments}
311
+ return parsed if isinstance(parsed, dict) else {"arguments": parsed}
helpers/persist_chat.py
+69
@@ -4,6 +4,7 @@ from typing import Any
4
import uuid
5
from agent import Agent, AgentConfig, AgentContext, AgentContextType
6
from helpers import files, history
7
+from helpers.litellm_transport import delete_stored_response_ids
8
from helpers.localization import Localization
9
import json
10
from initialize import initialize_agent
@@ -118,6 +119,7 @@ def export_json_chat(context: AgentContext):
119
120
def remove_chat(ctxid):
121
"""Remove a chat or task context"""
122
+ _delete_provider_responses_for_chat(ctxid)
123
path = get_chat_folder_path(ctxid)
124
files.delete_dir(path)
125
@@ -324,3 +326,70 @@ def _safe_json_serialize(obj, **kwargs):
326
return False
327
328
return json.dumps(obj, default=serializer, **kwargs)
329
+
330
+
331
+def _delete_provider_responses_for_chat(ctxid: str) -> None:
332
+ try:
333
+ data = json.loads(files.read_file(_get_chat_file_path(ctxid)))
334
+ except Exception:
335
+ return
336
+ if _responses_delete_disabled(data):
337
+ return
338
+ response_ids = _collect_response_ids(data)
339
+ if not response_ids:
340
+ return
341
+ delete_stored_response_ids(response_ids)
342
+
343
+
344
+def _responses_delete_disabled(data: dict[str, Any]) -> bool:
345
+ if data.get("responses_delete_on_chat_delete") is False:
346
+ return True
347
+ context_data = data.get("data")
348
+ if isinstance(context_data, dict) and context_data.get("responses_delete_on_chat_delete") is False:
349
+ return True
350
+ for agent_data in data.get("agents", []) or []:
351
+ if not isinstance(agent_data, dict):
352
+ continue
353
+ state = agent_data.get("data")
354
+ if isinstance(state, dict) and state.get("responses_delete_on_chat_delete") is False:
355
+ return True
356
+ return False
357
+
358
+
359
+def _collect_response_ids(data: Any) -> list[str]:
360
+ found: list[str] = []
361
+ seen: set[str] = set()
362
+
363
+ def add(value: Any) -> None:
364
+ response_id = str(value or "").strip()
365
+ if response_id and response_id not in seen:
366
+ seen.add(response_id)
367
+ found.append(response_id)
368
+
369
+ def walk(obj: Any) -> None:
370
+ if isinstance(obj, dict):
371
+ state = obj.get(Agent.DATA_NAME_RESPONSES_STATE)
372
+ if isinstance(state, dict):
373
+ add(state.get("response_id"))
374
+ for response_id in state.get("response_ids") or []:
375
+ add(response_id)
376
+
377
+ metadata = obj.get("metadata")
378
+ if isinstance(metadata, dict):
379
+ responses = metadata.get("responses")
380
+ if isinstance(responses, dict):
381
+ add(responses.get("response_id"))
382
+
383
+ for value in obj.values():
384
+ walk(value)
385
+ elif isinstance(obj, list):
386
+ for value in obj:
387
+ walk(value)
388
+ elif isinstance(obj, str) and '"response_id"' in obj:
389
+ try:
390
+ walk(json.loads(obj))
391
+ except Exception:
392
+ return
393
+
394
+ walk(data)
395
+ return found
helpers/responses_tools.py
new
+231
@@ -0,0 +1,231 @@
1
+from __future__ import annotations
2
+
3
+import hashlib
4
+import json
5
+import os
6
+import re
7
+from typing import Any
8
+
9
+from helpers import files, subagents
10
+
11
+
12
+FUNCTION_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
13
+TOOL_PROMPT_PREFIX = "agent.system.tool."
14
+TOOL_PROMPT_SUFFIX = ".md"
15
+MAX_TOOL_DESCRIPTION_CHARS = 1024
16
+
17
+
18
+def build_responses_function_tools(agent: Any) -> tuple[list[dict[str, Any]], dict[str, str]]:
19
+ """Build permissive Responses function tools from A0 tool prompts and MCP schemas."""
20
+
21
+ tools: list[dict[str, Any]] = []
22
+ name_map: dict[str, str] = {}
23
+
24
+ for tool_name, prompt in _local_tool_prompts(agent):
25
+ native_name = _native_tool_name(tool_name)
26
+ name_map[native_name] = tool_name
27
+ tools.append(
28
+ {
29
+ "type": "function",
30
+ "name": native_name,
31
+ "description": _description_from_prompt(prompt, fallback=tool_name),
32
+ "parameters": _schema_from_prompt(prompt),
33
+ }
34
+ )
35
+
36
+ for tool_name, tool in _mcp_tools(agent):
37
+ native_name = _native_tool_name(tool_name)
38
+ name_map[native_name] = tool_name
39
+ tools.append(
40
+ {
41
+ "type": "function",
42
+ "name": native_name,
43
+ "description": _truncate(str(tool.get("description") or tool_name)),
44
+ "parameters": _schema_from_any(tool.get("input_schema")),
45
+ }
46
+ )
47
+
48
+ return _dedupe_tools(tools), name_map
49
+
50
+
51
+def original_tool_name(native_name: str, name_map: dict[str, str] | None) -> str:
52
+ if not name_map:
53
+ return native_name
54
+ return name_map.get(native_name, native_name)
55
+
56
+
57
+def _local_tool_prompts(agent: Any) -> list[tuple[str, str]]:
58
+ prompt_dirs = subagents.get_paths(agent, "prompts")
59
+ tool_files = files.get_unique_filenames_in_dirs(
60
+ prompt_dirs, f"{TOOL_PROMPT_PREFIX}*{TOOL_PROMPT_SUFFIX}"
61
+ )
62
+ result: list[tuple[str, str]] = []
63
+ for tool_file in tool_files:
64
+ basename = os.path.basename(tool_file)
65
+ tool_name = _tool_name_from_prompt_basename(basename)
66
+ if not tool_name:
67
+ continue
68
+ try:
69
+ prompt = agent.read_prompt(basename)
70
+ except Exception:
71
+ try:
72
+ prompt = files.read_file(tool_file)
73
+ except Exception:
74
+ prompt = ""
75
+ result.append((tool_name, prompt))
76
+ return result
77
+
78
+
79
+def _mcp_tools(agent: Any) -> list[tuple[str, dict[str, Any]]]:
80
+ try:
81
+ import helpers.mcp_handler as mcp_helper
82
+
83
+ raw_tools = mcp_helper.MCPConfig.get_instance().get_tools()
84
+ except Exception:
85
+ return []
86
+
87
+ result: list[tuple[str, dict[str, Any]]] = []
88
+ for entry in raw_tools or []:
89
+ if not isinstance(entry, dict):
90
+ continue
91
+ for tool_name, tool in entry.items():
92
+ if isinstance(tool, dict):
93
+ result.append((str(tool_name), tool))
94
+ return result
95
+
96
+
97
+def _tool_name_from_prompt_basename(basename: str) -> str:
98
+ if not basename.startswith(TOOL_PROMPT_PREFIX) or not basename.endswith(TOOL_PROMPT_SUFFIX):
99
+ return ""
100
+ name = basename[len(TOOL_PROMPT_PREFIX) : -len(TOOL_PROMPT_SUFFIX)]
101
+ if not name or name in {"tools", "tools_vision"}:
102
+ return ""
103
+ return name
104
+
105
+
106
+def _native_tool_name(tool_name: str) -> str:
107
+ if FUNCTION_NAME_PATTERN.fullmatch(tool_name):
108
+ return tool_name
109
+ slug = re.sub(r"[^A-Za-z0-9_-]+", "_", tool_name).strip("_")
110
+ digest = hashlib.sha1(tool_name.encode("utf-8")).hexdigest()[:8]
111
+ native = f"{slug[:52]}_{digest}" if slug else f"a0_tool_{digest}"
112
+ return native[:64]
113
+
114
+
115
+def _description_from_prompt(prompt: str, *, fallback: str) -> str:
116
+ lines: list[str] = []
117
+ in_fence = False
118
+ for raw_line in (prompt or "").splitlines():
119
+ line = raw_line.strip()
120
+ if line.startswith("```"):
121
+ in_fence = not in_fence
122
+ continue
123
+ if in_fence or not line:
124
+ continue
125
+ if line.startswith("#"):
126
+ line = line.lstrip("#").strip()
127
+ if line.lower() == fallback.lower():
128
+ continue
129
+ lines.append(line)
130
+ if sum(len(part) for part in lines) >= MAX_TOOL_DESCRIPTION_CHARS:
131
+ break
132
+ description = " ".join(lines).strip() or fallback
133
+ return _truncate(description)
134
+
135
+
136
+def _schema_from_prompt(prompt: str) -> dict[str, Any]:
137
+ schema = _schema_from_embedded_json(prompt)
138
+ if schema:
139
+ return schema
140
+ return _schema_from_args_line(prompt)
141
+
142
+
143
+def _schema_from_embedded_json(prompt: str) -> dict[str, Any]:
144
+ marker = "Input schema for tool_args:"
145
+ index = (prompt or "").find(marker)
146
+ if index == -1:
147
+ return {}
148
+ tail = prompt[index + len(marker) :].strip()
149
+ match = re.search(r"\{(?:[^{}]|(?R))*\}", tail, flags=re.DOTALL) if hasattr(re, "VERSION1") else None
150
+ candidate = match.group(0) if match else _balanced_json_object(tail)
151
+ if not candidate:
152
+ return {}
153
+ try:
154
+ return _schema_from_any(json.loads(candidate))
155
+ except Exception:
156
+ return {}
157
+
158
+
159
+def _schema_from_args_line(prompt: str) -> dict[str, Any]:
160
+ properties: dict[str, Any] = {}
161
+ for line in (prompt or "").splitlines():
162
+ normalized = line.strip()
163
+ if "args:" not in normalized.lower() and "argument:" not in normalized.lower():
164
+ continue
165
+ for name in re.findall(r"`([A-Za-z_][A-Za-z0-9_-]*)`", normalized):
166
+ properties.setdefault(name, {"type": "string"})
167
+ if properties:
168
+ return {
169
+ "type": "object",
170
+ "properties": properties,
171
+ "additionalProperties": True,
172
+ }
173
+ return _permissive_schema()
174
+
175
+
176
+def _schema_from_any(schema: Any) -> dict[str, Any]:
177
+ if isinstance(schema, dict):
178
+ normalized = dict(schema)
179
+ normalized.setdefault("type", "object")
180
+ normalized.setdefault("additionalProperties", True)
181
+ return normalized
182
+ return _permissive_schema()
183
+
184
+
185
+def _permissive_schema() -> dict[str, Any]:
186
+ return {"type": "object", "additionalProperties": True}
187
+
188
+
189
+def _balanced_json_object(text: str) -> str:
190
+ start = text.find("{")
191
+ if start == -1:
192
+ return ""
193
+ depth = 0
194
+ in_string = False
195
+ escape = False
196
+ for index, char in enumerate(text[start:], start=start):
197
+ if in_string:
198
+ if escape:
199
+ escape = False
200
+ elif char == "\\":
201
+ escape = True
202
+ elif char == '"':
203
+ in_string = False
204
+ continue
205
+ if char == '"':
206
+ in_string = True
207
+ elif char == "{":
208
+ depth += 1
209
+ elif char == "}":
210
+ depth -= 1
211
+ if depth == 0:
212
+ return text[start : index + 1]
213
+ return ""
214
+
215
+
216
+def _dedupe_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
217
+ seen: set[str] = set()
218
+ result: list[dict[str, Any]] = []
219
+ for tool in tools:
220
+ name = str(tool.get("name") or "")
221
+ if not name or name in seen:
222
+ continue
223
+ seen.add(name)
224
+ result.append(tool)
225
+ return result
226
+
227
+
228
+def _truncate(text: str) -> str:
229
+ if len(text) <= MAX_TOOL_DESCRIPTION_CHARS:
230
+ return text
231
+ return text[: MAX_TOOL_DESCRIPTION_CHARS - 3].rstrip() + "..."
models.py
+211
-134
@@ -14,19 +14,19 @@ from typing import (
14
TypedDict,
15
)
16
17
-from litellm import completion, acompletion, embedding
17
+from litellm import embedding
18
import litellm
19
import openai
20
-from litellm.types.utils import ModelResponse
20
21
from helpers import dotenv
23
-from helpers import settings, dirty_json, images
22
+from helpers import settings, images
23
from helpers.dotenv import load_dotenv
24
from helpers.providers import ModelType as ProviderModelType, get_provider_config
25
from helpers.rate_limiter import RateLimiter
26
from helpers.tokens import approximate_tokens
28
-from helpers import dirty_json
27
from helpers.extension import extensible # extensible: allows plugins to intercept get_api_key()
28
+from helpers.litellm_transport import LiteLLMTransport, ResponsesTransport
29
+from helpers.llm_result import LLMResult
30
31
from langchain_core.language_models.chat_models import SimpleChatModel
32
from langchain_core.outputs.chat_generation import ChatGenerationChunk
@@ -155,6 +155,7 @@ class ChatChunk(TypedDict):
155
response_delta: str
156
reasoning_delta: str
157
158
+
159
class ChatGenerationResult:
160
"""Chat generation result object"""
161
def __init__(self, chunk: ChatChunk|None = None):
@@ -434,14 +435,6 @@ class LiteLLMChatWrapper(SimpleChatModel):
435
436
result.append(message_dict)
437
437
- if explicit_caching and result:
438
- if result[0]["role"] == "system":
439
- result[0]["cache_control"] = {"type": "ephemeral"}
440
- for i in range(len(result) - 1, -1, -1):
441
- if result[i]["role"] == "assistant":
442
- result[i]["cache_control"] = {"type": "ephemeral"}
443
- break
444
-
438
return result
439
440
def _call(
@@ -451,24 +444,20 @@ class LiteLLMChatWrapper(SimpleChatModel):
444
run_manager: Optional[CallbackManagerForLLMRun] = None,
445
**kwargs: Any,
446
) -> str:
454
- import asyncio
455
-
447
configure_litellm()
448
msgs = self._convert_messages(messages)
449
450
# Apply rate limiting if configured
451
apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
452
462
- # Call the model
463
- call_kwargs = _without_stream_kwarg(
464
- _merge_litellm_call_kwargs(self.kwargs, kwargs)
465
- )
466
- resp = completion(
467
- model=self.model_name, messages=msgs, stop=stop, **call_kwargs
453
+ call_kwargs = _merge_litellm_call_kwargs(self.kwargs, kwargs)
454
+ transport = LiteLLMTransport(
455
+ model=self.model_name,
456
+ messages=msgs,
457
+ kwargs=call_kwargs,
458
+ stop=stop,
459
)
469
-
470
- # Parse output
471
- parsed = _parse_chunk(resp)
460
+ parsed = transport.complete()
461
output = ChatGenerationResult(parsed).output()
462
return output["response_delta"]
463
@@ -479,8 +468,6 @@ class LiteLLMChatWrapper(SimpleChatModel):
468
run_manager: Optional[CallbackManagerForLLMRun] = None,
469
**kwargs: Any,
470
) -> Iterator[ChatGenerationChunk]:
482
- import asyncio
483
-
471
configure_litellm()
472
msgs = self._convert_messages(messages)
473
@@ -488,22 +475,15 @@ class LiteLLMChatWrapper(SimpleChatModel):
475
apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
476
477
result = ChatGenerationResult()
491
- call_kwargs = _without_stream_kwarg(
492
- _merge_litellm_call_kwargs(self.kwargs, kwargs)
493
- )
494
-
495
- for chunk in completion(
478
+ call_kwargs = _merge_litellm_call_kwargs(self.kwargs, kwargs)
479
+ transport = LiteLLMTransport(
480
model=self.model_name,
481
messages=msgs,
498
- stream=True,
482
+ kwargs=call_kwargs,
483
stop=stop,
500
- **call_kwargs,
501
- ):
502
- # parse chunk
503
- parsed = _parse_chunk(chunk) # chunk parsing
504
- output = result.add_chunk(parsed) # chunk processing
505
-
506
- # Only yield chunks with non-None content
484
+ )
485
+ for parsed in transport.stream():
486
+ output = result.add_chunk(parsed)
487
if output["response_delta"]:
488
yield ChatGenerationChunk(
489
message=AIMessageChunk(content=output["response_delta"])
@@ -523,23 +503,15 @@ class LiteLLMChatWrapper(SimpleChatModel):
503
await apply_rate_limiter(self.a0_model_conf, str(msgs))
504
505
result = ChatGenerationResult()
526
- call_kwargs = _without_stream_kwarg(
527
- _merge_litellm_call_kwargs(self.kwargs, kwargs)
528
- )
529
-
530
- response = await acompletion(
506
+ call_kwargs = _merge_litellm_call_kwargs(self.kwargs, kwargs)
507
+ transport = LiteLLMTransport(
508
model=self.model_name,
509
messages=msgs,
533
- stream=True,
510
+ kwargs=call_kwargs,
511
stop=stop,
535
- **call_kwargs,
512
)
537
- async for chunk in response: # type: ignore
538
- # parse chunk
539
- parsed = _parse_chunk(chunk) # chunk parsing
540
- output = result.add_chunk(parsed) # chunk processing
541
-
542
- # Only yield chunks with non-None content
513
+ async for parsed in transport.astream():
514
+ output = result.add_chunk(parsed)
515
if output["response_delta"]:
516
yield ChatGenerationChunk(
517
message=AIMessageChunk(content=output["response_delta"])
@@ -579,12 +551,19 @@ class LiteLLMChatWrapper(SimpleChatModel):
551
)
552
553
# Prepare call kwargs and retry config (strip A0-only params before calling LiteLLM)
582
- call_kwargs: dict[str, Any] = _without_stream_kwarg(
583
- _merge_litellm_call_kwargs(self.kwargs, kwargs)
554
+ call_kwargs: dict[str, Any] = _merge_litellm_call_kwargs(
555
+ self.kwargs, kwargs
556
)
557
+ if explicit_caching:
558
+ call_kwargs["a0_explicit_prompt_caching"] = True
559
max_retries: int = int(call_kwargs.pop("a0_retry_attempts", 2))
560
retry_delay_s: float = float(call_kwargs.pop("a0_retry_delay_seconds", 1.5))
561
stream = reasoning_callback is not None or response_callback is not None or tokens_callback is not None
562
+ transport = LiteLLMTransport(
563
+ model=self.model_name,
564
+ messages=msgs_conv,
565
+ kwargs=call_kwargs,
566
+ )
567
568
# results
569
result = ChatGenerationResult()
@@ -593,60 +572,45 @@ class LiteLLMChatWrapper(SimpleChatModel):
572
while True:
573
got_any_chunk = False
574
try:
596
- # call model
597
- _completion = await acompletion(
598
- model=self.model_name,
599
- messages=msgs_conv,
600
- stream=stream,
601
- **call_kwargs,
602
- )
603
-
575
if stream:
605
- # iterate over chunks
576
stop_response: str | None = None
607
- try:
608
- async for chunk in _completion: # type: ignore
609
- got_any_chunk = True
610
- # parse chunk
611
- parsed = _parse_chunk(chunk)
612
- output = result.add_chunk(parsed)
613
-
614
- # collect reasoning delta and call callbacks
615
- if output["reasoning_delta"]:
616
- if reasoning_callback:
617
- await reasoning_callback(output["reasoning_delta"], result.reasoning)
618
- if tokens_callback:
619
- await tokens_callback(
620
- output["reasoning_delta"],
621
- approximate_tokens(output["reasoning_delta"]),
622
- )
623
- # Add output tokens to rate limiter if configured
624
- if limiter:
625
- limiter.add(output=approximate_tokens(output["reasoning_delta"]))
626
- # collect response delta and call callbacks
627
- if output["response_delta"]:
628
- if response_callback:
629
- stop_response = await response_callback(
630
- output["response_delta"], result.response
631
- )
632
- if tokens_callback:
633
- await tokens_callback(
634
- output["response_delta"],
635
- approximate_tokens(output["response_delta"]),
636
- )
637
- # Add output tokens to rate limiter if configured
638
- if limiter:
639
- limiter.add(output=approximate_tokens(output["response_delta"]))
640
- if stop_response is not None:
641
- result.response = stop_response
642
- break
643
- finally:
644
- if stop_response is not None and hasattr(_completion, "aclose"):
645
- await _completion.aclose() # type: ignore[attr-defined]
577
+ async for parsed in transport.astream():
578
+ got_any_chunk = True
579
+ output = result.add_chunk(parsed)
580
+
581
+ # collect reasoning delta and call callbacks
582
+ if output["reasoning_delta"]:
583
+ if reasoning_callback:
584
+ await reasoning_callback(output["reasoning_delta"], result.reasoning)
585
+ if tokens_callback:
586
+ await tokens_callback(
587
+ output["reasoning_delta"],
588
+ approximate_tokens(output["reasoning_delta"]),
589
+ )
590
+ # Add output tokens to rate limiter if configured
591
+ if limiter:
592
+ limiter.add(output=approximate_tokens(output["reasoning_delta"]))
593
+ # collect response delta and call callbacks
594
+ if output["response_delta"]:
595
+ if response_callback:
596
+ stop_response = await response_callback(
597
+ output["response_delta"], result.response
598
+ )
599
+ if tokens_callback:
600
+ await tokens_callback(
601
+ output["response_delta"],
602
+ approximate_tokens(output["response_delta"]),
603
+ )
604
+ # Add output tokens to rate limiter if configured
605
+ if limiter:
606
+ limiter.add(output=approximate_tokens(output["response_delta"]))
607
+ if stop_response is not None:
608
+ result.response = stop_response
609
+ break
610
611
# non-stream response
612
else:
649
- parsed = _parse_chunk(_completion)
613
+ parsed = await transport.acomplete()
614
output = result.add_chunk(parsed)
615
if limiter:
616
if output["response_delta"]:
@@ -666,6 +630,151 @@ class LiteLLMChatWrapper(SimpleChatModel):
630
attempt += 1
631
await asyncio.sleep(retry_delay_s)
632
633
+ async def unified_turn(
634
+ self,
635
+ system_message="",
636
+ user_message="",
637
+ messages: List[BaseMessage] | None = None,
638
+ response_callback: Callable[[str, str], Awaitable[str | None]] | None = None,
639
+ reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None,
640
+ tokens_callback: Callable[[str, int], Awaitable[None]] | None = None,
641
+ rate_limiter_callback: (
642
+ Callable[[str, str, int, int], Awaitable[bool]] | None
643
+ ) = None,
644
+ explicit_caching: bool = False,
645
+ **kwargs: Any,
646
+ ) -> LLMResult:
647
+ """Canonical internal LLM turn with Responses metadata.
648
+
649
+ Public plugin-facing callers should keep using ``unified_call``. Core
650
+ orchestration uses this method when it needs response ids, native output
651
+ items, and state/capability metadata.
652
+ """
653
+
654
+ configure_litellm()
655
+
656
+ if not messages:
657
+ messages = []
658
+ if system_message:
659
+ messages.insert(0, SystemMessage(content=system_message))
660
+ if user_message:
661
+ messages.append(HumanMessage(content=user_message))
662
+
663
+ msgs_conv = self._convert_messages(messages, explicit_caching=explicit_caching)
664
+
665
+ limiter = await apply_rate_limiter(
666
+ self.a0_model_conf, str(msgs_conv), rate_limiter_callback
667
+ )
668
+
669
+ call_kwargs: dict[str, Any] = _merge_litellm_call_kwargs(
670
+ self.kwargs, kwargs
671
+ )
672
+ if explicit_caching:
673
+ call_kwargs["a0_explicit_prompt_caching"] = True
674
+ max_retries: int = int(call_kwargs.pop("a0_retry_attempts", 2))
675
+ retry_delay_s: float = float(call_kwargs.pop("a0_retry_delay_seconds", 1.5))
676
+ stream = (
677
+ reasoning_callback is not None
678
+ or response_callback is not None
679
+ or tokens_callback is not None
680
+ )
681
+ transport = LiteLLMTransport(
682
+ model=self.model_name,
683
+ messages=msgs_conv,
684
+ kwargs=call_kwargs,
685
+ )
686
+
687
+ result = ChatGenerationResult()
688
+
689
+ attempt = 0
690
+ while True:
691
+ got_any_chunk = False
692
+ try:
693
+ if stream:
694
+ stop_response: str | None = None
695
+ async for parsed in transport.astream():
696
+ got_any_chunk = True
697
+ output = result.add_chunk(parsed)
698
+
699
+ if output["reasoning_delta"]:
700
+ if reasoning_callback:
701
+ await reasoning_callback(
702
+ output["reasoning_delta"], result.reasoning
703
+ )
704
+ if tokens_callback:
705
+ await tokens_callback(
706
+ output["reasoning_delta"],
707
+ approximate_tokens(output["reasoning_delta"]),
708
+ )
709
+ if limiter:
710
+ limiter.add(
711
+ output=approximate_tokens(
712
+ output["reasoning_delta"]
713
+ )
714
+ )
715
+
716
+ if output["response_delta"]:
717
+ if response_callback:
718
+ stop_response = await response_callback(
719
+ output["response_delta"], result.response
720
+ )
721
+ if tokens_callback:
722
+ await tokens_callback(
723
+ output["response_delta"],
724
+ approximate_tokens(output["response_delta"]),
725
+ )
726
+ if limiter:
727
+ limiter.add(
728
+ output=approximate_tokens(
729
+ output["response_delta"]
730
+ )
731
+ )
732
+ if (
733
+ stop_response is not None
734
+ and not transport.policy.using_responses
735
+ ):
736
+ result.response = stop_response
737
+ break
738
+ if stop_response is not None:
739
+ result.response = stop_response
740
+ else:
741
+ parsed = await transport.acomplete()
742
+ output = result.add_chunk(parsed)
743
+ if limiter:
744
+ if output["response_delta"]:
745
+ limiter.add(
746
+ output=approximate_tokens(output["response_delta"])
747
+ )
748
+ if output["reasoning_delta"]:
749
+ limiter.add(
750
+ output=approximate_tokens(output["reasoning_delta"])
751
+ )
752
+
753
+ llm_result = transport.last_result or LLMResult.from_chat(
754
+ response=result.output()["response_delta"],
755
+ reasoning=result.output()["reasoning_delta"],
756
+ input_items=ResponsesTransport.input_from_messages(msgs_conv),
757
+ provider_model_key=self.model_name,
758
+ capability=transport._capability_metadata(),
759
+ )
760
+ if result.output()["response_delta"]:
761
+ llm_result.response = result.output()["response_delta"]
762
+ if result.output()["reasoning_delta"]:
763
+ llm_result.reasoning = result.output()["reasoning_delta"]
764
+ return llm_result
765
+
766
+ except Exception as e:
767
+ import asyncio
768
+
769
+ if (
770
+ got_any_chunk
771
+ or not _is_transient_litellm_error(e)
772
+ or attempt >= max_retries
773
+ ):
774
+ raise
775
+ attempt += 1
776
+ await asyncio.sleep(retry_delay_s)
777
+
778
779
class LiteLLMEmbeddingWrapper(Embeddings):
780
model_name: str
@@ -820,38 +929,6 @@ def _get_litellm_embedding(
929
)
930
931
823
-def _parse_chunk(chunk: Any) -> ChatChunk:
824
- delta = chunk["choices"][0].get("delta", {})
825
- message = chunk["choices"][0].get("message", {}) or chunk["choices"][0].get(
826
- "model_extra", {}
827
- ).get("message", {})
828
- response_delta = (
829
- delta.get("content", "")
830
- if isinstance(delta, dict)
831
- else getattr(delta, "content", "")
832
- ) or (
833
- message.get("content", "")
834
- if isinstance(message, dict)
835
- else getattr(message, "content", "")
836
- ) or ""
837
- reasoning_delta = (
838
- delta.get("reasoning_content", "")
839
- if isinstance(delta, dict)
840
- else getattr(delta, "reasoning_content", "")
841
- ) or (
842
- message.get("reasoning_content", "")
843
- if isinstance(message, dict)
844
- else getattr(message, "reasoning_content", "")
845
- ) or ""
846
-
847
- return ChatChunk(reasoning_delta=reasoning_delta, response_delta=response_delta)
848
-
849
-
850
-def _without_stream_kwarg(kwargs: dict[str, Any]) -> dict[str, Any]:
851
- kwargs.pop("stream", None)
852
- return kwargs
853
-
854
-
932
933
def _adjust_call_args(provider_name: str, model_name: str, kwargs: dict):
934
tests/test_responses_architecture.py
new
+385
@@ -0,0 +1,385 @@
1
+import sys
2
+from pathlib import Path
3
+
4
+import pytest
5
+from langchain_core.messages import HumanMessage
6
+
7
+
8
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
9
+if str(PROJECT_ROOT) not in sys.path:
10
+ sys.path.insert(0, str(PROJECT_ROOT))
11
+
12
+import models
13
+from agent import Agent, AgentConfig, LoopData
14
+from helpers import history, litellm_transport
15
+from helpers.log import Log
16
+from helpers.llm_result import LLMResult, result_from_metadata
17
+from helpers.persist_chat import _collect_response_ids
18
+from helpers.tool import Response
19
+
20
+
21
+@pytest.fixture(autouse=True)
22
+def _clear_transport_capability_cache():
23
+ litellm_transport.clear_transport_capability_cache()
24
+
25
+
26
+class _AsyncEventStream:
27
+ def __init__(self, events: list[dict]):
28
+ self.events = events
29
+ self.index = 0
30
+ self.closed = False
31
+
32
+ def __aiter__(self):
33
+ return self
34
+
35
+ async def __anext__(self):
36
+ if self.index >= len(self.events):
37
+ raise StopAsyncIteration
38
+ event = self.events[self.index]
39
+ self.index += 1
40
+ return event
41
+
42
+ async def aclose(self):
43
+ self.closed = True
44
+
45
+
46
+def test_llm_result_round_trips_responses_metadata():
47
+ result = LLMResult.from_response(
48
+ {
49
+ "id": "resp_123",
50
+ "usage": {"input_tokens": 10},
51
+ "output": [
52
+ {"type": "reasoning", "summary": [{"text": "because"}]},
53
+ {
54
+ "type": "function_call",
55
+ "id": "fc_1",
56
+ "call_id": "call_1",
57
+ "name": "lookup",
58
+ "arguments": '{"q":"a0"}',
59
+ },
60
+ {
61
+ "type": "web_search_call",
62
+ "id": "ws_1",
63
+ "status": "completed",
64
+ },
65
+ ],
66
+ },
67
+ input_items=[{"role": "user", "content": "question"}],
68
+ previous_response_id="resp_prev",
69
+ provider_model_key="openai/gpt-5.4",
70
+ )
71
+
72
+ loaded = result_from_metadata(result.metadata())
73
+
74
+ assert loaded is not None
75
+ assert loaded.response_id == "resp_123"
76
+ assert loaded.previous_response_id == "resp_prev"
77
+ assert loaded.function_calls[0].name == "lookup"
78
+ assert loaded.function_calls[0].arguments == {"q": "a0"}
79
+ assert loaded.builtin_items[0].type == "web_search_call"
80
+
81
+
82
+def test_history_serializes_metadata_and_migrates_old_messages():
83
+ class DummyAgent:
84
+ pass
85
+
86
+ hist = history.History(DummyAgent())
87
+ result = LLMResult.from_response(
88
+ {"id": "resp_1", "output": [{"type": "message", "content": [{"type": "output_text", "text": "ok"}]}]},
89
+ provider_model_key="openai/gpt-5.4",
90
+ )
91
+
92
+ message = hist.add_message(True, "ok", metadata=result.metadata())
93
+ restored = history.deserialize_history(hist.serialize(), DummyAgent())
94
+
95
+ restored_message = restored.all_messages()[0]
96
+ assert restored_message.sequence == message.sequence
97
+ assert result_from_metadata(restored_message.metadata).response_id == "resp_1"
98
+
99
+ old = history.Message.from_dict({"_cls": "Message", "ai": False, "content": "old"}, restored)
100
+ assert old.metadata == {}
101
+ assert old.sequence == 0
102
+
103
+
104
+def test_responses_provider_state_uses_previous_response_and_new_items():
105
+ new_items = [{"type": "function_call_output", "call_id": "call_1", "output": "done"}]
106
+ local_items = [{"role": "user", "content": "full replay"}]
107
+
108
+ request = litellm_transport.ResponsesTransport.from_chat(
109
+ [{"role": "user", "content": "ignored while continuing provider state"}],
110
+ {
111
+ "previous_response_id": "resp_1",
112
+ "responses_input_items": new_items,
113
+ "responses_local_input_items": local_items,
114
+ },
115
+ model="openai/gpt-5.4",
116
+ )
117
+
118
+ assert request["store"] is True
119
+ assert request["previous_response_id"] == "resp_1"
120
+ assert request["input"] == new_items
121
+
122
+ local_request = litellm_transport.ResponsesTransport.from_chat(
123
+ [{"role": "user", "content": "ignored"}],
124
+ {
125
+ "responses_state": "local",
126
+ "previous_response_id": "resp_1",
127
+ "responses_input_items": new_items,
128
+ "responses_local_input_items": local_items,
129
+ },
130
+ model="openai/gpt-5.4",
131
+ )
132
+
133
+ assert local_request["store"] is False
134
+ assert "previous_response_id" not in local_request
135
+ assert local_request["input"] == local_items
136
+
137
+
138
+@pytest.mark.asyncio
139
+async def test_transport_retries_provider_state_as_local_replay(monkeypatch):
140
+ calls: list[dict] = []
141
+
142
+ async def fake_aresponses(*args, **kwargs):
143
+ calls.append(kwargs)
144
+ if len(calls) == 1:
145
+ raise RuntimeError("previous_response_id is not supported by this provider")
146
+ return {
147
+ "id": "resp_local",
148
+ "output": [
149
+ {
150
+ "type": "message",
151
+ "content": [{"type": "output_text", "text": "ok"}],
152
+ }
153
+ ],
154
+ }
155
+
156
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
157
+
158
+ transport = litellm_transport.LiteLLMTransport(
159
+ model="openai/gpt-5.4",
160
+ messages=[{"role": "user", "content": "new"}],
161
+ kwargs={
162
+ "previous_response_id": "resp_1",
163
+ "responses_input_items": [{"role": "user", "content": "new"}],
164
+ "responses_local_input_items": [{"role": "user", "content": "full"}],
165
+ },
166
+ )
167
+
168
+ parsed = await transport.acomplete()
169
+
170
+ assert parsed["response_delta"] == "ok"
171
+ assert calls[0]["store"] is True
172
+ assert calls[0]["previous_response_id"] == "resp_1"
173
+ assert calls[1]["store"] is False
174
+ assert "previous_response_id" not in calls[1]
175
+ assert calls[1]["input"] == [{"role": "user", "content": "full"}]
176
+ assert transport.last_result.response_id == "resp_local"
177
+
178
+
179
+@pytest.mark.asyncio
180
+async def test_transport_downgrades_unsupported_builtin_tools(monkeypatch):
181
+ calls: list[dict] = []
182
+
183
+ async def fake_aresponses(*args, **kwargs):
184
+ calls.append(kwargs)
185
+ if len(calls) == 1:
186
+ raise RuntimeError("unsupported tool type: web_search")
187
+ return {
188
+ "id": "resp_no_builtin",
189
+ "output": [
190
+ {
191
+ "type": "message",
192
+ "content": [{"type": "output_text", "text": "ok"}],
193
+ }
194
+ ],
195
+ }
196
+
197
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
198
+
199
+ transport = litellm_transport.LiteLLMTransport(
200
+ model="openai/gpt-5.4",
201
+ messages=[{"role": "user", "content": "new"}],
202
+ kwargs={"responses_builtin_tools": [{"type": "web_search"}]},
203
+ )
204
+
205
+ parsed = await transport.acomplete()
206
+
207
+ assert parsed["response_delta"] == "ok"
208
+ assert calls[0]["tools"] == [{"type": "web_search"}]
209
+ assert "tools" not in calls[1]
210
+ assert transport.last_result.capability["builtin_tool_downgrades"] == [
211
+ "web_search"
212
+ ]
213
+
214
+ next_transport = litellm_transport.LiteLLMTransport(
215
+ model="openai/gpt-5.4",
216
+ messages=[{"role": "user", "content": "again"}],
217
+ kwargs={"responses_builtin_tools": [{"type": "web_search"}]},
218
+ )
219
+ request = next_transport._responses_request(stream=False)
220
+ assert "tools" not in request
221
+
222
+
223
+@pytest.mark.asyncio
224
+async def test_unified_turn_keeps_stream_open_to_capture_response_id(monkeypatch):
225
+ stream = _AsyncEventStream(
226
+ [
227
+ {
228
+ "type": "response.output_item.added",
229
+ "output_index": 0,
230
+ "item": {
231
+ "type": "function_call",
232
+ "id": "fc_1",
233
+ "call_id": "call_1",
234
+ "name": "lookup",
235
+ "arguments": "",
236
+ },
237
+ },
238
+ {
239
+ "type": "response.function_call_arguments.done",
240
+ "item_id": "fc_1",
241
+ "output_index": 0,
242
+ "name": "lookup",
243
+ "arguments": '{"q":"a0"}',
244
+ },
245
+ {
246
+ "type": "response.completed",
247
+ "response": {
248
+ "id": "resp_1",
249
+ "output": [
250
+ {
251
+ "type": "function_call",
252
+ "id": "fc_1",
253
+ "call_id": "call_1",
254
+ "name": "lookup",
255
+ "arguments": '{"q":"a0"}',
256
+ }
257
+ ],
258
+ },
259
+ },
260
+ ]
261
+ )
262
+
263
+ async def fake_aresponses(*args, **kwargs):
264
+ return stream
265
+
266
+ async def fake_rate_limiter(*args, **kwargs):
267
+ return None
268
+
269
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
270
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
271
+
272
+ wrapper = models.LiteLLMChatWrapper(
273
+ model="test-model",
274
+ provider="openai",
275
+ model_config=None,
276
+ )
277
+
278
+ async def response_callback(chunk: str, full: str):
279
+ return full
280
+
281
+ result = await wrapper.unified_turn(
282
+ messages=[HumanMessage(content="hi")],
283
+ response_callback=response_callback,
284
+ )
285
+
286
+ assert stream.index == 3
287
+ assert stream.closed is False
288
+ assert result.response_id == "resp_1"
289
+ assert result.function_calls[0].call_id == "call_1"
290
+
291
+
292
+def test_collect_response_ids_from_agent_state_and_history_metadata():
293
+ payload = {
294
+ "agents": [
295
+ {
296
+ "data": {
297
+ "responses_state": {
298
+ "response_id": "resp_latest",
299
+ "response_ids": ["resp_old", "resp_latest"],
300
+ }
301
+ },
302
+ "history": '{"current":{"messages":[{"metadata":{"responses":{"response_id":"resp_history"}}}]}}',
303
+ }
304
+ ]
305
+ }
306
+
307
+ assert _collect_response_ids(payload) == [
308
+ "resp_latest",
309
+ "resp_old",
310
+ "resp_history",
311
+ ]
312
+
313
+
314
+@pytest.mark.asyncio
315
+async def test_agent_executes_native_responses_function_call_and_records_output():
316
+ class DummyContext:
317
+ paused = False
318
+ log = Log()
319
+
320
+ def get_data(self, key, recursive=True):
321
+ return None
322
+
323
+ class DummyTool:
324
+ name = "lookup"
325
+ progress = ""
326
+
327
+ def __init__(self, agent):
328
+ self.agent = agent
329
+
330
+ async def before_execution(self, **kwargs):
331
+ self.args = kwargs
332
+
333
+ async def execute(self, **kwargs):
334
+ return Response(message=f"done:{kwargs['q']}", break_loop=False)
335
+
336
+ async def after_execution(self, response):
337
+ self.agent.hist_add_tool_result(
338
+ self.name,
339
+ response.message,
340
+ **(response.additional or {}),
341
+ )
342
+
343
+ agent = object.__new__(Agent)
344
+ agent.data = {Agent.DATA_NAME_RESPONSES_TOOL_NAME_MAP: {}}
345
+ agent.context = DummyContext()
346
+ agent.config = AgentConfig(mcp_servers="")
347
+ agent.loop_data = LoopData()
348
+ agent.history = history.History(agent)
349
+ agent.intervention = None
350
+ agent.agent_name = "A0"
351
+ agent.number = 0
352
+
353
+ def get_tool(**kwargs):
354
+ return DummyTool(agent)
355
+
356
+ agent.get_tool = get_tool
357
+
358
+ result = LLMResult.from_response(
359
+ {
360
+ "id": "resp_1",
361
+ "output": [
362
+ {
363
+ "type": "function_call",
364
+ "id": "fc_1",
365
+ "call_id": "call_1",
366
+ "name": "lookup",
367
+ "arguments": '{"q":"a0"}',
368
+ }
369
+ ],
370
+ },
371
+ provider_model_key="openai/gpt-5.4",
372
+ )
373
+
374
+ assert await Agent.process_llm_result_tools(agent, result) is None
375
+
376
+ recorded = agent.history.all_messages()[0]
377
+ metadata = result_from_metadata(recorded.metadata)
378
+ assert recorded.content["tool_result"] == "done:a0"
379
+ assert metadata.input_items == [
380
+ {
381
+ "type": "function_call_output",
382
+ "call_id": "call_1",
383
+ "output": "done:a0",
384
+ }
385
+ ]
tests/test_stream_tool_early_stop.py
+774
-6
@@ -2,6 +2,7 @@ import sys
2
from pathlib import Path
3
4
import pytest
5
+from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
6
7
8
PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -10,16 +11,27 @@ if str(PROJECT_ROOT) not in sys.path:
11
12
import models
13
from helpers import extract_tools
14
+from helpers import litellm_transport
15
+
16
+
17
+@pytest.fixture(autouse=True)
18
+def _clear_transport_capability_cache():
19
+ litellm_transport.clear_transport_capability_cache()
20
21
22
def _chunk(content: str) -> dict:
23
return {"choices": [{"delta": {"content": content}, "message": {}}]}
24
25
26
+def _response_event(delta: str) -> dict:
27
+ return {"type": "response.output_text.delta", "delta": delta}
28
+
29
+
30
class _AsyncChunkStream:
31
def __init__(self, chunks: list[dict]):
32
self._chunks = chunks
33
self.index = 0
34
+ self.closed = False
35
36
def __aiter__(self):
37
return self
@@ -31,6 +43,24 @@ class _AsyncChunkStream:
43
self.index += 1
44
return chunk
45
46
+ async def aclose(self):
47
+ self.closed = True
48
+
49
+
50
+class _FailingAsyncChunkStream:
51
+ def __init__(self, exc: Exception):
52
+ self.exc = exc
53
+ self.closed = False
54
+
55
+ def __aiter__(self):
56
+ return self
57
+
58
+ async def __anext__(self):
59
+ raise self.exc
60
+
61
+ async def aclose(self):
62
+ self.closed = True
63
+
64
65
def test_extract_json_root_string_returns_canonical_snapshot():
66
text = (
@@ -94,22 +124,24 @@ def test_litellm_global_kwargs_merge_defaults_and_config(monkeypatch):
124
async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
125
stream = _AsyncChunkStream(
126
[
97
- _chunk(
127
+ {"type": "response.created"},
128
+ _response_event(
129
'{"tool_name":"response","tool_args":{"text":"hello"}} trailing text'
130
),
100
- _chunk(" unreachable"),
131
+ _response_event(" unreachable"),
132
]
133
)
134
104
- async def fake_acompletion(*args, **kwargs):
135
+ async def fake_aresponses(*args, **kwargs):
136
assert kwargs["stream"] is True
106
- assert kwargs["drop_params"] is True
137
+ assert kwargs["input"] == ""
138
+ assert kwargs["store"] is True
139
return stream
140
141
async def fake_rate_limiter(*args, **kwargs):
142
return None
143
112
- monkeypatch.setattr(models, "acompletion", fake_acompletion)
144
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
145
monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
146
monkeypatch.setattr(
147
models.settings,
@@ -139,6 +171,742 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
171
172
assert response == '{"tool_name":"response","tool_args":{"text":"hello"}}'
173
assert reasoning == ""
142
- assert stream.index == 1
174
+ assert stream.index == 2
175
+ assert stream.closed is True
176
assert len(seen) == 1
177
assert seen[0][1] == '{"tool_name":"response","tool_args":{"text":"hello"}} trailing text'
178
+
179
+
180
+@pytest.mark.asyncio
181
+async def test_unified_call_closes_responses_stream_when_callback_raises(monkeypatch):
182
+ stream = _AsyncChunkStream([_response_event("interrupt me")])
183
+
184
+ class ExpectedIntervention(Exception):
185
+ pass
186
+
187
+ async def fake_aresponses(*args, **kwargs):
188
+ assert kwargs["stream"] is True
189
+ return stream
190
+
191
+ async def fake_rate_limiter(*args, **kwargs):
192
+ return None
193
+
194
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
195
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
196
+
197
+ wrapper = models.LiteLLMChatWrapper(
198
+ model="test-model",
199
+ provider="openai",
200
+ model_config=None,
201
+ )
202
+
203
+ async def response_callback(chunk: str, full: str):
204
+ raise ExpectedIntervention()
205
+
206
+ with pytest.raises(ExpectedIntervention):
207
+ await wrapper.unified_call(
208
+ messages=[],
209
+ response_callback=response_callback,
210
+ )
211
+
212
+ assert stream.closed is True
213
+
214
+
215
+@pytest.mark.asyncio
216
+async def test_chat_completions_escape_hatch_still_uses_acompletion(monkeypatch):
217
+ stream = _AsyncChunkStream([_chunk("hello")])
218
+ calls: list[str] = []
219
+
220
+ async def fake_acompletion(*args, **kwargs):
221
+ calls.append("chat")
222
+ assert kwargs["stream"] is True
223
+ assert "a0_api_mode" not in kwargs
224
+ return stream
225
+
226
+ async def fake_aresponses(*args, **kwargs):
227
+ raise AssertionError("Responses path should not be used")
228
+
229
+ async def fake_rate_limiter(*args, **kwargs):
230
+ return None
231
+
232
+ monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
233
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
234
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
235
+
236
+ wrapper = models.LiteLLMChatWrapper(
237
+ model="test-model",
238
+ provider="openai",
239
+ model_config=None,
240
+ a0_api_mode="chat_completions",
241
+ )
242
+
243
+ async def response_callback(chunk: str, full: str):
244
+ return None
245
+
246
+ response, reasoning = await wrapper.unified_call(
247
+ messages=[],
248
+ response_callback=response_callback,
249
+ )
250
+
251
+ assert response == "hello"
252
+ assert reasoning == ""
253
+ assert calls == ["chat"]
254
+
255
+
256
+@pytest.mark.asyncio
257
+async def test_unified_call_retries_responses_with_high_reasoning(monkeypatch):
258
+ validation_error = ValueError(
259
+ "1 validation error for ResponseCreatedEvent\n"
260
+ "response.reasoning.effort\n"
261
+ "Input should be 'minimal', 'low', 'medium' or 'high' "
262
+ "[type=literal_error, input_value='none', input_type=str]"
263
+ )
264
+ failing_stream = _FailingAsyncChunkStream(validation_error)
265
+ working_stream = _AsyncChunkStream([_response_event("ok")])
266
+ calls: list[dict] = []
267
+
268
+ async def fake_aresponses(*args, **kwargs):
269
+ calls.append(kwargs)
270
+ if len(calls) == 1:
271
+ return failing_stream
272
+ return working_stream
273
+
274
+ async def fake_rate_limiter(*args, **kwargs):
275
+ return None
276
+
277
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
278
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
279
+
280
+ wrapper = models.LiteLLMChatWrapper(
281
+ model="gpt-5.4",
282
+ provider="openai",
283
+ model_config=None,
284
+ )
285
+
286
+ async def response_callback(chunk: str, full: str):
287
+ return None
288
+
289
+ response, reasoning = await wrapper.unified_call(
290
+ messages=[],
291
+ response_callback=response_callback,
292
+ )
293
+
294
+ assert response == "ok"
295
+ assert reasoning == ""
296
+ assert failing_stream.closed is True
297
+ assert len(calls) == 2
298
+ assert "reasoning" not in calls[0]
299
+ assert calls[1]["reasoning"] == {"effort": "high"}
300
+
301
+
302
+@pytest.mark.asyncio
303
+async def test_unified_call_falls_back_to_chat_when_responses_endpoint_missing(
304
+ monkeypatch,
305
+):
306
+ calls: list[str] = []
307
+
308
+ async def fake_aresponses(*args, **kwargs):
309
+ calls.append("responses")
310
+ raise RuntimeError(
311
+ "Client error '404 Not Found' for url "
312
+ "'https://llm.agent-zero.ai/v1/responses'"
313
+ )
314
+
315
+ async def fake_acompletion(*args, **kwargs):
316
+ calls.append("chat")
317
+ assert kwargs["stream"] is True
318
+ assert kwargs["drop_params"] is True
319
+ assert "tool_choice" not in kwargs
320
+ assert "parallel_tool_calls" not in kwargs
321
+ return _AsyncChunkStream([_chunk("fallback")])
322
+
323
+ async def fake_rate_limiter(*args, **kwargs):
324
+ return None
325
+
326
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
327
+ monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
328
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
329
+
330
+ wrapper = models.LiteLLMChatWrapper(
331
+ model="claude-opus-4.7",
332
+ provider="openai",
333
+ model_config=None,
334
+ tool_choice="auto",
335
+ parallel_tool_calls=True,
336
+ )
337
+
338
+ async def response_callback(chunk: str, full: str):
339
+ return None
340
+
341
+ response, reasoning = await wrapper.unified_call(
342
+ messages=[],
343
+ response_callback=response_callback,
344
+ )
345
+
346
+ assert response == "fallback"
347
+ assert reasoning == ""
348
+ assert calls == ["responses", "chat"]
349
+
350
+ response, reasoning = await wrapper.unified_call(
351
+ messages=[],
352
+ response_callback=response_callback,
353
+ )
354
+
355
+ assert response == "fallback"
356
+ assert reasoning == ""
357
+ assert calls == ["responses", "chat", "chat"]
358
+
359
+
360
+@pytest.mark.asyncio
361
+async def test_unified_call_preserves_cache_control_with_chat_for_non_native_responses(
362
+ monkeypatch,
363
+):
364
+ calls: list[str] = []
365
+
366
+ async def fake_aresponses(*args, **kwargs):
367
+ raise AssertionError("cache_control should keep Anthropic-family calls on chat")
368
+
369
+ async def fake_acompletion(*args, **kwargs):
370
+ calls.append("chat")
371
+ assert kwargs["stream"] is True
372
+ messages = kwargs["messages"]
373
+ assert "cache_control" not in messages[0]
374
+ assert messages[0]["content"][-1]["cache_control"] == {
375
+ "type": "ephemeral"
376
+ }
377
+ assert messages[1]["content"][-1]["cache_control"] == {
378
+ "type": "ephemeral"
379
+ }
380
+ assert "cache_control" not in messages[2]
381
+ assert messages[3]["content"][-1]["cache_control"] == {
382
+ "type": "ephemeral"
383
+ }
384
+ return _AsyncChunkStream([_chunk("cached")])
385
+
386
+ async def fake_rate_limiter(*args, **kwargs):
387
+ return None
388
+
389
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
390
+ monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
391
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
392
+
393
+ wrapper = models.LiteLLMChatWrapper(
394
+ model="claude-sonnet-4-5",
395
+ provider="anthropic",
396
+ model_config=None,
397
+ )
398
+
399
+ async def response_callback(chunk: str, full: str):
400
+ return None
401
+
402
+ response, reasoning = await wrapper.unified_call(
403
+ messages=[
404
+ SystemMessage(content="static instructions"),
405
+ HumanMessage(content="question"),
406
+ AIMessage(content="previous answer"),
407
+ HumanMessage(content="follow up"),
408
+ ],
409
+ response_callback=response_callback,
410
+ explicit_caching=True,
411
+ )
412
+
413
+ assert response == "cached"
414
+ assert reasoning == ""
415
+ assert calls == ["chat"]
416
+
417
+
418
+def test_responses_request_translates_messages_and_params():
419
+ messages = [
420
+ {"role": "system", "content": "You are precise."},
421
+ {
422
+ "role": "user",
423
+ "content": [
424
+ {"type": "text", "text": "Inspect this."},
425
+ {
426
+ "type": "image_url",
427
+ "image_url": {"url": "https://example.test/a.png"},
428
+ },
429
+ ],
430
+ },
431
+ {
432
+ "role": "assistant",
433
+ "content": "empty",
434
+ "tool_calls": [
435
+ {
436
+ "id": "call_1",
437
+ "type": "function",
438
+ "function": {"name": "lookup", "arguments": '{"q":"a0"}'},
439
+ }
440
+ ],
441
+ },
442
+ {"role": "tool", "tool_call_id": "call_1", "content": "done"},
443
+ ]
444
+ kwargs = {
445
+ "max_tokens": 42,
446
+ "reasoning_effort": "high",
447
+ "response_format": {
448
+ "type": "json_schema",
449
+ "json_schema": {
450
+ "name": "answer",
451
+ "schema": {"type": "object"},
452
+ "strict": True,
453
+ },
454
+ },
455
+ "tools": [
456
+ {
457
+ "type": "function",
458
+ "function": {
459
+ "name": "lookup",
460
+ "description": "Search",
461
+ "parameters": {"type": "object"},
462
+ "strict": True,
463
+ },
464
+ }
465
+ ],
466
+ }
467
+
468
+ request = litellm_transport.ResponsesTransport.from_chat(messages, kwargs)
469
+
470
+ assert "instructions" not in request
471
+ assert request["store"] is True
472
+ assert request["max_output_tokens"] == 42
473
+ assert request["reasoning"] == {"effort": "high"}
474
+ assert request["text"] == {
475
+ "format": {
476
+ "type": "json_schema",
477
+ "name": "answer",
478
+ "schema": {"type": "object"},
479
+ "strict": True,
480
+ }
481
+ }
482
+ assert request["tools"] == [
483
+ {
484
+ "type": "function",
485
+ "name": "lookup",
486
+ "description": "Search",
487
+ "parameters": {"type": "object"},
488
+ "strict": True,
489
+ }
490
+ ]
491
+ assert request["input"] == [
492
+ {"role": "system", "content": "You are precise."},
493
+ {
494
+ "role": "user",
495
+ "content": [
496
+ {"type": "input_text", "text": "Inspect this."},
497
+ {
498
+ "type": "input_image",
499
+ "image_url": "https://example.test/a.png",
500
+ },
501
+ ],
502
+ },
503
+ {
504
+ "type": "function_call",
505
+ "call_id": "call_1",
506
+ "id": "call_1",
507
+ "name": "lookup",
508
+ "arguments": '{"q":"a0"}',
509
+ "status": "completed",
510
+ },
511
+ {"type": "function_call_output", "call_id": "call_1", "output": "done"},
512
+ ]
513
+
514
+
515
+def test_responses_request_normalizes_reasoning_and_orphan_tool_choice():
516
+ request = litellm_transport.ResponsesTransport.from_chat(
517
+ [],
518
+ {
519
+ "reasoning_effort": "none",
520
+ "tools": [],
521
+ "tool_choice": "auto",
522
+ "parallel_tool_calls": True,
523
+ },
524
+ )
525
+
526
+ assert "reasoning" not in request
527
+ assert "tools" not in request
528
+ assert "tool_choice" not in request
529
+ assert "parallel_tool_calls" not in request
530
+
531
+ request = litellm_transport.ResponsesTransport.from_chat(
532
+ [],
533
+ {"reasoning": {"effort": "xhigh"}},
534
+ )
535
+
536
+ assert request["reasoning"] == {"effort": "high"}
537
+
538
+ request = litellm_transport.ResponsesTransport.from_chat(
539
+ [],
540
+ {"reasoning_effort": "off"},
541
+ )
542
+
543
+ assert "reasoning" not in request
544
+
545
+
546
+def test_responses_request_adds_openai_prompt_cache_key_for_static_prefix():
547
+ request = litellm_transport.ResponsesTransport.from_chat(
548
+ [
549
+ {"role": "system", "content": "stable system prompt"},
550
+ {"role": "user", "content": "dynamic question"},
551
+ ],
552
+ {
553
+ "tools": [
554
+ {
555
+ "type": "function",
556
+ "function": {
557
+ "name": "lookup",
558
+ "description": "Search",
559
+ "parameters": {"type": "object"},
560
+ },
561
+ }
562
+ ],
563
+ },
564
+ model="openai/gpt-5.4",
565
+ )
566
+
567
+ assert request["prompt_cache_key"].startswith("a0-")
568
+ assert len(request["prompt_cache_key"]) == 35
569
+ assert "stable system prompt" not in request["prompt_cache_key"]
570
+
571
+ request_again = litellm_transport.ResponsesTransport.from_chat(
572
+ [
573
+ {"role": "system", "content": "stable system prompt"},
574
+ {"role": "user", "content": "different dynamic question"},
575
+ ],
576
+ {
577
+ "tools": [
578
+ {
579
+ "type": "function",
580
+ "function": {
581
+ "name": "lookup",
582
+ "description": "Search",
583
+ "parameters": {"type": "object"},
584
+ },
585
+ }
586
+ ],
587
+ },
588
+ model="openai/gpt-5.4",
589
+ )
590
+
591
+ assert request_again["prompt_cache_key"] == request["prompt_cache_key"]
592
+
593
+
594
+def test_responses_request_respects_explicit_prompt_cache_and_retention():
595
+ request = litellm_transport.ResponsesTransport.from_chat(
596
+ [{"role": "system", "content": "stable system prompt"}],
597
+ {
598
+ "prompt_cache_key": "user-provided-key",
599
+ "prompt_cache_retention": "24h",
600
+ "extra_body": {"prompt_cache_retention": "in_memory"},
601
+ },
602
+ model="openai/gpt-5.4",
603
+ )
604
+
605
+ assert request["prompt_cache_key"] == "user-provided-key"
606
+ assert "prompt_cache_retention" not in request
607
+ assert request["extra_body"]["prompt_cache_retention"] == "in_memory"
608
+
609
+
610
+def test_responses_request_adds_azure_prompt_cache_params():
611
+ request = litellm_transport.ResponsesTransport.from_chat(
612
+ [{"role": "system", "content": "stable system prompt"}],
613
+ {"prompt_cache_retention": "24h"},
614
+ model="azure/gpt-4.1",
615
+ )
616
+
617
+ assert request["prompt_cache_key"].startswith("a0-")
618
+ assert "prompt_cache_retention" not in request
619
+ assert request["extra_body"]["prompt_cache_retention"] == "24h"
620
+
621
+
622
+def test_responses_request_does_not_add_openai_cache_key_to_custom_api_base():
623
+ request = litellm_transport.ResponsesTransport.from_chat(
624
+ [{"role": "system", "content": "stable system prompt"}],
625
+ {"api_base": "https://llm.agent-zero.ai/v1"},
626
+ model="openai/gpt-5.4",
627
+ )
628
+
629
+ assert "prompt_cache_key" not in request
630
+
631
+
632
+def test_chat_kwargs_add_openai_prompt_cache_key_for_chat_completions():
633
+ kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
634
+ {"max_tokens": 10},
635
+ model="openai/gpt-5.4",
636
+ messages=[
637
+ {"role": "system", "content": "stable system prompt"},
638
+ {"role": "user", "content": "dynamic question"},
639
+ ],
640
+ )
641
+
642
+ assert kwargs["prompt_cache_key"].startswith("a0-")
643
+ assert kwargs["max_tokens"] == 10
644
+
645
+
646
+def test_chat_messages_strip_cache_control_for_openai_prompt_cache():
647
+ messages = [
648
+ {
649
+ "role": "system",
650
+ "cache_control": {"type": "ephemeral"},
651
+ "content": [
652
+ {
653
+ "type": "text",
654
+ "text": "stable system prompt",
655
+ "cache_control": {"type": "ephemeral"},
656
+ }
657
+ ],
658
+ }
659
+ ]
660
+
661
+ prepared = litellm_transport.ChatCompletionsTransport.prepare_messages(
662
+ messages,
663
+ model="openai/gpt-5.4",
664
+ kwargs={},
665
+ )
666
+
667
+ assert "cache_control" not in prepared[0]
668
+ assert "cache_control" not in prepared[0]["content"][0]
669
+ assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
670
+
671
+
672
+def test_chat_kwargs_mark_cached_tools_for_cache_control_providers():
673
+ kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
674
+ {
675
+ "tools": [
676
+ {
677
+ "type": "function",
678
+ "function": {
679
+ "name": "lookup",
680
+ "description": "Search",
681
+ "parameters": {"type": "object"},
682
+ },
683
+ }
684
+ ],
685
+ },
686
+ model="anthropic/claude-sonnet-4-5",
687
+ messages=[
688
+ {
689
+ "role": "system",
690
+ "content": [
691
+ {
692
+ "type": "text",
693
+ "text": "static instructions",
694
+ "cache_control": {"type": "ephemeral"},
695
+ }
696
+ ],
697
+ }
698
+ ],
699
+ explicit_prompt_caching=True,
700
+ )
701
+
702
+ assert kwargs["tools"][0]["function"]["cache_control"] == {
703
+ "type": "ephemeral"
704
+ }
705
+
706
+
707
+def test_chat_kwargs_strip_orphan_tool_choice_and_enable_fallback_drop_params():
708
+ kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
709
+ {
710
+ "tool_choice": "auto",
711
+ "parallel_tool_calls": True,
712
+ "max_tokens": 10,
713
+ },
714
+ fallback_error=RuntimeError("This model does not support Responses API"),
715
+ )
716
+
717
+ assert kwargs["max_tokens"] == 10
718
+ assert kwargs["drop_params"] is True
719
+ assert "tool_choice" not in kwargs
720
+ assert "parallel_tool_calls" not in kwargs
721
+
722
+
723
+def test_cache_control_policy_keeps_native_responses_first():
724
+ messages = [
725
+ {
726
+ "role": "system",
727
+ "content": "static instructions",
728
+ "cache_control": {"type": "ephemeral"},
729
+ }
730
+ ]
731
+
732
+ openai_policy = litellm_transport.TransportPolicy.from_request(
733
+ "openai/gpt-5.4",
734
+ {},
735
+ messages=messages,
736
+ )
737
+ anthropic_policy = litellm_transport.TransportPolicy.from_request(
738
+ "anthropic/claude-sonnet-4-5",
739
+ {},
740
+ messages=messages,
741
+ )
742
+
743
+ assert openai_policy.mode is litellm_transport.TransportMode.RESPONSES
744
+ assert anthropic_policy.mode is litellm_transport.TransportMode.CHAT_COMPLETIONS
745
+
746
+
747
+def test_responses_fallback_does_not_mask_rate_limits():
748
+ exc = RuntimeError(
749
+ "RateLimitError: 429 Too Many Requests for url "
750
+ "https://api.openai.com/v1/responses"
751
+ )
752
+
753
+ policy = litellm_transport.TransportPolicy(
754
+ mode=litellm_transport.TransportMode.RESPONSES
755
+ )
756
+
757
+ assert (
758
+ policy.recover(exc, got_any_chunk=False)
759
+ is litellm_transport.TransportRecovery.RAISE
760
+ )
761
+
762
+
763
+def test_responses_response_parser_extracts_text_reasoning_and_function_calls():
764
+ text_response = {
765
+ "output": [
766
+ {"type": "reasoning", "summary": [{"text": "because"}]},
767
+ {
768
+ "type": "message",
769
+ "content": [{"type": "output_text", "text": "answer"}],
770
+ },
771
+ ]
772
+ }
773
+
774
+ parsed = litellm_transport.ResponsesTransport.parse_response(text_response)
775
+
776
+ assert parsed == {"response_delta": "answer", "reasoning_delta": "because"}
777
+
778
+ tool_response = {
779
+ "output": [
780
+ {
781
+ "type": "function_call",
782
+ "name": "lookup",
783
+ "arguments": '{"q":"a0"}',
784
+ }
785
+ ]
786
+ }
787
+
788
+ parsed_tool = litellm_transport.ResponsesTransport.parse_response(tool_response)
789
+
790
+ assert extract_tools.json_parse_dirty(parsed_tool["response_delta"]) == {
791
+ "tool_name": "lookup",
792
+ "tool_args": {"q": "a0"},
793
+ }
794
+
795
+
796
+def test_responses_stream_parser_accumulates_function_call_arguments():
797
+ parser = litellm_transport.ResponsesEventParser()
798
+
799
+ assert parser.parse(
800
+ {
801
+ "type": "response.output_item.added",
802
+ "output_index": 0,
803
+ "item": {
804
+ "type": "function_call",
805
+ "id": "fc_1",
806
+ "call_id": "call_1",
807
+ "name": "lookup",
808
+ "arguments": "",
809
+ },
810
+ }
811
+ ) == {"reasoning_delta": "", "response_delta": ""}
812
+ assert parser.parse(
813
+ {
814
+ "type": "response.function_call_arguments.delta",
815
+ "item_id": "fc_1",
816
+ "output_index": 0,
817
+ "delta": '{"q":',
818
+ }
819
+ ) == {"reasoning_delta": "", "response_delta": ""}
820
+
821
+ parsed = parser.parse(
822
+ {
823
+ "type": "response.function_call_arguments.done",
824
+ "item_id": "fc_1",
825
+ "output_index": 0,
826
+ "name": "lookup",
827
+ "arguments": '{"q":"a0"}',
828
+ }
829
+ )
830
+
831
+ assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
832
+ "tool_name": "lookup",
833
+ "tool_args": {"q": "a0"},
834
+ }
835
+ assert parser.parse(
836
+ {
837
+ "type": "response.output_item.done",
838
+ "output_index": 0,
839
+ "item": {
840
+ "type": "function_call",
841
+ "id": "fc_1",
842
+ "call_id": "call_1",
843
+ "name": "lookup",
844
+ "arguments": '{"q":"a0"}',
845
+ },
846
+ }
847
+ ) == {"reasoning_delta": "", "response_delta": ""}
848
+
849
+
850
+def test_responses_stream_parser_uses_completed_response_when_no_deltas_arrive():
851
+ parser = litellm_transport.ResponsesEventParser()
852
+
853
+ parsed = parser.parse(
854
+ {
855
+ "type": "response.completed",
856
+ "response": {
857
+ "output": [
858
+ {
859
+ "type": "message",
860
+ "content": [{"type": "output_text", "text": "done"}],
861
+ }
862
+ ]
863
+ },
864
+ }
865
+ )
866
+
867
+ assert parsed == {"reasoning_delta": "", "response_delta": "done"}
868
+
869
+
870
+def test_responses_stream_parser_handles_refusal_and_failed_events():
871
+ parser = litellm_transport.ResponsesEventParser()
872
+
873
+ assert parser.parse(
874
+ {"type": "response.refusal.delta", "delta": "no"}
875
+ ) == {"reasoning_delta": "", "response_delta": "no"}
876
+
877
+ with pytest.raises(RuntimeError, match="policy"):
878
+ parser.parse(
879
+ {
880
+ "type": "response.failed",
881
+ "response": {"error": {"message": "policy"}},
882
+ }
883
+ )
884
+
885
+
886
+def test_responses_response_parser_groups_parallel_function_calls():
887
+ response = {
888
+ "output": [
889
+ {
890
+ "type": "function_call",
891
+ "name": "lookup",
892
+ "arguments": '{"q":"a0"}',
893
+ },
894
+ {
895
+ "type": "function_call",
896
+ "name": "rank",
897
+ "arguments": '{"limit":2}',
898
+ },
899
+ ]
900
+ }
901
+
902
+ parsed = litellm_transport.ResponsesTransport.parse_response(response)
903
+
904
+ assert extract_tools.json_parse_dirty(parsed["response_delta"]) == {
905
+ "tool_name": "parallel_tool_calls",
906
+ "tool_args": {
907
+ "calls": [
908
+ {"tool_name": "lookup", "tool_args": {"q": "a0"}},
909
+ {"tool_name": "rank", "tool_args": {"limit": 2}},
910
+ ]
911
+ },
912
+ }