mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(routing): cost-tiered complexity routing + saved presets (#656)
The 08-31 lever: model_assignments gains one compound rung —
AGENT_SLUG > ROLE('{role}:{complexity}') > ROLE > GLOBAL — so a
low-complexity task can route to a cheaper tier while coordinators stay
pinned. Structurally opt-in: zero rows means byte-identical routing
(pinned by a named test across every precedence case), the cost_tiered
apply-mode (seeds developer:low→haiku) is reachable only from the
explicit PM-gated endpoint — verified no startup path can apply it.
Overrides are downgrade-only (input-price comparator), allowlisted to
{developer, qa, documenter} — cell_pm excluded per the org's own
coordinator definition and its documented weak-model incidents — and
validated at write time (disabled/unconfigured provider rejected with
remediation; cross-provider-family overrides warn explicitly).
Per adversarial review: the four mode-switch applies now spare compound
rows exactly like agent pins (the 2026-07-17 unscoped-wipe class, new
victim, same fix extended via one shared wipe helper) with panel cache
invalidation + truthful confirm dialogs; preset apply validates the
entire payload BEFORE the wipe (validate-all-first), with a savepoint
crash test proving rollback.
Presets (CEO request): routing_presets table (migration 082) snapshots
the full mix — mode, per-agent overrides, complexity rows — with
save/apply/delete endpoints and a panel preset bar; applying skips
since-removed models with per-entry notes, never silently.
Task complexity threads task_id through _resolve_agent_route at both
call sites; taskless spawns unchanged. 235 backend + 23 panel tests.
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
import type {
|
||||
ComplexityOverride,
|
||||
RoutingPreset,
|
||||
} from "@/lib/api/providers";
|
||||
|
||||
const {
|
||||
catalog,
|
||||
@@ -15,6 +26,13 @@ const {
|
||||
saveSelfHostedConfig,
|
||||
testSelfHosted,
|
||||
getSelfHostedModels,
|
||||
getComplexityOverrides,
|
||||
setComplexityOverride,
|
||||
deleteComplexityOverride,
|
||||
listPresets,
|
||||
savePreset,
|
||||
applyPreset,
|
||||
deletePreset,
|
||||
} = vi.hoisted(() => ({
|
||||
catalog: vi.fn(async () => [
|
||||
{
|
||||
@@ -53,6 +71,21 @@ const {
|
||||
error: null,
|
||||
})),
|
||||
getSelfHostedModels: vi.fn(async () => []),
|
||||
getComplexityOverrides: vi.fn(async (): Promise<ComplexityOverride[]> => []),
|
||||
setComplexityOverride: vi.fn(async (payload: ComplexityOverride) => payload),
|
||||
deleteComplexityOverride: vi.fn(async () => undefined),
|
||||
listPresets: vi.fn(async (): Promise<RoutingPreset[]> => []),
|
||||
savePreset: vi.fn(async (name: string) => ({
|
||||
id: "preset-1",
|
||||
name,
|
||||
created_at: "2026-07-23T00:00:00Z",
|
||||
})),
|
||||
applyPreset: vi.fn(async () => ({
|
||||
mode: "mix",
|
||||
assignments: [],
|
||||
skipped: [] as string[],
|
||||
})),
|
||||
deletePreset: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/providers", () => ({
|
||||
@@ -68,10 +101,95 @@ vi.mock("@/lib/api/providers", () => ({
|
||||
saveSelfHostedConfig,
|
||||
testSelfHosted,
|
||||
getSelfHostedModels,
|
||||
getComplexityOverrides,
|
||||
setComplexityOverride,
|
||||
deleteComplexityOverride,
|
||||
listPresets,
|
||||
savePreset,
|
||||
applyPreset,
|
||||
deletePreset,
|
||||
},
|
||||
COMPLEXITY_OVERRIDE_ROLES: ["developer", "qa", "documenter"],
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
// Functional Select mock (mirrors select-repo-picker.test.tsx /
|
||||
// a2a-reply-composer.test.tsx): SelectItem renders as a clickable button
|
||||
// wired to onValueChange via context, so a real "pick a value" interaction
|
||||
// can be simulated without Radix's portal/pointer machinery. SelectTrigger
|
||||
// keeps `role="combobox"` so the pre-existing per-agent-table combobox-count
|
||||
// assertion is unaffected, and stamps `data-value` from context so a test can
|
||||
// `waitFor` an async-loaded value (e.g. a complexity override) landing before
|
||||
// interacting further.
|
||||
vi.mock("@/components/ui/select", () => {
|
||||
const Ctx = React.createContext<{
|
||||
value?: string;
|
||||
onValueChange: (v: string) => void;
|
||||
}>({ onValueChange: () => {} });
|
||||
return {
|
||||
Select: ({
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
}: {
|
||||
value?: string;
|
||||
onValueChange?: (v: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Ctx.Provider
|
||||
value={{ value, onValueChange: onValueChange ?? (() => {}) }}
|
||||
>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
|
||||
const { value } = React.useContext(Ctx);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={false}
|
||||
aria-controls="mock-select-content"
|
||||
data-value={value ?? ""}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
SelectValue: () => null,
|
||||
SelectGroup: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectLabel: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { onValueChange } = React.useContext(Ctx);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => onValueChange(value)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Full live roster (minus CEO/system) — mirrors foundation/identity.py so the
|
||||
// per-agent override grid is exercised against the real 25-agent org chart,
|
||||
@@ -277,6 +395,13 @@ describe("AIRoutingCard", () => {
|
||||
saveSelfHostedConfig.mockClear();
|
||||
testSelfHosted.mockClear();
|
||||
getSelfHostedModels.mockClear();
|
||||
getComplexityOverrides.mockClear();
|
||||
setComplexityOverride.mockClear();
|
||||
deleteComplexityOverride.mockClear();
|
||||
listPresets.mockClear();
|
||||
savePreset.mockClear();
|
||||
applyPreset.mockClear();
|
||||
deletePreset.mockClear();
|
||||
useAgentDefinitions.mockReturnValue({
|
||||
data: FULL_ROSTER,
|
||||
isLoading: false,
|
||||
@@ -468,4 +593,298 @@ describe("AIRoutingCard", () => {
|
||||
);
|
||||
expect(applyMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Complexity overrides (cost-tiered routing)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Complexity overrides section", () => {
|
||||
it("renders one row per allowlisted role with Low/High selects, and no coordinator role", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Complexity overrides");
|
||||
|
||||
const section = screen.getByText("Complexity overrides").closest("section")!;
|
||||
for (const label of ["Developer", "QA", "Documenter"]) {
|
||||
expect(within(section).getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
// Coordinator (cell_pm, main_pm)/pr_reviewer/board/CEO-facing roles are
|
||||
// never offered a row here — scoped to this section, since "Cell PM"/
|
||||
// "Main PM"/"PR Reviewer" legitimately appear elsewhere (the per-agent
|
||||
// mix table's roster).
|
||||
expect(within(section).queryByText("Cell PM")).not.toBeInTheDocument();
|
||||
expect(within(section).queryByText("Main PM")).not.toBeInTheDocument();
|
||||
expect(within(section).queryByText("PR Reviewer")).not.toBeInTheDocument();
|
||||
|
||||
// 3 roles x 2 (low/high) selects.
|
||||
expect(section.querySelectorAll('[role="combobox"]')).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("picking a model for a role+complexity calls setComplexityOverride with the right payload", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Complexity overrides");
|
||||
|
||||
const devLow = screen.getByTestId("complexity-select-developer-low");
|
||||
fireEvent.click(
|
||||
await within(devLow).findByRole("option", { name: "Claude Opus 4.6" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setComplexityOverride).toHaveBeenCalledWith({
|
||||
role: "developer",
|
||||
complexity: "low",
|
||||
model_name: "claude-opus-4-6",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a returned cross-family warning as its own warning toast", async () => {
|
||||
setComplexityOverride.mockResolvedValueOnce({
|
||||
role: "developer",
|
||||
complexity: "low",
|
||||
model_name: "grok-build-0.1",
|
||||
warning:
|
||||
"'grok-build-0.1' routes through grok, a different provider family than developer's Anthropic baseline.",
|
||||
});
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Complexity overrides");
|
||||
|
||||
const devLow = screen.getByTestId("complexity-select-developer-low");
|
||||
fireEvent.click(
|
||||
await within(devLow).findByRole("option", { name: "Grok Build 0.1" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("different provider family"),
|
||||
),
|
||||
);
|
||||
// Still allowed — the success toast still fires alongside the warning.
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"developer:low → grok-build-0.1",
|
||||
);
|
||||
});
|
||||
|
||||
it("never shows a warning toast when the response carries none", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Complexity overrides");
|
||||
|
||||
const devLow = screen.getByTestId("complexity-select-developer-low");
|
||||
fireEvent.click(
|
||||
await within(devLow).findByRole("option", { name: "Claude Opus 4.6" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setComplexityOverride).toHaveBeenCalled());
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("picking '(none)' on a row with no existing override never calls deleteComplexityOverride", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Complexity overrides");
|
||||
|
||||
const qaHigh = screen.getByTestId("complexity-select-qa-high");
|
||||
fireEvent.click(
|
||||
await within(qaHigh).findByRole("option", { name: "(none)" }),
|
||||
);
|
||||
|
||||
// No pre-existing row for qa:high (getComplexityOverrides returns []
|
||||
// by default) — clearing an already-empty selection is a no-op.
|
||||
await waitFor(() => expect(setComplexityOverride).not.toHaveBeenCalled());
|
||||
expect(deleteComplexityOverride).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("picking '(none)' on a row WITH an existing override calls deleteComplexityOverride", async () => {
|
||||
getComplexityOverrides.mockResolvedValueOnce([
|
||||
{ role: "documenter", complexity: "low", model_name: "grok-build-0.1" },
|
||||
]);
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Complexity overrides");
|
||||
|
||||
const documenterLow = await screen.findByTestId(
|
||||
"complexity-select-documenter-low",
|
||||
);
|
||||
// Wait for the async-loaded override to actually land on the Select
|
||||
// (its combobox reflects the current value via data-value) before
|
||||
// clearing it — otherwise the click races the query resolving.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(documenterLow).getByRole("combobox"),
|
||||
).toHaveAttribute("data-value", "grok-build-0.1"),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(documenterLow).getByRole("option", { name: "(none)" }),
|
||||
);
|
||||
|
||||
// deleteComplexityOverride(role, complexity) — positional, per the
|
||||
// providersApi signature (mirrors the real hook's mutationFn).
|
||||
await waitFor(() =>
|
||||
expect(deleteComplexityOverride).toHaveBeenCalledWith(
|
||||
"documenter",
|
||||
"low",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cost-Tiered mode button (additive seed, never wipes routing)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Cost-Tiered mode button", () => {
|
||||
it("renders beside the other mode buttons and applies mode='cost_tiered' on confirm", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Grok (xAI) API key");
|
||||
|
||||
fireEvent.click(screen.getByText("Cost-Tiered"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(applyMode).toHaveBeenCalledWith({ mode: "cost_tiered" }),
|
||||
);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does nothing when the confirm dialog is declined", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Grok (xAI) API key");
|
||||
|
||||
fireEvent.click(screen.getByText("Cost-Tiered"));
|
||||
|
||||
await waitFor(() => expect(confirmSpy).toHaveBeenCalled());
|
||||
expect(applyMode).not.toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mode switches preserve complexity overrides (2026-07-17-style incident:
|
||||
// these same buttons once wiped AGENT_SLUG pins) — the confirm text says so
|
||||
// and the query cache is refreshed for both, not just the mode snapshot.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Mode switches preserve complexity overrides", () => {
|
||||
it("applying Anthropic mode also refetches complexity overrides, not just the mode snapshot", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Grok (xAI) API key");
|
||||
getComplexityOverrides.mockClear();
|
||||
|
||||
fireEvent.click(screen.getByText("Anthropic"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(applyMode).toHaveBeenCalledWith({ mode: "anthropic" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getComplexityOverrides.mock.calls.length).toBeGreaterThan(0),
|
||||
);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("the Anthropic confirm dialog states complexity overrides are kept", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Grok (xAI) API key");
|
||||
|
||||
fireEvent.click(screen.getByText("Anthropic"));
|
||||
|
||||
await waitFor(() => expect(confirmSpy).toHaveBeenCalled());
|
||||
expect(confirmSpy.mock.calls[0][0]).toContain("complexity");
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Routing presets (named, full snapshots)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Routing presets bar", () => {
|
||||
it("lists saved presets and disables Apply/Delete until one is picked", async () => {
|
||||
listPresets.mockResolvedValueOnce([
|
||||
{ id: "p1", name: "Cheap Fleet", created_at: "2026-07-01T00:00:00Z" },
|
||||
]);
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Cheap Fleet");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("save-as-preset flow: reveals a name input and Confirm calls savePreset", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Routing presets");
|
||||
|
||||
fireEvent.click(screen.getByText("Save as preset…"));
|
||||
const nameInput = screen.getByPlaceholderText("Preset name");
|
||||
fireEvent.change(nameInput, { target: { value: "My Setup" } });
|
||||
fireEvent.click(screen.getByText("Confirm"));
|
||||
|
||||
await waitFor(() => expect(savePreset).toHaveBeenCalledWith("My Setup"));
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith('Saved preset "My Setup"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("save-as-preset with an empty name shows an error and never calls savePreset", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Routing presets");
|
||||
|
||||
fireEvent.click(screen.getByText("Save as preset…"));
|
||||
fireEvent.click(screen.getByText("Confirm"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Enter a preset name first"),
|
||||
);
|
||||
expect(savePreset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applying a preset with skipped rows surfaces them in a toast error", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
listPresets.mockResolvedValueOnce([
|
||||
{ id: "p1", name: "Cheap Fleet", created_at: "2026-07-01T00:00:00Z" },
|
||||
]);
|
||||
applyPreset.mockResolvedValueOnce({
|
||||
mode: "mix",
|
||||
assignments: [],
|
||||
skipped: ["Skipped role:developer (ghost-model) — Unknown model"],
|
||||
});
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
const presetSection = (await screen.findByText("Routing presets")).closest(
|
||||
"section",
|
||||
)!;
|
||||
fireEvent.click(
|
||||
await within(presetSection).findByRole("option", {
|
||||
name: "Cheap Fleet",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
|
||||
await waitFor(() => expect(applyPreset).toHaveBeenCalledWith("p1"));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("1 row(s) skipped"),
|
||||
),
|
||||
);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("deleting a preset calls deletePreset and clears the selection", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
listPresets.mockResolvedValueOnce([
|
||||
{ id: "p1", name: "Cheap Fleet", created_at: "2026-07-01T00:00:00Z" },
|
||||
]);
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
const presetSection = (await screen.findByText("Routing presets")).closest(
|
||||
"section",
|
||||
)!;
|
||||
fireEvent.click(
|
||||
await within(presetSection).findByRole("option", {
|
||||
name: "Cheap Fleet",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deletePreset).toHaveBeenCalledWith("p1"));
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import {
|
||||
useApplyMode,
|
||||
useApplyPreset,
|
||||
useCatalog,
|
||||
useComplexityOverrides,
|
||||
useDeleteComplexityOverride,
|
||||
useDeletePreset,
|
||||
useGrokKey,
|
||||
useOllamaKey,
|
||||
useRoutingMode,
|
||||
useRoutingPresets,
|
||||
useSavePreset,
|
||||
useSetComplexityOverride,
|
||||
useSetGrokKey,
|
||||
useSetOllamaKey,
|
||||
useSelfHostedModels,
|
||||
@@ -34,6 +41,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Key,
|
||||
KeyRound,
|
||||
Server,
|
||||
@@ -43,7 +51,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AssignmentScope, AgentRole, ModelProvider } from "@/types";
|
||||
import type { SelfHostedModel } from "@/lib/api/providers";
|
||||
import {
|
||||
COMPLEXITY_OVERRIDE_ROLES,
|
||||
type ComplexityLevel,
|
||||
type SelfHostedModel,
|
||||
} from "@/lib/api/providers";
|
||||
import type { RoutingMode, SelfHostedTestResult } from "@/lib/api/providers";
|
||||
import { SelfHostedSection } from "@/components/settings/self-hosted-section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -126,11 +138,29 @@ function byOrgOrder(a: AgentDefinition, b: AgentDefinition): number {
|
||||
return ra !== rb ? ra - rb : a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
// Display labels for the complexity-override allowlist — mirrors the
|
||||
// backend's fixed `_COMPLEXITY_OVERRIDE_ROLES` (developer/qa/documenter).
|
||||
// cell_pm is deliberately excluded (a coordinator role — see
|
||||
// COMPLEXITY_OVERRIDE_ROLES), along with every other coordinator/board/
|
||||
// CEO-facing role.
|
||||
const COMPLEXITY_ROLE_LABELS: Record<string, string> = {
|
||||
developer: "Developer",
|
||||
qa: "QA",
|
||||
documenter: "Documenter",
|
||||
};
|
||||
|
||||
export function AIRoutingCard() {
|
||||
const { data: catalog = [] } = useCatalog();
|
||||
const { data: keyStatus } = useOllamaKey();
|
||||
const { data: snapshot } = useRoutingMode();
|
||||
const { data: selfHostedModels = [] } = useSelfHostedModels();
|
||||
const { data: complexityOverrides = [] } = useComplexityOverrides();
|
||||
const setComplexityOverride = useSetComplexityOverride();
|
||||
const deleteComplexityOverride = useDeleteComplexityOverride();
|
||||
const { data: presets = [] } = useRoutingPresets();
|
||||
const savePreset = useSavePreset();
|
||||
const applyPreset = useApplyPreset();
|
||||
const deletePreset = useDeletePreset();
|
||||
const {
|
||||
data: agentDefs,
|
||||
isLoading: agentsLoading,
|
||||
@@ -255,14 +285,15 @@ export function AIRoutingCard() {
|
||||
const flipToAnthropic = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Switch every agent to Anthropic? Per-agent pins are kept; role/global assignments are replaced.",
|
||||
"Switch every agent to Anthropic? Per-agent pins and complexity " +
|
||||
"overrides are kept; other role/global assignments are replaced.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await applyMode.mutateAsync({ mode: "anthropic" });
|
||||
toast.success(
|
||||
"Role/global routing now on Anthropic — per-agent pins kept",
|
||||
"Role/global routing now on Anthropic — per-agent pins and complexity overrides kept",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error("Switch failed: " + errMsg(e));
|
||||
@@ -276,13 +307,16 @@ export function AIRoutingCard() {
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
"Switch every agent to Grok? Per-agent pins are kept; role/global assignments are replaced.",
|
||||
"Switch every agent to Grok? Per-agent pins and complexity " +
|
||||
"overrides are kept; other role/global assignments are replaced.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await applyMode.mutateAsync({ mode: "grok" });
|
||||
toast.success("Role/global routing now on Grok — per-agent pins kept");
|
||||
toast.success(
|
||||
"Role/global routing now on Grok — per-agent pins and complexity overrides kept",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error("Switch failed: " + errMsg(e));
|
||||
}
|
||||
@@ -295,13 +329,16 @@ export function AIRoutingCard() {
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
"Switch every agent to Ollama? Per-agent pins are kept; role/global assignments are replaced.",
|
||||
"Switch every agent to Ollama? Per-agent pins and complexity " +
|
||||
"overrides are kept; other role/global assignments are replaced.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await applyMode.mutateAsync({ mode: "ollama" });
|
||||
toast.success("Role/global routing now on Ollama — per-agent pins kept");
|
||||
toast.success(
|
||||
"Role/global routing now on Ollama — per-agent pins and complexity overrides kept",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error("Switch failed: " + errMsg(e));
|
||||
}
|
||||
@@ -314,7 +351,9 @@ export function AIRoutingCard() {
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
"Switch every agent to the self-hosted LLM? Per-agent pins are kept; role/global assignments are replaced.",
|
||||
"Switch every agent to the self-hosted LLM? Per-agent pins and " +
|
||||
"complexity overrides are kept; other role/global assignments " +
|
||||
"are replaced.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -324,7 +363,7 @@ export function AIRoutingCard() {
|
||||
...(selfHostedModel ? { default_model: selfHostedModel } : {}),
|
||||
});
|
||||
toast.success(
|
||||
"Role/global routing now on Self-Hosted LLM — per-agent pins kept",
|
||||
"Role/global routing now on Self-Hosted LLM — per-agent pins and complexity overrides kept",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error("Switch failed: " + errMsg(e));
|
||||
@@ -382,6 +421,132 @@ export function AIRoutingCard() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Cost-tiered defaults (additive seed — never wipes existing routing) ---
|
||||
const flipToCostTiered = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Seed the day-1 cost-tiered default (developer:low → Haiku)? " +
|
||||
"Unlike the other buttons this is additive — it does not clear " +
|
||||
"any existing routing.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await applyMode.mutateAsync({ mode: "cost_tiered" });
|
||||
toast.success(
|
||||
"Cost-tiered default seeded (developer:low → Haiku)",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error("Apply failed: " + errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
// --- Complexity overrides (compound ROLE(":"complexity) rows) ---
|
||||
const complexityMap = useMemo(() => {
|
||||
const map: Record<string, Partial<Record<ComplexityLevel, string>>> = {};
|
||||
for (const o of complexityOverrides) {
|
||||
map[o.role] = { ...map[o.role], [o.complexity]: o.model_name };
|
||||
}
|
||||
return map;
|
||||
}, [complexityOverrides]);
|
||||
|
||||
const handleComplexityChange = async (
|
||||
role: string,
|
||||
complexity: ComplexityLevel,
|
||||
modelName: string,
|
||||
) => {
|
||||
try {
|
||||
if (!modelName) {
|
||||
if (complexityMap[role]?.[complexity]) {
|
||||
await deleteComplexityOverride.mutateAsync({ role, complexity });
|
||||
toast.success(`Cleared ${role}:${complexity} override`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await setComplexityOverride.mutateAsync({
|
||||
role,
|
||||
complexity,
|
||||
model_name: modelName,
|
||||
});
|
||||
toast.success(`${role}:${complexity} → ${modelName}`);
|
||||
// Allowed but never silent: a cross-provider-family override (e.g. an
|
||||
// Anthropic role pinned to a Grok/Ollama/self-hosted model) also gets
|
||||
// its own warning toast.
|
||||
if (result.warning) {
|
||||
toast.warning(result.warning);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Save failed: " + errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
// --- Routing presets (named, full snapshots) ---
|
||||
const [selectedPresetId, setSelectedPresetId] = useState("");
|
||||
const [showPresetNameInput, setShowPresetNameInput] = useState(false);
|
||||
const [presetNameDraft, setPresetNameDraft] = useState("");
|
||||
const selectedPreset = presets.find((p) => p.id === selectedPresetId);
|
||||
|
||||
const handleSavePreset = async () => {
|
||||
const name = presetNameDraft.trim();
|
||||
if (!name) {
|
||||
toast.error("Enter a preset name first");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const saved = await savePreset.mutateAsync(name);
|
||||
toast.success(`Saved preset "${saved.name}"`);
|
||||
setPresetNameDraft("");
|
||||
setShowPresetNameInput(false);
|
||||
setSelectedPresetId(saved.id);
|
||||
} catch (e) {
|
||||
toast.error("Save failed: " + errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyPreset = async () => {
|
||||
if (!selectedPresetId) {
|
||||
toast.error("Pick a preset first");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
`Apply preset "${selectedPreset?.name ?? selectedPresetId}"? This ` +
|
||||
"replaces the ENTIRE current routing state (every per-agent pin, " +
|
||||
"role row, and global default) with the saved snapshot.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const result = await applyPreset.mutateAsync(selectedPresetId);
|
||||
if (result.skipped.length > 0) {
|
||||
toast.error(
|
||||
`Applied with ${result.skipped.length} row(s) skipped: ` +
|
||||
result.skipped.join("; "),
|
||||
);
|
||||
} else {
|
||||
toast.success("Preset applied");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Apply failed: " + errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePreset = async () => {
|
||||
if (!selectedPresetId) {
|
||||
toast.error("Pick a preset first");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete preset "${selectedPreset?.name ?? selectedPresetId}"?`))
|
||||
return;
|
||||
try {
|
||||
await deletePreset.mutateAsync(selectedPresetId);
|
||||
toast.success("Preset deleted");
|
||||
setSelectedPresetId("");
|
||||
} catch (e) {
|
||||
toast.error("Delete failed: " + errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -534,7 +699,7 @@ export function AIRoutingCard() {
|
||||
<HelpTip label="Anthropic / Grok / Ollama / Self-Hosted replace role/global routing with that provider; per-agent pins in the table below survive the switch. Mix keeps whatever's picked in the table.">
|
||||
<Label className="text-sm font-medium">Routing mode</Label>
|
||||
</HelpTip>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
<ModeButton
|
||||
icon={<ShieldCheck className="h-4 w-4" />}
|
||||
label="Anthropic"
|
||||
@@ -591,6 +756,15 @@ export function AIRoutingCard() {
|
||||
disabled={false}
|
||||
highlight={currentMode === "mix"}
|
||||
/>
|
||||
<ModeButton
|
||||
icon={<Gauge className="h-4 w-4" />}
|
||||
label="Cost-Tiered"
|
||||
description="Seed developer:low → Haiku (see below)."
|
||||
active={false}
|
||||
onClick={flipToCostTiered}
|
||||
disabled={applyMode.isPending}
|
||||
labelHint="Unlike every button to the left this never wipes existing routing — it's a one-time additive seed you can re-run anytime. Edit or remove individual rows in the Complexity overrides section below."
|
||||
/>
|
||||
</div>
|
||||
{currentMode === "mix" && !hasOllamaKey ? (
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1">
|
||||
@@ -649,6 +823,95 @@ export function AIRoutingCard() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* -------- Preset bar (compact, sits right above the per-agent table) -------- */}
|
||||
<Separator />
|
||||
<section className="space-y-2">
|
||||
<HelpTip label="A preset snapshots the ENTIRE current routing state — mode, every per-agent pin, every role row, and every complexity override — so you can switch between whole setups in one click instead of re-picking every Select.">
|
||||
<Label className="text-sm font-medium">Routing presets</Label>
|
||||
</HelpTip>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed p-2">
|
||||
<Select value={selectedPresetId} onValueChange={setSelectedPresetId}>
|
||||
<SelectTrigger size="sm" className="w-48 text-xs">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
presets.length ? "Choose a preset…" : "No presets saved"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{presets.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpTip label="Replaces the ENTIRE current routing state with this preset's snapshot — every per-agent pin, role row, and global default, not merged with what's here now.">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleApplyPreset}
|
||||
disabled={!selectedPresetId || applyPreset.isPending}
|
||||
>
|
||||
{applyPreset.isPending ? "Applying…" : "Apply"}
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Deletes the saved snapshot only — has no effect on the routing currently applied.">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDeletePreset}
|
||||
disabled={!selectedPresetId || deletePreset.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
{showPresetNameInput ? (
|
||||
<>
|
||||
<Input
|
||||
value={presetNameDraft}
|
||||
onChange={(e) => setPresetNameDraft(e.target.value)}
|
||||
placeholder="Preset name"
|
||||
className="h-8 w-40 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSavePreset}
|
||||
disabled={savePreset.isPending}
|
||||
>
|
||||
{savePreset.isPending ? "Saving…" : "Confirm"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowPresetNameInput(false);
|
||||
setPresetNameDraft("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<HelpTip label="Snapshots exactly what this card currently shows — the applied mode, every per-agent pin, and every complexity override.">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowPresetNameInput(true)}
|
||||
>
|
||||
Save as preset…
|
||||
</Button>
|
||||
</HelpTip>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* -------- Mix-mode per-agent picker -------- */}
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
@@ -843,6 +1106,78 @@ export function AIRoutingCard() {
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* -------- Complexity overrides (cost-tiered routing) -------- */}
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<HelpTip label="Downgrade-only by policy: a role+complexity override can never point to a costlier tier than that role's baseline model — this lever only saves cost, it never spends more. Coordinator roles (cell_pm, main_pm), pr_reviewer, and board/CEO-facing roles aren't offered a row here at all; tier pinning for those is deliberate — cell_pm especially, since a coordinator is the last place to gamble a downgrade.">
|
||||
<Label className="text-sm font-medium">
|
||||
Complexity overrides
|
||||
</Label>
|
||||
</HelpTip>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pin a role to a cheaper model for LOW- or HIGH-complexity tasks
|
||||
specifically — wins over that role's plain default and the
|
||||
global mode, still loses to a per-agent pin above. Leave a Select
|
||||
blank to remove the override. Coordinator roles (cell_pm, main_pm)
|
||||
aren't offered a row.
|
||||
</p>
|
||||
<div className="divide-y rounded-md border">
|
||||
{COMPLEXITY_OVERRIDE_ROLES.map((role) => (
|
||||
<div
|
||||
key={role}
|
||||
className="grid grid-cols-[1fr_140px_140px] items-center gap-4 p-3"
|
||||
>
|
||||
<HelpTip
|
||||
label={`Applies only to ${COMPLEXITY_ROLE_LABELS[role]} agents; a per-agent pin above still wins over this.`}
|
||||
>
|
||||
<div className="text-xs font-medium">
|
||||
{COMPLEXITY_ROLE_LABELS[role]}
|
||||
</div>
|
||||
</HelpTip>
|
||||
{(["low", "high"] as const).map((complexity) => (
|
||||
<div
|
||||
key={complexity}
|
||||
className="space-y-1"
|
||||
data-testid={`complexity-select-${role}-${complexity}`}
|
||||
>
|
||||
<HelpTip
|
||||
label={`Model used for ${COMPLEXITY_ROLE_LABELS[role]} tasks a PM estimates as ${complexity.toUpperCase()} complexity. Must be no costlier than ${COMPLEXITY_ROLE_LABELS[role]}'s baseline model — the server rejects an upgrade attempt here.`}
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{complexity}
|
||||
</div>
|
||||
</HelpTip>
|
||||
<Select
|
||||
value={complexityMap[role]?.[complexity] ?? "__clear__"}
|
||||
onValueChange={(v: string) =>
|
||||
handleComplexityChange(
|
||||
role,
|
||||
complexity,
|
||||
v === "__clear__" ? "" : v,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-full text-xs">
|
||||
<SelectValue placeholder="(none)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__clear__">(none)</SelectItem>
|
||||
{catalogForMix.map(
|
||||
(c: { model_name: string; display_name: string }) => (
|
||||
<SelectItem key={c.model_name} value={c.model_name}>
|
||||
{c.display_name}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user