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
12 changes: 8 additions & 4 deletions multi_llm_chatbot_backend/app/api/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand All @@ -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}

Expand Down
12 changes: 12 additions & 0 deletions multi_llm_chatbot_backend/app/api/routes/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions multi_llm_chatbot_backend/app/core/brainforge_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def build_brainforge_personas(
system_prompt=system_prompt,
llm=llm_client,
temperature=5,
backend_locked=True,
)
personas.append(persona)

Expand Down
2 changes: 1 addition & 1 deletion multi_llm_chatbot_backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})

Expand Down
3 changes: 2 additions & 1 deletion multi_llm_chatbot_backend/app/models/persona.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]

Expand All @@ -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

Expand All @@ -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]

Expand All @@ -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]

Expand All @@ -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]

Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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()

Expand Down Expand Up @@ -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":
Expand All @@ -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()

Expand Down Expand Up @@ -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()

Expand Down
8 changes: 5 additions & 3 deletions phd-advisor-frontend/src/components/AdvisorConfigPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -78,7 +79,7 @@ const AdvisorConfigPanel = ({
const isControlled = value !== undefined;

const [internal, setInternal] = useState(() =>
buildInitial(initialConfig, personaIds, availableBackends)
buildInitial(initialConfig, personaIds, availableBackends, advisors)
);

useEffect(() => {
Expand All @@ -87,14 +88,15 @@ 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;
}
}
return changed ? { ...prev, persona_backends: next } : prev;
});
}, [personaIds, availableBackends, isControlled]);
}, [personaIds, availableBackends, isControlled, advisors]);

const config = isControlled ? value : internal;

Expand Down
5 changes: 4 additions & 1 deletion phd-advisor-frontend/src/components/SettingsModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 15 additions & 6 deletions phd-advisor-frontend/src/pages/ChatPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -1172,7 +1181,7 @@ const handleNewChat = async (sessionId = null) => {
availableBackends={availableBackends}
llmConfig={llmConfig}
isSaving={isProviderSwitching}
onSubmitConfig={handleHybridSubmit}
onSubmitConfig={handleConfigSubmit}
onClose={() => setIsSettingsOpen(false)}
/>
)}
Expand Down
Loading