feat(litellm): añadir gateway de IA con proveedor Codex personalizado

Proxy LiteLLM que enruta peticiones a DeepSeek y a los modelos Codex
de OpenAI (gpt-5.5, gpt-5.4, gpt-5.3-codex, gpt-5.4-mini, gpt-5.2)
usando los tokens OAuth del CLI de Codex en lugar de la API estándar.

- codex_provider.py: proveedor CustomLLM que llama a
  chatgpt.com/backend-api/codex/responses con renovación automática
  de tokens JWT
- config.yaml: model_list con prefijo cx- para evitar interceptación
  por litellm.open_ai_chat_completion_models
- docker-compose.yml y stack.env: configuración del stack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduardo David Paredes Vara
2026-05-25 05:49:34 +00:00
parent 7b4ed3e05e
commit 975554892a
4 changed files with 509 additions and 0 deletions
+371
View File
@@ -0,0 +1,371 @@
"""
LiteLLM custom provider — OpenAI Codex via chatgpt.com/backend-api.
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 json
import os
import time
from typing import AsyncIterator, Callable, Iterator, Optional, Union
import httpx
import litellm
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
AUTH_FILE = os.environ.get("CODEX_AUTH_FILE", "/root/.codex/auth.json")
# Self-register so the Router can validate this provider before custom_llm_setup() runs
if "codex" not in litellm.provider_list:
litellm.provider_list.append("codex")
if "codex" not in litellm._custom_providers:
litellm._custom_providers.append("codex")
TOKEN_URL = "https://auth.openai.com/oauth/token"
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
API_URL = "https://chatgpt.com/backend-api/codex/responses"
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def _load_auth() -> dict:
with open(AUTH_FILE) as f:
return json.load(f)
def _save_auth(data: dict) -> None:
with open(AUTH_FILE, "w") as f:
json.dump(data, f, indent=2)
def _jwt_exp(token: str) -> int:
try:
payload = token.split(".")[1]
payload += "=" * (4 - len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload)).get("exp", 0)
except Exception:
return 0
def _refresh(auth: dict) -> dict:
resp = httpx.post(
TOKEN_URL,
json={
"client_id": CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": auth["tokens"]["refresh_token"],
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
auth["tokens"]["access_token"] = data["access_token"]
if "refresh_token" in data:
auth["tokens"]["refresh_token"] = data["refresh_token"]
auth["last_refresh"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
_save_auth(auth)
return auth
def _get_auth() -> dict:
auth = _load_auth()
if time.time() > _jwt_exp(auth["tokens"]["access_token"]) - 60:
auth = _refresh(auth)
return auth
# ---------------------------------------------------------------------------
# Request / response helpers
# ---------------------------------------------------------------------------
def _split_messages(messages: list) -> tuple[str, list]:
"""Extract system/developer messages as instructions; return (instructions, input_messages)."""
instructions_parts = []
input_msgs = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role in ("system", "developer"):
instructions_parts.append(content)
else:
input_msgs.append({"role": role, "content": content})
return "\n\n".join(instructions_parts), input_msgs
def _resolve_model(model: str) -> str:
"""Translate internal cx-* names back to the real Codex API model names (gpt-*)."""
if model.startswith("cx-"):
return "gpt-" + model[3:]
return model
def _build_body(model: str, messages: list, optional_params: dict, stream: bool) -> dict:
instructions, input_msgs = _split_messages(messages)
body: dict = {
"model": _resolve_model(model),
"instructions": instructions or "You are a helpful assistant.",
"input": input_msgs,
"stream": True, # API only supports streaming; non-stream calls aggregate internally
"store": False,
"reasoning": {"effort": optional_params.get("reasoning_effort", "medium")},
}
return body
def _auth_headers(auth: dict) -> dict:
return {
"Authorization": f"Bearer {auth['tokens']['access_token']}",
"ChatGPT-Account-Id": auth["tokens"]["account_id"],
"Content-Type": "application/json",
"User-Agent": "opencode/1.0",
"originator": "opencode",
}
def _iter_sse(response: httpx.Response) -> Iterator[GenericStreamingChunk]:
for line in response.iter_lines():
if not line or line.startswith(":") or not line.startswith("data: "):
continue
raw = line[6:]
if raw == "[DONE]":
break
try:
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)},
)
break
async def _aiter_sse(response: httpx.Response) -> AsyncIterator[GenericStreamingChunk]:
async for line in response.aiter_lines():
if not line or line.startswith(":") or not line.startswith("data: "):
continue
raw = line[6:]
if raw == "[DONE]":
break
try:
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)},
)
break
# ---------------------------------------------------------------------------
# LiteLLM CustomLLM subclass
# ---------------------------------------------------------------------------
class CodexProvider(CustomLLM):
def completion(
self,
model: str,
messages: list,
api_base: str,
custom_prompt_dict: dict,
model_response: ModelResponse,
print_verbose: Callable,
encoding,
api_key,
logging_obj,
optional_params: dict,
acompletion=None,
litellm_params=None,
logger_fn=None,
headers={},
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[HTTPHandler] = None,
) -> ModelResponse:
model_name = model.split("/", 1)[-1] if "/" in model else model
try:
auth = _get_auth()
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
body = _build_body(model_name, messages, optional_params, stream=True)
text_parts = []
prompt_tokens = completion_tokens = 0
try:
with httpx.Client(timeout=120) as c:
with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
for chunk in _iter_sse(resp):
text_parts.append(chunk["text"])
if chunk.get("usage"):
prompt_tokens = chunk["usage"].get("prompt_tokens", 0)
completion_tokens = chunk["usage"].get("completion_tokens", 0)
except httpx.HTTPStatusError as e:
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
model_response.model = _resolve_model(model_name)
model_response.usage = Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)
return model_response
def streaming(
self,
model: str,
messages: list,
api_base: str,
custom_prompt_dict: dict,
model_response: ModelResponse,
print_verbose: Callable,
encoding,
api_key,
logging_obj,
optional_params: dict,
acompletion=None,
litellm_params=None,
logger_fn=None,
headers={},
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[HTTPHandler] = None,
) -> Iterator[GenericStreamingChunk]:
model_name = model.split("/", 1)[-1] if "/" in model else model
try:
auth = _get_auth()
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
body = _build_body(model_name, messages, optional_params, stream=True)
try:
with httpx.Client(timeout=120) as c:
with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
yield from _iter_sse(resp)
except httpx.HTTPStatusError as e:
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
async def acompletion(
self,
model: str,
messages: list,
api_base: str,
custom_prompt_dict: dict,
model_response: ModelResponse,
print_verbose: Callable,
encoding,
api_key,
logging_obj,
optional_params: dict,
acompletion=None,
litellm_params=None,
logger_fn=None,
headers={},
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[AsyncHTTPHandler] = None,
) -> ModelResponse:
model_name = model.split("/", 1)[-1] if "/" in model else model
try:
auth = _get_auth()
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
body = _build_body(model_name, messages, optional_params, stream=True)
text_parts = []
prompt_tokens = completion_tokens = 0
try:
async with httpx.AsyncClient(timeout=120) as c:
async with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
async for chunk in _aiter_sse(resp):
text_parts.append(chunk["text"])
if chunk.get("usage"):
prompt_tokens = chunk["usage"].get("prompt_tokens", 0)
completion_tokens = chunk["usage"].get("completion_tokens", 0)
except httpx.HTTPStatusError as e:
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
model_response.model = _resolve_model(model_name)
model_response.usage = Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)
return model_response
async def astreaming(
self,
model: str,
messages: list,
api_base: str,
custom_prompt_dict: dict,
model_response: ModelResponse,
print_verbose: Callable,
encoding,
api_key,
logging_obj,
optional_params: dict,
acompletion=None,
litellm_params=None,
logger_fn=None,
headers={},
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[AsyncHTTPHandler] = None,
) -> AsyncIterator[GenericStreamingChunk]:
model_name = model.split("/", 1)[-1] if "/" in model else model
try:
auth = _get_auth()
except Exception as e:
raise CustomLLMError(status_code=401, message=f"Codex auth error: {e}")
body = _build_body(model_name, messages, optional_params, stream=True)
try:
async with httpx.AsyncClient(timeout=120) as c:
async with c.stream("POST", API_URL, json=body, headers=_auth_headers(auth)) as resp:
resp.raise_for_status()
async for chunk in _aiter_sse(resp):
yield chunk
except httpx.HTTPStatusError as e:
raise CustomLLMError(status_code=e.response.status_code, message=str(e))
codex_provider = CodexProvider()
+47
View File
@@ -0,0 +1,47 @@
model_list:
# DeepSeek via API directa
- model_name: "deepseek-chat"
litellm_params:
model: "deepseek/deepseek-chat"
api_key: "os.environ/DEEPSEEK_API_KEY"
- model_name: "deepseek-reasoner"
litellm_params:
model: "deepseek/deepseek-reasoner"
api_key: "os.environ/DEEPSEEK_API_KEY"
# OpenAI Codex via chatgpt.com/backend-api (auth auto-renovada)
# Internal model names use "cx-" prefix to avoid clash with litellm.open_ai_chat_completion_models.
# The provider strips "cx-" and prepends "gpt-" to get the actual Codex API model name.
- model_name: "gpt-5.5"
litellm_params:
model: "codex/cx-5.5"
custom_llm_provider: "codex"
- model_name: "gpt-5.4"
litellm_params:
model: "codex/cx-5.4"
custom_llm_provider: "codex"
- model_name: "gpt-5.3-codex"
litellm_params:
model: "codex/cx-5.3-codex"
custom_llm_provider: "codex"
- model_name: "gpt-5.4-mini"
litellm_params:
model: "codex/cx-5.4-mini"
custom_llm_provider: "codex"
- model_name: "gpt-5.2"
litellm_params:
model: "codex/cx-5.2"
custom_llm_provider: "codex"
litellm_settings:
drop_params: true
# custom_provider_map va dentro de litellm_settings para que se cargue
# antes de que el router inicialice el model_list
custom_provider_map:
- provider: "codex"
custom_handler: codex_provider.codex_provider
+69
View File
@@ -0,0 +1,69 @@
services:
litellm:
image: ghcr.io/berriai/litellm:main-latest
pull_policy: always
container_name: litellm
restart: unless-stopped
command: ["--config", "/app/config.yaml"]
environment:
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY}
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
LITELLM_SALT_KEY: ${LITELLM_SALT_KEY}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@litellm-db:5432/${POSTGRES_DB}
STORE_MODEL_IN_DB: "True"
CODEX_AUTH_FILE: /root/.codex/auth.json
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./codex_provider.py:/app/codex_provider.py:ro
- /home/felidae/.codex/auth.json:/root/.codex/auth.json:rw
depends_on:
- litellm-db
networks:
- litellm
- proxy
labels:
traefik.enable: "true"
traefik.docker.network: "proxy"
# API — sin auth, accesible con LITELLM_MASTER_KEY
traefik.http.routers.litellm-api.rule: "Host(`llm.sherlockhomeless.net`) && (PathPrefix(`/v1`) || PathPrefix(`/health`) || PathPrefix(`/key`) || PathPrefix(`/model`))"
traefik.http.routers.litellm-api.entrypoints: "websecure"
traefik.http.routers.litellm-api.tls: "true"
traefik.http.routers.litellm-api.tls.certresolver: "letsencrypt"
traefik.http.routers.litellm-api.middlewares: "crowdsec-bouncer@file"
traefik.http.routers.litellm-api.service: "litellm"
traefik.http.routers.litellm-api.priority: "20"
# UI — protegida con Authentik
traefik.http.routers.litellm-ui.rule: "Host(`llm.sherlockhomeless.net`)"
traefik.http.routers.litellm-ui.entrypoints: "websecure"
traefik.http.routers.litellm-ui.tls: "true"
traefik.http.routers.litellm-ui.tls.certresolver: "letsencrypt"
traefik.http.routers.litellm-ui.middlewares: "crowdsec-bouncer@file,ths-authentik@docker"
traefik.http.routers.litellm-ui.service: "litellm"
traefik.http.routers.litellm-ui.priority: "10"
traefik.http.services.litellm.loadbalancer.server.port: "4000"
homepage.group: "Automatizacion y desarrollo"
homepage.name: "LiteLLM"
homepage.href: "https://llm.sherlockhomeless.net"
homepage.description: "Proxy de APIs LLM (DeepSeek, OpenAI...)"
homepage.icon: "https://docs.litellm.ai/img/favicon.ico"
homepage.siteMonitor: "https://llm.sherlockhomeless.net"
litellm-db:
image: postgres:16
pull_policy: always
container_name: litellm-pg
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ${LITELLM_DB_DATA_PATH}:/var/lib/postgresql/data:Z
networks:
- litellm
networks:
litellm:
driver: bridge
proxy:
external: true
+22
View File
@@ -0,0 +1,22 @@
##### DeepSeek #####
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
##### LiteLLM - Autenticación #####
# Esta es la key que usarás en OpenCode (y cualquier cliente)
# Genera una nueva con: openssl rand -hex 16
LITELLM_MASTER_KEY=sk-change-me-generate-with-openssl-rand-hex-16
# Salt interno (no lo uses como API key)
# Genera uno nuevo con: openssl rand -hex 32
LITELLM_SALT_KEY=change-me-salt-32-chars-minimum-here
##### PostgreSQL interno #####
POSTGRES_USER=litellm
POSTGRES_PASSWORD=change-me
POSTGRES_DB=litellm
# Ruta en el host para los datos de Postgres
LITELLM_DB_DATA_PATH=/opt/litellm/postgres
##### Traefik #####
LITELLM_DOMAIN=llm.sherlockhomeless.net