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:
|
||||
|
||||
- `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.5`
|
||||
- `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.
|
||||
"""
|
||||
import base64
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
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.types.utils import GenericStreamingChunk, Usage
|
||||
from litellm.utils import ModelResponse
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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]:
|
||||
"""Convert OpenAI chat messages into Responses API input items."""
|
||||
instructions_parts = []
|
||||
@@ -119,10 +139,12 @@ def _split_messages(messages: list) -> tuple[str, list]:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
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"):
|
||||
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"]:
|
||||
if not isinstance(tool_call, dict):
|
||||
tool_call = tool_call.model_dump()
|
||||
@@ -140,7 +162,7 @@ def _split_messages(messages: list) -> tuple[str, list]:
|
||||
"output": content,
|
||||
})
|
||||
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
|
||||
|
||||
|
||||
@@ -229,16 +251,26 @@ def _event_chunk(event: dict, state: dict) -> Optional[GenericStreamingChunk]:
|
||||
finish_reason="",
|
||||
usage=None,
|
||||
)
|
||||
if etype == "response.output_item.added":
|
||||
if etype in ("response.output_item.added", "response.output_item.done"):
|
||||
item = event.get("item", {})
|
||||
if item.get("type") == "function_call":
|
||||
index = event.get("output_index", 0)
|
||||
output_index = event.get("output_index", 0)
|
||||
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(
|
||||
text="",
|
||||
tool_use={
|
||||
"index": index,
|
||||
"index": tools[output_index],
|
||||
"id": item.get("call_id"),
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -250,20 +282,6 @@ def _event_chunk(event: dict, state: dict) -> Optional[GenericStreamingChunk]:
|
||||
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(
|
||||
@@ -527,3 +545,29 @@ class CodexProvider(CustomLLM):
|
||||
|
||||
|
||||
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:
|
||||
# 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.
|
||||
- model_name: "gpt-5.6-sol"
|
||||
litellm_params:
|
||||
@@ -55,6 +78,7 @@ model_list:
|
||||
num_retries: 0
|
||||
|
||||
litellm_settings:
|
||||
callbacks: ["codex_provider.codex_audio_auth"]
|
||||
drop_params: true
|
||||
# custom_provider_map va dentro de litellm_settings para que se cargue
|
||||
# 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