feat(litellm): add model aliases and audio auth, fix streamed tool calls
This commit is contained in:
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user