feat(litellm): support Codex function tools
This commit is contained in:
+10
-7
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user