diff --git a/litellm/README.md b/litellm/README.md index 6423742..7bff187 100644 --- a/litellm/README.md +++ b/litellm/README.md @@ -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 +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. diff --git a/litellm/claude_provider.py b/litellm/claude_provider.py new file mode 100644 index 0000000..07797d8 --- /dev/null +++ b/litellm/claude_provider.py @@ -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() diff --git a/litellm/config.yaml b/litellm/config.yaml index b4222e7..cb187bf 100644 --- a/litellm/config.yaml +++ b/litellm/config.yaml @@ -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 diff --git a/litellm/docker-compose.yml b/litellm/docker-compose.yml index afd8ede..c7abec1 100644 --- a/litellm/docker-compose.yml +++ b/litellm/docker-compose.yml @@ -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: diff --git a/opencode/README.md b/opencode/README.md index 2ed2819..59a10ec 100644 --- a/opencode/README.md +++ b/opencode/README.md @@ -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. diff --git a/openwebui/README.md b/openwebui/README.md index a5643c5..a3b68cb 100644 --- a/openwebui/README.md +++ b/openwebui/README.md @@ -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/.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. diff --git a/openwebui/traefik-dynamic.yml b/openwebui/traefik-dynamic.yml new file mode 100644 index 0000000..045ad47 --- /dev/null +++ b/openwebui/traefik-dynamic.yml @@ -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