feat(litellm): add Claude Code OAuth model

This commit is contained in:
Eduardo David Paredes Vara
2026-08-16 21:45:05 +00:00
parent 4ab365deda
commit 4935473f3c
7 changed files with 602 additions and 13 deletions
+169 -13
View File
@@ -24,12 +24,17 @@ Open WebUI / n8n / Paperless AI / clientes externos
| +----> Codex backend API
| +----> auth.json de Codex
|
+----> claude_provider.py
| |
| +----> api.anthropic.com/v1/messages
| +----> .credentials.json de Claude Code
|
+----> PostgreSQL 16
```
Los consumidores nunca reciben una credencial de DeepSeek ni los tokens de
Codex. Sólo conocen la URL de LiteLLM, una clave de LiteLLM y un alias de
modelo.
Codex o Claude. Sólo conocen la URL de LiteLLM, una clave de LiteLLM y un alias
de modelo.
## Contenido de la carpeta
@@ -38,8 +43,9 @@ modelo.
| `docker-compose.yml` | Define LiteLLM, PostgreSQL, redes y montajes. |
| `.env` | Configuración real local. Contiene secretos y no está versionado. |
| `stack.env` | Plantilla histórica versionada. No usar para desplegar. |
| `config.yaml` | Catálogo declarativo de modelos y registro del proveedor Codex. |
| `config.yaml` | Catálogo declarativo de modelos y registro de proveedores personalizados. |
| `codex_provider.py` | Adaptador entre LiteLLM y la API Responses usada por Codex. |
| `claude_provider.py` | Renovación OAuth de Claude y delegación al proveedor Anthropic nativo. |
| `README.md` | Arquitectura, operación, seguridad y diagnóstico. |
## Servicios y persistencia
@@ -60,12 +66,16 @@ Montajes:
```text
./config.yaml -> /app/config.yaml (solo lectura)
./codex_provider.py -> /app/codex_provider.py (solo lectura)
./claude_provider.py -> /app/claude_provider.py (solo lectura)
/home/felidae/.codex/auth.json -> /root/.codex/auth.json (lectura/escritura)
/home/felidae/.claude/.credentials.json
-> /root/.claude/.credentials.json (lectura/escritura)
```
El montaje de `auth.json` es deliberadamente escribible porque el proveedor
renueva el token y persiste la sesión actualizada. Esto también significa que
el contenedor tiene acceso al refresh token de la cuenta de ChatGPT.
Los dos archivos de autenticación son deliberadamente escribibles porque los
proveedores renuevan tokens y persisten las sesiones actualizadas. Esto también
significa que el contenedor tiene acceso a los refresh tokens de ChatGPT y
Claude.ai.
### `litellm-db`
@@ -151,6 +161,13 @@ DeepSeek mediante su API:
- `thsllm-reasoner`
- `thsllm-v4-pro`
Claude mediante la sesión OAuth de Claude Code:
- `claude-sonnet-5`
Aunque la cuenta OAuth puede enumerar otros modelos Anthropic, sólo Sonnet 5
se publica deliberadamente a los consumidores de LiteLLM.
Los nombres `thsllm-*` evitan exponer el proveedor real en los consumidores y
permiten cambiar el backend manteniendo un alias estable.
@@ -158,6 +175,12 @@ Para los modelos Codex se usa internamente el prefijo `cx-`. Esto evita que
LiteLLM los clasifique como modelos OpenAI nativos. `codex_provider.py` vuelve
a convertir `cx-*` en `gpt-*` antes de enviar la petición.
Los modelos Claude usan internamente `claudeoauth/clx-*`. El proveedor
personalizado renueva la sesión, convierte `clx-*` en `claude-*` y delega la
traducción de mensajes, herramientas y respuestas al proveedor Anthropic
nativo de LiteLLM. Los reintentos están desactivados para estos modelos y así
un `429` de la suscripción no multiplica solicitudes contra la misma cuota.
### Catálogo efectivo
LiteLLM puede conservar modelos en PostgreSQL. Por eso `config.yaml` no es
@@ -239,6 +262,91 @@ Para un servicio multiusuario o una automatización crítica, la opción más
estable es evaluar la API pública de OpenAI con una API key dedicada y límites
propios, manteniendo LiteLLM como pasarela.
## Autenticación de Claude
### Endpoint y almacenamiento
Claude Code 2.1.233 está autenticado contra el proveedor first-party mediante
una cuenta Claude.ai Pro. Su caché OAuth está en:
```text
/home/felidae/.claude/.credentials.json
```
El estado se comprueba sin mostrar tokens mediante:
```bash
claude auth status
```
El objeto `claudeAiOauth` contiene `accessToken`, `refreshToken`, `expiresAt`,
`refreshTokenExpiresAt`, scopes y tipo de suscripción. El archivo tiene permisos
`0600` y debe tratarse como una contraseña.
La inferencia usa:
```text
POST https://api.anthropic.com/v1/messages
Authorization: Bearer <access token>
anthropic-beta: claude-code-20250219,oauth-2025-04-20
```
`claude_provider.py` entrega el token al proveedor Anthropic nativo mediante
`ANTHROPIC_AUTH_TOKEN`; no debe pasarlo como `api_key`, porque eso añadiría
`x-api-key` y Anthropic rechaza mezclar ambos esquemas. El adaptador añade la
identidad mínima de Claude Code (beta, sesión, `x-app` y mensaje de sistema) y
delega la traducción de mensajes, herramientas y respuestas a LiteLLM. Si
falta esa identidad, Anthropic puede responder con un `429 rate_limit_error`
genérico aunque la misma cuenta funcione desde el CLI.
### Renovación
Cinco minutos antes de `expiresAt`, el proveedor envía el refresh token a:
```text
POST https://platform.claude.com/v1/oauth/token
```
La petición usa el flujo `refresh_token` y el identificador público del cliente
Claude Code. El refresh token puede rotar; por eso el archivo está montado como
lectura/escritura. La escritura se realiza bajo un lock exclusivo, conserva el
resto del JSON, trunca el contenido anterior y fuerza la sincronización a disco.
El lock evita dos renovaciones concurrentes dentro del contenedor. No coordina
formalmente con una versión de Claude Code que ignore ese mismo advisory lock;
evita ejecutar `claude auth login` o `claude auth logout` mientras LiteLLM esté
renovando la sesión.
### Estado soportado
Anthropic documenta que Claude Code utiliza `api.anthropic.com` y documenta
LiteLLM como gateway, pero la configuración soportada del gateway usa API keys,
Amazon Bedrock o Google Vertex AI. Montar la sesión OAuth de una suscripción
Claude.ai dentro de un proxy es una integración experimental y no está
documentada por Anthropic como arquitectura de producción.
Referencias:
- [Claude Code: configuración de LLM gateways](https://docs.anthropic.com/en/docs/claude-code/llm-gateway)
- [Claude Code: configuración y autenticación](https://docs.anthropic.com/en/docs/claude-code/getting-started)
- [Claude API: modelos](https://platform.claude.com/docs/en/api/models)
Para un servicio multiusuario o crítico debe preferirse una
`ANTHROPIC_API_KEY` dedicada, Bedrock o Vertex AI. Compartir
`LITELLM_MASTER_KEY` concede acceso indirecto a la cuota de la suscripción Pro.
### Compatibilidad
La traducción principal la realiza LiteLLM, por lo que conserva más funciones
Anthropic que el proveedor Codex personalizado. La capa OAuth filtra
`temperature`, porque los modelos Claude 5 actuales lo rechazan aunque algunos
clientes OpenAI-compatible lo envían por defecto.
El streaming personalizado normaliza texto, razón de finalización y uso. Debe
probarse expresamente el streaming con herramientas antes de depender de tool
calls complejas, porque esa normalización puede no conservar todos los deltas
especializados de Anthropic.
## Comportamiento y límites de `codex_provider.py`
El proveedor implementa llamadas síncronas, asíncronas y streaming. El backend
@@ -287,13 +395,13 @@ Model: uno de los alias publicados
```
Usa la URL interna cuando ambos contenedores compartan `proxy`. Para clientes
externos usa HTTPS. Nunca configures el token de Codex como API key de un
consumidor.
externos usa HTTPS. Nunca configures un token de Codex o Claude como API key de
un consumidor.
Flujo de Paperless AI:
```text
Paperless -> Paperless AI -> LiteLLM -> proveedor Codex -> Codex
Paperless -> Paperless AI -> LiteLLM -> proveedor seleccionado -> LLM
```
Open WebUI y n8n siguen el mismo patrón desde la red `proxy`.
@@ -314,8 +422,8 @@ No usar:
docker compose --env-file stack.env up -d
```
Tras modificar `config.yaml` o `codex_provider.py`, recrea LiteLLM para evitar
depender del estado importado por el proceso anterior:
Tras modificar `config.yaml`, `codex_provider.py` o `claude_provider.py`, recrea
LiteLLM para evitar depender del estado importado por el proceso anterior:
```bash
docker compose --env-file .env up -d --force-recreate litellm
@@ -344,6 +452,13 @@ codex login status
stat -c '%a %U:%G %n' /home/felidae/.codex/auth.json
```
Comprobar autenticación de Claude en el host:
```bash
claude auth status
stat -c '%a %U:%G %n' /home/felidae/.claude/.credentials.json
```
Consultar el catálogo desde dentro del contenedor sin imprimir la clave:
```bash
@@ -390,6 +505,36 @@ Comprueba:
Si el refresh token fue revocado, ejecuta `codex login` de nuevo en el host y
recrea el contenedor si fuera necesario.
### `401` o error de autenticación sólo en modelos Claude
Comprueba:
1. Que `claude auth status` muestre `loggedIn: true`.
2. Que `/home/felidae/.claude/.credentials.json` exista y tenga permisos
`0600`.
3. Que el montaje aparezca en `docker inspect litellm`.
4. Que `CLAUDE_AUTH_FILE` sea `/root/.claude/.credentials.json`.
5. Que el access token y el refresh token estén presentes, sin imprimirlos.
Si están vacíos o revocados, ejecuta `claude auth login` en el host. El mismo
archivo es compartido por Claude Code y LiteLLM, por lo que el contenedor verá
la sesión nueva sin copiar secretos al repositorio.
### `429` en modelos Claude
Primero ejecuta una petición mínima con Claude Code. Si el CLI también devuelve
`429`, la suscripción puede haber agotado temporalmente su cuota: espera a la
renovación o usa una API key dedicada. Si el CLI funciona pero LiteLLM falla,
revisa que el adaptador use `ANTHROPIC_AUTH_TOKEN`, no envíe `x-api-key` y
conserve las cabeceras e identidad de Claude Code descritas arriba. Los
reintentos permanecen desactivados para no multiplicar consumo.
### Claude rechaza `temperature`
`claude_provider.py` elimina este parámetro antes de delegar en Anthropic. Si
otro parámetro queda obsoleto en modelos futuros, añádelo al filtrado sólo
después de confirmar el error devuelto por la API.
### Los modelos del YAML no coinciden con `/v1/models`
Revisa el estado persistido en PostgreSQL. Con `STORE_MODEL_IN_DB=True`, los
@@ -420,6 +565,8 @@ Prioridad alta:
4. Añadir bloqueo y escritura atómica a la renovación de `auth.json`.
5. Evaluar una API key dedicada de OpenAI para automatización o uso
multiusuario.
6. Sustituir el OAuth Claude.ai experimental por una `ANTHROPIC_API_KEY`,
Bedrock o Vertex AI para cargas multiusuario o críticas.
Prioridad operativa:
@@ -428,7 +575,8 @@ Prioridad operativa:
`config.yaml`.
3. Probar explícitamente streaming, llamadas no streaming y errores de
renovación tras cada actualización de la imagen.
4. Revisar periódicamente que únicamente LiteLLM monte `auth.json`.
4. Revisar periódicamente que únicamente LiteLLM monte `auth.json` y
`.credentials.json`.
## Backups y recuperación
@@ -436,6 +584,7 @@ Prioridad operativa:
- Mantener estable `LITELLM_SALT_KEY`; perderla puede impedir descifrar datos
persistidos.
- Si se respalda `auth.json`, hacerlo cifrado y con acceso muy restringido.
- Aplicar la misma protección a `.claude/.credentials.json`.
- No restaurar simultáneamente copias distintas de `auth.json` desde varios
hosts: la rotación del refresh token puede invalidar copias anteriores.
- Después de una restauración, validar PostgreSQL, `/v1/models` y un alias de
@@ -449,9 +598,16 @@ Esta sección es una fotografía, no una garantía permanente:
- LiteLLM y PostgreSQL activos desde hacía 13 días.
- LiteLLM versión `1.82.6`.
- `/health/liveliness` respondía HTTP 200.
- `/v1/models` respondía HTTP 200 y publicaba diez modelos.
- `/v1/models` respondía HTTP 200 y, tras limitar el catálogo, publica un único
modelo Claude OAuth: `claude-sonnet-5`.
- PostgreSQL aceptaba conexiones.
- Codex CLI indicaba una sesión de ChatGPT activa.
- Claude Code 2.1.233 indicaba `loggedIn: true`, proveedor first-party y
suscripción Pro.
- La consulta OAuth a `GET /v1/models` de Anthropic respondía HTTP 200.
- Las generaciones normal y streaming mediante LiteLLM respondieron HTTP 200
después de adoptar el esquema Bearer y la identidad requerida por Claude
Code.
- La renovación de `auth.json` había ocurrido correctamente.
- `auth.json` tenía permisos `0600`.
- `.env` tenía permisos `0664`, pendiente de endurecimiento.
+355
View File
@@ -0,0 +1,355 @@
"""LiteLLM custom provider backed by a Claude Code OAuth session.
The provider only manages the OAuth cache and delegates Anthropic request and
response translation to LiteLLM's native ``anthropic`` provider.
This is an experimental integration. Anthropic documents API keys, Bedrock and
Vertex AI as provider credentials for LiteLLM; reusing a Claude.ai subscription
OAuth session in a shared gateway is not an officially supported deployment.
"""
import fcntl
import json
import os
import time
import uuid
from typing import AsyncIterator, Callable, Iterator, Optional, Union
import httpx
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.custom_llm import CustomLLM, CustomLLMError
from litellm.types.utils import GenericStreamingChunk, ModelResponse, Usage
AUTH_FILE = os.environ.get(
"CLAUDE_AUTH_FILE", "/root/.claude/.credentials.json"
)
TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
REFRESH_MARGIN_MS = 5 * 60 * 1000
CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude."
CLAUDE_CODE_BETA = "claude-code-20250219,oauth-2025-04-20"
def _load_auth(file_obj=None) -> dict:
if file_obj is None:
with open(AUTH_FILE, encoding="utf-8") as auth_file:
return json.load(auth_file)
file_obj.seek(0)
return json.load(file_obj)
def _oauth_data(auth: dict) -> dict:
oauth = auth.get("claudeAiOauth")
if not isinstance(oauth, dict):
raise ValueError("claudeAiOauth is missing from the Claude credentials file")
return oauth
def _save_auth(file_obj, auth: dict) -> None:
"""Rewrite the bind-mounted file while holding an advisory exclusive lock."""
file_obj.seek(0)
json.dump(auth, file_obj, indent=2)
file_obj.write("\n")
file_obj.truncate()
file_obj.flush()
os.fsync(file_obj.fileno())
def _refresh_locked(file_obj, auth: dict) -> dict:
oauth = _oauth_data(auth)
refresh_token = oauth.get("refreshToken")
if not refresh_token:
raise ValueError("Claude refresh token is missing; run `claude auth login`")
response = httpx.post(
TOKEN_URL,
json={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
},
headers={"Content-Type": "application/json"},
timeout=30,
)
response.raise_for_status()
data = response.json()
access_token = data.get("access_token")
if not access_token:
raise ValueError("Claude OAuth refresh response did not contain an access token")
now_ms = int(time.time() * 1000)
oauth["accessToken"] = access_token
if data.get("refresh_token"):
oauth["refreshToken"] = data["refresh_token"]
if data.get("expires_in") is not None:
oauth["expiresAt"] = now_ms + int(float(data["expires_in"]) * 1000)
if data.get("refresh_token_expires_in") is not None:
oauth["refreshTokenExpiresAt"] = now_ms + int(
float(data["refresh_token_expires_in"]) * 1000
)
_save_auth(file_obj, auth)
return auth
def _get_access_token() -> str:
"""Return a valid OAuth token, refreshing once under a file lock if needed."""
with open(AUTH_FILE, "r+", encoding="utf-8") as auth_file:
fcntl.flock(auth_file.fileno(), fcntl.LOCK_EX)
try:
auth = _load_auth(auth_file)
oauth = _oauth_data(auth)
expires_at = int(oauth.get("expiresAt") or 0)
if int(time.time() * 1000) >= expires_at - REFRESH_MARGIN_MS:
auth = _refresh_locked(auth_file, auth)
oauth = _oauth_data(auth)
access_token = oauth.get("accessToken")
if not access_token:
raise ValueError("Claude access token is missing; run `claude auth login`")
return access_token
finally:
fcntl.flock(auth_file.fileno(), fcntl.LOCK_UN)
def _resolve_model(model: str) -> str:
model_name = model.split("/", 1)[-1]
if model_name.startswith("clx-"):
return "claude-" + model_name[4:]
return model_name
def _forward_params(optional_params: Optional[dict]) -> dict:
params = dict(optional_params or {})
params.pop("stream", None)
# Current Claude 5 models reject temperature even though OpenAI-compatible
# clients commonly send it by default.
params.pop("temperature", None)
# Authentication and Claude Code identity headers are controlled here.
params.pop("extra_headers", None)
return params
def _request_context(messages: list) -> tuple[list, dict]:
"""Build the request identity required by Claude Code subscription OAuth."""
access_token = _get_access_token()
# LiteLLM's Anthropic adapter only resolves OAuth bearer credentials from
# ANTHROPIC_AUTH_TOKEN. Passing the token as api_key would emit X-Api-Key;
# Anthropic rejects a request that contains both authentication schemes.
os.environ["ANTHROPIC_AUTH_TOKEN"] = access_token
session_id = str(uuid.uuid4())
headers = {
"anthropic-beta": CLAUDE_CODE_BETA,
"anthropic-dangerous-direct-browser-access": "true",
"user-agent": "claude-cli/2.1.233 (external, sdk-cli)",
"x-app": "cli",
"x-claude-code-session-id": session_id,
}
prepared_messages = [
{"role": "system", "content": CLAUDE_CODE_SYSTEM},
*messages,
]
return prepared_messages, headers
def _usage_from_chunk(chunk) -> Optional[dict]:
usage = getattr(chunk, "usage", None)
if usage is None:
return None
prompt = int(getattr(usage, "prompt_tokens", 0) or 0)
completion = int(getattr(usage, "completion_tokens", 0) or 0)
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": int(getattr(usage, "total_tokens", prompt + completion) or 0),
}
def _chunk_to_generic(chunk) -> GenericStreamingChunk:
choice = chunk.choices[0] if getattr(chunk, "choices", None) else None
delta = getattr(choice, "delta", None)
text = getattr(delta, "content", "") if delta is not None else ""
finish_reason = getattr(choice, "finish_reason", None) if choice else None
return GenericStreamingChunk(
text=text or "",
is_finished=finish_reason is not None,
finish_reason=finish_reason or "",
usage=_usage_from_chunk(chunk),
)
def _status_code(error: Exception) -> int:
if isinstance(error, litellm.RateLimitError):
return 429
if isinstance(error, litellm.AuthenticationError):
return 401
if isinstance(error, litellm.PermissionDeniedError):
return 403
if isinstance(error, litellm.BadRequestError):
return 400
if isinstance(error, litellm.NotFoundError):
return 404
return int(getattr(error, "status_code", 500) or 500)
class ClaudeOAuthProvider(CustomLLM):
"""Delegate completions to LiteLLM's native Anthropic implementation."""
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:
try:
prepared_messages, extra_headers = _request_context(messages)
return litellm.completion(
model=f"anthropic/{_resolve_model(model)}",
messages=prepared_messages,
api_key=None,
extra_headers=extra_headers,
timeout=timeout or 120,
drop_params=True,
**_forward_params(optional_params),
)
except CustomLLMError:
raise
except Exception as error:
raise CustomLLMError(
status_code=_status_code(error), message=str(error)
) from error
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]:
try:
prepared_messages, extra_headers = _request_context(messages)
response = litellm.completion(
model=f"anthropic/{_resolve_model(model)}",
messages=prepared_messages,
api_key=None,
extra_headers=extra_headers,
timeout=timeout or 120,
stream=True,
drop_params=True,
**_forward_params(optional_params),
)
for chunk in response:
yield _chunk_to_generic(chunk)
except Exception as error:
raise CustomLLMError(
status_code=_status_code(error), message=str(error)
) from error
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:
try:
prepared_messages, extra_headers = _request_context(messages)
return await litellm.acompletion(
model=f"anthropic/{_resolve_model(model)}",
messages=prepared_messages,
api_key=None,
extra_headers=extra_headers,
timeout=timeout or 120,
drop_params=True,
**_forward_params(optional_params),
)
except CustomLLMError:
raise
except Exception as error:
raise CustomLLMError(
status_code=_status_code(error), message=str(error)
) from error
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]:
try:
prepared_messages, extra_headers = _request_context(messages)
response = await litellm.acompletion(
model=f"anthropic/{_resolve_model(model)}",
messages=prepared_messages,
api_key=None,
extra_headers=extra_headers,
timeout=timeout or 120,
stream=True,
drop_params=True,
**_forward_params(optional_params),
)
async for chunk in response:
yield _chunk_to_generic(chunk)
except Exception as error:
raise CustomLLMError(
status_code=_status_code(error), message=str(error)
) from error
claude_oauth_provider = ClaudeOAuthProvider()
+12
View File
@@ -44,6 +44,16 @@ model_list:
model: "codex/cx-5.2"
custom_llm_provider: "codex"
# Anthropic through the Claude Code OAuth cache. The clx- prefix prevents
# LiteLLM from selecting its native provider before the custom handler can
# refresh the mounted credentials. The handler delegates the actual request
# transformation back to LiteLLM's native Anthropic implementation.
- model_name: "claude-sonnet-5"
litellm_params:
model: "claudeoauth/clx-sonnet-5"
custom_llm_provider: "claudeoauth"
num_retries: 0
litellm_settings:
drop_params: true
# custom_provider_map va dentro de litellm_settings para que se cargue
@@ -51,3 +61,5 @@ litellm_settings:
custom_provider_map:
- provider: "codex"
custom_handler: codex_provider.codex_provider
- provider: "claudeoauth"
custom_handler: claude_provider.claude_oauth_provider
+3
View File
@@ -11,10 +11,13 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@litellm-db:5432/${POSTGRES_DB}
STORE_MODEL_IN_DB: "True"
CODEX_AUTH_FILE: /root/.codex/auth.json
CLAUDE_AUTH_FILE: /root/.claude/.credentials.json
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./codex_provider.py:/app/codex_provider.py:ro
- ./claude_provider.py:/app/claude_provider.py:ro
- /home/felidae/.codex/auth.json:/root/.codex/auth.json:rw
- /home/felidae/.claude/.credentials.json:/root/.claude/.credentials.json:rw
depends_on:
- litellm-db
networks:
+8
View File
@@ -39,3 +39,11 @@ El router activo se define en `/opt/traefik/dynamic/opencode.yml` y aplica Authe
En Authentik, `ocode.sherlockhomeless.net` necesita su propio `Proxy Provider` publicado en el `authentik Embedded Outpost`.
Para aislar el acceso, crea un grupo exclusivo, por ejemplo `opencode-ocode-users`, y una `Policy Binding` que permita solo ese grupo. El contenedor usa los modelos configurados mediante `DEEPSEEK_API_KEY` en su `.env`.
## Claude Sonnet 5 mediante LiteLLM
OpenCode consume `claude-sonnet-5` desde el proveedor OpenAI-compatible de LiteLLM. La configuracion activa debe incluir el modelo con el identificador `litellm/claude-sonnet-5` y apuntar `options.baseURL` a `https://llm.sherlockhomeless.net/v1` (o a `http://litellm:4000/v1` si ambos servicios comparten la red Docker).
El fichero `opencode/opencode.json` contiene la configuracion local efectiva y esta ignorado por Git porque incluye la clave maestra de LiteLLM. OpenCode solo necesita esa clave; el OAuth de Claude se mantiene y renueva exclusivamente en LiteLLM, por lo que no se debe montar `.claude/.credentials.json` dentro de OpenCode.
OpenWebUI no requiere una entrada estatica equivalente: obtiene dinamicamente `claude-sonnet-5` de `/v1/models` usando su conexion OpenAI-compatible existente con LiteLLM.
+8
View File
@@ -30,13 +30,21 @@ Open WebUI nunca monta ni lee `auth.json`. Envía una petición OpenAI-compatibl
cd openwebui
docker compose --env-file .env config
docker compose --env-file .env up -d
sudo install -o root -g root -m 0644 traefik-dynamic.yml /opt/traefik/dynamic/openwebui.yml
```
Traefik observa `/opt/traefik/dynamic` y recarga este fichero sin reiniciar el contenedor. La ruta `/_app/immutable/` usa un router de mayor prioridad: conserva CrowdSec, omite el `ForwardAuth` repetitivo de Authentik y entrega los bundles con compresion y cache inmutable. El HTML, `/api`, `/static` y las rutas de usuario siguen protegidos por Authentik. No amplíes la excepción a rutas que puedan contener datos o recursos mutables.
## Verificación
```bash
docker compose --env-file .env ps
docker compose --env-file .env logs -f openwebui
curl -I -H 'Accept-Encoding: br, gzip' \
https://oweb.sherlockhomeless.net/_app/immutable/chunks/<bundle>.js
curl -I https://oweb.sherlockhomeless.net/api/config
```
El primer comando debe devolver `200`, `Content-Encoding` y `Cache-Control: public, max-age=31536000, immutable`. Sin una sesión activa, el segundo debe seguir redirigiendo a Authentik.
Si no aparecen modelos, prueba primero `/v1/models` contra LiteLLM con la misma clave. Si los modelos aparecen pero fallan sólo los alias Codex, revisa el montaje y renovación de `auth.json` en LiteLLM.
+47
View File
@@ -0,0 +1,47 @@
http:
routers:
openwebui-static:
rule: Host(`oweb.sherlockhomeless.net`) && PathPrefix(`/_app/immutable/`)
priority: 200
entryPoints:
- websecure
tls:
certResolver: letsencrypt
middlewares:
- crowdsec-bouncer@file
- openwebui-compress
- openwebui-static-cache
service: openwebui
openwebui:
rule: Host(`oweb.sherlockhomeless.net`)
priority: 100
entryPoints:
- websecure
tls:
certResolver: letsencrypt
middlewares:
- crowdsec-bouncer@file
- ths-authentik@docker
- openwebui-compress
service: openwebui
middlewares:
openwebui-compress:
compress:
minResponseBodyBytes: 1024
encodings:
- br
- zstd
- gzip
openwebui-static-cache:
headers:
customResponseHeaders:
Cache-Control: public, max-age=31536000, immutable
services:
openwebui:
loadBalancer:
servers:
- url: http://openwebui:8080