diff --git a/litellm/README.md b/litellm/README.md index bf902a9..9a3bdee 100644 --- a/litellm/README.md +++ b/litellm/README.md @@ -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` diff --git a/litellm/codex_provider.py b/litellm/codex_provider.py index e8c3dab..6e524a5 100644 --- a/litellm/codex_provider.py +++ b/litellm/codex_provider.py @@ -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() diff --git a/litellm/config.yaml b/litellm/config.yaml index cb187bf..3a1b257 100644 --- a/litellm/config.yaml +++ b/litellm/config.yaml @@ -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 diff --git a/litellm/test_codex_provider.py b/litellm/test_codex_provider.py new file mode 100644 index 0000000..ff96e90 --- /dev/null +++ b/litellm/test_codex_provider.py @@ -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()