diff --git a/CLAUDE.md b/CLAUDE.md index fcaa06c..02cf5b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`) diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py index 3c74d95..9690e76 100644 --- a/backend/app/api/messages.py +++ b/backend/app/api/messages.py @@ -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): @@ -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: @@ -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) diff --git a/backend/app/api/openai_compat.py b/backend/app/api/openai_compat.py index 73912c8..aaa3232 100644 --- a/backend/app/api/openai_compat.py +++ b/backend/app/api/openai_compat.py @@ -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" diff --git a/backend/app/api/providers.py b/backend/app/api/providers.py index c8f3aab..c33bddf 100644 --- a/backend/app/api/providers.py +++ b/backend/app/api/providers.py @@ -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 @@ -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) diff --git a/backend/app/models/provider.py b/backend/app/models/provider.py index db8ac1f..ecfcb46 100644 --- a/backend/app/models/provider.py +++ b/backend/app/models/provider.py @@ -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] = [] diff --git a/backend/app/services/llamacpp_service.py b/backend/app/services/llamacpp_service.py index 90fce77..e3a0feb 100644 --- a/backend/app/services/llamacpp_service.py +++ b/backend/app/services/llamacpp_service.py @@ -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")), @@ -174,8 +180,9 @@ 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) @@ -183,9 +190,11 @@ async def start_llamacpp(provider: Provider) -> dict: 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" diff --git a/backend/app/services/providers_service.py b/backend/app/services/providers_service.py index 7c36cb9..d867a7c 100644 --- a/backend/app/services/providers_service.py +++ b/backend/app/services/providers_service.py @@ -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) diff --git a/backend/tests/test_llamacpp_service.py b/backend/tests/test_llamacpp_service.py index e56aab4..5f2c40a 100644 --- a/backend/tests/test_llamacpp_service.py +++ b/backend/tests/test_llamacpp_service.py @@ -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) diff --git a/backend/tests/test_messages_native.py b/backend/tests/test_messages_native.py index d69ae18..2657560 100644 --- a/backend/tests/test_messages_native.py +++ b/backend/tests/test_messages_native.py @@ -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" diff --git a/backend/tests/test_routing.py b/backend/tests/test_routing.py new file mode 100644 index 0000000..ec59b70 --- /dev/null +++ b/backend/tests/test_routing.py @@ -0,0 +1,250 @@ +"""Tests for scenario-based provider routing.""" +from unittest.mock import Mock + +import pytest +from fastapi.testclient import TestClient + +from app.api import providers as providers_api +from app.models.provider import Provider, ProviderRegistry, RoutingRule +from app.services import providers_service + + +@pytest.fixture +def client(): + from app.main import app + from app.core.config import get_settings + + api_key = get_settings().ui_api_key + return TestClient(app, headers={"x-api-key": api_key}) + + +def _provider(provider_id: str, active_model: str = "active-model") -> Provider: + return Provider( + id=provider_id, + name=provider_id, + api_base=f"https://{provider_id}.example.com/v1", + active_model=active_model, + ) + + +def _patch_registry( + monkeypatch, + *, + providers: list[Provider], + rules: list[RoutingRule], + enabled: bool = True, +) -> ProviderRegistry: + registry = ProviderRegistry( + providers=providers, + routing_enabled=enabled, + routing_rules=rules, + ) + monkeypatch.setattr(providers_service, "load_registry", lambda: registry) + return registry + + +def test_resolve_route_returns_none_when_routing_disabled(monkeypatch): + target = _provider("target") + _patch_registry( + monkeypatch, + providers=[target], + rules=[RoutingRule(pattern="haiku", provider_id=target.id)], + enabled=False, + ) + + assert providers_service.resolve_route("claude-haiku") is None + + +def test_resolve_route_matches_pattern_case_insensitively(monkeypatch): + target = _provider("target") + _patch_registry( + monkeypatch, + providers=[target], + rules=[RoutingRule(pattern="haiku", provider_id=target.id)], + ) + + provider, model = providers_service.resolve_route("claude-3-5-HAIKU-latest") + + assert provider is target + assert model == target.active_model + + +def test_resolve_route_uses_first_matching_rule(monkeypatch): + first = _provider("first", "first-model") + second = _provider("second", "second-model") + _patch_registry( + monkeypatch, + providers=[first, second], + rules=[ + RoutingRule(pattern="claude", provider_id=first.id), + RoutingRule(pattern="claude", provider_id=second.id), + ], + ) + + provider, model = providers_service.resolve_route("claude-sonnet") + + assert provider is first + assert model == "first-model" + + +def test_resolve_route_applies_min_tokens_gate(monkeypatch): + target = _provider("long-context") + _patch_registry( + monkeypatch, + providers=[target], + rules=[RoutingRule(min_tokens=60_000, provider_id=target.id)], + ) + + assert providers_service.resolve_route("any-model", prompt_tokens=1_000) is None + provider, model = providers_service.resolve_route( + "any-model", prompt_tokens=70_000 + ) + + assert provider is target + assert model == target.active_model + + +def test_resolve_route_empty_pattern_and_zero_min_tokens_matches_anything(monkeypatch): + target = _provider("catch-all") + _patch_registry( + monkeypatch, + providers=[target], + rules=[RoutingRule(pattern="", min_tokens=0, provider_id=target.id)], + ) + + provider, model = providers_service.resolve_route("completely-unrelated-model") + + assert provider is target + assert model == target.active_model + + +def test_resolve_route_skips_unknown_provider(monkeypatch): + fallback = _provider("fallback") + unknown_rule = RoutingRule(pattern="claude", provider_id="missing") + fallback_rule = RoutingRule(pattern="claude", provider_id=fallback.id) + registry = _patch_registry( + monkeypatch, + providers=[fallback], + rules=[unknown_rule, fallback_rule], + ) + + provider, model = providers_service.resolve_route("claude-sonnet") + + assert provider is fallback + assert model == fallback.active_model + + registry.routing_rules = [unknown_rule] + assert providers_service.resolve_route("claude-sonnet") is None + + +@pytest.mark.parametrize( + ("rule_model", "expected_model"), + [ + ("", "provider-active-model"), + ("rule-specific-model", "rule-specific-model"), + ], +) +def test_resolve_route_selects_rule_model_or_provider_active_model( + monkeypatch, rule_model, expected_model +): + target = _provider("target", "provider-active-model") + _patch_registry( + monkeypatch, + providers=[target], + rules=[RoutingRule(provider_id=target.id, model=rule_model)], + ) + + provider, model = providers_service.resolve_route("any-model") + + assert provider is target + assert model == expected_model + + +def test_get_routing_returns_enabled_and_rules(client, monkeypatch): + rule = RoutingRule(pattern="haiku", provider_id="fast", model="fast-model") + registry = ProviderRegistry(routing_enabled=True, routing_rules=[rule]) + mock_load_registry = Mock(return_value=registry) + mock_set_routing = Mock() + mock_get_provider = Mock() + monkeypatch.setattr( + providers_api.providers_service, "load_registry", mock_load_registry + ) + monkeypatch.setattr( + providers_api.providers_service, "set_routing", mock_set_routing + ) + monkeypatch.setattr( + providers_api.providers_service, "get_provider", mock_get_provider + ) + + response = client.get("/api/providers/routing") + + assert response.status_code == 200 + assert response.json() == { + "enabled": True, + "rules": [rule.model_dump()], + } + mock_load_registry.assert_called_once_with() + mock_set_routing.assert_not_called() + mock_get_provider.assert_not_called() + + +def test_put_routing_rejects_unknown_provider(client, monkeypatch): + mock_load_registry = Mock() + mock_set_routing = Mock() + mock_get_provider = Mock(return_value=None) + monkeypatch.setattr( + providers_api.providers_service, "load_registry", mock_load_registry + ) + monkeypatch.setattr( + providers_api.providers_service, "set_routing", mock_set_routing + ) + monkeypatch.setattr( + providers_api.providers_service, "get_provider", mock_get_provider + ) + rule = {"pattern": "haiku", "provider_id": "missing"} + + response = client.put( + "/api/providers/routing", json={"enabled": True, "rules": [rule]} + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Providers no registrados: ['missing']" + mock_get_provider.assert_called_once_with("missing") + mock_set_routing.assert_not_called() + mock_load_registry.assert_not_called() + + +def test_put_routing_calls_set_routing_and_returns_result(client, monkeypatch): + target = _provider("target") + rule_payload = { + "pattern": "haiku", + "min_tokens": 0, + "provider_id": target.id, + "model": "fast-model", + } + service_result = {"enabled": True, "rules": [rule_payload]} + mock_load_registry = Mock() + mock_set_routing = Mock(return_value=service_result) + mock_get_provider = Mock(return_value=target) + monkeypatch.setattr( + providers_api.providers_service, "load_registry", mock_load_registry + ) + monkeypatch.setattr( + providers_api.providers_service, "set_routing", mock_set_routing + ) + monkeypatch.setattr( + providers_api.providers_service, "get_provider", mock_get_provider + ) + + response = client.put( + "/api/providers/routing", + json={"enabled": True, "rules": [rule_payload]}, + ) + + assert response.status_code == 200 + assert response.json() == service_result + mock_get_provider.assert_called_once_with(target.id) + mock_set_routing.assert_called_once_with( + True, [RoutingRule(**rule_payload)] + ) + mock_load_registry.assert_not_called() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 349ed68..6a15779 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "bipolar-code-frontend", - "version": "2.10.0", + "version": "2.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bipolar-code-frontend", - "version": "2.10.0", + "version": "2.11.0", "dependencies": { "@tanstack/react-query": "^5.40.0", "axios": "^1.7.2", diff --git a/frontend/package.json b/frontend/package.json index cef4a33..0fd1f0e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1 +1 @@ -{"name":"bipolar-code-frontend","private":true,"version":"2.10.0","type":"module","scripts":{"dev":"vite","build":"tsc -b && vite build","preview":"vite preview","test":"vitest run"},"dependencies":{"@tanstack/react-query":"^5.40.0","axios":"^1.7.2","react":"^18.3.1","react-dom":"^18.3.1","react-router-dom":"^6.23.1","recharts":"^3.8.1"},"devDependencies":{"@testing-library/jest-dom":"^6.4.6","@testing-library/react":"^16.0.0","@types/react":"^18.3.3","@types/react-dom":"^18.3.0","@vitejs/plugin-react":"^4.3.1","autoprefixer":"^10.4.19","jsdom":"^29.0.2","postcss":"^8.4.39","tailwindcss":"^3.4.4","typescript":"^5.4.5","vite":"^5.3.1","vitest":"^1.6.0"}} \ No newline at end of file +{"name":"bipolar-code-frontend","private":true,"version":"2.11.0","type":"module","scripts":{"dev":"vite","build":"tsc -b && vite build","preview":"vite preview","test":"vitest run"},"dependencies":{"@tanstack/react-query":"^5.40.0","axios":"^1.7.2","react":"^18.3.1","react-dom":"^18.3.1","react-router-dom":"^6.23.1","recharts":"^3.8.1"},"devDependencies":{"@testing-library/jest-dom":"^6.4.6","@testing-library/react":"^16.0.0","@types/react":"^18.3.3","@types/react-dom":"^18.3.0","@vitejs/plugin-react":"^4.3.1","autoprefixer":"^10.4.19","jsdom":"^29.0.2","postcss":"^8.4.39","tailwindcss":"^3.4.4","typescript":"^5.4.5","vite":"^5.3.1","vitest":"^1.6.0"}} \ No newline at end of file diff --git a/frontend/src/components/LlamaCppPanel.tsx b/frontend/src/components/LlamaCppPanel.tsx index fd4f8ff..1f71561 100644 --- a/frontend/src/components/LlamaCppPanel.tsx +++ b/frontend/src/components/LlamaCppPanel.tsx @@ -152,6 +152,15 @@ export function LlamaCppPanel({ provider }: LlamaCppPanelProps) { /> Iniciar automáticamente al arrancar bipolar-code +
+ Enruta cada request según el modelo pedido por Claude Code: primer match gana.
+ Ej: patrón haiku (tareas background) → modelo
+ chico local; opus → Anthropic real; min tokens
+ 60000 → provider con contexto largo. Sin match → provider activo.
+
+ {(save.error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Error al guardar'} +
+ )} +