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: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ 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`).

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

### Frontend layout (`frontend/src/`)
Expand Down
29 changes: 20 additions & 9 deletions backend/app/api/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,19 @@ async def messages_passthrough(request: Request):
messages = body.get("messages", [])
model = body.get("model", "__default__")

active = providers_service.get_active_provider()
active_provider_id = active.id if active else "unknown"
is_anthropic = active and active.litellm_prefix == "anthropic"
is_native = bool(active and active.anthropic_native)

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_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")))
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):
Expand All @@ -355,12 +361,17 @@ async def generate():
if is_anthropic or is_native:
if is_native:
# Provider con /v1/messages nativo (llama-server, LM Studio >=0.4.1,
# Ollama 2026+): reenvío verbatim, solo se reescribe el model
body["model"] = active.active_model or model
# Ollama 2026+, api.anthropic.com): reenvío verbatim, solo se reescribe el model
body["model"] = routed_model or active.active_model or model
native_base = active.api_base.rstrip("/").removesuffix("/v1")
target_url = f"{native_base}/v1/messages"
forward_headers = {"Content-Type": "application/json"}
native_key = os.environ.get(active.auth_env_var, "") if active.auth_env_var else ""
native_key = ""
if active.auth_env_var:
# settings fallback: el .env del config dir no siempre está en os.environ
native_key = os.environ.get(active.auth_env_var, "") or str(
getattr(settings, active.auth_env_var.lower(), "") or ""
)
if native_key:
forward_headers["x-api-key"] = native_key
else:
Expand Down Expand Up @@ -404,7 +415,7 @@ async def generate():

else:
# Non-Anthropic: call provider directly with OAI format
provider_model = (active.active_model or model) if active else model
provider_model = routed_model or ((active.active_model or model) if active else model)
max_tools = active.max_tools if active else 0
model_info = active.model_info if active else {}
is_claude = _is_claude_model(provider_model)
Expand Down
8 changes: 8 additions & 0 deletions backend/app/api/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,17 @@ 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
provider_id = active.id if active else "unknown"

url, headers, model = resolve_target(active, settings)
if routed_model:
model = routed_model
if model:
body["model"] = model
headers["Content-Type"] = "application/json"
Expand Down
24 changes: 23 additions & 1 deletion backend/app/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Literal, Optional
from app.models.provider import Provider
from app.models.provider import Provider, RoutingRule
from app.services import providers_service
from app.core.logging import get_logger
from app.core.config import get_settings
Expand Down Expand Up @@ -98,6 +98,28 @@ def list_providers():
}


# Declarado ANTES de /{provider_id} — si no, "routing" matchea como provider_id
@router.get("/routing")
def get_routing():
registry = providers_service.load_registry()
return {"enabled": registry.routing_enabled, "rules": registry.routing_rules}


class RoutingUpdate(BaseModel):
enabled: bool
rules: list[RoutingRule] = []


@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 unknown:
raise HTTPException(status_code=400, detail=f"Providers no registrados: {unknown}")
result = providers_service.set_routing(body.enabled, body.rules)
log.info("routing_updated", enabled=body.enabled, rules=len(body.rules))
return result


@router.get("/{provider_id}")
def get_provider(provider_id: str):
provider = providers_service.get_provider(provider_id)
Expand Down
12 changes: 12 additions & 0 deletions backend/app/models/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ class Provider(BaseModel):
local_launch: dict = {}


class RoutingRule(BaseModel):
"""Regla de routing por escenario: primer match gana (orden de la lista).
pattern: substring case-insensitive sobre el model pedido ("" = cualquiera).
min_tokens: umbral longContext — solo aplica si el prompt >= umbral (0 = sin umbral)."""
pattern: str = ""
min_tokens: int = 0
provider_id: str
model: str = "" # "" = active_model del provider destino


class ProviderRegistry(BaseModel):
active_provider_id: str = "copilot"
providers: list[Provider] = []
routing_enabled: bool = False
routing_rules: list[RoutingRule] = []
23 changes: 16 additions & 7 deletions backend/app/services/llamacpp_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,15 @@ def build_cmdline(provider: Provider, devices: list[dict]) -> list[str]:
ngl = int(launch.get("ngl", 999))
split_mode = str(launch.get("split_mode", "layer"))

cmd = [
exe,
"--model", str(launch.get("model_path", "")),
cmd = [exe]
if launch.get("router_mode"):
# Router mode: sin --model, sirve todos los GGUF del dir con
# load/unload dinámico; los requests eligen modelo por nombre
from app.services.hf_models_service import models_dir
cmd += ["--models-dir", str(launch.get("models_dir") or models_dir())]
else:
cmd += ["--model", str(launch.get("model_path", ""))]
cmd += [
"--ctx-size", str(ctx_size),
"--n-gpu-layers", str(ngl),
"--host", str(launch.get("host", "127.0.0.1")),
Expand Down Expand Up @@ -174,18 +180,21 @@ async def start_llamacpp(provider: Provider) -> dict:
"llama-server no encontrado. Instala un release Vulkan de llama.cpp "
"y configura exe_path en el provider."
)
router_mode = bool(provider.local_launch.get("router_mode"))
model_path = str(provider.local_launch.get("model_path", "")).strip()
if not model_path or not Path(model_path).exists():
if not router_mode and (not model_path or not Path(model_path).exists()):
raise ValueError(f"Modelo GGUF no encontrado: '{model_path}'")

current = await get_status(provider)
if current["running"]:
return {**current, "already_running": True}

devices = list_devices(exe)
fit = estimate_fit(model_path, int(provider.local_launch.get("ctx_size", 32768)), devices)
if devices and not fit["fits"]:
log.warning("model_may_not_fit", **fit)
fit = None
if not router_mode:
fit = estimate_fit(model_path, int(provider.local_launch.get("ctx_size", 32768)), devices)
if devices and not fit["fits"]:
log.warning("model_may_not_fit", **fit)

cmd = build_cmdline(provider, devices)
out_log = _config_dir() / "llamacpp-out.log"
Expand Down
26 changes: 26 additions & 0 deletions backend/app/services/providers_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,32 @@ def get_provider(provider_id: str) -> Optional[Provider]:
return next((p for p in registry.providers if p.id == provider_id), None)


def set_routing(enabled: bool, rules: list) -> dict:
with _registry_lock:
registry = load_registry()
registry.routing_enabled = enabled
registry.routing_rules = rules
save_registry(registry)
return {"enabled": enabled, "rules": rules}


def resolve_route(model_name: str, prompt_tokens: int = 0) -> Optional[tuple[Provider, str]]:
"""Primer RoutingRule que matchea → (provider destino, model destino).
None = sin routing (usar provider activo)."""
registry = load_registry()
if not registry.routing_enabled:
return None
for rule in registry.routing_rules:
if rule.min_tokens and prompt_tokens < rule.min_tokens:
continue
if rule.pattern and rule.pattern.lower() not in model_name.lower():
continue
provider = next((p for p in registry.providers if p.id == rule.provider_id), None)
if provider:
return provider, (rule.model or provider.active_model or model_name)
return None


def get_active_provider() -> Optional[Provider]:
registry = load_registry()
return get_provider(registry.active_provider_id)
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/test_llamacpp_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,16 @@ async def test_stop_llamacpp_force_kills_busy_server(
process_mock.return_value.kill.assert_called_once_with()
process_iter_mock.assert_called_once_with(["pid", "name", "cmdline"])
assert not pid_file.exists()


def test_build_cmdline_router_mode_serves_models_dir(provider_factory, tmp_path, monkeypatch):
from app.services import hf_models_service

monkeypatch.setattr(hf_models_service, "models_dir", lambda: tmp_path)
provider = provider_factory(local_launch={"router_mode": True})

cmd = llamacpp_service.build_cmdline(provider, [])

assert "--model" not in cmd
assert "--models-dir" in cmd
assert cmd[cmd.index("--models-dir") + 1] == str(tmp_path)
34 changes: 34 additions & 0 deletions backend/tests/test_messages_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,37 @@ def test_native_no_auth_header_without_env_var(client):
_, _, kwargs = stream_mock.mock_calls[0]
assert "Authorization" not in kwargs["headers"]
assert "x-api-key" not in kwargs["headers"]


def test_routing_overrides_active_provider(client):
routed = Provider(
id="llamacpp-small",
name="small",
api_base="http://127.0.0.1:4003",
litellm_prefix="openai",
active_model="qwen3-4b",
anthropic_native=True,
)
body = {
"model": "claude-3-5-haiku-latest",
"messages": [{"role": "user", "content": "hola"}],
"max_tokens": 10,
}
lines = ['data: {"type": "message_stop"}']
with patch(
"app.api.messages.providers_service.get_active_provider",
return_value=_native_provider(),
), patch(
"app.api.messages.providers_service.resolve_route",
return_value=(routed, "qwen3-4b"),
), patch("app.api.messages.httpx.AsyncClient") as mock_client:
instance = mock_client.return_value
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.stream = MagicMock(return_value=_mock_stream(lines))
resp = client.post("/v1/messages", json=body)

assert resp.status_code == 200
_, args, kwargs = instance.stream.mock_calls[0]
assert args[1] == "http://127.0.0.1:4003/v1/messages"
assert kwargs["json"]["model"] == "qwen3-4b"
Loading
Loading