From 437e0c7dacf6f88ecd580476a7238275bd9754ae Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Thu, 25 Jun 2026 16:15:22 +0530 Subject: [PATCH] feat(ai): OpenAI-compatible endpoints support (#8365) --- .../Preferences/AI/CompatEndpointCard.js | 467 ++++++++++++++++++ .../Preferences/AI/StyledWrapper.js | 87 ++++ .../src/components/Preferences/AI/index.js | 248 ++++++++-- packages/bruno-electron/src/ipc/ai/index.js | 37 +- .../bruno-electron/src/ipc/ai/providers.js | 213 ++++++-- .../bruno-electron/src/store/preferences.js | 15 + 6 files changed, 984 insertions(+), 83 deletions(-) create mode 100644 packages/bruno-app/src/components/Preferences/AI/CompatEndpointCard.js 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 */} +
+
+ + onChangeName(e.target.value)} + onClick={stopBubble} + /> +
+
+ + onChangeBaseURL(e.target.value)} + autoComplete="off" + autoCorrect="off" + spellCheck="false" + onClick={stopBubble} + /> +
+
+ + {/* 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 && ( +
+ {models.map((model) => { + const enabled = isModelEnabled(model.id); + const disabled = !provider.configured || !providerEnabled; + return ( +
+ onToggleModel(model.id, !enabled)} + /> + onUpdateModel(model.id, { label: e.target.value })} + /> + onUpdateModel(model.id, { modelId: e.target.value })} + /> + +
+ ); + })} +
+ )} + +
+ 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(),