Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
130 changes: 130 additions & 0 deletions backend/app/api/openai_compat.py
Original file line number Diff line number Diff line change
@@ -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])}}
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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():
Expand Down
21 changes: 14 additions & 7 deletions backend/app/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion backend/app/services/proxy_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 15 additions & 5 deletions backend/tests/test_auth_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="")
Expand Down
94 changes: 94 additions & 0 deletions backend/tests/test_openai_compat.py
Original file line number Diff line number Diff line change
@@ -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
Loading