feat(litellm): add model aliases and audio auth, fix streamed tool calls

This commit is contained in:
Eduardo David Paredes Vara
2026-10-18 22:03:04 +00:00
parent 07ec97ad45
commit 93088f47e2
4 changed files with 172 additions and 21 deletions
+2
View File
@@ -149,6 +149,8 @@ son necesarias.
Codex mediante el proveedor personalizado: Codex mediante el proveedor personalizado:
- `gpt-6.1-sol` (razonamiento `low`)
- `gpt-6-astra` (alias de compatibilidad: usa `gpt-6.1-sol` con `low`)
- `gpt-5.6-sol` - `gpt-5.6-sol`
- `gpt-5.5` - `gpt-5.5`
- `gpt-5.3-codex` - `gpt-5.3-codex`
+65 -21
View File
@@ -5,6 +5,7 @@ Auth tokens are read from CODEX_AUTH_FILE (default /root/.codex/auth.json)
and auto-refreshed using the OpenAI OAuth endpoint before expiry. and auto-refreshed using the OpenAI OAuth endpoint before expiry.
""" """
import base64 import base64
import asyncio
import json import json
import os import os
import time import time
@@ -17,6 +18,7 @@ from litellm.llms.custom_llm import CustomLLM, CustomLLMError
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.types.utils import GenericStreamingChunk, Usage from litellm.types.utils import GenericStreamingChunk, Usage
from litellm.utils import ModelResponse from litellm.utils import ModelResponse
from litellm.integrations.custom_logger import CustomLogger
AUTH_FILE = os.environ.get("CODEX_AUTH_FILE", "/root/.codex/auth.json") AUTH_FILE = os.environ.get("CODEX_AUTH_FILE", "/root/.codex/auth.json")
@@ -111,6 +113,24 @@ def _get_auth(force: bool = False) -> dict:
# Request / response helpers # Request / response helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _responses_content(content, role: str):
"""Translate Chat Completions content blocks into Responses content blocks."""
if not isinstance(content, list):
return content
result = []
for part in content:
part = dict(part)
if part.get("type") == "text":
part["type"] = "output_text" if role == "assistant" else "input_text"
elif part.get("type") == "image_url":
image = part["image_url"]
part = {"type": "input_image", "image_url": image["url"]}
if image.get("detail"):
part["detail"] = image["detail"]
result.append(part)
return result
def _split_messages(messages: list) -> tuple[str, list]: def _split_messages(messages: list) -> tuple[str, list]:
"""Convert OpenAI chat messages into Responses API input items.""" """Convert OpenAI chat messages into Responses API input items."""
instructions_parts = [] instructions_parts = []
@@ -119,10 +139,12 @@ def _split_messages(messages: list) -> tuple[str, list]:
role = msg.get("role", "user") role = msg.get("role", "user")
content = msg.get("content", "") content = msg.get("content", "")
if role in ("system", "developer"): if role in ("system", "developer"):
instructions_parts.append(content) instructions_parts.append(content if isinstance(content, str) else "\n".join(
part.get("text", "") for part in content
))
elif role == "assistant" and msg.get("tool_calls"): elif role == "assistant" and msg.get("tool_calls"):
if content: if content:
input_msgs.append({"role": role, "content": content}) input_msgs.append({"role": role, "content": _responses_content(content, role)})
for tool_call in msg["tool_calls"]: for tool_call in msg["tool_calls"]:
if not isinstance(tool_call, dict): if not isinstance(tool_call, dict):
tool_call = tool_call.model_dump() tool_call = tool_call.model_dump()
@@ -140,7 +162,7 @@ def _split_messages(messages: list) -> tuple[str, list]:
"output": content, "output": content,
}) })
else: else:
input_msgs.append({"role": role, "content": content}) input_msgs.append({"role": role, "content": _responses_content(content, role)})
return "\n\n".join(instructions_parts), input_msgs return "\n\n".join(instructions_parts), input_msgs
@@ -229,16 +251,26 @@ def _event_chunk(event: dict, state: dict) -> Optional[GenericStreamingChunk]:
finish_reason="", finish_reason="",
usage=None, usage=None,
) )
if etype == "response.output_item.added": if etype in ("response.output_item.added", "response.output_item.done"):
item = event.get("item", {}) item = event.get("item", {})
if item.get("type") == "function_call": if item.get("type") == "function_call":
index = event.get("output_index", 0) output_index = event.get("output_index", 0)
state["tool_calls"] = True state["tool_calls"] = True
state.setdefault("tools", {})[index] = item tools = state.setdefault("tools", {})
if output_index not in tools:
tools[output_index] = len(tools)
# Publish a complete call once. Argument-only deltas can be dropped
# by downstream stream handling, leaving clients with invalid JSON.
if etype == "response.output_item.added":
return None
emitted = state.setdefault("emitted_tools", set())
if output_index in emitted:
return None
emitted.add(output_index)
return GenericStreamingChunk( return GenericStreamingChunk(
text="", text="",
tool_use={ tool_use={
"index": index, "index": tools[output_index],
"id": item.get("call_id"), "id": item.get("call_id"),
"type": "function", "type": "function",
"function": { "function": {
@@ -250,20 +282,6 @@ def _event_chunk(event: dict, state: dict) -> Optional[GenericStreamingChunk]:
finish_reason="", finish_reason="",
usage=None, 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"): if etype in ("response.completed", "response.done"):
usage_data = event.get("response", {}).get("usage", {}) usage_data = event.get("response", {}).get("usage", {})
return GenericStreamingChunk( return GenericStreamingChunk(
@@ -527,3 +545,29 @@ class CodexProvider(CustomLLM):
codex_provider = CodexProvider() codex_provider = CodexProvider()
class CodexAudioAuth(CustomLogger):
"""Supply fresh OAuth credentials to explicitly opted-in native audio routes.
CustomLLM has no transcription adapter. Let LiteLLM's native OpenAI adapter
handle multipart audio, while resolving the credential after routing.
"""
async def async_pre_call_deployment_hook(self, kwargs, call_type=None):
if kwargs.get("api_key") != "codex-oauth-runtime":
return None
if (kwargs.get("api_base") != "https://api.openai.com/v1"
or kwargs.get("model") not in (
"openai/gpt-4o-mini-transcribe", "gpt-4o-mini-transcribe")):
raise ValueError("Codex audio OAuth is restricted to the configured transcription endpoint")
auth = await asyncio.to_thread(_get_auth)
kwargs["api_key"] = auth["tokens"]["access_token"]
kwargs["extra_headers"] = {
**(kwargs.get("extra_headers") or {}),
"ChatGPT-Account-Id": auth["tokens"]["account_id"],
}
return kwargs
codex_audio_auth = CodexAudioAuth()
+24
View File
@@ -1,4 +1,27 @@
model_list: model_list:
# Native multipart transcription, authenticated at call time by the callback.
- model_name: "gpt-4o-mini-transcribe"
litellm_params:
model: "openai/gpt-4o-mini-transcribe"
api_base: "https://api.openai.com/v1"
api_key: "codex-oauth-runtime"
model_info:
mode: audio_transcription
# GPT-6.1 Sol with low reasoning for all gateway users.
- model_name: "gpt-6.1-sol"
litellm_params:
model: "codex/cx-6.1-sol"
custom_llm_provider: "codex"
reasoning_effort: "low"
# Compatibility alias for existing clients and saved chats.
- model_name: "gpt-6-astra"
litellm_params:
model: "codex/cx-6.1-sol"
custom_llm_provider: "codex"
reasoning_effort: "low"
# Default OpenAI Codex model via chatgpt.com/backend-api. # Default OpenAI Codex model via chatgpt.com/backend-api.
- model_name: "gpt-5.6-sol" - model_name: "gpt-5.6-sol"
litellm_params: litellm_params:
@@ -55,6 +78,7 @@ model_list:
num_retries: 0 num_retries: 0
litellm_settings: litellm_settings:
callbacks: ["codex_provider.codex_audio_auth"]
drop_params: true drop_params: true
# custom_provider_map va dentro de litellm_settings para que se cargue # custom_provider_map va dentro de litellm_settings para que se cargue
# antes de que el router inicialice el model_list # antes de que el router inicialice el model_list
+81
View File
@@ -0,0 +1,81 @@
"""Run inside the LiteLLM runtime: python < test_codex_provider.py."""
import unittest
from unittest.mock import patch
from codex_provider import _accumulate_tool_chunk, _event_chunk, _split_messages
from codex_provider import CodexAudioAuth
class CodexAudioAuthTests(unittest.IsolatedAsyncioTestCase):
async def test_resolves_credential_only_for_opted_in_audio_endpoint(self):
hook = CodexAudioAuth()
untouched = {"model": "gpt-6.1-sol", "api_key": "other"}
with patch("codex_provider._get_auth") as auth:
self.assertIsNone(await hook.async_pre_call_deployment_hook(untouched))
auth.assert_not_called()
for endpoint in ("https://untrusted.example/v1", "https://api.openai.com/v1"):
with self.assertRaises(ValueError):
await hook.async_pre_call_deployment_hook({
"api_base": endpoint, "model": "unrelated-model",
"api_key": "codex-oauth-runtime"})
request = {"api_base": "https://api.openai.com/v1",
"model": "openai/gpt-4o-mini-transcribe",
"api_key": "codex-oauth-runtime"}
with patch("codex_provider._get_auth", return_value={"tokens": {
"access_token": "test-token", "account_id": "test-account"}}):
result = await hook.async_pre_call_deployment_hook(request)
self.assertEqual(result["api_key"], "test-token")
self.assertEqual(result["extra_headers"]["ChatGPT-Account-Id"], "test-account")
class CodexStreamingTests(unittest.TestCase):
def test_parallel_calls_keep_complete_json_and_dense_indices(self):
state, calls = {}, {}
items = [
{"type": "function_call", "call_id": "call_a", "name": "fetch",
"arguments": '{"url":"https://example.com/a"}'},
{"type": "function_call", "call_id": "call_b", "name": "fetch",
"arguments": '{"url":"https://example.com/b"}'},
]
# Responses output indices include reasoning/text; tool indices must not.
for output_index, item in zip((1, 3), items):
self.assertIsNone(_event_chunk({
"type": "response.output_item.added", "output_index": output_index,
"item": {**item, "arguments": ""},
}, state))
for output_index, item in zip((3, 1), reversed(items)):
self.assertIsNone(_event_chunk({
"type": "response.function_call_arguments.delta",
"output_index": output_index, "delta": item["arguments"],
}, state))
event = {"type": "response.output_item.done",
"output_index": output_index, "item": item}
chunk = _event_chunk(event, state)
_accumulate_tool_chunk(calls, chunk)
self.assertIsNone(_event_chunk(event, state))
self.assertEqual(sorted(calls), [0, 1])
for index, item in enumerate(items):
self.assertEqual(calls[index]["id"], item["call_id"])
self.assertEqual(calls[index]["function"], {
"name": item["name"], "arguments": item["arguments"],
})
finish = _event_chunk({"type": "response.completed"}, state)
self.assertEqual(finish["finish_reason"], "tool_calls")
def test_text_stays_streamed(self):
chunk = _event_chunk({"type": "response.output_text.delta", "delta": "OK"}, {})
self.assertEqual(chunk["text"], "OK")
self.assertFalse(chunk["is_finished"])
def test_chat_content_blocks_are_converted_without_mutating_input(self):
messages = [{"role": "system", "content": [{"type": "text", "text": "Help"}]},
{"role": "user", "content": [{"type": "text", "text": "Hi"}]}]
instructions, items = _split_messages(messages)
self.assertEqual(instructions, "Help")
self.assertEqual(items[0]["content"], [{"type": "input_text", "text": "Hi"}])
self.assertEqual(messages[1]["content"][0]["type"], "text")
if __name__ == "__main__":
unittest.main()