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
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.git
build
dist
frontend/node_modules
frontend/dist
backend/__pycache__
**/__pycache__
**/*.pyc
.playwright-mcp
docs
*.png
*.jar
*.html
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ pyinstaller bipolar-code.spec # run from repo root

**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`).

**Scenario routing**: `ProviderRegistry.routing_rules` (UI: Providers → "Routing por escenario") route each request by requested model name — first match wins; `pattern` = case-insensitive substring, `min_tokens` = longContext threshold. E.g. `haiku` → small local model, `opus` → real Anthropic (routed anthropic goes DIRECT to api.anthropic.com, not through litellm), 60k+ tokens → long-context provider. `local_launch.router_mode` runs llama-server without `--model` serving every GGUF in the models dir with dynamic load/unload (`--models-dir`).
**Scenario routing**: `ProviderRegistry.routing_rules` (UI: Providers → "Routing por escenario") route each request by requested model name — first match wins; `pattern` = case-insensitive substring, `min_tokens` = longContext threshold. E.g. `haiku` → small local model, `opus` → real Anthropic (routed anthropic goes DIRECT to api.anthropic.com, not through litellm), 60k+ tokens → long-context provider. `local_launch.router_mode` runs llama-server without `--model` serving every GGUF in the models dir with dynamic load/unload (`--models-dir`). `local_launch.rpc_servers` adds remote `ggml-rpc-server` workers (`--rpc`, multi-PC farm).

**Failover**: `ProviderRegistry.fallback_provider_ids` — if the effective provider has a local/LAN api_base and its port doesn't answer (0.4s TCP check in `pick_provider`), the request falls to the first reachable fallback. **Semantic compression** (`SEMANTIC_COMPRESSION=true`, toggle in Settings): near the context limit, `compression_service` summarizes the old half of the conversation via the active provider instead of truncating; any failure falls back to plain truncation. **Telegram bot** (opt-in): `TELEGRAM_BOT_TOKEN` + `TELEGRAM_ALLOWED_CHAT_IDS` env vars — long-polling relay to the active provider; empty allowlist = inert. **Docker**: `docker compose up -d --build` deploys the gateway; llama-server stays on the host.

**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
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# bipolar-code — imagen de gateway (backend + UI compilada).
# llama-server NO va dentro: corre en el host con las GPUs; apunta el provider
# llamacpp a http://host.docker.internal:4002.
FROM node:20-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

FROM python:3.11-slim
WORKDIR /app/backend
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt "litellm[proxy]>=1.60"
COPY backend/ .
COPY --from=frontend /app/frontend/dist /app/frontend/dist

ENV LITELLM_CONFIG_DIR=/data \
PYTHONUNBUFFERED=1
VOLUME ["/data"]
EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ Web UI para gestionar un proxy [LiteLLM](https://github.com/BerriAI/litellm) —

---

## Deploy con Docker (servidor remoto / headless)

```bash
docker compose up -d --build
```

- Gateway + UI en `:8000`; config persistida en el volumen `bipolar-data`.
- `llama-server` NO va dentro del contenedor (necesita las GPUs del host): córrelo en el host y apunta el provider `llamacpp` a `http://host.docker.internal:4002`.
- Fuera de tu LAN usa una VPN (Tailscale/WireGuard) — no expongas el puerto a internet.

---

## Dev Setup (código fuente)

### Requisitos
Expand Down
27 changes: 17 additions & 10 deletions backend/app/api/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.utils import sanitize_error as _sanitize_error
from app.services import providers_service, token_service, usage_tracker
from app.services import compression_service, providers_service, token_service, usage_tracker
from app.services.pricing_service import estimate_cost

log = get_logger(__name__)
Expand Down Expand Up @@ -326,20 +326,27 @@ async def messages_passthrough(request: Request):
ctx_window = token_service.get_context_window(model)
used = token_service.count_tokens(messages)

active = providers_service.get_active_provider()
routed_model: str | None = None
route = providers_service.resolve_route(model, used)
if route:
active, routed_model = route
active, routed_model, is_active_provider = await providers_service.pick_provider(model, used)
active_provider_id = active.id if active else "unknown"
# Provider anthropic RUTEADO va directo a api.anthropic.com: litellm corre
# con el config del provider activo, no del destino de la regla
is_native = bool(active and (active.anthropic_native or (route and active.litellm_prefix == "anthropic")))
# anthropic vía litellm SOLO si es el provider activo configurado (litellm
# corre con SU config); ruteado o failover → directo a api.anthropic.com
is_native = bool(active and (active.anthropic_native or (active.litellm_prefix == "anthropic" and not is_active_provider)))
is_anthropic = bool(active and active.litellm_prefix == "anthropic" and not is_native)
truncated = False

if ctx_window > 0 and used >= int(ctx_window * 0.9):
messages = token_service.truncate_messages(messages, ctx_window)
compressed = None
if settings.semantic_compression and active:
compressed = await compression_service.compress_messages(messages, active, model)
if compressed:
messages = compressed
log.info(
"semantic_compression_applied",
before_tokens=used,
after_tokens=token_service.count_tokens(messages),
)
else:
messages = token_service.truncate_messages(messages, ctx_window)
body["messages"] = messages
truncated = True

Expand Down
15 changes: 8 additions & 7 deletions backend/app/api/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,14 @@ def _record_usage(provider_id: str, model: str, usage: dict) -> None:
async def chat_completions(request: Request):
settings = get_settings()
body = await request.json()
active = providers_service.get_active_provider()
routed_model = None
route = providers_service.resolve_route(str(body.get("model", "")))
# Routing en la superficie OAI: solo destinos OpenAI-compat (un destino
# anthropic requeriría traducir el formato, cosa que esta ruta no hace)
if route and route[0].litellm_prefix != "anthropic":
active, routed_model = route
active, routed_model, is_active_provider = await providers_service.pick_provider(
str(body.get("model", ""))
)
# Un destino anthropic NO activo requeriría traducir el formato OAI→Anthropic
# (litellm corre con el config del activo): en ese caso se ignora la ruta
if active and active.litellm_prefix == "anthropic" and not is_active_provider:
active = providers_service.get_active_provider()
routed_model = None
provider_id = active.id if active else "unknown"

url, headers, model = resolve_target(active, settings)
Expand Down
11 changes: 9 additions & 2 deletions backend/app/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,27 @@ def list_providers():
@router.get("/routing")
def get_routing():
registry = providers_service.load_registry()
return {"enabled": registry.routing_enabled, "rules": registry.routing_rules}
return {
"enabled": registry.routing_enabled,
"rules": registry.routing_rules,
"fallback_provider_ids": registry.fallback_provider_ids,
}


class RoutingUpdate(BaseModel):
enabled: bool
rules: list[RoutingRule] = []
fallback_provider_ids: Optional[list[str]] = None # None = no tocar


@router.put("/routing")
def set_routing(body: RoutingUpdate):
unknown = [r.provider_id for r in body.rules if not providers_service.get_provider(r.provider_id)]
if body.fallback_provider_ids:
unknown += [pid for pid in body.fallback_provider_ids if not providers_service.get_provider(pid)]
if unknown:
raise HTTPException(status_code=400, detail=f"Providers no registrados: {unknown}")
result = providers_service.set_routing(body.enabled, body.rules)
result = providers_service.set_routing(body.enabled, body.rules, body.fallback_provider_ids)
log.info("routing_updated", enabled=body.enabled, rules=len(body.rules))
return result

Expand Down
13 changes: 13 additions & 0 deletions backend/app/api/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,16 @@ async def set_proxy_route(body: RouteRequest):
except Exception as e:
log.error('proxy_set_route_error', error=str(e), requested=body.mode)
raise HTTPException(status_code=500, detail=str(e))


@router.get('/logs')
async def litellm_logs(lines: int = 80):
from pathlib import Path
from app.core.config import get_settings
from app.core.utils import tail_file

config_dir = Path(get_settings().litellm_config_dir)
merged = []
for name, tag in (('litellm-out.log', 'out'), ('litellm-err.log', 'err')):
merged += [f'[{tag}] {line}' for line in tail_file(config_dir / name, lines)]
return {'logs': merged[-lines:]}
1 change: 1 addition & 0 deletions backend/app/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ async def get_auth_info():
"rate_limit_rpm": s.rate_limit_rpm,
"allowed_origins": s.allowed_origins,
"proxy_base_url": "http://<tu-ip>:8000",
"semantic_compression": s.semantic_compression,
}


Expand Down
4 changes: 4 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ class Settings(BaseSettings):
# Rate limiting: máx requests por minuto por IP (0 = desactivado)
rate_limit_rpm: int = 120

# Compresión semántica: resumir historial viejo con el provider activo
# al acercarse al límite de contexto, en vez de solo truncar
semantic_compression: bool = False

# Anthropic
anthropic_api_key: str = ""
anthropic_real_api_key: str = ""
Expand Down
14 changes: 14 additions & 0 deletions backend/app/core/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
import re
from pathlib import Path


def tail_file(path: Path, lines: int) -> list[str]:
"""Últimas N líneas leyendo solo el bloque final (64KB) del archivo."""
try:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 65536))
data = f.read().decode("utf-8", errors="replace")
return data.splitlines()[-lines:]
except OSError:
return []


def sanitize_error(msg: str) -> str:
Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ async def _copilot_token_refresh_loop():
async def lifespan(app: FastAPI):
from app.services import usage_tracker
from app.services.llamacpp_service import autostart_if_configured
from app.services.telegram_bot import run_telegram_bot
await usage_tracker.init_db()
asyncio.create_task(autostart_if_configured())
asyncio.create_task(run_telegram_bot())
task = asyncio.create_task(_copilot_token_refresh_loop())
try:
yield
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,5 @@ class ProviderRegistry(BaseModel):
providers: list[Provider] = []
routing_enabled: bool = False
routing_rules: list[RoutingRule] = []
# Failover: si el provider efectivo (local) no responde, probar estos en orden
fallback_provider_ids: list[str] = []
97 changes: 97 additions & 0 deletions backend/app/services/compression_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""
Compresión semántica de conversaciones: resume la parte vieja del historial
con el provider activo en vez de solo truncar. Opt-in (SEMANTIC_COMPRESSION=true).
Cualquier fallo devuelve None y el caller cae al truncado clásico.
"""
import os

import httpx

from app.core.logging import get_logger
from app.models.provider import Provider

log = get_logger(__name__)

KEEP_RECENT_MESSAGES = 8
_MAX_SOURCE_CHARS = 60000

_SUMMARY_PROMPT = (
"Resume la siguiente conversación entre un usuario y un asistente de código. "
"Preserva: decisiones tomadas, archivos tocados y sus cambios, errores encontrados, "
"y el estado actual de la tarea. Sé compacto (máximo ~800 palabras). "
"Responde SOLO con el resumen.\n\n"
)


def message_to_text(message: dict) -> str:
content = message.get("content", "")
if isinstance(content, str):
return content
parts = []
for block in content if isinstance(content, list) else []:
btype = block.get("type", "")
if btype == "text":
parts.append(block.get("text", ""))
elif btype == "tool_use":
parts.append(f"[tool_use: {block.get('name', '?')}]")
elif btype == "tool_result":
parts.append("[tool_result]")
return "\n".join(p for p in parts if p)


def _starts_with_tool_result(message: dict) -> bool:
content = message.get("content")
if not isinstance(content, list) or not content:
return False
return content[0].get("type") == "tool_result"


def split_for_compression(messages: list[dict], keep_recent: int = KEEP_RECENT_MESSAGES) -> tuple[list[dict], list[dict]]:
"""(viejos_a_resumir, recientes_intactos) sin partir un par tool_use/tool_result."""
if len(messages) <= keep_recent:
return [], messages
cut = len(messages) - keep_recent
while cut > 0 and _starts_with_tool_result(messages[cut]):
cut -= 1
return messages[:cut], messages[cut:]


def build_summary_request(old_messages: list[dict], model: str) -> dict:
transcript = "\n".join(f"{m.get('role', '?')}: {message_to_text(m)}" for m in old_messages)
return {
"model": model,
"messages": [{"role": "user", "content": _SUMMARY_PROMPT + transcript[:_MAX_SOURCE_CHARS]}],
"max_tokens": 1500,
"stream": False,
}


async def compress_messages(messages: list[dict], provider: Provider, model: str) -> list[dict] | None:
old, recent = split_for_compression(messages)
if not old:
return None

url = f"{provider.api_base.rstrip('/')}/chat/completions"
api_key = os.environ.get(provider.auth_env_var, "") if provider.auth_env_var else ""
headers = {"Authorization": f"Bearer {api_key or 'no-key'}", "Content-Type": "application/json"}
if provider.extra_headers:
headers.update(provider.extra_headers)
body = build_summary_request(old, provider.active_model or model)

try:
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=90.0, write=5.0, pool=5.0)) as client:
resp = await client.post(url, json=body, headers=headers)
resp.raise_for_status()
summary = resp.json()["choices"][0]["message"]["content"]
except Exception as e:
log.warning("semantic_compression_failed", provider=provider.id, error=str(e)[:200])
return None

if not summary or not str(summary).strip():
return None

summary_message = {
"role": "user",
"content": [{"type": "text", "text": f"[Resumen de la conversación previa]\n{summary}"}],
}
return [summary_message] + recent
21 changes: 8 additions & 13 deletions backend/app/services/llamacpp_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ def build_cmdline(provider: Provider, devices: list[dict]) -> list[str]:
if ratios:
cmd += ["--tensor-split", ",".join(str(r) for r in ratios)]

rpc_servers = launch.get("rpc_servers") or []
if isinstance(rpc_servers, list) and rpc_servers:
# llama.cpp RPC: workers remotos (ggml-rpc-server) se suman como
# devices — la granja multi-PC del backlog v3
cmd += ["--rpc", ",".join(str(s) for s in rpc_servers)]

extra = launch.get("extra_args", [])
if isinstance(extra, list):
cmd += [str(a) for a in extra]
Expand Down Expand Up @@ -258,22 +264,11 @@ def _read_pid() -> int | None:
return None


def _tail_file(path: Path, lines: int) -> list[str]:
try:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 65536))
data = f.read().decode("utf-8", errors="replace")
return data.splitlines()[-lines:]
except OSError:
return []


def tail_logs(lines: int = 80) -> list[str]:
from app.core.utils import tail_file
merged = []
for name, tag in (("llamacpp-out.log", "out"), ("llamacpp-err.log", "err")):
merged += [f"[{tag}] {line}" for line in _tail_file(_config_dir() / name, lines)]
merged += [f"[{tag}] {line}" for line in tail_file(_config_dir() / name, lines)]
return merged[-lines:]


Expand Down
Loading
Loading