Skip to content

Commit 226f805

Browse files
authored
Support OpenAI responses API (#1807)
1 parent d76bd92 commit 226f805

1 file changed

Lines changed: 249 additions & 3 deletions

File tree

lumen/ai/llm.py

Lines changed: 249 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,11 @@ class OpenAI(Llm, OpenAIMixin):
983983
An LLM implementation using the OpenAI cloud.
984984
"""
985985

986+
api = param.Selector(default="chat_completions", objects=["chat_completions", "responses"], doc="""
987+
OpenAI API primitive to use.
988+
- ``chat_completions``: Uses ``/v1/chat/completions`` (default)
989+
- ``responses``: Uses ``/v1/responses``""")
990+
986991
display_name = param.String(default="OpenAI", constant=True)
987992

988993
mode = param.Selector(default=Mode.TOOLS)
@@ -1005,6 +1010,49 @@ class OpenAI(Llm, OpenAIMixin):
10051010

10061011
_supports_logfire = True
10071012

1013+
@classmethod
1014+
def _resolve_openai_mode(cls, mode: Mode) -> Mode:
1015+
if mode in (Mode.RESPONSES_TOOLS, Mode.RESPONSES_TOOLS_WITH_INBUILT_TOOLS):
1016+
return mode
1017+
return Mode.RESPONSES_TOOLS
1018+
1019+
@classmethod
1020+
def _transform_responses_tools(cls, tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
1021+
if not tools:
1022+
return tools
1023+
transformed: list[dict[str, Any]] = []
1024+
for tool in tools:
1025+
if (
1026+
isinstance(tool, dict)
1027+
and tool.get("type") == "function"
1028+
and isinstance(tool.get("function"), dict)
1029+
):
1030+
# Chat Completions format -> Responses format
1031+
function = tool["function"]
1032+
transformed.append({
1033+
"type": "function",
1034+
"name": function.get("name"),
1035+
"description": function.get("description", ""),
1036+
"parameters": function.get("parameters", {}),
1037+
})
1038+
else:
1039+
transformed.append(tool)
1040+
return transformed
1041+
1042+
@classmethod
1043+
def _tool_messages_to_response_inputs(cls, tool_messages: list[Message]) -> list[dict[str, Any]]:
1044+
inputs: list[dict[str, Any]] = []
1045+
for message in tool_messages:
1046+
call_id = message.get("tool_call_id")
1047+
if not call_id:
1048+
continue
1049+
inputs.append({
1050+
"type": "function_call_output",
1051+
"call_id": call_id,
1052+
"output": message["content"],
1053+
})
1054+
return inputs
1055+
10081056
def models(self) -> set[str]:
10091057
"""Return the set of available model identifiers from OpenAI."""
10101058
client = OpenAIClient(api_key=self.api_key, timeout=5)
@@ -1025,24 +1073,222 @@ def _create_base_client(self, **kwargs) -> Any:
10251073
self._logfire.instrument_openai(client)
10261074
return client
10271075

1076+
def _get_completion_method(self) -> Callable:
1077+
if self.api == "responses":
1078+
return self._base_client.responses.create
1079+
return super()._get_completion_method()
1080+
10281081
def _create_instructor_client(self, base_client: Any, mode: Mode) -> Any:
1082+
if self.api == "responses":
1083+
mode = self._resolve_openai_mode(mode)
10291084
if self.interceptor:
10301085
self.interceptor.patch_client(base_client, mode="store_inputs")
10311086
wrapped = instructor.from_openai(base_client, mode=mode)
10321087
if self.interceptor:
10331088
self.interceptor.patch_client_response(wrapped)
10341089
return wrapped
10351090

1036-
async def get_client(self, model_spec: str | dict, response_model: BaseModel | None = None, **kwargs):
1091+
@classmethod
1092+
def _get_content(cls, response) -> str | BaseModel:
1093+
if hasattr(response, "output_text"):
1094+
return response.output_text or ""
1095+
return super()._get_content(response)
1096+
1097+
@classmethod
1098+
def _get_delta(cls, chunk) -> str:
1099+
event_type = getattr(chunk, "type", None)
1100+
if event_type == "response.output_text.delta":
1101+
return getattr(chunk, "delta", "") or ""
1102+
if isinstance(chunk, dict):
1103+
if chunk.get("type") == "response.output_text.delta":
1104+
return chunk.get("delta") or ""
1105+
return super()._get_delta(chunk)
1106+
1107+
@classmethod
1108+
def _extract_tool_calls(cls, response: Any) -> list[Any]:
1109+
output_items = getattr(response, "output", None)
1110+
if output_items is None and isinstance(response, dict):
1111+
output_items = response.get("output")
1112+
if output_items:
1113+
tool_calls: list[dict[str, Any]] = []
1114+
for item in output_items:
1115+
item_type = item.get("type") if isinstance(item, dict) else getattr(item, "type", None)
1116+
if item_type != "function_call":
1117+
continue
1118+
call_id = item.get("call_id") if isinstance(item, dict) else getattr(item, "call_id", None)
1119+
name = item.get("name") if isinstance(item, dict) else getattr(item, "name", None)
1120+
arguments = item.get("arguments") if isinstance(item, dict) else getattr(item, "arguments", "")
1121+
tool_calls.append({
1122+
"id": call_id,
1123+
"type": "function",
1124+
"function": {"name": name, "arguments": arguments or ""},
1125+
})
1126+
if tool_calls:
1127+
return tool_calls
1128+
return super()._extract_tool_calls(response)
1129+
1130+
@classmethod
1131+
def _extract_stream_tool_calls(cls, chunk: Any) -> list[dict[str, Any]]:
1132+
event_type = getattr(chunk, "type", None)
1133+
if event_type is None and isinstance(chunk, dict):
1134+
event_type = chunk.get("type")
1135+
1136+
if event_type == "response.output_item.added":
1137+
item = getattr(chunk, "item", None)
1138+
output_index = getattr(chunk, "output_index", 0)
1139+
if item is not None and getattr(item, "type", None) == "function_call":
1140+
return [{
1141+
"index": output_index,
1142+
"id": getattr(item, "call_id", None),
1143+
"type": "function",
1144+
"function": {"name": getattr(item, "name", None), "arguments": ""},
1145+
}]
1146+
elif event_type == "response.function_call_arguments.delta":
1147+
return [{
1148+
"index": getattr(chunk, "output_index", 0),
1149+
"type": "function",
1150+
"function": {"name": None, "arguments": getattr(chunk, "delta", "") or ""},
1151+
}]
1152+
elif event_type == "response.function_call_arguments.done":
1153+
return [{
1154+
"index": getattr(chunk, "output_index", 0),
1155+
"id": getattr(chunk, "item_id", None),
1156+
"type": "function",
1157+
"function": {
1158+
"name": getattr(chunk, "name", None),
1159+
"arguments": getattr(chunk, "arguments", "") or "",
1160+
},
1161+
}]
1162+
1163+
if isinstance(chunk, dict):
1164+
if chunk.get("type") == "response.output_item.added":
1165+
item = chunk.get("item") or {}
1166+
if item.get("type") == "function_call":
1167+
return [{
1168+
"index": chunk.get("output_index", 0),
1169+
"id": item.get("call_id"),
1170+
"type": "function",
1171+
"function": {"name": item.get("name"), "arguments": ""},
1172+
}]
1173+
elif chunk.get("type") == "response.function_call_arguments.delta":
1174+
return [{
1175+
"index": chunk.get("output_index", 0),
1176+
"type": "function",
1177+
"function": {"name": None, "arguments": chunk.get("delta") or ""},
1178+
}]
1179+
elif chunk.get("type") == "response.function_call_arguments.done":
1180+
return [{
1181+
"index": chunk.get("output_index", 0),
1182+
"id": chunk.get("item_id"),
1183+
"type": "function",
1184+
"function": {
1185+
"name": chunk.get("name"),
1186+
"arguments": chunk.get("arguments") or "",
1187+
},
1188+
}]
1189+
1190+
return super()._extract_stream_tool_calls(chunk)
1191+
1192+
async def _run_tool_loop(
1193+
self,
1194+
messages: list[Message],
1195+
structured_model: type[BaseModel] | None,
1196+
tool_instances: dict,
1197+
tool_contexts: dict,
1198+
model_spec: str | dict = "default",
1199+
max_tool_rounds: int = 16,
1200+
**kwargs
1201+
) -> BaseModel | str:
1202+
if self.api != "responses":
1203+
return await super()._run_tool_loop(
1204+
messages, structured_model, tool_instances, tool_contexts, model_spec, max_tool_rounds, **kwargs
1205+
)
1206+
1207+
if structured_model is not None and not tool_instances:
1208+
kwargs["response_model"] = structured_model
1209+
else:
1210+
kwargs.pop("response_model", None)
1211+
1212+
output = await self.run_client(model_spec, messages, **kwargs)
1213+
if not tool_instances:
1214+
return output
1215+
1216+
for _ in range(max_tool_rounds):
1217+
tool_calls = self._extract_tool_calls(output)
1218+
if not tool_calls:
1219+
break
1220+
tool_messages = await self._run_tool_calls(
1221+
tool_instances, tool_calls, tool_contexts, messages
1222+
)
1223+
if not tool_messages:
1224+
break
1225+
tool_outputs = self._tool_messages_to_response_inputs(tool_messages)
1226+
if not tool_outputs:
1227+
break
1228+
next_kwargs = dict(kwargs)
1229+
response_id = getattr(output, "id", None)
1230+
if response_id:
1231+
next_kwargs["previous_response_id"] = response_id
1232+
output = await self.run_client(model_spec, tool_outputs, **next_kwargs)
1233+
1234+
if structured_model:
1235+
final_kwargs = dict(kwargs)
1236+
final_kwargs["response_model"] = structured_model
1237+
response_id = getattr(output, "id", None)
1238+
if response_id:
1239+
final_kwargs["previous_response_id"] = response_id
1240+
output = await self.run_client(model_spec, [], **final_kwargs)
1241+
return output
1242+
1243+
async def get_client(self, model_spec: str | dict, response_model: type[BaseModel] | None = None, **kwargs):
10371244
model_kwargs = self._get_model_kwargs(model_spec)
10381245
model = model_kwargs.pop("model")
10391246
log_debug(f"LLM Model: \033[96m{model!r}\033[0m")
1040-
model_kwargs["mode"] = model_kwargs.pop("mode", self.mode)
1247+
mode = model_kwargs.pop("mode", self.mode)
1248+
1249+
if self.api == "responses":
1250+
if self._base_client is None:
1251+
self._base_client = self._create_base_client(**model_kwargs)
1252+
if response_model:
1253+
mode = self._resolve_openai_mode(mode)
1254+
if mode not in self._instructor_clients:
1255+
self._instructor_clients[mode] = self._create_instructor_client(self._base_client, mode)
1256+
client = self._instructor_clients[mode]
1257+
client_callable = partial(client.responses.create, model=model, **self._get_create_kwargs(response_model))
1258+
else:
1259+
client_callable = partial(self._base_client.responses.create, model=model, **self._get_create_kwargs(response_model))
1260+
else:
1261+
model_kwargs["mode"] = mode
1262+
client_callable = self._get_cached_client(response_model, model=model, **model_kwargs)
10411263

1042-
client_callable = self._get_cached_client(response_model, model=model, **model_kwargs)
10431264
# Add timeout to the partial
10441265
return partial(client_callable.func, *client_callable.args, timeout=self.timeout, **client_callable.keywords)
10451266

1267+
async def run_client(self, model_spec: str | dict, messages: list[Message] | list[dict[str, Any]], **kwargs):
1268+
if self.api == "responses":
1269+
log_debug(f"Input messages: \033[95m{len(messages)} messages\033[0m including system")
1270+
for i, message in enumerate(messages):
1271+
role = message.get("role") if isinstance(message, dict) else None
1272+
content = message.get("content") if isinstance(message, dict) else None
1273+
if role == "system":
1274+
continue
1275+
if role in ("user", "assistant", "tool"):
1276+
role_char = "u" if role == "user" else "a"
1277+
log_debug(f"Message \033[95m{i} ({role_char})\033[0m: {format_msg_content(content)}")
1278+
else:
1279+
item_type = message.get("type") if isinstance(message, dict) else type(message).__name__
1280+
log_debug(f"Message \033[95m{i}\033[0m: [{item_type}] {truncate_string(str(message), max_length=2000)}")
1281+
1282+
if kwargs.get("tools"):
1283+
kwargs = dict(kwargs)
1284+
kwargs["tools"] = self._transform_responses_tools(kwargs.get("tools"))
1285+
client = await self.get_client(model_spec, **kwargs)
1286+
result = await client(input=messages, **kwargs)
1287+
log_debug(f"LLM Response: \033[95m{truncate_string(str(result), max_length=1000)}\033[0m\n---")
1288+
return result
1289+
1290+
return await super().run_client(model_spec, messages, **kwargs)
1291+
10461292

10471293
class AzureOpenAI(Llm, AzureOpenAIMixin):
10481294
"""

0 commit comments

Comments
 (0)