diff --git a/packages/bruno-app/src/components/Preferences/AI/CompatEndpointCard.js b/packages/bruno-app/src/components/Preferences/AI/CompatEndpointCard.js
new file mode 100644
index 000000000..cae1befd0
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/AI/CompatEndpointCard.js
@@ -0,0 +1,467 @@
+import { useEffect, useRef, useState } from 'react';
+import { v4 as uuid } from 'uuid';
+import {
+ IconAlertCircle,
+ IconBolt,
+ IconCheck,
+ IconChevronDown,
+ IconEye,
+ IconEyeOff,
+ IconLoader2,
+ IconPencil,
+ IconPlus,
+ IconServer,
+ IconTrash,
+ IconX
+} from '@tabler/icons';
+import toast from 'react-hot-toast';
+import { clearAiApiKey, getAiApiKey, setAiApiKey, testAiProvider } from 'utils/ai';
+
+const stopBubble = (e) => e.stopPropagation();
+
+const CompatEndpointCard = ({
+ endpoint,
+ provider,
+ providerEnabled,
+ providerToggle,
+ pending,
+ isModelEnabled,
+ onToggleModel,
+ onChangeName,
+ onChangeBaseURL,
+ onAddModel,
+ onRemoveModel,
+ onUpdateModel,
+ onRemoveEndpoint,
+ onStatusChange
+}) => {
+ const [expanded, setExpanded] = useState(!endpoint.baseURL);
+ 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 [newModelId, setNewModelId] = useState('');
+ const [newModelLabel, setNewModelLabel] = useState('');
+
+ 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 isEditingKey = editing || !provider.configured;
+
+ const handleSaveKey = 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 handleClearKey = async () => {
+ setFeedback(null);
+ try {
+ const status = await clearAiApiKey({ providerId: provider.id });
+ onStatusChange?.(status);
+ setEditing(false);
+ setKeyDraft('');
+ toast.success(`${endpoint.name || 'Endpoint'} 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 handleStartEditKey = async () => {
+ setEditing(true);
+ setFeedback(null);
+ try {
+ const current = await getAiApiKey({ providerId: provider.id });
+ setKeyDraft(current || '');
+ } catch (err) {
+ setKeyDraft('');
+ }
+ };
+
+ const handleCancelEditKey = () => {
+ setEditing(false);
+ setKeyDraft('');
+ setShowKey(false);
+ setFeedback(null);
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (keyDraft.trim() && !saving) handleSaveKey();
+ } else if (e.key === 'Escape' && provider.configured) {
+ e.preventDefault();
+ handleCancelEditKey();
+ }
+ };
+
+ const handleAddModel = () => {
+ const id = newModelId.trim();
+ if (!id) return;
+ onAddModel({
+ id: uuid(),
+ modelId: id,
+ label: newModelLabel.trim() || id
+ });
+ setNewModelId('');
+ setNewModelLabel('');
+ };
+
+ const handleAddModelKeyDown = (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddModel();
+ }
+ };
+
+ const models = endpoint.models || [];
+ 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);
+ }
+ }}
+ >
+
+
+
+ {endpoint.name || 'Unnamed endpoint'}
+ {endpoint.baseURL && (
+ {endpoint.baseURL}
+ )}
+
+
+
+
+
+ {provider.configured
+ ? `${enabledModelsCount}/${models.length} model${models.length === 1 ? '' : 's'}`
+ : 'Not configured'}
+
+
+ {providerToggle}
+
+
+
+
+
+
+
+
+
+
+ {/* Endpoint details */}
+
+
+ {/* API key */}
+
+
+ API Key
+
+
+ {!isEditingKey ? (
+
+
••••••••••••••••
+
+
+
+
+
+
+ ) : (
+
+
+ setKeyDraft(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onClick={stopBubble}
+ autoFocus
+ data-testid={`ai-endpoint-${endpoint.id}-key-input`}
+ />
+
+
+
+ {provider.configured && (
+
+ )}
+
+ )}
+
+ {pending && (
+
+
+ Saving endpoint…
+
+ )}
+
+ {feedback && (
+
+ {feedback.type === 'success' ? : }
+ {feedback.message}
+
+ )}
+
+
+ {/* Models */}
+
+
+ Models
+ {!provider.configured && (
+
+
+ Add an API key to enable
+
+ )}
+
+
+ {models.length === 0 && (
+
+ No models yet. Add the model id your provider expects (e.g. gpt-4o or llama3.1:8b).
+
+ )}
+
+ {models.length > 0 && (
+
+ )}
+
+
+ setNewModelId(e.target.value)}
+ onKeyDown={handleAddModelKeyDown}
+ data-testid={`ai-endpoint-${endpoint.id}-new-model-id`}
+ />
+ setNewModelLabel(e.target.value)}
+ onKeyDown={handleAddModelKeyDown}
+ data-testid={`ai-endpoint-${endpoint.id}-new-model-label`}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CompatEndpointCard;
diff --git a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js
index f1382d4f3..b6e5d5312 100644
--- a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js
@@ -379,6 +379,93 @@ const StyledWrapper = styled.div`
}
}
+ .compat-add-btn {
+ color: ${(props) => props.theme.colors.text.muted};
+ background: transparent;
+ border: 1px solid ${(props) => props.theme.input.border};
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ padding: 3px 8px;
+ transition: color 0.15s ease, border-color 0.15s ease;
+
+ &:hover {
+ color: ${(props) => props.theme.text};
+ border-color: ${(props) => props.theme.colors.accent}80;
+ }
+ }
+
+ .compat-models-empty {
+ color: ${(props) => props.theme.colors.text.muted};
+ border: 1px dashed ${(props) => props.theme.input.border};
+ border-radius: ${(props) => props.theme.border.radius.sm};
+
+ code {
+ font-family: ${(props) => props.theme.font.monospace || 'monospace'};
+ color: ${(props) => props.theme.text};
+ }
+ }
+
+ .compat-model-row {
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ border: 1px solid ${(props) => props.theme.input.border};
+ background: ${(props) => props.theme.input.bg};
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+
+ &.selected {
+ background: ${(props) => props.theme.colors.accent}06;
+ }
+
+ &.disabled {
+ opacity: 0.45;
+
+ input {
+ cursor: not-allowed;
+ }
+ }
+ }
+
+ .compat-inline-input {
+ background: transparent;
+ border: none;
+ outline: none;
+ color: ${(props) => props.theme.text};
+ padding: 2px 4px;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ min-width: 0;
+ font-family: inherit;
+
+ &::placeholder {
+ color: ${(props) => props.theme.colors.text.muted};
+ opacity: 0.6;
+ }
+
+ &:focus {
+ background: ${(props) => props.theme.bg};
+ box-shadow: inset 0 0 0 1px ${(props) => props.theme.input.focusBorder};
+ }
+ }
+
+ .compat-inline-id {
+ font-family: ${(props) => props.theme.font.monospace || 'monospace'};
+ }
+
+ .compat-add-model {
+ padding-top: 4px;
+ }
+
+ .compat-remove-endpoint {
+ color: ${(props) => props.theme.colors.text.muted};
+ background: transparent;
+ border: none;
+ padding: 4px 6px;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ transition: color 0.15s ease, background-color 0.15s ease;
+
+ &:hover {
+ color: ${(props) => props.theme.colors.text.danger};
+ background: ${(props) => props.theme.colors.bg.danger}15;
+ }
+ }
+
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
diff --git a/packages/bruno-app/src/components/Preferences/AI/index.js b/packages/bruno-app/src/components/Preferences/AI/index.js
index 9604b6c2b..8294d8a8f 100644
--- a/packages/bruno-app/src/components/Preferences/AI/index.js
+++ b/packages/bruno-app/src/components/Preferences/AI/index.js
@@ -1,23 +1,42 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import get from 'lodash/get';
import debounce from 'lodash/debounce';
+import { v4 as uuid } from 'uuid';
import { useFormik } from 'formik';
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
-import { IconSettings, IconTerminal2 } from '@tabler/icons';
+import { IconPlus, IconSettings, IconTerminal2 } from '@tabler/icons';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import ToggleSwitch from 'components/ToggleSwitch';
-import { getAiStatus } from 'utils/ai';
+import { clearAiApiKey, getAiStatus } from 'utils/ai';
import ProviderCard from './ProviderCard';
+import CompatEndpointCard from './CompatEndpointCard';
import AutocompletePane from './AutocompletePane';
import StyledWrapper from './StyledWrapper';
+const OPENAI_COMPATIBLE_PREFIX = 'openai-compatible:';
+const isCompatProviderId = (id) => typeof id === 'string' && id.startsWith(OPENAI_COMPATIBLE_PREFIX);
+
const aiPreferencesSchema = Yup.object().shape({
enabled: Yup.boolean(),
providers: Yup.object(),
models: Yup.object(),
defaultModel: Yup.string().max(200).nullable(),
+ openaiCompatibleEndpoints: Yup.array().of(
+ Yup.object().shape({
+ id: Yup.string().required(),
+ name: Yup.string().max(120).nullable(),
+ baseURL: Yup.string().max(2048).nullable(),
+ models: Yup.array().of(
+ Yup.object().shape({
+ id: Yup.string().required(),
+ label: Yup.string().max(120).nullable(),
+ modelId: Yup.string().max(200).nullable()
+ })
+ )
+ })
+ ),
autocomplete: Yup.object().shape({
enabled: Yup.boolean(),
model: Yup.string().max(200).nullable(),
@@ -58,6 +77,7 @@ const AI = () => {
}, {}),
models: get(preferences, 'ai.models', {}),
defaultModel: get(preferences, 'ai.defaultModel', ''),
+ openaiCompatibleEndpoints: get(preferences, 'ai.openaiCompatibleEndpoints', []),
autocomplete: {
enabled: get(preferences, 'ai.autocomplete.enabled', true),
model: get(preferences, 'ai.autocomplete.model', ''),
@@ -69,7 +89,7 @@ const AI = () => {
});
const handleSave = useCallback(
- (values) => {
+ (values) =>
dispatch(
savePreferences({
...preferences,
@@ -78,6 +98,7 @@ const AI = () => {
providers: values.providers,
models: values.models,
defaultModel: values.defaultModel || '',
+ openaiCompatibleEndpoints: values.openaiCompatibleEndpoints || [],
autocomplete: {
enabled: values.autocomplete?.enabled !== false,
model: values.autocomplete?.model || '',
@@ -85,12 +106,14 @@ const AI = () => {
}
}
})
- ).catch((err) => {
- console.error('Failed to save AI preferences:', err);
- toast.error('Failed to save AI preferences');
- });
- },
- [dispatch, preferences]
+ )
+ .then(() => refreshStatus())
+ .catch((err) => {
+ console.error('Failed to save AI preferences:', err);
+ toast.error('Failed to save AI preferences');
+ throw err;
+ }),
+ [dispatch, preferences, refreshStatus]
);
const handleSaveRef = useRef(handleSave);
@@ -129,14 +152,96 @@ const AI = () => {
formik.setFieldValue(`models.${modelId}.enabled`, next);
};
+ const endpoints = formik.values.openaiCompatibleEndpoints || [];
+
+ const handleAddEndpoint = async () => {
+ const newEndpoint = {
+ id: uuid(),
+ name: `Endpoint ${endpoints.length + 1}`,
+ baseURL: '',
+ models: []
+ };
+ const next = [...endpoints, newEndpoint];
+ formik.setFieldValue('openaiCompatibleEndpoints', next);
+ formik.setFieldValue(`providers.${OPENAI_COMPATIBLE_PREFIX}${newEndpoint.id}.enabled`, true);
+ // Persist immediately so the backend recognises the new virtual provider id
+ // by the time the user enters an API key. The card derives a `pending` flag
+ // from `status.providers` so its key/test actions stay disabled until this
+ // resolves, which also closes the race with debouncedSave.
+ try {
+ await handleSaveRef.current({
+ ...formik.values,
+ openaiCompatibleEndpoints: next,
+ providers: {
+ ...formik.values.providers,
+ [`${OPENAI_COMPATIBLE_PREFIX}${newEndpoint.id}`]: { enabled: true }
+ }
+ });
+ } catch (_) {
+ // toast already raised by handleSave
+ }
+ };
+
+ const updateEndpoint = (endpointId, patch) => {
+ const next = endpoints.map((e) => (e.id === endpointId ? { ...e, ...patch } : e));
+ formik.setFieldValue('openaiCompatibleEndpoints', next);
+ };
+
+ const updateEndpointModels = (endpointId, mapFn) => {
+ const next = endpoints.map((e) => (e.id === endpointId ? { ...e, models: mapFn(e.models || []) } : e));
+ formik.setFieldValue('openaiCompatibleEndpoints', next);
+ };
+
+ const handleRemoveEndpoint = async (endpointId) => {
+ const providerId = `${OPENAI_COMPATIBLE_PREFIX}${endpointId}`;
+ const removed = endpoints.find((e) => e.id === endpointId);
+ const removedModelIds = new Set((removed?.models || []).map((m) => m.id));
+
+ const next = endpoints.filter((e) => e.id !== endpointId);
+ formik.setFieldValue('openaiCompatibleEndpoints', next);
+
+ const providersCopy = { ...formik.values.providers };
+ delete providersCopy[providerId];
+ formik.setFieldValue('providers', providersCopy);
+
+ // Drop per-model toggles and clear any selector still pointing at a removed
+ // model so the picker doesn't resolve to an unknown id later.
+ if (removedModelIds.size > 0) {
+ const modelsCopy = { ...(formik.values.models || {}) };
+ for (const id of removedModelIds) delete modelsCopy[id];
+ formik.setFieldValue('models', modelsCopy);
+
+ if (removedModelIds.has(formik.values.defaultModel)) {
+ formik.setFieldValue('defaultModel', '');
+ }
+ if (removedModelIds.has(formik.values.autocomplete?.model)) {
+ formik.setFieldValue('autocomplete.model', '');
+ }
+ }
+
+ // Best-effort key cleanup so we don't leave orphan encrypted blobs on disk.
+ try {
+ await clearAiApiKey({ providerId });
+ } catch (_) {
+ // ignore, key may not have been set
+ }
+ };
+
const usableModels = useMemo(() => {
if (!status) return [];
+ const endpointsById = new Map((formik.values.openaiCompatibleEndpoints || []).map((e) => [e.id, e]));
return (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);
+ if (!isModelEnabled(m.id)) return false;
+ if (isCompatProviderId(m.provider)) {
+ const endpointId = m.provider.slice(OPENAI_COMPATIBLE_PREFIX.length);
+ const endpoint = endpointsById.get(endpointId);
+ if (!endpoint?.baseURL) return false;
+ }
+ return true;
});
- }, [status, formik.values.providers, formik.values.models]);
+ }, [status, formik.values.providers, formik.values.models, formik.values.openaiCompatibleEndpoints]);
return (
@@ -201,33 +306,106 @@ const AI = () => {
Providers
- {providerIds.map((id) => {
- const provider = status.providers[id];
- const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
+ {providerIds
+ .filter((id) => !isCompatProviderId(id))
+ .map((id) => {
+ const provider = status.providers[id];
+ const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
- const providerToggle = (
-
- formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
- />
- );
+ const providerToggle = (
+
+ formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
+ />
+ );
- return (
- setStatus(next)}
- />
- );
- })}
+ return (
+ setStatus(next)}
+ />
+ );
+ })}
+
+
+ OpenAI-Compatible Endpoints
+
+
+
+ {endpoints.length === 0 && (
+
+ Point Bruno at any OpenAI-compatible API — Ollama, LM Studio, Together, Groq, OpenRouter, vLLM, and more.
+
+ )}
+
+ {endpoints.length > 0 && (
+
+ {endpoints.map((endpoint) => {
+ const providerId = `${OPENAI_COMPATIBLE_PREFIX}${endpoint.id}`;
+ const pending = !status.providers[providerId];
+ const provider = status.providers[providerId] || {
+ id: providerId,
+ label: endpoint.name,
+ configured: false,
+ isCustom: true
+ };
+ const providerEnabled = get(formik.values, `providers.${providerId}.enabled`, false);
+
+ const providerToggle = (
+
+ formik.setFieldValue(`providers.${providerId}.enabled`, !providerEnabled)}
+ />
+ );
+
+ return (
+ updateEndpoint(endpoint.id, { name })}
+ onChangeBaseURL={(baseURL) => updateEndpoint(endpoint.id, { baseURL })}
+ onAddModel={(model) =>
+ updateEndpointModels(endpoint.id, (models) => [...models, model])}
+ onRemoveModel={(modelId) =>
+ updateEndpointModels(endpoint.id, (models) =>
+ models.filter((m) => m.id !== modelId)
+ )}
+ onUpdateModel={(modelId, patch) =>
+ updateEndpointModels(endpoint.id, (models) =>
+ models.map((m) => (m.id === modelId ? { ...m, ...patch } : m))
+ )}
+ onRemoveEndpoint={handleRemoveEndpoint}
+ onStatusChange={(next) => setStatus(next)}
+ />
+ );
+ })}
+
+ )}
>
)}
diff --git a/packages/bruno-electron/src/ipc/ai/index.js b/packages/bruno-electron/src/ipc/ai/index.js
index 0cdc7ae37..75c6a5c19 100644
--- a/packages/bruno-electron/src/ipc/ai/index.js
+++ b/packages/bruno-electron/src/ipc/ai/index.js
@@ -8,7 +8,10 @@ const {
listModels,
getModel,
getAvailableModels,
- clearSdkCache
+ clearSdkCache,
+ isKnownProviderId,
+ validateApiKeyForProvider,
+ providerLabel
} = require('./providers');
const { SCRIPT_PROMPTS, SCRIPT_TYPES, buildScriptUserPrompt, stripCodeFences } = require('./script-prompts');
@@ -23,7 +26,7 @@ const buildStatus = () => {
const hasApiKey = (providerId) => aiKeyStore.hasKey(providerId);
const providers = {};
- for (const provider of listProviders()) {
+ for (const provider of listProviders(aiPreferences)) {
providers[provider.id] = {
...provider,
enabled: Boolean(aiPreferences?.providers?.[provider.id]?.enabled),
@@ -34,7 +37,7 @@ const buildStatus = () => {
return {
enabled: Boolean(aiPreferences.enabled),
providers,
- models: listModels(),
+ models: listModels(aiPreferences),
availableModels: getAvailableModels({ aiPreferences, hasApiKey })
};
};
@@ -59,13 +62,17 @@ const pickDefaultModelId = () => {
return available[0].id;
};
+const assertKnownProvider = (providerId) => {
+ if (!isKnownProviderId(providerId, getAiPrefs())) {
+ throw new Error(`Unknown AI provider: ${providerId}`);
+ }
+};
+
const registerAiIpc = (mainWindow) => {
ipcMain.handle('renderer:get-ai-status', async () => buildStatus());
ipcMain.handle('renderer:set-ai-api-key', async (_event, { providerId, apiKey }) => {
- if (!PROVIDERS[providerId]) {
- throw new Error(`Unknown AI provider: ${providerId}`);
- }
+ assertKnownProvider(providerId);
const trimmed = typeof apiKey === 'string' ? apiKey.trim() : '';
if (!trimmed) {
throw new Error('API key cannot be empty');
@@ -76,23 +83,20 @@ const registerAiIpc = (mainWindow) => {
});
ipcMain.handle('renderer:clear-ai-api-key', async (_event, { providerId }) => {
- if (!PROVIDERS[providerId]) {
- throw new Error(`Unknown AI provider: ${providerId}`);
- }
+ assertKnownProvider(providerId);
aiKeyStore.clearKey(providerId);
clearSdkCache();
return buildStatus();
});
ipcMain.handle('renderer:get-ai-api-key', async (_event, { providerId }) => {
- if (!PROVIDERS[providerId]) {
- throw new Error(`Unknown AI provider: ${providerId}`);
- }
+ assertKnownProvider(providerId);
return aiKeyStore.getKey(providerId) || '';
});
ipcMain.handle('renderer:ai-test-provider', async (_event, { providerId }) => {
- if (!PROVIDERS[providerId]) {
+ const aiPrefs = getAiPrefs();
+ if (!isKnownProviderId(providerId, aiPrefs)) {
return { ok: false, error: `Unknown provider: ${providerId}` };
}
const apiKey = aiKeyStore.getKey(providerId);
@@ -100,14 +104,13 @@ const registerAiIpc = (mainWindow) => {
return { ok: false, error: 'No API key configured' };
}
- const aiPrefs = getAiPrefs();
const providerEnabled = aiPrefs?.providers?.[providerId]?.enabled;
if (!providerEnabled) {
- return { ok: false, error: `${PROVIDERS[providerId].label} is disabled` };
+ return { ok: false, error: `${providerLabel(providerId, aiPrefs)} is disabled` };
}
try {
- const res = await PROVIDERS[providerId].validateApiKey({ apiKey });
+ const res = await validateApiKeyForProvider({ providerId, apiKey, aiPreferences: aiPrefs });
if (res.ok) {
return { ok: true };
}
@@ -119,7 +122,7 @@ const registerAiIpc = (mainWindow) => {
}
return { ok: false, error: `Could not verify key (HTTP ${res.status})` };
} catch (err) {
- return { ok: false, error: 'Could not reach provider. Check your network connection.' };
+ return { ok: false, error: err.message || 'Could not reach provider. Check your network connection.' };
}
});
diff --git a/packages/bruno-electron/src/ipc/ai/providers.js b/packages/bruno-electron/src/ipc/ai/providers.js
index 5fe945ec8..dc88db88b 100644
--- a/packages/bruno-electron/src/ipc/ai/providers.js
+++ b/packages/bruno-electron/src/ipc/ai/providers.js
@@ -1,6 +1,16 @@
const { createOpenAI } = require('@ai-sdk/openai');
const { createAnthropic } = require('@ai-sdk/anthropic');
+const OPENAI_COMPATIBLE_PREFIX = 'openai-compatible:';
+
+const isOpenAiCompatibleProviderId = (id) =>
+ typeof id === 'string' && id.startsWith(OPENAI_COMPATIBLE_PREFIX);
+
+const endpointIdFromProviderId = (providerId) =>
+ isOpenAiCompatibleProviderId(providerId) ? providerId.slice(OPENAI_COMPATIBLE_PREFIX.length) : null;
+
+const providerIdFromEndpointId = (endpointId) => `${OPENAI_COMPATIBLE_PREFIX}${endpointId}`;
+
const PROVIDERS = {
openai: {
id: 'openai',
@@ -27,8 +37,8 @@ const PROVIDERS = {
};
/**
- * Model catalog. Each entry is keyed by a stable id used in preferences and IPC.
- * `modelId` is the value passed to the provider SDK; `id` is the Bruno-internal id.
+ * Static model catalog for built-in providers. User-defined custom models for
+ * OpenAI-compatible endpoints are layered on top at lookup time.
*/
const MODEL_DEFINITIONS = {
// OpenAI
@@ -42,18 +52,46 @@ const MODEL_DEFINITIONS = {
'claude-haiku-4-5': { provider: 'anthropic', modelId: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' }
};
-// Cache SDK instances. Keyed by `${providerId}:${apiKey}` so changing keys rebuilds the SDK.
+// Cache SDK instances. Built-in keyed by `${providerId}:${apiKey}`; compat
+// also folds baseURL in so editing the URL rebuilds the SDK.
const sdkCache = new Map();
-const getSdk = (providerId, apiKey) => {
- const cacheKey = `${providerId}:${apiKey}`;
- let sdk = sdkCache.get(cacheKey);
- if (!sdk) {
+// JSON-stringified tuple so values containing ":" (provider ids, URLs) can't
+// collide and reuse an SDK configured for a different endpoint/key.
+const sdkCacheKey = ({ providerId, apiKey, baseURL }) =>
+ JSON.stringify([providerId, baseURL || '', apiKey]);
+
+const getCompatEndpoint = (aiPreferences, endpointId) => {
+ const list = Array.isArray(aiPreferences?.openaiCompatibleEndpoints)
+ ? aiPreferences.openaiCompatibleEndpoints
+ : [];
+ return list.find((e) => e?.id === endpointId) || null;
+};
+
+const compatProviderEntry = (endpoint) => ({
+ id: providerIdFromEndpointId(endpoint.id),
+ label: endpoint.name || 'OpenAI-compatible',
+ apiKeyPlaceholder: 'sk-...',
+ apiKeyHelpUrl: null,
+ isCustom: true,
+ endpointId: endpoint.id,
+ baseURL: endpoint.baseURL || ''
+});
+
+const getSdk = ({ providerId, apiKey, baseURL }) => {
+ const key = sdkCacheKey({ providerId, apiKey, baseURL });
+ let sdk = sdkCache.get(key);
+ if (sdk) return sdk;
+
+ if (isOpenAiCompatibleProviderId(providerId)) {
+ sdk = createOpenAI({ apiKey, baseURL });
+ } else {
const provider = PROVIDERS[providerId];
if (!provider) throw new Error(`Unknown AI provider: ${providerId}`);
sdk = provider.createSdk({ apiKey });
- sdkCache.set(cacheKey, sdk);
}
+
+ sdkCache.set(key, sdk);
return sdk;
};
@@ -61,38 +99,114 @@ const clearSdkCache = () => {
sdkCache.clear();
};
-const listProviders = () => Object.values(PROVIDERS).map((p) => ({
- id: p.id,
- label: p.label,
- apiKeyPlaceholder: p.apiKeyPlaceholder,
- apiKeyHelpUrl: p.apiKeyHelpUrl
-}));
+const listProviders = (aiPreferences) => {
+ const builtIn = Object.values(PROVIDERS).map((p) => ({
+ id: p.id,
+ label: p.label,
+ apiKeyPlaceholder: p.apiKeyPlaceholder,
+ apiKeyHelpUrl: p.apiKeyHelpUrl,
+ isCustom: false
+ }));
-const listModels = () => Object.entries(MODEL_DEFINITIONS).map(([id, def]) => ({
- id,
- label: def.label,
- provider: def.provider
-}));
+ const endpoints = Array.isArray(aiPreferences?.openaiCompatibleEndpoints)
+ ? aiPreferences.openaiCompatibleEndpoints
+ : [];
+
+ return [...builtIn, ...endpoints.map(compatProviderEntry)];
+};
+
+const listModels = (aiPreferences) => {
+ const builtIn = Object.entries(MODEL_DEFINITIONS).map(([id, def]) => ({
+ id,
+ label: def.label,
+ provider: def.provider,
+ isCustom: false
+ }));
+
+ const endpoints = Array.isArray(aiPreferences?.openaiCompatibleEndpoints)
+ ? aiPreferences.openaiCompatibleEndpoints
+ : [];
+
+ const custom = [];
+ for (const endpoint of endpoints) {
+ if (!endpoint?.id || !Array.isArray(endpoint.models)) continue;
+ for (const model of endpoint.models) {
+ if (!model?.id || !model?.modelId) continue;
+ custom.push({
+ id: model.id,
+ label: model.label || model.modelId,
+ provider: providerIdFromEndpointId(endpoint.id),
+ isCustom: true
+ });
+ }
+ }
+
+ return [...builtIn, ...custom];
+};
+
+/** Resolve a Bruno model id (built-in or custom) into its provider config. */
+const resolveModelDefinition = (modelId, aiPreferences) => {
+ if (MODEL_DEFINITIONS[modelId]) {
+ const def = MODEL_DEFINITIONS[modelId];
+ return {
+ providerId: def.provider,
+ sdkModelId: def.modelId,
+ label: def.label,
+ baseURL: null
+ };
+ }
+
+ const endpoints = Array.isArray(aiPreferences?.openaiCompatibleEndpoints)
+ ? aiPreferences.openaiCompatibleEndpoints
+ : [];
+ for (const endpoint of endpoints) {
+ if (!endpoint?.id || !Array.isArray(endpoint.models)) continue;
+ const match = endpoint.models.find((m) => m?.id === modelId);
+ if (match) {
+ return {
+ providerId: providerIdFromEndpointId(endpoint.id),
+ sdkModelId: match.modelId,
+ label: match.label || match.modelId,
+ baseURL: endpoint.baseURL || ''
+ };
+ }
+ }
+ return null;
+};
+
+const providerLabel = (providerId, aiPreferences) => {
+ if (PROVIDERS[providerId]) return PROVIDERS[providerId].label;
+ const endpointId = endpointIdFromProviderId(providerId);
+ if (endpointId) {
+ const endpoint = getCompatEndpoint(aiPreferences, endpointId);
+ if (endpoint) return endpoint.name || 'OpenAI-compatible';
+ }
+ return providerId;
+};
/**
* Resolve a Bruno model id to a vercel-ai SDK model instance.
* Throws if the provider isn't configured (no key) or the model is unknown.
*/
const getModel = (modelId, { aiPreferences, getApiKey }) => {
- const def = MODEL_DEFINITIONS[modelId];
+ const def = resolveModelDefinition(modelId, aiPreferences);
if (!def) throw new Error(`Unknown model: ${modelId}`);
- const providerConfig = aiPreferences?.providers?.[def.provider];
+ const providerConfig = aiPreferences?.providers?.[def.providerId];
if (!providerConfig?.enabled) {
- throw new Error(`${PROVIDERS[def.provider].label} is not enabled. Enable it in Preferences > AI.`);
+ throw new Error(`${providerLabel(def.providerId, aiPreferences)} is not enabled. Enable it in Preferences > AI.`);
}
- const apiKey = getApiKey(def.provider);
+ const apiKey = getApiKey(def.providerId);
if (!apiKey) {
- throw new Error(`${PROVIDERS[def.provider].label} API key is not configured. Add it in Preferences > AI.`);
+ throw new Error(`${providerLabel(def.providerId, aiPreferences)} API key is not configured. Add it in Preferences > AI.`);
}
- return getSdk(def.provider, apiKey)(def.modelId);
+ if (isOpenAiCompatibleProviderId(def.providerId) && !def.baseURL) {
+ throw new Error(`${providerLabel(def.providerId, aiPreferences)} is missing a Base URL. Set one in Preferences > AI.`);
+ }
+
+ return getSdk({ providerId: def.providerId, apiKey, baseURL: def.baseURL })(def.sdkModelId);
};
/**
@@ -100,25 +214,62 @@ const getModel = (modelId, { aiPreferences, getApiKey }) => {
*/
const getAvailableModels = ({ aiPreferences, hasApiKey }) => {
const out = [];
- for (const [id, def] of Object.entries(MODEL_DEFINITIONS)) {
- const providerConfig = aiPreferences?.providers?.[def.provider];
+ for (const model of listModels(aiPreferences)) {
+ const providerConfig = aiPreferences?.providers?.[model.provider];
if (!providerConfig?.enabled) continue;
- if (!hasApiKey(def.provider)) continue;
+ if (!hasApiKey(model.provider)) continue;
- const modelConfig = aiPreferences?.models?.[id];
+ const modelConfig = aiPreferences?.models?.[model.id];
if (modelConfig?.enabled === false) continue;
- out.push({ id, label: def.label, provider: def.provider });
+ if (isOpenAiCompatibleProviderId(model.provider)) {
+ const endpointId = endpointIdFromProviderId(model.provider);
+ const endpoint = getCompatEndpoint(aiPreferences, endpointId);
+ if (!endpoint?.baseURL) continue;
+ }
+
+ out.push({ id: model.id, label: model.label, provider: model.provider });
}
return out;
};
+const isKnownProviderId = (providerId, aiPreferences) => {
+ if (PROVIDERS[providerId]) return true;
+ const endpointId = endpointIdFromProviderId(providerId);
+ if (!endpointId) return false;
+ return Boolean(getCompatEndpoint(aiPreferences, endpointId));
+};
+
+const validateApiKeyForProvider = async ({ providerId, apiKey, aiPreferences }) => {
+ if (PROVIDERS[providerId]) {
+ return PROVIDERS[providerId].validateApiKey({ apiKey });
+ }
+ const endpointId = endpointIdFromProviderId(providerId);
+ const endpoint = endpointId ? getCompatEndpoint(aiPreferences, endpointId) : null;
+ if (!endpoint?.baseURL) {
+ throw new Error('Endpoint Base URL is not configured');
+ }
+ const url = `${endpoint.baseURL.replace(/\/$/, '')}/models`;
+ return fetch(url, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ signal: AbortSignal.timeout(10000)
+ });
+};
+
module.exports = {
PROVIDERS,
MODEL_DEFINITIONS,
+ OPENAI_COMPATIBLE_PREFIX,
listProviders,
listModels,
getModel,
getAvailableModels,
- clearSdkCache
+ clearSdkCache,
+ isOpenAiCompatibleProviderId,
+ endpointIdFromProviderId,
+ providerIdFromEndpointId,
+ getCompatEndpoint,
+ isKnownProviderId,
+ validateApiKeyForProvider,
+ providerLabel
};
diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js
index de0bb3a5f..bb3b59c9b 100644
--- a/packages/bruno-electron/src/store/preferences.js
+++ b/packages/bruno-electron/src/store/preferences.js
@@ -81,6 +81,7 @@ const defaultPreferences = {
},
models: {},
defaultModel: '',
+ openaiCompatibleEndpoints: [],
autocomplete: {
enabled: true,
model: '',
@@ -163,6 +164,20 @@ const preferencesSchema = Yup.object().shape({
providers: Yup.object().optional(),
models: Yup.object().optional(),
defaultModel: Yup.string().max(200).nullable(),
+ openaiCompatibleEndpoints: Yup.array().of(
+ Yup.object({
+ id: Yup.string().required(),
+ name: Yup.string().max(120).nullable(),
+ baseURL: Yup.string().max(2048).nullable(),
+ models: Yup.array().of(
+ Yup.object({
+ id: Yup.string().required(),
+ label: Yup.string().max(120).nullable(),
+ modelId: Yup.string().max(200).nullable()
+ })
+ )
+ })
+ ).optional(),
autocomplete: Yup.object({
enabled: Yup.boolean(),
model: Yup.string().max(200).nullable(),