Skip to content

Commit 17f9181

Browse files
authored
🤖 feat: add Mux Gateway toggle with cleaner API (#950)
## Summary Add a cloud icon toggle for routing models through Mux Gateway, with a cleaner API design and reactive state management. ### Key Changes - **Gateway toggle UI**: Cloud icon button next to supported models (anthropic, openai, google, xai) to enable/disable gateway routing per-model - **Global enable/disable**: Toggle switch in Providers → Mux Gateway settings to disable gateway without removing coupon code (useful for testing) - **Custom gateway icon**: Circle with M logo, outer glow ring when active - **Reactive state**: `useGateway()` hook with clean interface: - `isActive`: gateway configured AND enabled - `canToggleModel(id)`: whether to show toggle - `isModelRoutingThroughGateway(id)`: for display badge - `toggleModelGateway(id)`: toggle per-model - **Migration**: Legacy `mux-gateway:provider/model` format auto-migrates to canonical form with gateway enabled ### Bug Fixes - Gateway toggle now reliably affects message sending (reactive `useSendMessageOptions`) - Synchronous localStorage writes prevent race conditions _Generated with `mux`_
1 parent cf865dd commit 17f9181

File tree

11 files changed

+495
-43
lines changed

11 files changed

+495
-43
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
212212
const sendMessageOptions = useSendMessageOptions(
213213
variant === "workspace" ? props.workspaceId : getProjectScopeId(props.projectPath)
214214
);
215-
// Extract model for convenience (don't create separate state - use hook as single source of truth)
215+
// Extract models for convenience (don't create separate state - use hook as single source of truth)
216+
// - preferredModel: gateway-transformed model for API calls
217+
// - baseModel: canonical format for UI display and policy checks (e.g., ThinkingSlider)
216218
const preferredModel = sendMessageOptions.model;
219+
const baseModel = sendMessageOptions.baseModel;
217220
const deferredModel = useDeferredValue(preferredModel);
218221
const deferredInput = useDeferredValue(input);
219222
const tokenCountPromise = useMemo(() => {
@@ -1342,7 +1345,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
13421345
className="flex items-center [&_.thinking-slider]:[@container(max-width:550px)]:hidden"
13431346
data-component="ThinkingSliderGroup"
13441347
>
1345-
<ThinkingSliderComponent modelString={preferredModel} />
1348+
<ThinkingSliderComponent modelString={baseModel} />
13461349
</div>
13471350

13481351
<div className="ml-4 flex items-center" data-component="ModelSettingsGroup">

src/browser/components/ModelSelector.tsx

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import React, {
88
} from "react";
99
import { cn } from "@/common/lib/utils";
1010
import { Settings, Star } from "lucide-react";
11+
import { GatewayIcon } from "./icons/GatewayIcon";
1112
import { TooltipWrapper, Tooltip } from "./Tooltip";
1213
import { useSettings } from "@/browser/contexts/SettingsContext";
14+
import { useGateway } from "@/browser/hooks/useGatewayModels";
1315

1416
interface ModelSelectorProps {
1517
value: string;
@@ -27,6 +29,7 @@ export interface ModelSelectorRef {
2729
export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
2830
({ value, onChange, recentModels, onComplete, defaultModel, onSetDefaultModel }, ref) => {
2931
const { open: openSettings } = useSettings();
32+
const gateway = useGateway();
3033
const [isEditing, setIsEditing] = useState(false);
3134
const [inputValue, setInputValue] = useState(value);
3235
const [error, setError] = useState<string | null>(null);
@@ -190,8 +193,17 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
190193
}, [highlightedIndex]);
191194

192195
if (!isEditing) {
196+
const gatewayActive = gateway.isModelRoutingThroughGateway(value);
193197
return (
194198
<div ref={containerRef} className="relative flex items-center gap-1">
199+
{gatewayActive && (
200+
<TooltipWrapper inline>
201+
<GatewayIcon className="text-accent h-3 w-3 shrink-0" active />
202+
<Tooltip className="tooltip" align="center">
203+
Using Mux Gateway
204+
</Tooltip>
205+
</TooltipWrapper>
206+
)}
195207
<div
196208
className="text-muted-light font-monospace dir-rtl hover:bg-hover max-w-36 cursor-pointer truncate rounded-sm px-1 py-0.5 text-left font-mono text-[10px] leading-[11px] transition-colors duration-200"
197209
onClick={handleClick}
@@ -250,36 +262,74 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
250262
)}
251263
onClick={() => handleSelectModel(model)}
252264
>
253-
<div className="grid w-full grid-cols-[1fr_24px] items-center gap-2">
265+
<div className="grid w-full grid-cols-[1fr_auto] items-center gap-2">
254266
<span className="min-w-0 truncate">{model}</span>
255-
{onSetDefaultModel && (
256-
<TooltipWrapper inline>
257-
<button
258-
type="button"
259-
onMouseDown={(e) => e.preventDefault()}
260-
onClick={(e) => handleSetDefault(e, model)}
261-
className={cn(
262-
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
263-
defaultModel === model
264-
? "text-yellow-400 border-yellow-400/40 cursor-default"
265-
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
266-
)}
267-
aria-label={
268-
defaultModel === model
267+
<div className="flex items-center gap-0.5">
268+
{/* Gateway toggle */}
269+
{gateway.canToggleModel(model) && (
270+
<TooltipWrapper inline>
271+
<button
272+
type="button"
273+
onMouseDown={(e) => e.preventDefault()}
274+
onClick={(e) => {
275+
e.preventDefault();
276+
e.stopPropagation();
277+
gateway.toggleModelGateway(model);
278+
}}
279+
className={cn(
280+
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
281+
gateway.modelUsesGateway(model)
282+
? "text-accent border-accent/40"
283+
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
284+
)}
285+
aria-label={
286+
gateway.modelUsesGateway(model)
287+
? "Disable Mux Gateway"
288+
: "Enable Mux Gateway"
289+
}
290+
>
291+
<GatewayIcon
292+
className="h-3 w-3"
293+
active={gateway.modelUsesGateway(model)}
294+
/>
295+
</button>
296+
<Tooltip className="tooltip" align="center">
297+
{gateway.modelUsesGateway(model)
298+
? "Using Mux Gateway"
299+
: "Use Mux Gateway"}
300+
</Tooltip>
301+
</TooltipWrapper>
302+
)}
303+
{/* Default model toggle */}
304+
{onSetDefaultModel && (
305+
<TooltipWrapper inline>
306+
<button
307+
type="button"
308+
onMouseDown={(e) => e.preventDefault()}
309+
onClick={(e) => handleSetDefault(e, model)}
310+
className={cn(
311+
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
312+
defaultModel === model
313+
? "text-yellow-400 border-yellow-400/40 cursor-default"
314+
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
315+
)}
316+
aria-label={
317+
defaultModel === model
318+
? "Current default model"
319+
: "Set as default model"
320+
}
321+
disabled={defaultModel === model}
322+
>
323+
<Star className="h-3 w-3" />
324+
</button>
325+
<Tooltip className="tooltip" align="center">
326+
{defaultModel === model
269327
? "Current default model"
270-
: "Set as default model"
271-
}
272-
disabled={defaultModel === model}
273-
>
274-
<Star className="h-3 w-3" />
275-
</button>
276-
<Tooltip className="tooltip" align="center">
277-
{defaultModel === model
278-
? "Current default model"
279-
: "Set as default model"}
280-
</Tooltip>
281-
</TooltipWrapper>
282-
)}
328+
: "Set as default model"}
329+
</Tooltip>
330+
</TooltipWrapper>
331+
)}
332+
</div>
283333
</div>
284334
</div>
285335
))

src/browser/components/ProviderIcon.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function ProviderWithIcon(props: ProviderWithIconProps) {
6767
: props.provider;
6868

6969
return (
70-
<span className={cn("inline-flex items-center gap-1", props.className)}>
70+
<span className={cn("inline-flex items-center gap-1 whitespace-nowrap", props.className)}>
7171
<ProviderIcon provider={props.provider} className={props.iconClassName} />
7272
<span>{name}</span>
7373
</span>

src/browser/components/Settings/sections/ModelRow.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from "react";
22
import { Check, Pencil, Star, Trash2, X } from "lucide-react";
3+
import { GatewayIcon } from "@/browser/components/icons/GatewayIcon";
34
import { cn } from "@/common/lib/utils";
45
import { TooltipWrapper, Tooltip } from "@/browser/components/Tooltip";
56
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
@@ -16,12 +17,16 @@ export interface ModelRowProps {
1617
editError?: string | null;
1718
saving?: boolean;
1819
hasActiveEdit?: boolean;
20+
/** Whether gateway mode is enabled for this model */
21+
isGatewayEnabled?: boolean;
1922
onSetDefault: () => void;
2023
onStartEdit?: () => void;
2124
onSaveEdit?: () => void;
2225
onCancelEdit?: () => void;
2326
onEditChange?: (value: string) => void;
2427
onRemove?: () => void;
28+
/** Toggle gateway mode for this model */
29+
onToggleGateway?: () => void;
2530
}
2631

2732
export function ModelRow(props: ModelRowProps) {
@@ -90,6 +95,25 @@ export function ModelRow(props: ModelRowProps) {
9095
</>
9196
) : (
9297
<>
98+
{/* Gateway toggle button */}
99+
{props.onToggleGateway && (
100+
<TooltipWrapper inline>
101+
<button
102+
type="button"
103+
onClick={props.onToggleGateway}
104+
className={cn(
105+
"p-0.5 transition-colors",
106+
props.isGatewayEnabled ? "text-accent" : "text-muted hover:text-accent"
107+
)}
108+
aria-label={props.isGatewayEnabled ? "Disable Mux Gateway" : "Enable Mux Gateway"}
109+
>
110+
<GatewayIcon className="h-3.5 w-3.5" active={props.isGatewayEnabled} />
111+
</button>
112+
<Tooltip className="tooltip" align="center">
113+
{props.isGatewayEnabled ? "Using Mux Gateway" : "Use Mux Gateway"}
114+
</Tooltip>
115+
</TooltipWrapper>
116+
)}
93117
{/* Favorite/default button */}
94118
<TooltipWrapper inline>
95119
<button

src/browser/components/Settings/sections/ModelsSection.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import { Plus, Loader2 } from "lucide-react";
33
import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers";
44
import { KNOWN_MODELS } from "@/common/constants/knownModels";
55
import { useModelLRU } from "@/browser/hooks/useModelLRU";
6+
import { useGateway } from "@/browser/hooks/useGatewayModels";
67
import { ModelRow } from "./ModelRow";
78
import { useAPI } from "@/browser/contexts/API";
89
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
910

11+
// Providers to exclude from the custom models UI (handled specially or internal)
12+
const HIDDEN_PROVIDERS = new Set(["mux-gateway"]);
13+
1014
interface NewModelForm {
1115
provider: string;
1216
modelId: string;
@@ -25,6 +29,7 @@ export function ModelsSection() {
2529
const [editing, setEditing] = useState<EditingState | null>(null);
2630
const [error, setError] = useState<string | null>(null);
2731
const { defaultModel, setDefaultModel } = useModelLRU();
32+
const gateway = useGateway();
2833

2934
// Check if a model already exists (for duplicate prevention)
3035
const modelExists = useCallback(
@@ -125,10 +130,12 @@ export function ModelsSection() {
125130
);
126131
}
127132

128-
// Get all custom models across providers
133+
// Get all custom models across providers (excluding hidden providers like mux-gateway)
129134
const getCustomModels = (): Array<{ provider: string; modelId: string; fullId: string }> => {
130135
const models: Array<{ provider: string; modelId: string; fullId: string }> = [];
131136
for (const [provider, providerConfig] of Object.entries(config)) {
137+
// Skip hidden providers (mux-gateway models are accessed via the cloud toggle, not listed separately)
138+
if (HIDDEN_PROVIDERS.has(provider)) continue;
132139
if (providerConfig.models) {
133140
for (const modelId of providerConfig.models) {
134141
models.push({ provider, modelId, fullId: `${provider}:${modelId}` });
@@ -213,6 +220,7 @@ export function ModelsSection() {
213220
editError={isModelEditing ? error : undefined}
214221
saving={false}
215222
hasActiveEdit={editing !== null}
223+
isGatewayEnabled={gateway.modelUsesGateway(model.fullId)}
216224
onSetDefault={() => setDefaultModel(model.fullId)}
217225
onStartEdit={() => handleStartEdit(model.provider, model.modelId)}
218226
onSaveEdit={handleSaveEdit}
@@ -221,6 +229,11 @@ export function ModelsSection() {
221229
setEditing((prev) => (prev ? { ...prev, newModelId: value } : null))
222230
}
223231
onRemove={() => handleRemoveModel(model.provider, model.modelId)}
232+
onToggleGateway={
233+
gateway.canToggleModel(model.fullId)
234+
? () => gateway.toggleModelGateway(model.fullId)
235+
: undefined
236+
}
224237
/>
225238
);
226239
})}
@@ -241,7 +254,13 @@ export function ModelsSection() {
241254
isCustom={false}
242255
isDefault={defaultModel === model.fullId}
243256
isEditing={false}
257+
isGatewayEnabled={gateway.modelUsesGateway(model.fullId)}
244258
onSetDefault={() => setDefaultModel(model.fullId)}
259+
onToggleGateway={
260+
gateway.canToggleModel(model.fullId)
261+
? () => gateway.toggleModelGateway(model.fullId)
262+
: undefined
263+
}
245264
/>
246265
))}
247266
</div>

src/browser/components/Settings/sections/ProvidersSection.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ProviderName } from "@/common/constants/providers";
55
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
66
import { useAPI } from "@/browser/contexts/API";
77
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
8+
import { useGateway } from "@/browser/hooks/useGatewayModels";
89

910
interface FieldConfig {
1011
key: string;
@@ -69,6 +70,7 @@ function getProviderFields(provider: ProviderName): FieldConfig[] {
6970
export function ProvidersSection() {
7071
const { api } = useAPI();
7172
const { config, updateOptimistically } = useProvidersConfig();
73+
const gateway = useGateway();
7274
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
7375
const [editingField, setEditingField] = useState<{
7476
provider: string;
@@ -315,6 +317,31 @@ export function ProvidersSection() {
315317
</div>
316318
);
317319
})}
320+
321+
{/* Gateway enabled toggle - only for mux-gateway when configured */}
322+
{provider === "mux-gateway" && gateway.isConfigured && (
323+
<div className="border-border-light flex items-center justify-between border-t pt-3">
324+
<div>
325+
<label className="text-foreground block text-xs font-medium">Enabled</label>
326+
<span className="text-muted text-xs">Route requests through Mux Gateway</span>
327+
</div>
328+
<button
329+
type="button"
330+
onClick={gateway.toggleEnabled}
331+
className={`relative h-5 w-9 rounded-full transition-colors ${
332+
gateway.isEnabled ? "bg-accent" : "bg-border-medium"
333+
}`}
334+
role="switch"
335+
aria-checked={gateway.isEnabled}
336+
>
337+
<span
338+
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
339+
gateway.isEnabled ? "translate-x-4" : "translate-x-0"
340+
}`}
341+
/>
342+
</button>
343+
</div>
344+
)}
318345
</div>
319346
)}
320347
</div>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React from "react";
2+
3+
interface GatewayIconProps extends React.SVGProps<SVGSVGElement> {
4+
className?: string;
5+
/** When true, shows the active/enabled state with double ring */
6+
active?: boolean;
7+
}
8+
9+
/**
10+
* Gateway icon - represents routing through Mux Gateway.
11+
* Circle with M logo. Active state adds outer ring.
12+
*/
13+
export function GatewayIcon(props: GatewayIconProps) {
14+
const { active, ...svgProps } = props;
15+
16+
return (
17+
<svg
18+
xmlns="http://www.w3.org/2000/svg"
19+
viewBox="0 0 24 24"
20+
fill="none"
21+
stroke="currentColor"
22+
strokeWidth="2"
23+
strokeLinecap="round"
24+
strokeLinejoin="round"
25+
{...svgProps}
26+
>
27+
{/* Outer glow ring when active */}
28+
{active && <circle cx="12" cy="12" r="11" strokeWidth="1" opacity="0.5" />}
29+
{/* Main circle */}
30+
<circle cx="12" cy="12" r="8" />
31+
{/* M letter */}
32+
<path d="M8 16V8l4 5 4-5v8" />
33+
</svg>
34+
);
35+
}

0 commit comments

Comments
 (0)