diff --git a/src/browser/features/ChatInput/ProviderNotConfiguredBanner.test.tsx b/src/browser/features/ChatInput/ProviderNotConfiguredBanner.test.tsx
new file mode 100644
index 0000000000..c1616d67fb
--- /dev/null
+++ b/src/browser/features/ChatInput/ProviderNotConfiguredBanner.test.tsx
@@ -0,0 +1,233 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import { GlobalWindow } from "happy-dom";
+import { cleanup, fireEvent, render } from "@testing-library/react";
+import {
+ ProviderNotConfiguredBanner,
+ getUnconfiguredProvider,
+} from "./ProviderNotConfiguredBanner";
+import type { ProvidersConfigMap } from "@/common/orpc/types";
+
+describe("ProviderNotConfiguredBanner", () => {
+ beforeEach(() => {
+ globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
+ globalThis.document = globalThis.window.document;
+ });
+
+ afterEach(() => {
+ cleanup();
+ globalThis.window = undefined as unknown as Window & typeof globalThis;
+ globalThis.document = undefined as unknown as Document;
+ });
+
+ test("renders when provider is not configured", () => {
+ const onOpenProviders = mock(() => undefined);
+ const config: ProvidersConfigMap = {
+ anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
+ };
+
+ const view = render(
+
+ );
+
+ expect(view.getByTestId("provider-not-configured-banner")).toBeTruthy();
+ expect(view.getByText("API key required for Anthropic.")).toBeTruthy();
+ expect(view.getByText("Providers")).toBeTruthy();
+
+ fireEvent.click(view.getByText("Providers"));
+ expect(onOpenProviders).toHaveBeenCalledTimes(1);
+ });
+
+ test("renders disabled message when provider is disabled", () => {
+ const config: ProvidersConfigMap = {
+ openai: { apiKeySet: true, isEnabled: false, isConfigured: true },
+ };
+
+ const view = render(
+ undefined}
+ />
+ );
+
+ expect(view.getByTestId("provider-not-configured-banner")).toBeTruthy();
+ expect(view.getByText("OpenAI provider is disabled.")).toBeTruthy();
+ });
+
+ test("does not render when provider is configured and enabled", () => {
+ const config: ProvidersConfigMap = {
+ anthropic: { apiKeySet: true, isEnabled: true, isConfigured: true },
+ };
+
+ const view = render(
+ undefined}
+ />
+ );
+
+ expect(view.queryByTestId("provider-not-configured-banner")).toBeNull();
+ });
+
+ test("does not render when config is still loading", () => {
+ const view = render(
+ undefined}
+ />
+ );
+
+ expect(view.queryByTestId("provider-not-configured-banner")).toBeNull();
+ });
+
+ test("does not render for unknown providers", () => {
+ const config: ProvidersConfigMap = {
+ anthropic: { apiKeySet: true, isEnabled: true, isConfigured: true },
+ };
+
+ const view = render(
+ undefined}
+ />
+ );
+
+ expect(view.queryByTestId("provider-not-configured-banner")).toBeNull();
+ });
+
+ test("does not render when model is routed through Mux Gateway", () => {
+ const config: ProvidersConfigMap = {
+ anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
+ "mux-gateway": {
+ apiKeySet: false,
+ isEnabled: true,
+ isConfigured: true,
+ couponCodeSet: true,
+ gatewayModels: ["anthropic:claude-sonnet-4-5"],
+ },
+ };
+
+ const view = render(
+ undefined}
+ />
+ );
+
+ expect(view.queryByTestId("provider-not-configured-banner")).toBeNull();
+ });
+
+ test("renders when model's provider is unsupported by gateway even if gateway is active", () => {
+ const config: ProvidersConfigMap = {
+ ollama: { apiKeySet: false, isEnabled: true, isConfigured: false },
+ "mux-gateway": {
+ apiKeySet: false,
+ isEnabled: true,
+ isConfigured: true,
+ couponCodeSet: true,
+ gatewayModels: [],
+ },
+ };
+
+ const view = render(
+ undefined}
+ />
+ );
+
+ expect(view.getByTestId("provider-not-configured-banner")).toBeTruthy();
+ });
+
+ test("renders when gateway is active but model is not enrolled", () => {
+ const config: ProvidersConfigMap = {
+ anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
+ "mux-gateway": {
+ apiKeySet: false,
+ isEnabled: true,
+ isConfigured: true,
+ couponCodeSet: true,
+ gatewayModels: ["openai:gpt-4o"],
+ },
+ };
+
+ const view = render(
+ undefined}
+ />
+ );
+
+ expect(view.getByTestId("provider-not-configured-banner")).toBeTruthy();
+ });
+});
+
+describe("getUnconfiguredProvider", () => {
+ test("returns null when config is null", () => {
+ expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", null)).toBeNull();
+ });
+
+ test("returns provider when not configured", () => {
+ const config: ProvidersConfigMap = {
+ anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
+ };
+ expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", config)).toBe("anthropic");
+ });
+
+ test("returns provider when disabled", () => {
+ const config: ProvidersConfigMap = {
+ openai: { apiKeySet: true, isEnabled: false, isConfigured: true },
+ };
+ expect(getUnconfiguredProvider("openai:gpt-4o", config)).toBe("openai");
+ });
+
+ test("returns null when configured and enabled", () => {
+ const config: ProvidersConfigMap = {
+ anthropic: { apiKeySet: true, isEnabled: true, isConfigured: true },
+ };
+ expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", config)).toBeNull();
+ });
+
+ test("returns null for model without provider prefix", () => {
+ const config: ProvidersConfigMap = {};
+ expect(getUnconfiguredProvider("some-model-no-colon", config)).toBeNull();
+ });
+
+ test("returns null when gateway routes the model", () => {
+ const config: ProvidersConfigMap = {
+ anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
+ "mux-gateway": {
+ apiKeySet: false,
+ isEnabled: true,
+ isConfigured: true,
+ couponCodeSet: true,
+ gatewayModels: ["anthropic:claude-sonnet-4-5"],
+ },
+ };
+ expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", config)).toBeNull();
+ });
+
+ test("returns provider when gateway is disabled", () => {
+ const config: ProvidersConfigMap = {
+ anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
+ "mux-gateway": {
+ apiKeySet: false,
+ isEnabled: false,
+ isConfigured: true,
+ couponCodeSet: true,
+ gatewayModels: ["anthropic:claude-sonnet-4-5"],
+ },
+ };
+ expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", config)).toBe("anthropic");
+ });
+});
diff --git a/src/browser/features/ChatInput/ProviderNotConfiguredBanner.tsx b/src/browser/features/ChatInput/ProviderNotConfiguredBanner.tsx
new file mode 100644
index 0000000000..ec24737130
--- /dev/null
+++ b/src/browser/features/ChatInput/ProviderNotConfiguredBanner.tsx
@@ -0,0 +1,95 @@
+import { AlertTriangle } from "lucide-react";
+import { Button } from "@/browser/components/Button/Button";
+import { getModelProvider } from "@/common/utils/ai/models";
+import {
+ PROVIDER_DEFINITIONS,
+ PROVIDER_DISPLAY_NAMES,
+ type ProviderName,
+} from "@/common/constants/providers";
+import type { ProvidersConfigMap } from "@/common/orpc/types";
+import { isProviderSupported } from "@/browser/hooks/useGatewayModels";
+
+interface Props {
+ activeModel: string;
+ providersConfig: ProvidersConfigMap | null;
+ onOpenProviders: () => void;
+}
+
+/**
+ * Returns the provider key if the active model's provider is not configured (disabled or
+ * missing credentials), and the model is NOT being routed through Mux Gateway.
+ * Returns null when no warning is needed.
+ */
+export function getUnconfiguredProvider(
+ activeModel: string,
+ config: ProvidersConfigMap | null
+): string | null {
+ if (config == null) return null; // Config still loading — avoid false positives.
+
+ const provider = getModelProvider(activeModel);
+ if (!provider) return null;
+
+ const info = config[provider];
+ // Unknown providers are treated as available (same logic as useModelsFromSettings).
+ if (!info) return null;
+
+ if (info.isEnabled && info.isConfigured) return null;
+
+ // If the model is routed through Mux Gateway, the native provider credentials aren't needed.
+ const gwConfig = config["mux-gateway"];
+ const gatewayActive = (gwConfig?.couponCodeSet ?? false) && (gwConfig?.isEnabled ?? true);
+ if (gatewayActive && isProviderSupported(activeModel)) {
+ const gatewayModels = gwConfig?.gatewayModels ?? [];
+ if (gatewayModels.includes(activeModel)) return null;
+ }
+
+ return provider;
+}
+
+export function ProviderNotConfiguredBanner(props: Props) {
+ const provider = getUnconfiguredProvider(props.activeModel, props.providersConfig);
+ if (!provider) return null;
+
+ const displayName = PROVIDER_DISPLAY_NAMES[provider as ProviderName] ?? provider;
+ const info = props.providersConfig?.[provider];
+ const isDisabled = info != null && !info.isEnabled;
+ const definition = PROVIDER_DEFINITIONS[provider as ProviderName];
+ // Providers like bedrock/ollama don't use API keys — use generic guidance.
+ const usesApiKey = definition?.requiresApiKey !== false;
+
+ return (
+
+
+
+
+
+ {isDisabled
+ ? `${displayName} provider is disabled.`
+ : usesApiKey
+ ? `API key required for ${displayName}.`
+ : `${displayName} is not configured.`}
+ {" "}
+ Open Settings → Providers to{" "}
+ {isDisabled
+ ? "enable this provider"
+ : usesApiKey
+ ? "add an API key"
+ : "configure this provider"}{" "}
+ before sending.
+
+
+
+
+ );
+}
diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx
index 5457909dd6..cbee450a11 100644
--- a/src/browser/features/ChatInput/index.tsx
+++ b/src/browser/features/ChatInput/index.tsx
@@ -112,6 +112,7 @@ import {
getModelCapabilities,
getModelCapabilitiesResolved,
} from "@/common/utils/ai/modelCapabilities";
+import { getModelProvider } from "@/common/utils/ai/models";
import { KNOWN_MODELS, MODEL_ABBREVIATION_EXAMPLES } from "@/common/constants/knownModels";
import { useTelemetry } from "@/browser/hooks/useTelemetry";
import { trackCommandUsed } from "@/common/telemetry";
@@ -123,6 +124,7 @@ import type { ChatInputProps, ChatInputAPI, QueueDispatchMode } from "./types";
import { CreationControls } from "./CreationControls";
import { SEND_DISPATCH_MODES } from "./sendDispatchModes";
import { CodexOauthWarningBanner } from "./CodexOauthWarningBanner";
+import { ProviderNotConfiguredBanner } from "./ProviderNotConfiguredBanner";
import { useCreationWorkspace } from "./useCreationWorkspace";
import { useCoderWorkspace } from "@/browser/hooks/useCoderWorkspace";
import { useTutorial } from "@/browser/contexts/TutorialContext";
@@ -2486,6 +2488,14 @@ const ChatInputInner: React.FC = (props) => {
onOpenProviders={() => open("providers", { expandProvider: "openai" })}
/>
+ {
+ open("providers", { expandProvider: getModelProvider(baseModel) });
+ }}
+ />
+
{/* File path suggestions (@src/foo.ts) */}
void }) {
setHasConfiguredProvidersAtStart(configuredProviders.length > 0);
}, [configuredProviders.length, hasConfiguredProvidersAtStart, providersLoading]);
+ // ---- Key Discovery ----
+ interface DiscoveredKeyEntry {
+ provider: string;
+ source: string;
+ keyPreview: string;
+ }
+ const [discoveredKeys, setDiscoveredKeys] = useState([]);
+ const [discoveredKeysLoading, setDiscoveredKeysLoading] = useState(false);
+ const [selectedKeys, setSelectedKeys] = useState>(new Set());
+ const [importingKeys, setImportingKeys] = useState(false);
+ const [importResults, setImportResults] = useState>({});
+
+ useEffect(() => {
+ // Only discover when no providers are configured at start
+ if (hasConfiguredProvidersAtStart !== false || !api) {
+ return;
+ }
+
+ let cancelled = false;
+ setDiscoveredKeysLoading(true);
+ api.keyDiscovery
+ .discover()
+ .then((keys) => {
+ if (!cancelled) {
+ setDiscoveredKeys(keys);
+ // Pre-select only the first discovered key per provider so
+ // duplicates require an explicit user choice (Codex review).
+ const seenProviders = new Set();
+ const preselected = new Set();
+ for (const k of keys) {
+ if (!seenProviders.has(k.provider)) {
+ seenProviders.add(k.provider);
+ preselected.add(`${k.provider}:${k.source}`);
+ }
+ }
+ setSelectedKeys(preselected);
+ }
+ })
+ .catch(() => {
+ // Non-fatal — user can configure manually
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setDiscoveredKeysLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [api, hasConfiguredProvidersAtStart]);
+
+ const handleImportKeys = useCallback(async () => {
+ if (!api || selectedKeys.size === 0) {
+ return;
+ }
+
+ setImportingKeys(true);
+ // Merge with prior import state so earlier results are preserved.
+ const results: Record = { ...importResults };
+
+ // Deduplicate: only import the first selected entry per provider.
+ const importedProviders = new Set();
+
+ for (const key of discoveredKeys) {
+ const id = `${key.provider}:${key.source}`;
+ if (!selectedKeys.has(id)) {
+ continue;
+ }
+
+ // Skip keys that were already successfully imported.
+ if (importResults[id] === "success") {
+ continue;
+ }
+
+ // Only import the first selected key per provider.
+ if (importedProviders.has(key.provider)) {
+ continue;
+ }
+ importedProviders.add(key.provider);
+
+ try {
+ const result = await api.keyDiscovery.import({
+ provider: key.provider,
+ source: key.source,
+ });
+ results[id] = result.success ? "success" : "error";
+ } catch {
+ results[id] = "error";
+ }
+ }
+
+ setImportResults(results);
+ setImportingKeys(false);
+ }, [api, discoveredKeys, importResults, selectedKeys]);
+
const commandPaletteShortcut = formatKeybind(KEYBINDS.OPEN_COMMAND_PALETTE);
const commandPaletteActionsShortcut = formatKeybind(KEYBINDS.OPEN_COMMAND_PALETTE_ACTIONS);
const agentPickerShortcut = formatKeybind(KEYBINDS.TOGGLE_AGENT);
@@ -693,6 +790,111 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
});
}
+ // Key discovery step — only shown when keys were found from other tools
+ if (
+ hasConfiguredProvidersAtStart === false &&
+ discoveredKeys.length > 0 &&
+ !discoveredKeysLoading
+ ) {
+ // Check whether all *selected* entries have been imported.
+ const allSelectedImported =
+ selectedKeys.size > 0 && [...selectedKeys].every((id) => importResults[id] === "success");
+
+ nextSteps.push({
+ key: "key-discovery",
+ title: "Import keys from other tools",
+ icon: ,
+ body: (
+ <>
+
+ We found API keys from other AI tools on your system. Would you like to import them
+ into Mux?
+
+
+
+ Keys are read from config files of other tools and stored in{" "}
+ ~/.mux/providers.jsonc with restricted
+ permissions. No data is sent externally.
+