mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-26 06:05:45 +00:00
feat(ai): OpenAI-compatible endpoints support (#8365)
This commit is contained in:
@@ -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;
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user