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:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user