feat(litellm): support Codex function tools

This commit is contained in:
Eduardo David Paredes Vara
2026-08-24 12:35:43 +00:00
parent 1000b8b877
commit 751eaf5735
2 changed files with 166 additions and 46 deletions
+10 -7
View File
@@ -384,20 +384,23 @@ El proveedor implementa llamadas síncronas, asíncronas y streaming. El backend
se consume siempre mediante streaming; en llamadas no streaming se agregan los
fragmentos antes de devolver la respuesta.
La traducción actual sólo conserva principalmente:
La traducción actual conserva principalmente:
- Mensajes de usuario y asistente.
- Mensajes `system` y `developer`, unidos como `instructions`.
- Herramientas de función, `tool_choice` y `parallel_tool_calls`.
- Llamadas de herramienta del asistente y sus resultados en turnos posteriores.
- Nombre del modelo.
- `reasoning_effort`, con valor predeterminado `medium`.
- Texto generado y contadores básicos de tokens.
- Texto, llamadas de herramienta y contadores básicos de tokens en la respuesta.
No es una implementación completa de Responses ni de Chat Completions. Entre
otros, no reenvía explícitamente herramientas/function calling, límites de
tokens, temperatura, formatos estructurados ni la mayoría de parámetros
opcionales. El parser SSE sólo procesa deltas de texto y el evento final de
uso. Un cliente puede ser compatible con la API de LiteLLM y aun así perder
funciones al usar un alias Codex.
otros, no reenvía límites de tokens, temperatura, formatos estructurados ni la
mayoría de parámetros opcionales. El soporte de herramientas traduce las
funciones personalizadas del formato Chat Completions usado por Open WebUI al
formato Responses, y convierte los eventos SSE de function calling de vuelta
a chunks compatibles con OpenAI. Otros tipos de herramienta alojada de
Responses no se traducen explícitamente.
### Riesgo de concurrencia en la renovación
+156 -39
View File
@@ -112,7 +112,7 @@ def _get_auth(force: bool = False) -> dict:
# ---------------------------------------------------------------------------
def _split_messages(messages: list) -> tuple[str, list]:
"""Extract system/developer messages as instructions; return (instructions, input_messages)."""
"""Convert OpenAI chat messages into Responses API input items."""
instructions_parts = []
input_msgs = []
for msg in messages:
@@ -120,6 +120,25 @@ def _split_messages(messages: list) -> tuple[str, list]:
content = msg.get("content", "")
if role in ("system", "developer"):
instructions_parts.append(content)
elif role == "assistant" and msg.get("tool_calls"):
if content:
input_msgs.append({"role": role, "content": content})
for tool_call in msg["tool_calls"]:
if not isinstance(tool_call, dict):
tool_call = tool_call.model_dump()
function = tool_call.get("function", {})
input_msgs.append({
"type": "function_call",
"call_id": tool_call["id"],
"name": function["name"],
"arguments": function.get("arguments", ""),
})
elif role == "tool":
input_msgs.append({
"type": "function_call_output",
"call_id": msg["tool_call_id"],
"output": content,
})
else:
input_msgs.append({"role": role, "content": content})
return "\n\n".join(instructions_parts), input_msgs
@@ -142,6 +161,33 @@ def _build_body(model: str, messages: list, optional_params: dict, stream: bool)
"store": False,
"reasoning": {"effort": optional_params.get("reasoning_effort", "medium")},
}
tools = []
for tool in optional_params.get("tools") or []:
if not isinstance(tool, dict):
tool = tool.model_dump()
if tool.get("type") == "function" and "function" in tool:
function = tool["function"]
tools.append({
"type": "function",
"name": function["name"],
"description": function.get("description", ""),
"parameters": function.get("parameters", {"type": "object"}),
**({"strict": function["strict"]} if "strict" in function else {}),
})
else:
tools.append(tool)
if tools:
body["tools"] = tools
tool_choice = optional_params.get("tool_choice")
if isinstance(tool_choice, dict) and "function" in tool_choice:
function = tool_choice["function"]
body["tool_choice"] = {"type": "function", "name": function["name"]}
elif tool_choice is not None:
body["tool_choice"] = tool_choice
if "parallel_tool_calls" in optional_params:
body["parallel_tool_calls"] = optional_params["parallel_tool_calls"]
return body
@@ -155,7 +201,87 @@ def _auth_headers(auth: dict) -> dict:
}
def _accumulate_tool_chunk(tool_calls: dict, chunk: GenericStreamingChunk) -> None:
tool_use = chunk.get("tool_use")
if not tool_use:
return
index = tool_use.get("index", 0)
current = tool_calls.setdefault(index, {
"id": "",
"type": "function",
"function": {"name": "", "arguments": ""},
})
if tool_use.get("id"):
current["id"] = tool_use["id"]
function = tool_use.get("function") or {}
if function.get("name"):
current["function"]["name"] = function["name"]
current["function"]["arguments"] += function.get("arguments") or ""
def _event_chunk(event: dict, state: dict) -> Optional[GenericStreamingChunk]:
"""Translate a Responses API SSE event into LiteLLM's generic chunk."""
etype = event.get("type", "")
if etype == "response.output_text.delta":
return GenericStreamingChunk(
text=event.get("delta", ""),
is_finished=False,
finish_reason="",
usage=None,
)
if etype == "response.output_item.added":
item = event.get("item", {})
if item.get("type") == "function_call":
index = event.get("output_index", 0)
state["tool_calls"] = True
state.setdefault("tools", {})[index] = item
return GenericStreamingChunk(
text="",
tool_use={
"index": index,
"id": item.get("call_id"),
"type": "function",
"function": {
"name": item.get("name", ""),
"arguments": item.get("arguments", ""),
},
},
is_finished=False,
finish_reason="",
usage=None,
)
if etype == "response.function_call_arguments.delta":
index = event.get("output_index", 0)
return GenericStreamingChunk(
text="",
tool_use={
"index": index,
"id": None,
"type": "function",
"function": {"name": None, "arguments": event.get("delta", "")},
},
is_finished=False,
finish_reason="",
usage=None,
)
if etype in ("response.completed", "response.done"):
usage_data = event.get("response", {}).get("usage", {})
return GenericStreamingChunk(
text="",
is_finished=True,
finish_reason="tool_calls" if state.get("tool_calls") else "stop",
usage={
"prompt_tokens": usage_data.get("input_tokens", 0),
"completion_tokens": usage_data.get("output_tokens", 0),
"total_tokens": usage_data.get("input_tokens", 0)
+ usage_data.get("output_tokens", 0),
},
)
return None
def _iter_sse(response: httpx.Response) -> Iterator[GenericStreamingChunk]:
state: dict = {}
for line in response.iter_lines():
if not line or line.startswith(":") or not line.startswith("data: "):
continue
@@ -166,28 +292,15 @@ def _iter_sse(response: httpx.Response) -> Iterator[GenericStreamingChunk]:
event = json.loads(raw)
except json.JSONDecodeError:
continue
etype = event.get("type", "")
if etype == "response.output_text.delta":
yield GenericStreamingChunk(
text=event.get("delta", ""),
is_finished=False,
finish_reason="",
usage=None,
)
elif etype in ("response.completed", "response.done"):
usage_data = event.get("response", {}).get("usage", {})
yield GenericStreamingChunk(
text="",
is_finished=True,
finish_reason="stop",
usage={"prompt_tokens": usage_data.get("input_tokens", 0),
"completion_tokens": usage_data.get("output_tokens", 0),
"total_tokens": usage_data.get("input_tokens", 0) + usage_data.get("output_tokens", 0)},
)
chunk = _event_chunk(event, state)
if chunk is not None:
yield chunk
if chunk is not None and chunk["is_finished"]:
break
async def _aiter_sse(response: httpx.Response) -> AsyncIterator[GenericStreamingChunk]:
state: dict = {}
async for line in response.aiter_lines():
if not line or line.startswith(":") or not line.startswith("data: "):
continue
@@ -198,24 +311,10 @@ async def _aiter_sse(response: httpx.Response) -> AsyncIterator[GenericStreaming
event = json.loads(raw)
except json.JSONDecodeError:
continue
etype = event.get("type", "")
if etype == "response.output_text.delta":
yield GenericStreamingChunk(
text=event.get("delta", ""),
is_finished=False,
finish_reason="",
usage=None,
)
elif etype in ("response.completed", "response.done"):
usage_data = event.get("response", {}).get("usage", {})
yield GenericStreamingChunk(
text="",
is_finished=True,
finish_reason="stop",
usage={"prompt_tokens": usage_data.get("input_tokens", 0),
"completion_tokens": usage_data.get("output_tokens", 0),
"total_tokens": usage_data.get("input_tokens", 0) + usage_data.get("output_tokens", 0)},
)
chunk = _event_chunk(event, state)
if chunk is not None:
yield chunk
if chunk is not None and chunk["is_finished"]:
break
@@ -247,6 +346,7 @@ class CodexProvider(CustomLLM):
model_name = model.split("/", 1)[-1] if "/" in model else model
body = _build_body(model_name, messages, optional_params, stream=True)
text_parts = []
tool_calls: dict = {}
prompt_tokens = completion_tokens = 0
for attempt in range(2):
try:
@@ -259,6 +359,7 @@ class CodexProvider(CustomLLM):
resp.raise_for_status()
for chunk in _iter_sse(resp):
text_parts.append(chunk["text"])
_accumulate_tool_chunk(tool_calls, chunk)
if chunk.get("usage"):
prompt_tokens = chunk["usage"].get("prompt_tokens", 0)
completion_tokens = chunk["usage"].get("completion_tokens", 0)
@@ -266,11 +367,18 @@ class CodexProvider(CustomLLM):
except httpx.HTTPStatusError as e:
if attempt == 0 and e.response.status_code == 401:
text_parts.clear()
tool_calls.clear()
continue
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
model_response.choices[0].message.content = "".join(text_parts) # type: ignore
model_response.choices[0].finish_reason = "stop" # type: ignore
if tool_calls:
model_response.choices[0].message.tool_calls = [ # type: ignore
tool_calls[index] for index in sorted(tool_calls)
]
model_response.choices[0].finish_reason = ( # type: ignore
"tool_calls" if tool_calls else "stop"
)
model_response.model = _resolve_model(model_name)
model_response.usage = Usage(
prompt_tokens=prompt_tokens,
@@ -338,6 +446,7 @@ class CodexProvider(CustomLLM):
model_name = model.split("/", 1)[-1] if "/" in model else model
body = _build_body(model_name, messages, optional_params, stream=True)
text_parts = []
tool_calls: dict = {}
prompt_tokens = completion_tokens = 0
for attempt in range(2):
try:
@@ -350,6 +459,7 @@ class CodexProvider(CustomLLM):
resp.raise_for_status()
async for chunk in _aiter_sse(resp):
text_parts.append(chunk["text"])
_accumulate_tool_chunk(tool_calls, chunk)
if chunk.get("usage"):
prompt_tokens = chunk["usage"].get("prompt_tokens", 0)
completion_tokens = chunk["usage"].get("completion_tokens", 0)
@@ -357,11 +467,18 @@ class CodexProvider(CustomLLM):
except httpx.HTTPStatusError as e:
if attempt == 0 and e.response.status_code == 401:
text_parts.clear()
tool_calls.clear()
continue
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
model_response.choices[0].message.content = "".join(text_parts) # type: ignore
model_response.choices[0].finish_reason = "stop" # type: ignore
if tool_calls:
model_response.choices[0].message.tool_calls = [ # type: ignore
tool_calls[index] for index in sorted(tool_calls)
]
model_response.choices[0].finish_reason = ( # type: ignore
"tool_calls" if tool_calls else "stop"
)
model_response.model = _resolve_model(model_name)
model_response.usage = Usage(
prompt_tokens=prompt_tokens,