feat(litellm): add Claude Code OAuth model
This commit is contained in:
+169
-13
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user