diff --git a/package-lock.json b/package-lock.json
index c83299f43..bfe8ce05c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -67,6 +67,84 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@ai-sdk/anthropic": {
+ "version": "3.0.15",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.15.tgz",
+ "integrity": "sha512-FCNy6pABPe5Qb1VPbdLLIi/XkQN2g/fKUcl1GcXxIU3Ofr+vOND8cyZfH20cMODR523FSGfwswJoJic8skr8qg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "3.0.4",
+ "@ai-sdk/provider-utils": "4.0.8"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/gateway": {
+ "version": "3.0.16",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.16.tgz",
+ "integrity": "sha512-OOY5CfRJiHvh/8np2vs1RQaCZ5hWv2qOeEmmeiABXK3gLQHUVnCO+1hhoLsZdHM5iElu6M407dAOfyvTsKJqcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "3.0.4",
+ "@ai-sdk/provider-utils": "4.0.8",
+ "@vercel/oidc": "3.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/openai": {
+ "version": "3.0.12",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.12.tgz",
+ "integrity": "sha512-zqLWEKuaKnjXhu7xCw1jgz/+yTbd3F7EtgU4T2Q8BAo8OJC5wZv14l+kwM7Jai7M1/2Y2T/zBkrfiIu+7NsvfQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "3.0.4",
+ "@ai-sdk/provider-utils": "4.0.8"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/provider": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.4.tgz",
+ "integrity": "sha512-5KXyBOSEX+l67elrEa+wqo/LSsSTtrPj9Uoh3zMbe/ceQX4ucHI3b9nUEfNkGF3Ry1svv90widAt+aiKdIJasQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "json-schema": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@ai-sdk/provider-utils": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.8.tgz",
+ "integrity": "sha512-ns9gN7MmpI8vTRandzgz+KK/zNMLzhrriiKECMt4euLtQFSBgNfydtagPOX4j4pS1/3KvHF6RivhT3gNQgBZsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "3.0.4",
+ "@standard-schema/spec": "^1.1.0",
+ "eventsource-parser": "^3.0.6"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -9616,6 +9694,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
+ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/@parcel/watcher": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz",
@@ -11290,6 +11377,12 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
"node_modules/@storybook/addon-webpack5-compiler-babel": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@storybook/addon-webpack5-compiler-babel/-/addon-webpack5-compiler-babel-4.0.0.tgz",
@@ -13412,6 +13505,15 @@
"resolved": "packages/bruno-toml",
"link": true
},
+ "node_modules/@vercel/oidc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
+ "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -14085,6 +14187,24 @@
"node": ">= 14"
}
},
+ "node_modules/ai": {
+ "version": "6.0.39",
+ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.39.tgz",
+ "integrity": "sha512-hF05gF4H+IxuilA8kNANVVHQXduTJsJaH74jmlmy8mcQt3NZgPYe2zZNyGBV4DPDYTUDt1h31hbLgQqJTn5LGA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/gateway": "3.0.16",
+ "@ai-sdk/provider": "3.0.4",
+ "@ai-sdk/provider-utils": "4.0.8",
+ "@opentelemetry/api": "1.9.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -19033,6 +19153,15 @@
"bare-events": "^2.7.0"
}
},
+ "node_modules/eventsource-parser": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
+ "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/evp_bytestokey": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
@@ -23421,6 +23550,12 @@
"node": "*"
}
},
+ "node_modules/json-schema": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
+ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+ "license": "(AFL-2.1 OR BSD-3-Clause)"
+ },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -32765,6 +32900,15 @@
"node": ">= 14"
}
},
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"packages/bruno-app": {
"name": "@usebruno/app",
"version": "2.0.0",
@@ -35035,6 +35179,8 @@
"name": "bruno",
"version": "2.0.0",
"dependencies": {
+ "@ai-sdk/anthropic": "3.0.15",
+ "@ai-sdk/openai": "3.0.12",
"@aws-sdk/credential-providers": "3.1019.0",
"@grpc/grpc-js": "^1.13.2",
"@grpc/proto-loader": "^0.7.13",
@@ -35049,6 +35195,7 @@
"@usebruno/schema": "0.7.0",
"about-window": "^1.15.2",
"adm-zip": "^0.5.16",
+ "ai": "6.0.39",
"archiver": "^7.0.1",
"aws4-axios": "^3.3.15",
"axios": "1.13.6",
@@ -35081,7 +35228,8 @@
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^10.0.0",
- "yup": "^0.32.11"
+ "yup": "^0.32.11",
+ "zod": "^4.1.8"
},
"devDependencies": {
"electron": "~37.6.1",
diff --git a/packages/bruno-app/src/components/Preferences/AI/ProviderCard.js b/packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
new file mode 100644
index 000000000..5946c7b97
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
@@ -0,0 +1,334 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ IconAlertCircle,
+ IconBolt,
+ IconCheck,
+ IconChevronDown,
+ IconEye,
+ IconEyeOff,
+ IconLoader2,
+ IconPencil,
+ IconTrash,
+ IconX
+} from '@tabler/icons';
+import toast from 'react-hot-toast';
+import { clearAiApiKey, getAiApiKey, setAiApiKey, testAiProvider } from 'utils/ai';
+
+const OpenAiLogo = (props) => (
+
+);
+
+const AnthropicLogo = (props) => (
+
+);
+
+const PROVIDER_LOGOS = {
+ openai: OpenAiLogo,
+ anthropic: AnthropicLogo
+};
+
+const stopBubble = (e) => e.stopPropagation();
+
+const ProviderCard = ({
+ provider,
+ providerEnabled,
+ providerToggle,
+ models,
+ isModelEnabled,
+ onToggleModel,
+ onStatusChange
+}) => {
+ const Logo = PROVIDER_LOGOS[provider.id];
+
+ const [expanded, setExpanded] = useState(false);
+ const [keyDraft, setKeyDraft] = useState('');
+ const [editing, setEditing] = useState(false);
+ const [showKey, setShowKey] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [testing, setTesting] = useState(false);
+ const [feedback, setFeedback] = useState(null);
+
+ const prev = useRef({ enabled: providerEnabled });
+ useEffect(() => {
+ const was = prev.current;
+ if (!was.enabled && providerEnabled) {
+ setExpanded(true);
+ } else if (was.enabled && !providerEnabled) {
+ setExpanded(false);
+ }
+ prev.current = { enabled: providerEnabled };
+ }, [providerEnabled]);
+
+ const isEditing = editing || !provider.configured;
+
+ const handleSave = async () => {
+ const trimmed = keyDraft.trim();
+ if (!trimmed) return;
+ setSaving(true);
+ setFeedback(null);
+ try {
+ const status = await setAiApiKey({ providerId: provider.id, apiKey: trimmed });
+ onStatusChange?.(status);
+ setKeyDraft('');
+ setShowKey(false);
+ setEditing(false);
+ setFeedback({ type: 'success', message: 'API key saved' });
+ } catch (err) {
+ setFeedback({ type: 'error', message: err.message || 'Failed to save API key' });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleClear = async () => {
+ setFeedback(null);
+ try {
+ const status = await clearAiApiKey({ providerId: provider.id });
+ onStatusChange?.(status);
+ setEditing(false);
+ setKeyDraft('');
+ toast.success(`${provider.label} API key removed`);
+ } catch (err) {
+ toast.error(err.message || 'Failed to clear API key');
+ }
+ };
+
+ const handleTest = async () => {
+ setTesting(true);
+ setFeedback(null);
+ try {
+ const result = await testAiProvider({ providerId: provider.id });
+ if (result.ok) {
+ setFeedback({ type: 'success', message: 'Connection successful' });
+ } else {
+ setFeedback({ type: 'error', message: result.error || 'Connection failed' });
+ }
+ } catch (err) {
+ setFeedback({ type: 'error', message: err.message || 'Connection failed' });
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ const handleCancelEdit = () => {
+ setEditing(false);
+ setKeyDraft('');
+ setShowKey(false);
+ setFeedback(null);
+ };
+
+ const handleStartEdit = async () => {
+ setEditing(true);
+ setFeedback(null);
+ try {
+ const current = await getAiApiKey({ providerId: provider.id });
+ setKeyDraft(current || '');
+ } catch (err) {
+ // If we can't fetch it (decrypt failure etc.), leave the field empty.
+ setKeyDraft('');
+ }
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (keyDraft.trim() && !saving) handleSave();
+ } else if (e.key === 'Escape' && provider.configured) {
+ e.preventDefault();
+ handleCancelEdit();
+ }
+ };
+
+ const enabledModelsCount = models.filter((m) => isModelEnabled(m.id)).length;
+
+ return (
+
+
setExpanded(!expanded)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setExpanded(!expanded);
+ }
+ }}
+ >
+
+ {Logo ? : null}
+ {provider.label}
+
+
+
+
+ {provider.configured
+ ? `${enabledModelsCount}/${models.length} models`
+ : 'Not configured'}
+
+
+ {providerToggle}
+
+
+
+
+
+
+
+
+
+
+ {/* API key */}
+
+
+ API Key
+
+
+ {!isEditing ? (
+
+
••••••••••••••••
+
+
+
+
+
+
+ ) : (
+
+
+ setKeyDraft(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onClick={stopBubble}
+ autoFocus
+ data-testid={`ai-provider-${provider.id}-key-input`}
+ />
+
+
+
+ {provider.configured && (
+
+ )}
+
+ )}
+
+ {feedback && (
+
+ {feedback.type === 'success' ? : }
+ {feedback.message}
+
+ )}
+
+
+ {/* Models */}
+ {models.length > 0 && (
+
+
+ Models
+ {!provider.configured && (
+
+
+ Add an API key to enable
+
+ )}
+
+
+ {models.map((model) => {
+ const enabled = isModelEnabled(model.id);
+ const disabled = !provider.configured || !providerEnabled;
+ return (
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default ProviderCard;
diff --git a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js
new file mode 100644
index 000000000..b131588dd
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js
@@ -0,0 +1,243 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ color: ${(props) => props.theme.text};
+
+ .ai-master {
+ border: 1px solid ${(props) => props.theme.input.border};
+ border-radius: ${(props) => props.theme.border.radius.md};
+ background: ${(props) => props.theme.input.bg};
+ }
+
+ .ai-master-icon {
+ color: ${(props) => props.theme.colors.accent};
+ }
+
+ .ai-master-summary {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .ai-section-header {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .ai-empty-notice {
+ color: ${(props) => props.theme.colors.text.muted};
+ background: ${(props) => props.theme.input.bg};
+ border: 1px dashed ${(props) => props.theme.input.border};
+ border-radius: ${(props) => props.theme.border.radius.md};
+ }
+
+ .provider-row {
+ border: 1px solid ${(props) => props.theme.input.border};
+ border-radius: ${(props) => props.theme.border.radius.md};
+ background: ${(props) => props.theme.input.bg};
+ overflow: hidden;
+ transition: border-color 0.15s ease;
+
+ &.expanded {
+ border-color: ${(props) => props.theme.colors.accent}80;
+ }
+ }
+
+ .provider-header {
+ transition: background-color 0.15s ease;
+
+ &:hover {
+ background: ${(props) => props.theme.colors.accent}08;
+ }
+ }
+
+ .provider-logo {
+ color: ${(props) => props.theme.text};
+ }
+
+ .provider-status {
+ color: ${(props) => props.theme.colors.text.muted};
+
+ &.configured {
+ color: ${(props) => props.theme.colors.text.green};
+ }
+ }
+
+ .status-dot {
+ background: ${(props) => props.theme.input.border};
+
+ &.configured {
+ background: ${(props) => props.theme.colors.text.green};
+ box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.green}25;
+ }
+ }
+
+ .chevron {
+ color: ${(props) => props.theme.colors.text.muted};
+ transition: transform 0.2s ease;
+
+ &.expanded {
+ transform: rotate(180deg);
+ }
+ }
+
+ /* Smooth expand/collapse using grid-template-rows trick */
+ .provider-body-wrapper {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.2s ease;
+
+ &.open {
+ grid-template-rows: 1fr;
+ }
+ }
+
+ .provider-body-inner {
+ overflow: hidden;
+ min-height: 0;
+ }
+
+ .provider-body {
+ border-top: 1px solid ${(props) => props.theme.input.border};
+ }
+
+ .key-section-label {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .key-input {
+ font-family: ${(props) => props.theme.font.monospace || 'monospace'};
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ background-color: ${(props) => props.theme.input.bg};
+ border: 1px solid ${(props) => props.theme.input.border};
+ color: ${(props) => props.theme.text};
+
+ &::placeholder {
+ color: ${(props) => props.theme.colors.text.muted};
+ opacity: 0.7;
+ }
+
+ &:focus {
+ outline: none;
+ border-color: ${(props) => props.theme.input.focusBorder};
+ }
+ }
+
+ .key-eye-btn {
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ color: ${(props) => props.theme.colors.text.muted};
+ transition: background-color 0.15s ease, color 0.15s ease;
+
+ &:hover {
+ color: ${(props) => props.theme.text};
+ background: ${(props) => props.theme.colors.accent}10;
+ }
+ }
+
+ .key-display-row {
+ border: 1px solid ${(props) => props.theme.input.border};
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ background: ${(props) => props.theme.input.bg};
+ }
+
+ .key-display-mask {
+ font-family: ${(props) => props.theme.font.monospace || 'monospace'};
+ color: ${(props) => props.theme.colors.text.muted};
+ letter-spacing: 1px;
+ }
+
+ .btn-primary {
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ border: 1px solid ${(props) => props.theme.colors.accent};
+ background: ${(props) => props.theme.colors.accent};
+ color: white;
+ transition: opacity 0.15s ease;
+
+ &:hover:not(:disabled) {
+ opacity: 0.88;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ .btn-icon {
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ border: none;
+ background: transparent;
+ color: ${(props) => props.theme.colors.text.muted};
+ transition: background-color 0.15s ease, color 0.15s ease;
+
+ &:hover:not(:disabled) {
+ background: ${(props) => props.theme.colors.accent}10;
+ color: ${(props) => props.theme.text};
+ }
+
+ &.danger:hover:not(:disabled) {
+ color: ${(props) => props.theme.colors.text.danger};
+ background: ${(props) => props.theme.colors.bg.danger}15;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ .feedback {
+ border-radius: ${(props) => props.theme.border.radius.sm};
+
+ &.success {
+ color: ${(props) => props.theme.colors.text.green};
+ background: ${(props) => props.theme.colors.text.green}10;
+ }
+
+ &.error {
+ color: ${(props) => props.theme.colors.text.danger};
+ background: ${(props) => props.theme.colors.bg.danger}15;
+ }
+ }
+
+ .models-label-row {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .model-chip {
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ border: 1px solid transparent;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+
+ &:hover:not(.disabled) {
+ background: ${(props) => props.theme.colors.accent}08;
+ }
+
+ &.selected {
+ border-color: ${(props) => props.theme.input.border};
+ background: ${(props) => props.theme.colors.accent}06;
+ }
+
+ &.disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+
+ input,
+ label {
+ cursor: not-allowed;
+ }
+ }
+ }
+
+ .keyless-hint {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ @keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+ }
+
+ .spin {
+ animation: spin 1s linear infinite;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Preferences/AI/index.js b/packages/bruno-app/src/components/Preferences/AI/index.js
new file mode 100644
index 000000000..11a025241
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/AI/index.js
@@ -0,0 +1,202 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import get from 'lodash/get';
+import debounce from 'lodash/debounce';
+import { useFormik } from 'formik';
+import { useDispatch, useSelector } from 'react-redux';
+import * as Yup from 'yup';
+import toast from 'react-hot-toast';
+import { IconStars } from '@tabler/icons';
+import { savePreferences } from 'providers/ReduxStore/slices/app';
+import ToggleSwitch from 'components/ToggleSwitch';
+import { getAiStatus } from 'utils/ai';
+import ProviderCard from './ProviderCard';
+import StyledWrapper from './StyledWrapper';
+
+const aiPreferencesSchema = Yup.object().shape({
+ enabled: Yup.boolean(),
+ providers: Yup.object(),
+ models: Yup.object(),
+ defaultModel: Yup.string().max(200).nullable()
+});
+
+const AI = () => {
+ const dispatch = useDispatch();
+ const preferences = useSelector((state) => state.app.preferences);
+ const [status, setStatus] = useState(null);
+ const [statusError, setStatusError] = useState(null);
+
+ const refreshStatus = useCallback(async () => {
+ try {
+ const next = await getAiStatus();
+ setStatus(next);
+ setStatusError(null);
+ } catch (err) {
+ setStatusError(err.message || 'Failed to load AI status');
+ }
+ }, []);
+
+ useEffect(() => {
+ refreshStatus();
+ }, [refreshStatus]);
+
+ const providerIds = status ? Object.keys(status.providers) : [];
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ initialValues: {
+ enabled: get(preferences, 'ai.enabled', false),
+ providers: providerIds.reduce((acc, id) => {
+ acc[id] = { enabled: get(preferences, `ai.providers.${id}.enabled`, false) };
+ return acc;
+ }, {}),
+ models: get(preferences, 'ai.models', {}),
+ defaultModel: get(preferences, 'ai.defaultModel', '')
+ },
+ validationSchema: aiPreferencesSchema,
+ onSubmit: () => {}
+ });
+
+ const handleSave = useCallback(
+ (values) => {
+ dispatch(
+ savePreferences({
+ ...preferences,
+ ai: {
+ enabled: values.enabled,
+ providers: values.providers,
+ models: values.models,
+ defaultModel: values.defaultModel || ''
+ }
+ })
+ ).catch((err) => {
+ console.error('Failed to save AI preferences:', err);
+ toast.error('Failed to save AI preferences');
+ });
+ },
+ [dispatch, preferences]
+ );
+
+ const handleSaveRef = useRef(handleSave);
+ handleSaveRef.current = handleSave;
+
+ const debouncedSave = useCallback(
+ debounce((values) => {
+ aiPreferencesSchema
+ .validate(values, { abortEarly: true })
+ .then((validated) => handleSaveRef.current(validated))
+ .catch(() => {});
+ }, 400),
+ []
+ );
+
+ useEffect(() => {
+ if (formik.dirty && formik.isValid) {
+ debouncedSave(formik.values);
+ }
+ }, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
+
+ useEffect(() => () => debouncedSave.flush(), [debouncedSave]);
+
+ const modelsByProvider = useMemo(() => {
+ const grouped = {};
+ (status?.models || []).forEach((model) => {
+ if (!grouped[model.provider]) grouped[model.provider] = [];
+ grouped[model.provider].push(model);
+ });
+ return grouped;
+ }, [status]);
+
+ const isModelEnabled = (modelId) => get(formik.values, `models.${modelId}.enabled`, true);
+
+ const handleToggleModel = (modelId, next) => {
+ formik.setFieldValue(`models.${modelId}.enabled`, next);
+ };
+
+ const summary = useMemo(() => {
+ if (!status || !formik.values.enabled) return 'Turn on to configure providers and models';
+ const usableProviders = Object.values(status.providers).filter(
+ (p) => p.configured && formik.values.providers?.[p.id]?.enabled
+ );
+ if (usableProviders.length === 0) return 'Add a provider to get started';
+ // Count models live from formik + current key status, not the electron-side
+ // snapshot which lags behind toggle changes during the save debounce window.
+ const totalEnabledModels = (status.models || []).filter((m) => {
+ if (!formik.values.providers?.[m.provider]?.enabled) return false;
+ if (!status.providers?.[m.provider]?.configured) return false;
+ return isModelEnabled(m.id);
+ }).length;
+ const plural = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`;
+ return `${plural(usableProviders.length, 'provider')} · ${plural(totalEnabledModels, 'model')} ready`;
+ }, [status, formik.values.enabled, formik.values.providers, formik.values.models]);
+
+ return (
+
+ AI
+
+
+
+
+
+ AI Features
+
+
{summary}
+
+
formik.setFieldValue('enabled', !formik.values.enabled)}
+ />
+
+
+ {statusError && (
+
+ {statusError}
+
+ )}
+
+ {!formik.values.enabled && !statusError && (
+
+ Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
+
+ )}
+
+ {formik.values.enabled && status && (
+ <>
+
+ Providers
+
+
+ {providerIds.map((id) => {
+ const provider = status.providers[id];
+ const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
+
+ const providerToggle = (
+
+ formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
+ />
+ );
+
+ return (
+ setStatus(next)}
+ />
+ );
+ })}
+
+ >
+ )}
+
+ );
+};
+
+export default AI;
diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js
index 88a17be43..2dc5bbe8f 100644
--- a/packages/bruno-app/src/components/Preferences/index.js
+++ b/packages/bruno-app/src/components/Preferences/index.js
@@ -10,7 +10,8 @@ import {
IconKeyboard,
IconZoomQuestion,
IconSquareLetterB,
- IconDatabase
+ IconDatabase,
+ IconStars
} from '@tabler/icons';
import Support from './Support';
@@ -20,6 +21,7 @@ import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
import Beta from './Beta';
+import AI from './AI';
import StyledWrapper from './StyledWrapper';
import Cache from './Cache/index';
@@ -64,6 +66,10 @@ const Preferences = () => {
return ;
}
+ case 'ai': {
+ return ;
+ }
+
case 'support': {
return ;
}
@@ -98,6 +104,10 @@ const Preferences = () => {
Keybindings
+ setTab('ai')}>
+
+ AI
+
setTab('cache')}>
Cache
diff --git a/packages/bruno-app/src/components/ToggleSwitch/index.js b/packages/bruno-app/src/components/ToggleSwitch/index.js
index 299d77582..655e10b8b 100644
--- a/packages/bruno-app/src/components/ToggleSwitch/index.js
+++ b/packages/bruno-app/src/components/ToggleSwitch/index.js
@@ -1,10 +1,12 @@
+import { useId } from 'react';
import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper';
const ToggleSwitch = ({ isOn, handleToggle, size = 'm', activeColor, ...props }) => {
+ const id = useId();
return (
- {}} />
-