feat(ai): OpenAI-compatible endpoints support (#8365)

This commit is contained in:
naman-bruno
2026-06-25 16:15:22 +05:30
committed by GitHub
parent 7765320aa5
commit 437e0c7dac
6 changed files with 984 additions and 83 deletions

View File

@@ -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 (
<div className={`provider-row ${expanded ? 'expanded' : ''}`} data-testid={`ai-endpoint-${endpoint.id}`}>
<div
className="provider-header flex items-center justify-between gap-3 px-3 py-2.5 cursor-pointer select-none"
onClick={() => setExpanded(!expanded)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpanded(!expanded);
}
}}
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<IconServer size={16} strokeWidth={1.5} className="provider-logo flex-shrink-0" />
<div className="flex flex-col min-w-0">
<span className="font-semibold text-[12.5px] truncate">{endpoint.name || 'Unnamed endpoint'}</span>
{endpoint.baseURL && (
<span className="provider-status text-[10.5px] truncate">{endpoint.baseURL}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 flex-shrink-0">
<span className={`provider-status inline-flex items-center gap-1.5 text-[11px] ${provider.configured ? 'configured' : ''}`}>
<span className={`status-dot w-[7px] h-[7px] rounded-full ${provider.configured ? 'configured' : ''}`} />
{provider.configured
? `${enabledModelsCount}/${models.length} model${models.length === 1 ? '' : 's'}`
: 'Not configured'}
</span>
<span className="flex items-center" onClick={stopBubble}>
{providerToggle}
</span>
<span className={`chevron flex items-center ${expanded ? 'expanded' : ''}`}>
<IconChevronDown size={16} strokeWidth={1.5} />
</span>
</div>
</div>
<div className={`provider-body-wrapper ${expanded ? 'open' : ''}`}>
<div className="provider-body-inner">
<div className="provider-body flex flex-col gap-3.5 px-3 pt-3 pb-3">
{/* Endpoint details */}
<div className="grid grid-cols-2 gap-2" onClick={stopBubble}>
<div className="flex flex-col gap-1">
<label className="key-section-label text-[11px]" htmlFor={`endpoint-name-${endpoint.id}`}>
Name
</label>
<input
id={`endpoint-name-${endpoint.id}`}
type="text"
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-2"
placeholder="e.g. Ollama local"
value={endpoint.name || ''}
onChange={(e) => onChangeName(e.target.value)}
onClick={stopBubble}
/>
</div>
<div className="flex flex-col gap-1">
<label className="key-section-label text-[11px]" htmlFor={`endpoint-baseurl-${endpoint.id}`}>
Base URL
</label>
<input
id={`endpoint-baseurl-${endpoint.id}`}
type="text"
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-2"
placeholder="https://api.example.com/v1"
value={endpoint.baseURL || ''}
onChange={(e) => onChangeBaseURL(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck="false"
onClick={stopBubble}
/>
</div>
</div>
{/* API key */}
<div>
<div className="key-section-label flex items-center justify-between gap-2 text-[11px] mb-1">
<span>API Key</span>
</div>
{!isEditingKey ? (
<div
className="key-display-row flex items-center justify-between gap-2 h-8 box-border pl-2.5 pr-0.5"
onClick={stopBubble}
>
<span className="key-display-mask text-xs"></span>
<div className="flex items-center gap-0.5">
<button
type="button"
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleTest}
disabled={testing || pending || !providerEnabled || !endpoint.baseURL}
title={endpoint.baseURL ? 'Test connection' : 'Set Base URL first'}
aria-label="Test connection"
data-testid={`ai-endpoint-${endpoint.id}-test`}
>
{testing ? <IconLoader2 size={15} className="spin" /> : <IconBolt size={15} />}
</button>
<button
type="button"
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleStartEditKey}
disabled={pending}
title="Replace key"
aria-label="Replace key"
data-testid={`ai-endpoint-${endpoint.id}-edit-key`}
>
<IconPencil size={15} />
</button>
<button
type="button"
className="btn-icon danger w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleClearKey}
disabled={pending}
title="Remove key"
aria-label="Remove key"
data-testid={`ai-endpoint-${endpoint.id}-clear-key`}
>
<IconTrash size={15} />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-1.5" onClick={stopBubble}>
<div className="relative flex-1 flex items-center">
<input
id={`api-key-${provider.id}`}
type={showKey ? 'text' : 'password'}
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-8"
placeholder="sk-..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={keyDraft}
onChange={(e) => setKeyDraft(e.target.value)}
onKeyDown={handleKeyDown}
onClick={stopBubble}
autoFocus
data-testid={`ai-endpoint-${endpoint.id}-key-input`}
/>
<button
type="button"
className="key-eye-btn absolute right-1 p-1 inline-flex items-center cursor-pointer"
onClick={() => setShowKey(!showKey)}
tabIndex={-1}
aria-label={showKey ? 'Hide API key' : 'Show API key'}
>
{showKey ? <IconEyeOff size={14} /> : <IconEye size={14} />}
</button>
</div>
<button
type="button"
className="btn-primary h-8 box-border px-3 text-xs font-medium inline-flex items-center justify-center gap-1 cursor-pointer"
disabled={saving || pending || !keyDraft.trim()}
onClick={handleSaveKey}
data-testid={`ai-endpoint-${endpoint.id}-save-key`}
>
{saving ? <IconLoader2 size={13} className="spin" /> : <IconCheck size={13} />}
Save
</button>
{provider.configured && (
<button
type="button"
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleCancelEditKey}
title="Cancel"
>
<IconX size={15} />
</button>
)}
</div>
)}
{pending && (
<div className="feedback flex items-center gap-1.5 text-[11px] px-2 py-1 mt-1.5" role="status">
<IconLoader2 size={12} className="spin" />
Saving endpoint
</div>
)}
{feedback && (
<div
className={`feedback flex items-center gap-1.5 text-[11px] px-2 py-1 mt-1.5 ${feedback.type}`}
role="status"
>
{feedback.type === 'success' ? <IconCheck size={12} /> : <IconAlertCircle size={12} />}
{feedback.message}
</div>
)}
</div>
{/* Models */}
<div className="flex flex-col gap-1.5" onClick={stopBubble}>
<div className="models-label-row flex items-center justify-between text-[11px]">
<span>Models</span>
{!provider.configured && (
<span className="keyless-hint flex items-center gap-1.5 text-[11px] py-1">
<IconAlertCircle size={12} />
Add an API key to enable
</span>
)}
</div>
{models.length === 0 && (
<div className="compat-models-empty text-[11px] px-2.5 py-2">
No models yet. Add the model id your provider expects (e.g. <code>gpt-4o</code> or <code>llama3.1:8b</code>).
</div>
)}
{models.length > 0 && (
<div className="flex flex-col gap-1">
{models.map((model) => {
const enabled = isModelEnabled(model.id);
const disabled = !provider.configured || !providerEnabled;
return (
<div
key={model.id}
className={`compat-model-row flex items-center gap-2 px-2.5 py-1.5 ${enabled && !disabled ? 'selected' : ''} ${disabled ? 'disabled' : ''}`}
>
<input
type="checkbox"
className="cursor-pointer m-0"
checked={enabled}
disabled={disabled}
onChange={() => onToggleModel(model.id, !enabled)}
/>
<input
type="text"
className="compat-inline-input flex-1 text-xs"
value={model.label || ''}
placeholder="Display name"
onChange={(e) => onUpdateModel(model.id, { label: e.target.value })}
/>
<input
type="text"
className="compat-inline-input compat-inline-id flex-1 text-xs"
value={model.modelId || ''}
placeholder="Model id"
onChange={(e) => onUpdateModel(model.id, { modelId: e.target.value })}
/>
<button
type="button"
className="btn-icon danger w-6 h-6 box-border inline-flex items-center justify-center cursor-pointer"
onClick={() => onRemoveModel(model.id)}
title="Remove model"
aria-label="Remove model"
>
<IconTrash size={13} />
</button>
</div>
);
})}
</div>
)}
<div className="compat-add-model flex items-center gap-1.5 mt-1">
<input
type="text"
className="key-input flex-1 h-8 box-border text-xs leading-none pl-2.5 pr-2"
placeholder="Model id (required)"
value={newModelId}
onChange={(e) => setNewModelId(e.target.value)}
onKeyDown={handleAddModelKeyDown}
data-testid={`ai-endpoint-${endpoint.id}-new-model-id`}
/>
<input
type="text"
className="key-input flex-1 h-8 box-border text-xs leading-none pl-2.5 pr-2"
placeholder="Label (optional)"
value={newModelLabel}
onChange={(e) => setNewModelLabel(e.target.value)}
onKeyDown={handleAddModelKeyDown}
data-testid={`ai-endpoint-${endpoint.id}-new-model-label`}
/>
<button
type="button"
className="btn-primary h-8 box-border px-3 text-xs font-medium inline-flex items-center justify-center gap-1 cursor-pointer"
disabled={!newModelId.trim()}
onClick={handleAddModel}
data-testid={`ai-endpoint-${endpoint.id}-add-model`}
>
<IconPlus size={13} />
Add
</button>
</div>
</div>
<div className="flex justify-end pt-1" onClick={stopBubble}>
<button
type="button"
className="compat-remove-endpoint inline-flex items-center gap-1 text-[11px] cursor-pointer"
onClick={() => onRemoveEndpoint(endpoint.id)}
data-testid={`ai-endpoint-${endpoint.id}-remove`}
>
<IconTrash size={12} />
Remove endpoint
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default CompatEndpointCard;

View File

@@ -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); }

View File

@@ -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 (
<StyledWrapper className="w-full flex flex-col text-xs min-h-0 max-h-[calc(100%-30px)]">
@@ -201,33 +306,106 @@ const AI = () => {
Providers
</div>
<div className="flex flex-col gap-1.5">
{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 = (
<ToggleSwitch
size="s"
isOn={providerEnabled}
handleToggle={() =>
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
/>
);
const providerToggle = (
<ToggleSwitch
size="s"
isOn={providerEnabled}
handleToggle={() =>
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
/>
);
return (
<ProviderCard
key={id}
provider={provider}
providerEnabled={providerEnabled}
providerToggle={providerToggle}
models={modelsByProvider[id] || []}
isModelEnabled={isModelEnabled}
onToggleModel={handleToggleModel}
onStatusChange={(next) => setStatus(next)}
/>
);
})}
return (
<ProviderCard
key={id}
provider={provider}
providerEnabled={providerEnabled}
providerToggle={providerToggle}
models={modelsByProvider[id] || []}
isModelEnabled={isModelEnabled}
onToggleModel={handleToggleModel}
onStatusChange={(next) => setStatus(next)}
/>
);
})}
</div>
<div className="ai-section-header flex items-center justify-between text-[11px] font-medium uppercase tracking-wider mt-5 mb-2">
<span>OpenAI-Compatible Endpoints</span>
<button
type="button"
className="compat-add-btn inline-flex items-center gap-1 text-[11px] font-medium cursor-pointer normal-case tracking-normal"
onClick={handleAddEndpoint}
data-testid="ai-compat-add-endpoint"
>
<IconPlus size={13} strokeWidth={1.75} />
Add endpoint
</button>
</div>
{endpoints.length === 0 && (
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Point Bruno at any OpenAI-compatible API Ollama, LM Studio, Together, Groq, OpenRouter, vLLM, and more.
</div>
)}
{endpoints.length > 0 && (
<div className="flex flex-col gap-1.5">
{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 = (
<ToggleSwitch
size="s"
isOn={providerEnabled}
handleToggle={() =>
formik.setFieldValue(`providers.${providerId}.enabled`, !providerEnabled)}
/>
);
return (
<CompatEndpointCard
key={endpoint.id}
endpoint={endpoint}
provider={provider}
providerEnabled={providerEnabled}
providerToggle={providerToggle}
pending={pending}
isModelEnabled={isModelEnabled}
onToggleModel={handleToggleModel}
onChangeName={(name) => 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)}
/>
);
})}
</div>
)}
</>
)}
</div>

View File

@@ -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.' };
}
});

View File

@@ -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
};

View File

@@ -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(),