diff --git a/panel/src/components/settings/ai-routing-card.tsx b/panel/src/components/settings/ai-routing-card.tsx index 15cd8c1b..b4144bc1 100644 --- a/panel/src/components/settings/ai-routing-card.tsx +++ b/panel/src/components/settings/ai-routing-card.tsx @@ -39,6 +39,7 @@ import { Server, ShieldCheck, Sparkles, + Zap, } from "lucide-react"; import { toast } from "sonner"; import { AssignmentScope, ModelProvider } from "@/types"; @@ -175,6 +176,9 @@ export function AIRoutingCard() { const catalogOllamaOnly = catalog.filter( (c: { provider_type: ModelProvider }) => c.provider_type === ModelProvider.OLLAMA_CLOUD, ); + const catalogGrokOnly = catalog.filter( + (c: { provider_type: ModelProvider }) => c.provider_type === ModelProvider.GROK, + ); const catalogAnthropicOnly = catalog.filter( (c: { provider_type: ModelProvider }) => c.provider_type === ModelProvider.ANTHROPIC, ); @@ -190,6 +194,20 @@ export function AIRoutingCard() { } }; + const flipToGrok = async () => { + if (!hasGrokKey) { + toast.error("Save the Grok (xAI) API key first"); + return; + } + if (!confirm("Switch every agent to Grok? Clears any overrides.")) return; + try { + await applyMode.mutateAsync({ mode: "grok" }); + toast.success("All agents now on Grok"); + } catch (e) { + toast.error("Switch failed: " + errMsg(e)); + } + }; + const flipToOllama = async () => { if (!hasOllamaKey) { toast.error("Save an Ollama API key first"); @@ -236,6 +254,18 @@ export function AIRoutingCard() { toast.error("Pick a model for at least one agent"); return; } + const needsGrok = Object.values(per_agent).some((m) => + catalog.find( + (c: { model_name: string; provider_type: ModelProvider }) => + c.model_name === m && c.provider_type === ModelProvider.GROK, + ), + ); + if (needsGrok && !hasGrokKey) { + toast.error( + "At least one agent is routed to a Grok model but no key is saved", + ); + return; + } const needsKey = Object.values(per_agent).some((m) => catalog.find( (c: { model_name: string; provider_type: ModelProvider }) => @@ -274,9 +304,9 @@ export function AIRoutingCard() { Decide which model backs each agent. Anthropic uses the mounted - ~/.claude auth; Ollama Cloud uses - the API key you save below; Self-Hosted connects to any OpenAI-compatible - endpoint you run locally. + ~/.claude auth; Grok (xAI) and Ollama + Cloud use the API keys you save below; Self-Hosted connects to any + OpenAI-compatible endpoint you run locally. @@ -391,7 +421,7 @@ export function AIRoutingCard() { {/* -------- Mode toggle -------- */}
-
+
} label="Anthropic" @@ -400,6 +430,18 @@ export function AIRoutingCard() { onClick={flipToAnthropic} disabled={applyMode.isPending} /> + } + label="Grok" + description={ + hasGrokKey + ? "Every agent uses Grok (grok-build-0.1)." + : "Save the Grok (xAI) key first." + } + active={currentMode === "grok"} + onClick={flipToGrok} + disabled={applyMode.isPending || !hasGrokKey} + /> } label="Ollama" @@ -545,6 +587,23 @@ export function AIRoutingCard() { )} + {/* Grok (xAI) models */} + {catalogGrokOnly.length > 0 && ( + + + + Grok (xAI) + + {catalogGrokOnly.map( + (c: { model_name: string; display_name: string }) => ( + + {c.display_name} + + ), + )} + + )} + {/* Ollama Cloud models */} {catalogOllamaOnly.length > 0 && ( @@ -659,17 +718,19 @@ function errMsg(e: unknown): string { function ProviderBadge({ variant, }: { - variant: "anthropic" | "ollama" | "self-hosted"; + variant: "anthropic" | "grok" | "ollama" | "self-hosted"; }) { const styles: Record = { anthropic: "bg-blue-500/20 text-blue-700 dark:text-blue-400", ollama: "bg-violet-500/20 text-violet-700 dark:text-violet-400", "self-hosted": "bg-purple-500/20 text-purple-700 dark:text-purple-400", + grok: "bg-teal-500/20 text-teal-700 dark:text-teal-400", }; const labels: Record = { anthropic: "A", ollama: "O", "self-hosted": "S", + grok: "G", }; return ( Literal["anthropic", "ollama", "mix", "self_hosted"]: + async def derive_mode( + self, + ) -> Literal["anthropic", "grok", "ollama", "mix", "self_hosted"]: """Return the current "mode" label for the Settings UI. Decision tree matches what `apply_mode` writes: @@ -327,6 +329,8 @@ class ModelRoutingService(BaseService): len(assignments) == 1 and assignments[0].scope == AssignmentScope.GLOBAL ) if only_global: + if assignments[0].provider.type == ModelProvider.GROK: + return "grok" if assignments[0].provider.type == ModelProvider.OLLAMA_CLOUD: return "ollama" if assignments[0].provider.type == ModelProvider.LOCAL: @@ -429,6 +433,8 @@ class ModelRoutingService(BaseService): - "self_hosted": wipe all assignments, enable the LOCAL provider, and set the GLOBAL default to `default_model` (a self-hosted model name — not validated against the static catalog). + - "grok": wipe all assignments, set the GLOBAL default to a + Grok (xAI) model (default grok-build-0.1). Requires the xAI key. - "mix": apply per-agent map verbatim. Any agent not in the map falls through to the GLOBAL default — which is whatever it was (preserves prior state). Self-hosted model names (not in the @@ -436,6 +442,8 @@ class ModelRoutingService(BaseService): """ if mode == "anthropic": await self._apply_anthropic() + elif mode == "grok": + await self._apply_grok(default_model) elif mode == "ollama": await self._apply_ollama(default_model) elif mode == "self_hosted": @@ -445,7 +453,7 @@ class ModelRoutingService(BaseService): else: raise ValueError( f"Unknown mode '{mode}'." - " Use 'anthropic', 'ollama', 'self_hosted', or 'mix'." + " Use 'anthropic', 'grok', 'ollama', 'self_hosted', or 'mix'." ) async def _apply_anthropic(self) -> None: @@ -454,6 +462,24 @@ class ModelRoutingService(BaseService): await self.session.flush() self.log.info("Mode applied: anthropic (all assignments cleared)") + async def _apply_grok(self, default_model: str | None) -> None: + """Wipe assignments, set the GLOBAL default to a Grok (xAI) model. + + ``grok-build-0.1`` is in the catalog under the GROK provider, so the + upsert resolves to the seeded Grok provider row. Routing to Grok needs + the xAI key set (which enables the provider); without it, agents fall + back to the Anthropic path at spawn — same contract as Ollama. + """ + await self.session.execute(sa_delete(ModelAssignmentTable)) + await self.session.flush() + model_name = default_model or "grok-build-0.1" + await self.upsert_assignment( + scope=AssignmentScope.GLOBAL, + scope_value=None, + model_name=model_name, + ) + self.log.info("Mode applied: grok", default_model=model_name) + async def _apply_ollama(self, default_model: str | None) -> None: """Wipe assignments, set the GLOBAL default to an Ollama Cloud model.""" await self.session.execute(sa_delete(ModelAssignmentTable)) diff --git a/tests/integration/test_llm_routing.py b/tests/integration/test_llm_routing.py index 316b51f0..392aca07 100644 --- a/tests/integration/test_llm_routing.py +++ b/tests/integration/test_llm_routing.py @@ -37,13 +37,19 @@ async def llm_setup( type=ModelProvider.ANTHROPIC, enabled=True, ) + grok = ProviderConfigTable( + name="grok-test", + type=ModelProvider.GROK, + enabled=True, + base_url="https://api.x.ai/v1", + ) ollama = ProviderConfigTable( name="ollama-test", type=ModelProvider.OLLAMA_CLOUD, enabled=True, base_url="https://ollama.example.com", ) - db_session.add_all([anthropic, ollama]) + db_session.add_all([anthropic, grok, ollama]) await db_session.flush() yield {"svc": ModelRoutingService(db_session)} @@ -177,6 +183,16 @@ async def test_derive_mode_ollama_when_only_ollama_global(llm_setup: dict) -> No assert await svc.derive_mode() == "ollama" +@pytest.mark.asyncio +async def test_derive_mode_grok_when_only_grok_global(llm_setup: dict) -> None: + svc = llm_setup["svc"] + grok_model = _first_model_for_type(ModelProvider.GROK) + await svc.upsert_assignment( + scope=AssignmentScope.GLOBAL, scope_value=None, model_name=grok_model + ) + assert await svc.derive_mode() == "grok" + + @pytest.mark.asyncio async def test_derive_mode_mix_with_per_agent(llm_setup: dict) -> None: svc = llm_setup["svc"] @@ -215,6 +231,16 @@ async def test_apply_mode_ollama_sets_global(llm_setup: dict) -> None: assert assignments[0].scope == AssignmentScope.GLOBAL +@pytest.mark.asyncio +async def test_apply_mode_grok_sets_global(llm_setup: dict) -> None: + svc = llm_setup["svc"] + await svc.apply_mode(mode="grok") + assignments = await svc.list_assignments() + assert len(assignments) == 1 + assert assignments[0].scope == AssignmentScope.GLOBAL + assert assignments[0].provider.type == ModelProvider.GROK + + @pytest.mark.asyncio async def test_apply_mode_mix_requires_per_agent(llm_setup: dict) -> None: svc = llm_setup["svc"]