feat(routing): user-facing Smart Routing toggle (web + mobile)

Web: 'Smart routing' toggle in the Usage settings tab, persisted via the
smartRouting localStorage recoil atom. When on, new conversations on the
Hanzo endpoint default to model "auto" (gateway routes each prompt to the
best/cheapest capable model; billed as whatever serves it). Scoped to the
Hanzo house endpoint and only applied to defaults — an explicit model pick
or a non-Hanzo provider family is never rewritten. Message header surfaces
the served model (msg.model) when a routed convo echoes it. Docs link +
en localization strings.

Mobile: same toggle on the Usage screen, persisted to localStorage via a
small settings helper; flips chat between VITE_CHAT_MODEL and "auto" in
lib/api.ts.
This commit is contained in:
Hanzo AI
2026-07-07 17:49:06 -07:00
parent 77f768f7fa
commit 9e5e158145
9 changed files with 203 additions and 14 deletions
@@ -10,7 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, getMessageAriaLabel } from '~/utils';
import { cn, getMessageAriaLabel, SMART_ROUTING_MODEL } from '~/utils';
import { fontSizeAtom } from '~/store/fontSize';
import { MessageContext } from '~/Providers';
import store from '~/store';
@@ -85,6 +85,22 @@ const MessageRender = memo(
],
);
/**
* When smart routing served this conversation (`model === 'auto'`), the
* response echoes the model that actually ran in `msg.model`. Surface it so
* the user sees which model handled their prompt.
*/
const routedModel = useMemo(() => {
if (msg?.isCreatedByUser === true || conversation?.model !== SMART_ROUTING_MODEL) {
return null;
}
const served = msg?.model;
if (!served || served === SMART_ROUTING_MODEL) {
return null;
}
return served;
}, [msg?.isCreatedByUser, msg?.model, conversation?.model]);
const { hasParallelContent } = useContentMetadata(msg);
if (!msg) {
@@ -137,7 +153,17 @@ const MessageRender = memo(
)}
>
{!hasParallelContent && (
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('flex select-none items-center gap-1.5 font-semibold', fontSize)}>
{messageLabel}
{routedModel != null && (
<span
className="rounded bg-surface-tertiary px-1.5 py-0.5 text-xs font-normal text-text-secondary"
title={localize('com_ui_routed_to', { 0: routedModel })}
>
{routedModel}
</span>
)}
</h2>
)}
<div className="flex flex-col gap-1">
@@ -1,9 +1,51 @@
import React from 'react';
import { ExternalLink, TrendingUp, Zap, ArrowUpRight, BarChart3 } from 'lucide-react';
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { ExternalLink, TrendingUp, Zap, ArrowUpRight, BarChart3, Sparkles } from 'lucide-react';
import { useGetStartupConfig, useGetUserUsage } from '~/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
import store from '~/store';
const CONSOLE_URL = 'https://console.hanzo.ai/ai-accounts';
const ROUTING_DOCS_URL = 'https://docs.hanzo.ai/docs/usage/routing';
/** Smart-routing toggle: default new Hanzo chats to the gateway `auto` model. */
function SmartRoutingToggle() {
const localize = useLocalize();
const [smartRouting, setSmartRouting] = useRecoilState<boolean>(store.smartRouting);
const labelId = 'smartRouting-label';
return (
<div className="rounded-xl border border-border-medium bg-surface-secondary p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<Sparkles className="mt-0.5 h-4 w-4 flex-shrink-0 text-text-secondary" />
<div id={labelId} className="text-sm font-medium text-text-primary">
{localize('com_nav_smart_routing')}
</div>
</div>
<Switch
id="smartRouting"
checked={smartRouting}
onCheckedChange={setSmartRouting}
data-testid="smartRouting"
aria-labelledby={labelId}
/>
</div>
<p className="mt-2 text-xs text-text-secondary">
{localize('com_nav_smart_routing_desc')}{' '}
<a
href={ROUTING_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-text-primary underline hover:opacity-80"
>
{localize('com_nav_smart_routing_learn_more')}
</a>
</p>
</div>
);
}
function formatUsd(amount: number): string {
return new Intl.NumberFormat('en-US', {
@@ -83,6 +125,9 @@ function Usage() {
return (
<div className="flex flex-col gap-5 p-4 text-sm text-text-primary">
{/* Smart routing toggle */}
<SmartRoutingToggle />
{/* Spend header card */}
<div className="rounded-xl border border-border-medium bg-surface-secondary p-4">
<div className="flex items-start justify-between">
+25 -1
View File
@@ -31,6 +31,8 @@ import {
getDefaultEndpoint,
getModelSpecPreset,
buildDefaultConvo,
isHanzoEndpoint,
SMART_ROUTING_MODEL,
logger,
} from '~/utils';
import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
@@ -51,6 +53,7 @@ const useNewConvo = (index = 0) => {
const { setConversation } = store.useCreateConversationAtom(index);
const [files, setFiles] = useRecoilState(store.filesByIndex(index));
const saveBadgesState = useRecoilValue<boolean>(store.saveBadgesState);
const smartRouting = useRecoilValue<boolean>(store.smartRouting);
const clearAllLatestMessages = store.useClearLatestMessages(`useNewConvo ${index}`);
const setSubmission = useSetRecoilState<TSubmission | null>(store.submissionByIndex(index));
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
@@ -109,6 +112,11 @@ const useNewConvo = (index = 0) => {
activePreset.presetId === defaultPreset?.presetId);
if (buildDefaultConversation) {
// Did the user explicitly pick a model for THIS new conversation
// (model menu / preset)? If so, never override it — smart routing only
// sets the *default* model, it doesn't rewrite an explicit choice.
const userSelectedModel = !!(conversation.model ?? activePreset?.model);
let defaultEndpoint = getDefaultEndpoint({
convoSetup: activePreset ?? conversation,
endpointsConfig,
@@ -200,6 +208,15 @@ const useNewConvo = (index = 0) => {
models,
defaultParamsEndpoint,
});
// Smart routing: default a fresh Hanzo-endpoint conversation to the
// gateway's `auto` model (routes to the best/cheapest capable model).
// Scoped to the Hanzo endpoint and only when the user hasn't picked a
// model explicitly — provider families and explicit choices are left
// untouched.
if (smartRouting && !userSelectedModel && isHanzoEndpoint(defaultEndpoint)) {
conversation.model = SMART_ROUTING_MODEL;
}
}
if (disableParams === true) {
@@ -252,7 +269,14 @@ const useNewConvo = (index = 0) => {
state: disableFocus ? {} : { focusChat: true },
});
},
[endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data, hasAgentAccess],
[
endpointsConfig,
defaultPreset,
assistantsListMap,
modelsQuery.data,
hasAgentAccess,
smartRouting,
],
);
const newConversation = useCallback(
+4
View File
@@ -591,6 +591,9 @@
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
"com_nav_slash_command": "/-Command",
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
"com_nav_smart_routing": "Smart routing",
"com_nav_smart_routing_desc": "Automatically route each new chat to the best, cheapest capable model. You're billed for whichever model serves it.",
"com_nav_smart_routing_learn_more": "Learn more",
"com_nav_speech_to_text": "Speech to Text",
"com_nav_stop_generating": "Stop generating",
"com_nav_text_to_speech": "Text to Speech",
@@ -1347,6 +1350,7 @@
"com_ui_role_viewer": "Viewer",
"com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it",
"com_ui_roleplay": "Roleplay",
"com_ui_routed_to": "Routed to {{0}}",
"com_ui_rotate": "Rotate",
"com_ui_rotate_90": "Rotate 90 degrees",
"com_ui_run_code": "Run Code",
+5
View File
@@ -17,6 +17,11 @@ const staticAtoms = {
const localStorageAtoms = {
// General settings
autoScroll: atomWithLocalStorage('autoScroll', false),
// Smart routing: when on, new conversations on the Hanzo endpoint default to
// model "auto" (the gateway routes each prompt to the best/cheapest capable
// model; billed as whatever served it). Off by default so the user's model
// pick is respected until they opt in.
smartRouting: atomWithLocalStorage('smartRouting', false),
hideSidePanel: atomWithLocalStorage('hideSidePanel', false),
enableUserMsgMarkdown: atomWithLocalStorage<boolean>(
LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN,
+17
View File
@@ -13,6 +13,23 @@ import type * as t from 'librechat-data-provider';
import type { LocalizeFunction, IconsRecord } from '~/common';
import { getTimestampedValue } from './timestamps';
/**
* The house-brand Hanzo endpoint name (custom endpoint in librechat.yaml,
* `api.hanzo.ai/v1`). Canonical everywhere: config, guest scope (`GUEST_ENDPOINT`
* default), and here. Smart routing's `auto` model is scoped to this endpoint —
* the explicit provider families (Qwen, Meta Llama, …) are deliberate picks and
* must never be silently re-routed.
*/
export const HANZO_ENDPOINT = 'Hanzo';
/** The gateway model that routes each prompt to the best/cheapest capable model. */
export const SMART_ROUTING_MODEL = 'auto';
/** True when the endpoint is the Hanzo house-brand endpoint (auto-routing home). */
export function isHanzoEndpoint(endpoint?: EModelEndpoint | string | null): boolean {
return endpoint === HANZO_ENDPOINT;
}
/**
* Clears model for non-ephemeral agent conversations.
* Agents use their configured model internally, so the conversation model should be undefined.
+8 -1
View File
@@ -19,6 +19,10 @@
// both, so we send one token.
import { getToken } from './auth'
import { getSmartRouting } from './settings'
/** Gateway model that routes each prompt to the best/cheapest capable model. */
const SMART_ROUTING_MODEL = 'auto'
const BASE_URL = (
(import.meta.env.VITE_CHAT_API_URL as string | undefined) ?? 'https://hanzo.chat'
@@ -86,11 +90,14 @@ export async function chat(
messages: ChatMessage[],
opts: { model?: string; signal?: AbortSignal } = {},
): Promise<string> {
// Explicit opts.model wins; otherwise smart routing flips the default between
// "auto" (gateway routes) and the configured VITE_CHAT_MODEL.
const model = opts.model ?? (getSmartRouting() ? SMART_ROUTING_MODEL : DEFAULT_MODEL)
const res = await fetch(`${BASE_URL}/api/agents/v1/chat/completions`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
model: opts.model ?? DEFAULT_MODEL,
model,
messages,
stream: false,
}),
+26
View File
@@ -0,0 +1,26 @@
// Local user settings, persisted to localStorage (survives reloads and, in the
// Tauri webview, app restarts). Mirrors the auth token store — a tiny typed
// helper so screens don't touch localStorage keys directly.
const SMART_ROUTING_KEY = 'hanzo.chat.smartRouting'
/**
* Smart routing: when on, chat requests send model "auto" instead of the
* configured VITE_CHAT_MODEL, letting the gateway route each prompt to the
* best/cheapest capable model (billed as whatever served it). Off by default.
*/
export function getSmartRouting(): boolean {
try {
return localStorage.getItem(SMART_ROUTING_KEY) === 'true'
} catch {
return false
}
}
export function setSmartRouting(enabled: boolean): void {
try {
localStorage.setItem(SMART_ROUTING_KEY, enabled ? 'true' : 'false')
} catch {
// no-op: private-mode webview without storage
}
}
+44 -9
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { Button, Paragraph, ScrollView, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Button, Paragraph, ScrollView, Spinner, Switch, Text, XStack, YStack } from '@hanzo/gui'
import { useUsage } from '@hanzo/usage/react'
import type { UsageStore, ProviderState, RateWindow } from '@hanzo/usage'
import { createUsageStore, isTauri } from '../lib/usage'
import { getSmartRouting, setSmartRouting } from '../lib/settings'
const PROVIDERS: Array<{ id: string; label: string }> = [
{ id: 'hanzo', label: 'Hanzo' },
@@ -37,16 +38,50 @@ export function Usage() {
}
}, [])
if (!ready) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" backgroundColor="$background">
<Spinner size="large" color="$color10" />
// Smart routing is a chat setting, not usage data — keep it visible in every
// state (loading, connected, empty).
return (
<YStack flex={1} backgroundColor="$background">
<YStack padding={12} paddingBottom={0}>
<SmartRoutingToggle />
</YStack>
)
}
{!ready ? (
<YStack flex={1} alignItems="center" justifyContent="center">
<Spinner size="large" color="$color10" />
</YStack>
) : !store ? (
<ConnectEmptyState />
) : (
<UsageCards store={store} />
)}
</YStack>
)
}
if (!store) return <ConnectEmptyState />
return <UsageCards store={store} />
// Persisted (localStorage) toggle: flips chat between the configured model and
// the gateway's "auto" smart-routing model. See lib/settings + lib/api.
function SmartRoutingToggle() {
const [smartRouting, setSmartRoutingState] = useState(getSmartRouting)
const onChange = (value: boolean) => {
setSmartRoutingState(value)
setSmartRouting(value)
}
return (
<YStack backgroundColor="$color2" borderRadius="$5" padding="$4" gap="$2">
<XStack justifyContent="space-between" alignItems="center" gap="$3">
<Text fontSize="$5" fontWeight="600" color="$color">
Smart routing
</Text>
<Switch size="$3" checked={smartRouting} onCheckedChange={onChange}>
<Switch.Thumb />
</Switch>
</XStack>
<Paragraph fontSize="$2" color="$color10">
Automatically route each message to the best, cheapest capable model. You're billed for
whichever model serves it. Learn more at docs.hanzo.ai/docs/usage/routing
</Paragraph>
</YStack>
)
}
function ConnectEmptyState() {