diff --git a/multi_llm_chatbot_backend/app/api/routes/chat.py b/multi_llm_chatbot_backend/app/api/routes/chat.py index e5e76b8..ae1181e 100644 --- a/multi_llm_chatbot_backend/app/api/routes/chat.py +++ b/multi_llm_chatbot_backend/app/api/routes/chat.py @@ -44,7 +44,8 @@ def resolve_llm_clients(user: User) -> Dict[str, Any]: if config.mode == "uniform": client = get_llm_client(config.default_backend) persona_clients = { - pid: client for pid in chat_orchestrator.personas + pid: persona.llm if persona.backend_locked else client + for pid, persona in chat_orchestrator.personas.items() } return {"orchestrator": client, "personas": persona_clients} @@ -53,9 +54,12 @@ def resolve_llm_clients(user: User) -> Dict[str, Any]: orchestrator_client = get_llm_client(orchestrator_backend) persona_clients = {} - for pid in chat_orchestrator.personas: - backend = (config.persona_backends or {}).get(pid, config.default_backend) - persona_clients[pid] = get_llm_client(backend) + for pid, persona in chat_orchestrator.personas.items(): + if persona.backend_locked: + persona_clients[pid] = persona.llm + else: + backend = (config.persona_backends or {}).get(pid, config.default_backend) + persona_clients[pid] = get_llm_client(backend) return {"orchestrator": orchestrator_client, "personas": persona_clients} diff --git a/multi_llm_chatbot_backend/app/api/routes/provider.py b/multi_llm_chatbot_backend/app/api/routes/provider.py index 7d8b45f..31a99ae 100644 --- a/multi_llm_chatbot_backend/app/api/routes/provider.py +++ b/multi_llm_chatbot_backend/app/api/routes/provider.py @@ -40,6 +40,18 @@ async def switch_provider( f"Valid IDs: {sorted(registered)}", ) + # BrainForge personas use a dedicated client; reject overrides + # so incorrect mappings don't get persisted in the user's saved config. + locked = { + pid for pid in llm_config.persona_backends + if chat_orchestrator.personas[pid].backend_locked + } + if locked: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot override backend for locked advisors: {sorted(locked)}", + ) + backends_to_check = {llm_config.default_backend} if llm_config.orchestrator_backend: backends_to_check.add(llm_config.orchestrator_backend) diff --git a/multi_llm_chatbot_backend/app/core/brainforge_sync.py b/multi_llm_chatbot_backend/app/core/brainforge_sync.py index ba46f2a..cb80057 100644 --- a/multi_llm_chatbot_backend/app/core/brainforge_sync.py +++ b/multi_llm_chatbot_backend/app/core/brainforge_sync.py @@ -94,6 +94,7 @@ def build_brainforge_personas( system_prompt=system_prompt, llm=llm_client, temperature=5, + backend_locked=True, ) personas.append(persona) diff --git a/multi_llm_chatbot_backend/app/main.py b/multi_llm_chatbot_backend/app/main.py index d8214fc..6b0302f 100644 --- a/multi_llm_chatbot_backend/app/main.py +++ b/multi_llm_chatbot_backend/app/main.py @@ -124,7 +124,7 @@ def get_public_config(): "dark_color": colors["dark_color"], "dark_bg_color": colors["dark_bg_color"], "image": "icon://Brain", - "backend_locked": True, + "backend_locked": persona.backend_locked, "default_backend": "brainforge", }) diff --git a/multi_llm_chatbot_backend/app/models/persona.py b/multi_llm_chatbot_backend/app/models/persona.py index 2a19034..1b93672 100644 --- a/multi_llm_chatbot_backend/app/models/persona.py +++ b/multi_llm_chatbot_backend/app/models/persona.py @@ -238,12 +238,13 @@ def _ensure_compact_shape(text: str, response_length: str) -> str: return "\n".join(parts).strip() class Persona: - def __init__(self, id: str, name: str, system_prompt: str, llm: LLMClient, temperature: int = 5): + def __init__(self, id: str, name: str, system_prompt: str, llm: LLMClient, temperature: int = 5, backend_locked: bool = False): self.id = id self.name = name self.system_prompt = system_prompt self.llm = llm self.temperature = temperature + self.backend_locked = backend_locked async def respond(self, context: List[Dict], response_length: str = "medium", llm: LLMClient = None) -> str: diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py b/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py index bcac69d..f1b3f9e 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_llm_provider_config.py @@ -120,8 +120,8 @@ def test_no_config_returns_nones(self, mock_get, mock_orch): @patch("app.api.routes.chat.chat_orchestrator") @patch("app.api.routes.chat.get_llm_client") - def test_uniform_same_client_for_all(self, mock_get, mock_orch): - mock_orch.personas = {"a": MagicMock(), "b": MagicMock(), "c": MagicMock()} + def test_uniform_same_client_for_unlocked_personas(self, mock_get, mock_orch): + mock_orch.personas = {"a": MagicMock(backend_locked=False), "b": MagicMock(backend_locked=False), "c": MagicMock(backend_locked=False)} sentinel = MagicMock(name="shared_client") mock_get.return_value = sentinel @@ -136,7 +136,7 @@ def test_uniform_same_client_for_all(self, mock_get, mock_orch): @patch("app.api.routes.chat.chat_orchestrator") @patch("app.api.routes.chat.get_llm_client") def test_hybrid_orchestrator_override(self, mock_get, mock_orch): - mock_orch.personas = {"a": MagicMock()} + mock_orch.personas = {"a": MagicMock(backend_locked=False)} clients = {"gemini": MagicMock(), "ollama": MagicMock()} mock_get.side_effect = lambda b: clients[b] @@ -151,7 +151,7 @@ def test_hybrid_orchestrator_override(self, mock_get, mock_orch): @patch("app.api.routes.chat.chat_orchestrator") @patch("app.api.routes.chat.get_llm_client") def test_hybrid_orchestrator_falls_back_to_default(self, mock_get, mock_orch): - mock_orch.personas = {"a": MagicMock()} + mock_orch.personas = {"a": MagicMock(backend_locked=False)} sentinel = MagicMock() mock_get.return_value = sentinel @@ -165,7 +165,7 @@ def test_hybrid_orchestrator_falls_back_to_default(self, mock_get, mock_orch): @patch("app.api.routes.chat.chat_orchestrator") @patch("app.api.routes.chat.get_llm_client") def test_hybrid_persona_override(self, mock_get, mock_orch): - mock_orch.personas = {"a": MagicMock(), "b": MagicMock()} + mock_orch.personas = {"a": MagicMock(backend_locked=False), "b": MagicMock(backend_locked=False)} clients = {"gemini": MagicMock(), "vllm": MagicMock()} mock_get.side_effect = lambda b: clients[b] @@ -179,7 +179,7 @@ def test_hybrid_persona_override(self, mock_get, mock_orch): @patch("app.api.routes.chat.chat_orchestrator") @patch("app.api.routes.chat.get_llm_client") def test_hybrid_unmapped_persona_uses_default(self, mock_get, mock_orch): - mock_orch.personas = {"a": MagicMock(), "unmapped": MagicMock()} + mock_orch.personas = {"a": MagicMock(backend_locked=False), "unmapped": MagicMock(backend_locked=False)} clients = {"gemini": MagicMock(), "vllm": MagicMock()} mock_get.side_effect = lambda b: clients[b] @@ -193,7 +193,7 @@ def test_hybrid_unmapped_persona_uses_default(self, mock_get, mock_orch): @patch("app.api.routes.chat.chat_orchestrator") @patch("app.api.routes.chat.get_llm_client") def test_hybrid_mixed(self, mock_get, mock_orch): - mock_orch.personas = {"a": MagicMock(), "b": MagicMock()} + mock_orch.personas = {"a": MagicMock(backend_locked=False), "b": MagicMock(backend_locked=False)} clients = {"gemini": MagicMock(), "ollama": MagicMock(), "vllm": MagicMock()} mock_get.side_effect = lambda b: clients[b] @@ -207,6 +207,45 @@ def test_hybrid_mixed(self, mock_get, mock_orch): self.assertIs(result["personas"]["a"], clients["vllm"]) self.assertIs(result["personas"]["b"], clients["gemini"]) + @patch("app.api.routes.chat.chat_orchestrator") + @patch("app.api.routes.chat.get_llm_client") + def test_uniform_uses_locked_persona_own_client(self, mock_get, mock_orch): + bf_client = MagicMock(name="brainforge_client") + locked_persona = MagicMock(backend_locked=True, llm=bf_client) + mock_orch.personas = { + "regular": MagicMock(backend_locked=False), + "bf_neonai_NeonAI": locked_persona, + } + gemini_client = MagicMock(name="gemini_client") + mock_get.return_value = gemini_client + + user = _make_user(UserLLMConfig(mode="uniform", default_backend="gemini")) + result = resolve_llm_clients(user) + + self.assertIs(result["personas"]["regular"], gemini_client) + self.assertIs(result["personas"]["bf_neonai_NeonAI"], bf_client) + + @patch("app.api.routes.chat.chat_orchestrator") + @patch("app.api.routes.chat.get_llm_client") + def test_hybrid_uses_locked_persona_own_client(self, mock_get, mock_orch): + bf_client = MagicMock(name="brainforge_client") + locked_persona = MagicMock(backend_locked=True, llm=bf_client) + mock_orch.personas = { + "regular": MagicMock(backend_locked=False), + "bf_neonai_NeonAI": locked_persona, + } + clients = {"gemini": MagicMock(), "vllm": MagicMock()} + mock_get.side_effect = lambda b: clients[b] + + user = _make_user(UserLLMConfig( + mode="hybrid", default_backend="gemini", + persona_backends={"regular": "vllm"}, + )) + result = resolve_llm_clients(user) + + self.assertIs(result["personas"]["regular"], clients["vllm"]) + self.assertIs(result["personas"]["bf_neonai_NeonAI"], bf_client) + # =================================================================== # 3. switch_provider — endpoint validation @@ -219,7 +258,7 @@ class TestSwitchProvider(unittest.IsolatedAsyncioTestCase): @patch("app.api.routes.provider.get_llm_client") @patch("app.api.routes.provider.chat_orchestrator") async def test_rejects_unknown_persona_id(self, mock_orch, mock_get, mock_db): - mock_orch.personas = {"known": MagicMock()} + mock_orch.personas = {"known": MagicMock(backend_locked=False)} mock_get.return_value = MagicMock() cfg = UserLLMConfig( @@ -235,7 +274,7 @@ async def test_rejects_unknown_persona_id(self, mock_orch, mock_get, mock_db): @patch("app.api.routes.provider.get_llm_client") @patch("app.api.routes.provider.chat_orchestrator") async def test_accepts_known_persona_ids(self, mock_orch, mock_get, mock_db): - mock_orch.personas = {"a": MagicMock(), "b": MagicMock()} + mock_orch.personas = {"a": MagicMock(backend_locked=False), "b": MagicMock(backend_locked=False)} mock_get.return_value = MagicMock() mock_db.return_value.users.update_one = AsyncMock() @@ -284,7 +323,7 @@ def side_effect(backend): @patch("app.api.routes.provider.get_llm_client") @patch("app.api.routes.provider.chat_orchestrator") async def test_rejects_unconfigured_persona_backend(self, mock_orch, mock_get, mock_db): - mock_orch.personas = {"a": MagicMock()} + mock_orch.personas = {"a": MagicMock(backend_locked=False)} def side_effect(backend): if backend == "vllm": @@ -305,7 +344,7 @@ def side_effect(backend): @patch("app.api.routes.provider.get_llm_client") @patch("app.api.routes.provider.chat_orchestrator") async def test_checks_all_distinct_backends(self, mock_orch, mock_get, mock_db): - mock_orch.personas = {"a": MagicMock(), "b": MagicMock()} + mock_orch.personas = {"a": MagicMock(backend_locked=False), "b": MagicMock(backend_locked=False)} mock_get.return_value = MagicMock() mock_db.return_value.users.update_one = AsyncMock() @@ -341,11 +380,30 @@ async def test_persists_to_database(self, mock_orch, mock_get, mock_db): {"$set": {"llm_config": cfg.model_dump()}}, ) + @patch("app.api.routes.provider.get_database") + @patch("app.api.routes.provider.get_llm_client") + @patch("app.api.routes.provider.chat_orchestrator") + async def test_rejects_locked_persona_override(self, mock_orch, mock_get, mock_db): + mock_orch.personas = { + "regular": MagicMock(backend_locked=False), + "bf_neonai_NeonAI": MagicMock(backend_locked=True), + } + mock_get.return_value = MagicMock() + + cfg = UserLLMConfig( + mode="hybrid", default_backend="gemini", + persona_backends={"bf_neonai_NeonAI": "gemini"}, + ) + with self.assertRaises(HTTPException) as ctx: + await switch_provider(cfg, _make_user()) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("locked", ctx.exception.detail.lower()) + @patch("app.api.routes.provider.get_database") @patch("app.api.routes.provider.get_llm_client") @patch("app.api.routes.provider.chat_orchestrator") async def test_returns_updated_config(self, mock_orch, mock_get, mock_db): - mock_orch.personas = {"a": MagicMock()} + mock_orch.personas = {"a": MagicMock(backend_locked=False)} mock_get.return_value = MagicMock() mock_db.return_value.users.update_one = AsyncMock() diff --git a/phd-advisor-frontend/src/components/AdvisorConfigPanel.js b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js index 90f0d15..035401e 100644 --- a/phd-advisor-frontend/src/components/AdvisorConfigPanel.js +++ b/phd-advisor-frontend/src/components/AdvisorConfigPanel.js @@ -50,11 +50,12 @@ const optionStyle = { background: 'var(--bg-secondary)', color: 'var(--text-prim const titleStyle = { fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }; -const buildInitial = (initialConfig, personaIds, availableBackends) => { +const buildInitial = (initialConfig, personaIds, availableBackends, advisors) => { const fallback = initialConfig?.default_backend || availableBackends[0]; const seed = initialConfig?.persona_backends || {}; const personas = {}; for (const id of personaIds) { + if (advisors?.[id]?.backendLocked) continue; personas[id] = seed[id] || DEFAULT_BACKEND; } return { @@ -78,7 +79,7 @@ const AdvisorConfigPanel = ({ const isControlled = value !== undefined; const [internal, setInternal] = useState(() => - buildInitial(initialConfig, personaIds, availableBackends) + buildInitial(initialConfig, personaIds, availableBackends, advisors) ); useEffect(() => { @@ -87,6 +88,7 @@ const AdvisorConfigPanel = ({ const next = { ...prev.persona_backends }; let changed = false; for (const id of personaIds) { + if (advisors?.[id]?.backendLocked) continue; if (next[id] === undefined) { next[id] = DEFAULT_BACKEND; changed = true; @@ -94,7 +96,7 @@ const AdvisorConfigPanel = ({ } return changed ? { ...prev, persona_backends: next } : prev; }); - }, [personaIds, availableBackends, isControlled]); + }, [personaIds, availableBackends, isControlled, advisors]); const config = isControlled ? value : internal; diff --git a/phd-advisor-frontend/src/components/SettingsModal.js b/phd-advisor-frontend/src/components/SettingsModal.js index 11aaa78..5ba258f 100644 --- a/phd-advisor-frontend/src/components/SettingsModal.js +++ b/phd-advisor-frontend/src/components/SettingsModal.js @@ -120,7 +120,10 @@ const SettingsModal = ({ const fallback = llmConfig?.default_backend || availableBackends?.[0]; const seed = llmConfig?.persona_backends || {}; const personas = {}; - for (const id of personaIds) personas[id] = seed[id] || DEFAULT_BACKEND; + for (const id of personaIds) { + if (advisors?.[id]?.backendLocked) continue; + personas[id] = seed[id] || DEFAULT_BACKEND; + } return { default_backend: fallback, orchestrator_backend: llmConfig?.orchestrator_backend || DEFAULT_BACKEND, diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index ee11425..ae2d78c 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -152,11 +152,20 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig } }; - const handleHybridSubmit = async (hybridConfig) => { - const ok = await submitProviderConfig( - { mode: 'hybrid', ...hybridConfig }, - 'Hybrid configuration' - ); + // Receives a stripped config (via stripDefaultBackends) and sends the + // appropriate mode: "uniform" when no per-advisor overrides exist, + // "hybrid" when the user has customized individual backends. + const handleConfigSubmit = async (config) => { + const hasOverrides = + (config.orchestrator_backend && config.orchestrator_backend !== config.default_backend) || + Object.keys(config.persona_backends || {}).length > 0; + + const payload = hasOverrides + ? { mode: 'hybrid', ...config } + : { mode: 'uniform', default_backend: config.default_backend }; + + const label = hasOverrides ? 'Hybrid configuration' : config.default_backend; + const ok = await submitProviderConfig(payload, label); if (ok) setIsSettingsOpen(false); return ok; }; @@ -1172,7 +1181,7 @@ const handleNewChat = async (sessionId = null) => { availableBackends={availableBackends} llmConfig={llmConfig} isSaving={isProviderSwitching} - onSubmitConfig={handleHybridSubmit} + onSubmitConfig={handleConfigSubmit} onClose={() => setIsSettingsOpen(false)} /> )}