From 039bcc874a554d78c58d9a9bae39757a9f49dcdd Mon Sep 17 00:00:00 2001 From: Santiago Quiroz upegui Date: Wed, 19 Aug 2026 14:14:01 -0500 Subject: [PATCH] =?UTF-8?q?Historia=20t=C3=A9cnica:=20Endpoint=20OpenAI-co?= =?UTF-8?q?mpatible=20BYOK=20y=20cierre=20de=20autenticaci=C3=B3n=20en=20/?= =?UTF-8?q?v1/*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dominio: - proxy_service: el routing de Claude Code ahora escribe ui_api_key como ANTHROPIC_API_KEY (antes proxy_api_key débil por defecto). Aplicación: - Nuevo api/openai_compat.py: POST /v1/chat/completions — reenvía al provider activo (directo a OpenAI-compat con model reescrito; vía litellm para Anthropic), streaming SSE verbatim, errores upstream relayed, registro de usage cuando el response lo trae. - main.py: registro del router. Infraestructura: - middleware/auth.py: /v1/* deja de ser público — exige ui_api_key (o proxy_api_key legado, solo en /v1, para clientes ya configurados). Cierra acceso anónimo desde LAN a /v1/messages. - CLAUDE.md: documentación del endpoint BYOK y la regla de auth. Pruebas: - test_openai_compat.py: 6 tests (resolve_target por tipo de provider, extra headers, auth requerida, reescritura de model, relay de errores). - test_auth_middleware.py: actualizados a la propiedad nueva (401 sin key en /v1, ui key OK, proxy key solo /v1, /api no acepta proxy key). Cobertura global del proyecto: suite backend 101 passed. --- CLAUDE.md | 2 +- backend/app/api/openai_compat.py | 130 ++++++++++++++++++++++++++ backend/app/main.py | 2 + backend/app/middleware/auth.py | 21 +++-- backend/app/services/proxy_service.py | 3 +- backend/tests/test_auth_middleware.py | 20 +++- backend/tests/test_openai_compat.py | 94 +++++++++++++++++++ 7 files changed, 258 insertions(+), 14 deletions(-) create mode 100644 backend/app/api/openai_compat.py create mode 100644 backend/tests/test_openai_compat.py diff --git a/CLAUDE.md b/CLAUDE.md index d9692dc..fcaa06c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ pyinstaller bipolar-code.spec # run from repo root | Startup | `main.py` | Mounts `frontend/dist/` as SPA fallback; spawns Copilot token auto-refresh background loop | | Logging | `core/logging.py` | structlog setup; use `get_logger(__name__)` throughout | -**Provider registry** persists in `{config_dir}/providers.json`. Built-in providers include `copilot`, `anthropic`, `lmstudio`, `nvidia_nim`, `openrouter`, `deepseek`, `ollama` and `llamacpp`. litellm always exposes the aliases `claude-sonnet-4-6`, `claude-opus-4-6`, `gpt-4o` regardless of the active backend. Providers with `anthropic_native: true` (llama-server, LM Studio ≥0.4.1, Ollama 2026+) receive `/v1/messages` verbatim — no litellm, no OAI translation. The `llamacpp` provider spawns a managed local `llama-server` (Vulkan multi-GPU, port 4002) via `/api/llamacpp/*`. +**Provider registry** persists in `{config_dir}/providers.json`. Built-in providers include `copilot`, `anthropic`, `lmstudio`, `nvidia_nim`, `openrouter`, `deepseek`, `ollama` and `llamacpp`. litellm always exposes the aliases `claude-sonnet-4-6`, `claude-opus-4-6`, `gpt-4o` regardless of the active backend. Providers with `anthropic_native: true` (llama-server, LM Studio ≥0.4.1, Ollama 2026+) receive `/v1/messages` verbatim — no litellm, no OAI translation. The `llamacpp` provider spawns a managed local `llama-server` (Vulkan multi-GPU, port 4002) via `/api/llamacpp/*`. `/v1/chat/completions` on :8000 exposes the active provider as an OpenAI-compatible BYOK endpoint (VS Code Copilot Chat, Cursor, Cline). All `/v1/*` routes require auth (`ui_api_key`, or legacy `proxy_api_key`). **Platform guards**: `providers_service._start_litellm` uses PowerShell on Windows and `subprocess.Popen(start_new_session=True)` on Linux/macOS. `proxy_service._set_user_env` writes the Windows registry only on `sys.platform == "win32"`; it always writes `~/.claude/settings.json`. diff --git a/backend/app/api/openai_compat.py b/backend/app/api/openai_compat.py new file mode 100644 index 0000000..73912c8 --- /dev/null +++ b/backend/app/api/openai_compat.py @@ -0,0 +1,130 @@ +""" +Superficie OpenAI-compatible (/v1/chat/completions) para clientes BYOK: +VS Code Copilot Chat, Cursor, Cline, Continue. Reenvía al provider activo +sin transformar el formato (ambos lados hablan OpenAI chat completions). +""" +import asyncio +import json +import os + +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from app.core.config import get_settings +from app.core.logging import get_logger +from app.core.utils import sanitize_error +from app.services import providers_service, usage_tracker +from app.services.pricing_service import estimate_cost + +log = get_logger(__name__) +router = APIRouter(tags=["openai-compat"]) + +_background_tasks: set[asyncio.Task] = set() + + +def resolve_target(active, settings) -> tuple[str, dict, str]: + """(url, headers, model) del upstream para el provider activo.""" + if active and active.litellm_prefix == "anthropic": + # litellm traduce OAI→Anthropic; usar alias conocido del config + url = f"{settings.proxy_url}/v1/chat/completions" + headers = {"Authorization": f"Bearer {settings.proxy_api_key}"} + return url, headers, providers_service.PROXY_ALIASES[0] + + api_base = active.api_base.rstrip("/") if active and active.api_base else "" + url = f"{api_base}/chat/completions" if api_base else f"{settings.proxy_url}/v1/chat/completions" + + api_key = "" + if active and active.auth_env_var: + api_key = os.environ.get(active.auth_env_var, "") + headers = {"Authorization": f"Bearer {api_key or 'no-key'}"} + if active and active.extra_headers: + headers.update(active.extra_headers) + + model = (active.active_model if active else "") or "" + return url, headers, model + + +def _record_usage(provider_id: str, model: str, usage: dict) -> None: + input_tokens = usage.get("prompt_tokens", 0) + output_tokens = usage.get("completion_tokens", 0) + if not input_tokens and not output_tokens: + return + cost = estimate_cost(provider_id, model, input_tokens, output_tokens) + task = asyncio.create_task( + usage_tracker.record(provider_id, model, input_tokens, output_tokens, cost, False) + ) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + + +@router.post("/v1/chat/completions") +async def chat_completions(request: Request): + settings = get_settings() + body = await request.json() + active = providers_service.get_active_provider() + provider_id = active.id if active else "unknown" + + url, headers, model = resolve_target(active, settings) + if model: + body["model"] = model + headers["Content-Type"] = "application/json" + stream = bool(body.get("stream")) + + timeout = httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0) + + if not stream: + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, json=body, headers=headers) + except httpx.ConnectError: + return JSONResponse( + status_code=502, + content={"error": {"message": f"Provider '{provider_id}' no responde en {url}"}}, + ) + if resp.status_code < 400: + try: + _record_usage(provider_id, body.get("model", ""), resp.json().get("usage") or {}) + except ValueError: + pass + return JSONResponse(status_code=resp.status_code, content=_safe_json(resp)) + + async def generate(): + usage_seen: dict = {} + try: + async with httpx.AsyncClient(timeout=timeout) as client: + async with client.stream("POST", url, json=body, headers=headers) as resp: + if resp.status_code >= 400: + raw = await resp.aread() + yield f"data: {json.dumps({'error': {'message': sanitize_error(raw.decode(errors='replace'))}})}\n\n" + return + async for line in resp.aiter_lines(): + if line.startswith("data: ") and '"usage"' in line: + try: + chunk_usage = json.loads(line[6:]).get("usage") + if chunk_usage: + usage_seen = chunk_usage + except ValueError: + pass + yield f"{line}\n" + except httpx.ConnectError: + yield f"data: {json.dumps({'error': {'message': f'Provider {provider_id} no responde'}})}\n\n" + except Exception as e: + log.error("openai_compat_stream_error", error=sanitize_error(str(e))) + yield f"data: {json.dumps({'error': {'message': sanitize_error(str(e))}})}\n\n" + finally: + if usage_seen: + _record_usage(provider_id, body.get("model", ""), usage_seen) + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +def _safe_json(resp: httpx.Response): + try: + return resp.json() + except ValueError: + return {"error": {"message": sanitize_error(resp.text[:500])}} diff --git a/backend/app/main.py b/backend/app/main.py index b8cf842..581a2b9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from app.api import messages as messages_router from app.api import pricing as pricing_router from app.api import llamacpp as llamacpp_router +from app.api import openai_compat as openai_compat_router from app.core.logging import setup_logging, get_logger from app.core.config import get_settings @@ -124,6 +125,7 @@ def create_app() -> FastAPI: app.include_router(messages_router.router) app.include_router(pricing_router.router, prefix="/api") app.include_router(llamacpp_router.router, prefix="/api") + app.include_router(openai_compat_router.router) @app.get("/api/health") async def health(): diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py index ead4673..be42774 100644 --- a/backend/app/middleware/auth.py +++ b/backend/app/middleware/auth.py @@ -5,13 +5,10 @@ def _is_public(path: str) -> bool: - # /v1/* = passthrough Anthropic API (Claude Code usa su propio token) - if path.startswith("/v1"): - return True if path in {"/api/health"}: return True - # archivos estáticos - if not path.startswith("/api"): + # archivos estáticos y SPA + if not path.startswith("/api") and not path.startswith("/v1"): return True return False @@ -23,13 +20,21 @@ def _extract_key(request: Request) -> str: return request.headers.get("x-api-key", "") +def _matches(provided: str, expected: str) -> bool: + return bool(expected) and secrets.compare_digest(provided, expected) + + class APIKeyMiddleware(BaseHTTPMiddleware): def __init__(self, app, ui_key: str, proxy_key: str): super().__init__(app) self._ui_key = ui_key + # /v1/* también acepta proxy_key: clientes Claude Code configurados antes + # del cierre de /v1 tienen ANTHROPIC_API_KEY=proxy_key escrito + self._proxy_key = proxy_key async def dispatch(self, request: Request, call_next): - if _is_public(request.url.path): + path = request.url.path + if _is_public(path): return await call_next(request) if not self._ui_key: @@ -39,7 +44,9 @@ async def dispatch(self, request: Request, call_next): ) provided = _extract_key(request) - if provided and secrets.compare_digest(provided, self._ui_key): + if provided and _matches(provided, self._ui_key): + return await call_next(request) + if path.startswith("/v1") and provided and _matches(provided, self._proxy_key): return await call_next(request) return JSONResponse( diff --git a/backend/app/services/proxy_service.py b/backend/app/services/proxy_service.py index 61b9aae..b51d040 100644 --- a/backend/app/services/proxy_service.py +++ b/backend/app/services/proxy_service.py @@ -155,7 +155,8 @@ async def enable_proxy_routing() -> dict: break fastapi_url = 'http://127.0.0.1:8000' - api_key = settings.proxy_api_key or 'sk-litellm' + # ui_api_key: /v1/* ahora exige auth; el key fuerte generado es el correcto + api_key = settings.ui_api_key or settings.proxy_api_key or 'sk-litellm' _set_registry_env('ANTHROPIC_BASE_URL', fastapi_url) _set_registry_env('ANTHROPIC_API_KEY', api_key) async with _claude_settings_lock: diff --git a/backend/tests/test_auth_middleware.py b/backend/tests/test_auth_middleware.py index b64ee5c..3ec813c 100644 --- a/backend/tests/test_auth_middleware.py +++ b/backend/tests/test_auth_middleware.py @@ -59,18 +59,28 @@ def test_wrong_key_returns_401(): assert resp.status_code == 401 -def test_messages_endpoint_is_public(): - # /v1/* is public — Claude Code authenticates directly with the provider +def test_messages_endpoint_without_key_returns_401(): + # /v1/* requiere auth: expuesto en LAN, sin key cualquiera lo usaría resp = client.post("/v1/messages") - assert resp.status_code == 200 + assert resp.status_code == 401 -def test_messages_endpoint_with_valid_key(): - # Works with or without key since /v1/* is public +def test_messages_endpoint_with_ui_key(): resp = client.post("/v1/messages", headers={"x-api-key": VALID_KEY}) assert resp.status_code == 200 +def test_messages_endpoint_with_proxy_key(): + # Compat: clientes configurados antes del cierre tienen el proxy_key escrito + resp = client.post("/v1/messages", headers={"Authorization": "Bearer sk-proxy"}) + assert resp.status_code == 200 + + +def test_api_does_not_accept_proxy_key(): + resp = client.get("/api/protected", headers={"Authorization": "Bearer sk-proxy"}) + assert resp.status_code == 401 + + def make_app_no_key(): app = FastAPI() app.add_middleware(APIKeyMiddleware, ui_key="", proxy_key="") diff --git a/backend/tests/test_openai_compat.py b/backend/tests/test_openai_compat.py new file mode 100644 index 0000000..d539120 --- /dev/null +++ b/backend/tests/test_openai_compat.py @@ -0,0 +1,94 @@ +"""Tests del endpoint OpenAI-compatible /v1/chat/completions (BYOK).""" +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.api.openai_compat import resolve_target +from app.core.config import get_settings +from app.models.provider import Provider + + +@pytest.fixture +def client(): + from app.main import app + api_key = get_settings().ui_api_key + return TestClient(app, headers={"x-api-key": api_key}) + + +def _provider(**overrides) -> Provider: + base = dict( + id="llamacpp", + name="llama.cpp", + api_base="http://127.0.0.1:4002", + litellm_prefix="openai", + active_model="qwen3-coder-next", + ) + return Provider(**{**base, **overrides}) + + +def test_resolve_target_openai_provider_goes_direct(): + url, headers, model = resolve_target(_provider(), get_settings()) + assert url == "http://127.0.0.1:4002/chat/completions" + assert model == "qwen3-coder-next" + assert headers["Authorization"] == "Bearer no-key" + + +def test_resolve_target_anthropic_provider_goes_through_litellm(): + settings = get_settings() + provider = _provider(id="anthropic", litellm_prefix="anthropic") + url, headers, model = resolve_target(provider, settings) + assert url == f"{settings.proxy_url}/v1/chat/completions" + assert headers["Authorization"] == f"Bearer {settings.proxy_api_key}" + assert model == "claude-sonnet-4-6" + + +def test_resolve_target_includes_extra_headers(): + provider = _provider(id="copilot", extra_headers={"Copilot-Integration-Id": "vscode-chat"}) + _, headers, _ = resolve_target(provider, get_settings()) + assert headers["Copilot-Integration-Id"] == "vscode-chat" + + +def test_chat_completions_requires_auth(): + from app.main import app + unauth = TestClient(app, raise_server_exceptions=False) + resp = unauth.post("/v1/chat/completions", json={"model": "x", "messages": []}) + assert resp.status_code == 401 + + +def test_chat_completions_rewrites_model_and_forwards(client): + body = {"model": "gpt-4o", "messages": [{"role": "user", "content": "hola"}]} + upstream = AsyncMock() + upstream.status_code = 200 + upstream.json = lambda: {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 5}} + with patch( + "app.api.openai_compat.providers_service.get_active_provider", + return_value=_provider(), + ), patch("app.api.openai_compat.httpx.AsyncClient") as mock_client: + instance = mock_client.return_value + instance.__aenter__ = AsyncMock(return_value=instance) + instance.__aexit__ = AsyncMock(return_value=False) + instance.post = AsyncMock(return_value=upstream) + resp = client.post("/v1/chat/completions", json=body) + + assert resp.status_code == 200 + _, kwargs = instance.post.call_args + assert kwargs["json"]["model"] == "qwen3-coder-next" + assert instance.post.call_args[0][0] == "http://127.0.0.1:4002/chat/completions" + + +def test_chat_completions_upstream_error_relayed(client): + body = {"model": "x", "messages": []} + upstream = AsyncMock() + upstream.status_code = 400 + upstream.json = lambda: {"error": {"message": "bad"}} + with patch( + "app.api.openai_compat.providers_service.get_active_provider", + return_value=_provider(), + ), patch("app.api.openai_compat.httpx.AsyncClient") as mock_client: + instance = mock_client.return_value + instance.__aenter__ = AsyncMock(return_value=instance) + instance.__aexit__ = AsyncMock(return_value=False) + instance.post = AsyncMock(return_value=upstream) + resp = client.post("/v1/chat/completions", json=body) + assert resp.status_code == 400