mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
LLMs: OpenAI: Autocomplete + suggest hosts for Chutes, Fireworks, Novita. #921
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Autocomplete, AutocompleteOption, Box, FormControl, FormHelperText, FormLabel, Typography } from '@mui/joy';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
|
||||
// Verified OpenAI-compatible providers that work with the 'openai' dialect
|
||||
interface VerifiedProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
host: string;
|
||||
description: string;
|
||||
category: 'Example Proxies' | 'Example Providers';
|
||||
docsUrl?: string; // optional link to provider docs
|
||||
hostMatch?: string; // substring to match against current host (defaults to host)
|
||||
}
|
||||
|
||||
const OPENAI_COMPATIBLE_PROVIDERS: VerifiedProvider[] = [
|
||||
// Example Providers
|
||||
{ id: 'chutes', label: 'Chutes AI', host: 'https://llm.chutes.ai', hostMatch: '.chutes.ai', category: 'Example Providers', description: 'GPU marketplace for AI inference', docsUrl: 'https://chutes.ai/docs' },
|
||||
{ id: 'fireworks', label: 'Fireworks AI', host: 'https://api.fireworks.ai/inference', hostMatch: 'fireworks.ai', category: 'Example Providers', description: 'Fast inference for open models', docsUrl: 'https://docs.fireworks.ai/getting-started/quickstart' },
|
||||
{ id: 'novita', label: 'Novita AI', host: 'https://api.novita.ai/openai', hostMatch: 'novita.ai', category: 'Example Providers', description: 'OpenAI-compatible inference', docsUrl: 'https://novita.ai/docs' },
|
||||
// Example Proxies
|
||||
{ id: 'helicone', label: 'Helicone', host: 'https://oai.hconeai.com', hostMatch: 'hconeai.com', category: 'Example Proxies', description: 'OpenAI observability and caching proxy', docsUrl: 'https://docs.helicone.ai/getting-started/quick-start' },
|
||||
{ id: 'cloudflare', label: 'Cloudflare AI Gateway', host: 'https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openai', hostMatch: 'gateway.ai.cloudflare.com', category: 'Example Proxies', description: 'AI Gateway with caching and analytics', docsUrl: 'https://developers.cloudflare.com/ai-gateway/' },
|
||||
];
|
||||
|
||||
// Find matching provider based on current host value
|
||||
export function findMatchingOpenAIAutoProvider(host: string): VerifiedProvider | undefined {
|
||||
if (!host) return undefined;
|
||||
return OPENAI_COMPATIBLE_PROVIDERS.find(p =>
|
||||
host.includes(p.hostMatch ?? p.host),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Autocomplete component for selecting verified OpenAI-compatible providers or typing custom URLs
|
||||
|
||||
export function OpenAIHostAutocomplete(props: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
|
||||
// local input state for freeSolo
|
||||
const [inputValue, setInputValue] = React.useState(props.value ?? '');
|
||||
|
||||
// derived state
|
||||
const matchedProvider = findMatchingOpenAIAutoProvider(props.value);
|
||||
|
||||
// sync input when value prop changes externally
|
||||
React.useEffect(() => {
|
||||
setInputValue(props.value ?? '');
|
||||
}, [props.value]);
|
||||
|
||||
// handlers
|
||||
const handleChange = React.useCallback((_event: unknown, newValue: string | VerifiedProvider | null) => {
|
||||
// newValue can be: string (typed), VerifiedProvider (selected), or null (cleared)
|
||||
if (newValue === null)
|
||||
props.onChange('');
|
||||
else if (typeof newValue === 'string')
|
||||
props.onChange(newValue);
|
||||
else
|
||||
props.onChange(newValue.host);
|
||||
}, [props]);
|
||||
|
||||
const handleInputChange = React.useCallback((_event: unknown, newInputValue: string, reason: string) => {
|
||||
// Only update on user input, not on programmatic changes
|
||||
if (reason !== 'input')
|
||||
return;
|
||||
setInputValue(newInputValue);
|
||||
props.onChange(newInputValue);
|
||||
}, [props]);
|
||||
|
||||
// dynamic right label: show docs link when a provider is matched
|
||||
const rightLabel = matchedProvider?.docsUrl
|
||||
? <Link level='body-sm' href={matchedProvider.docsUrl} target='_blank'>{matchedProvider.label} docs</Link>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'baseline', flexWrap: 'wrap', justifyContent: 'space-between' }}>
|
||||
<FormLabel sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
API Endpoint
|
||||
<GoodTooltip title={`An OpenAI compatible endpoint to be used in place of 'api.openai.com'.\n\nSelect a verified provider from the list, or type any custom URL.`} arrow placement='top'>
|
||||
<InfoIcon sx={{ ml: 0.5, cursor: 'pointer', fontSize: 'md', color: 'primary.solidBg' }} />
|
||||
</GoodTooltip>
|
||||
</FormLabel>
|
||||
{rightLabel && <FormHelperText sx={{ display: 'block' }}>{rightLabel}</FormHelperText>}
|
||||
</Box>
|
||||
<Autocomplete<VerifiedProvider, false, false, true>
|
||||
freeSolo
|
||||
openOnFocus
|
||||
clearOnEscape
|
||||
placeholder='Select or type endpoint...'
|
||||
options={OPENAI_COMPATIBLE_PROVIDERS}
|
||||
groupBy={(option) => option.category}
|
||||
getOptionKey={(option) => typeof option === 'string' ? option : option.id}
|
||||
getOptionLabel={(option) => typeof option === 'string' ? option : option.host}
|
||||
isOptionEqualToValue={(option, val) => option.host === (typeof val === 'string' ? val : val.host)}
|
||||
value={OPENAI_COMPATIBLE_PROVIDERS.find(p => p.host === props.value) ?? (props.value || null)}
|
||||
onChange={handleChange}
|
||||
inputValue={inputValue}
|
||||
onInputChange={handleInputChange}
|
||||
renderGroup={(params) => (
|
||||
<Box component='li' key={params.key}>
|
||||
<Typography level='body-sm' sx={{ textAlign: 'center', my: 1 }}>
|
||||
{params.group}
|
||||
</Typography>
|
||||
<Box component='ul' sx={{ p: 0 }}>
|
||||
{params.children}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
renderOption={(optionProps, option) => {
|
||||
const { key, ...rest } = optionProps as any;
|
||||
return (
|
||||
<AutocompleteOption key={key} {...rest} sx={{ display: 'block', py: 1 }}>
|
||||
<Typography level='title-sm'>{option.label}</Typography>
|
||||
<Typography level='body-xs' textColor='text.tertiary' className='agi-ellipsize' mt={0.25}>{option.description}</Typography>
|
||||
</AutocompleteOption>
|
||||
);
|
||||
}}
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: { boxShadow: 'none' },
|
||||
},
|
||||
listbox: {
|
||||
sx: {
|
||||
maxWidth: 'min(450px, calc(100dvw - 1rem))',
|
||||
// // Add footer hint
|
||||
// '&::after': {
|
||||
// content: '"Or type any OpenAI-compatible base URL"',
|
||||
// display: 'block',
|
||||
// p: 1.5,
|
||||
// color: 'text.tertiary',
|
||||
// fontSize: 'xs',
|
||||
// fontStyle: 'italic',
|
||||
// borderTop: '1px solid',
|
||||
// borderColor: 'divider',
|
||||
// },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
+37
-35
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Alert, IconButton } from '@mui/joy';
|
||||
import { Alert, Divider, IconButton } from '@mui/joy';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
|
||||
import type { DModelsServiceId } from '~/common/stores/llms/llms.service.types';
|
||||
@@ -11,6 +11,7 @@ import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
|
||||
import { FormTextField } from '~/common/components/forms/FormTextField';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SetupFormClientSideToggle } from '~/common/components/forms/SetupFormClientSideToggle';
|
||||
import { SetupFormRefetchButton } from '~/common/components/forms/SetupFormRefetchButton';
|
||||
import { useToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
|
||||
@@ -19,7 +20,7 @@ import { useLlmUpdateModels } from '../../llm.client.hooks';
|
||||
import { useServiceSetup } from '../useServiceSetup';
|
||||
|
||||
import { ModelVendorOpenAI } from './openai.vendor';
|
||||
import { SetupFormClientSideToggle } from '~/common/components/forms/SetupFormClientSideToggle';
|
||||
import { OpenAIHostAutocomplete } from './OpenAIHostAutocomplete';
|
||||
|
||||
|
||||
// avoid repeating it all over
|
||||
@@ -28,9 +29,6 @@ const HELICONE_OPENAI_HOST = 'oai.hconeai.com';
|
||||
|
||||
export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
|
||||
// state
|
||||
const advanced = useToggleableBoolean(!!props.serviceId?.includes('-'));
|
||||
|
||||
// external state
|
||||
const { service, serviceAccess, serviceHasCloudTenantConfig, serviceHasLLMs, updateSettings, updateLabel } =
|
||||
useServiceSetup(props.serviceId, ModelVendorOpenAI);
|
||||
@@ -38,7 +36,11 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
// derived state
|
||||
const { clientSideFetch, oaiKey, oaiOrg, oaiHost, heliKey, moderationCheck } = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
// state
|
||||
const initialShowOAIAdvanced = !!props.serviceId?.includes('-') /* likely a custom service */ && needsUserKey && !oaiKey && !oaiHost /* missing both */;
|
||||
const advanced = useToggleableBoolean(initialShowOAIAdvanced);
|
||||
const showAdvanced = advanced.on;
|
||||
|
||||
const keyValid = true; //isValidOpenAIApiKey(oaiKey);
|
||||
const keyError = (/*needsUserKey ||*/ !!oaiKey) && !keyValid;
|
||||
@@ -52,26 +54,40 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
|
||||
<ApproximateCosts serviceId={service?.id} />
|
||||
|
||||
|
||||
{(showAdvanced || !!oaiHost) && (
|
||||
<OpenAIHostAutocomplete
|
||||
value={oaiHost}
|
||||
onChange={host => updateSettings({ oaiHost: host })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormInputKey
|
||||
autoCompleteId='openai-key' label='API Key'
|
||||
rightLabel={<>{needsUserKey
|
||||
? !oaiKey && <Link level='body-sm' href='https://platform.openai.com/account/api-keys' target='_blank'>create key</Link>
|
||||
: <AlreadySet />
|
||||
} {oaiKey && keyValid && <Link level='body-sm' href='https://platform.openai.com/account/usage' target='_blank'>check usage</Link>}
|
||||
? (!oaiKey && !oaiHost && <Link level='body-sm' href='https://platform.openai.com/account/api-keys' target='_blank'>create key</Link>)
|
||||
: (!oaiHost && <AlreadySet /> /* only show "Already set" when using default OpenAI, not custom endpoints */)
|
||||
} {oaiKey && !oaiHost && keyValid && <Link level='body-sm' href='https://platform.openai.com/account/usage' target='_blank'>check usage</Link>}
|
||||
</>}
|
||||
value={oaiKey} onChange={value => updateSettings({ oaiKey: value })}
|
||||
required={needsUserKey} isError={keyError}
|
||||
required={needsUserKey || !!oaiHost} isError={keyError}
|
||||
placeholder='sk-...'
|
||||
/>
|
||||
|
||||
{showAdvanced && <Divider sx={{ mx: 4, my: 2 }} />}
|
||||
|
||||
{showAdvanced && <FormTextField
|
||||
autoCompleteId='openai-host'
|
||||
title='API Endpoint'
|
||||
tooltip={`An OpenAI compatible endpoint to be used in place of 'api.openai.com'.\n\nCould be used for Helicone, Cloudflare, or other OpenAI compatible cloud or local services.\n\nExamples:\n - ${HELICONE_OPENAI_HOST}\n - localhost:1234`}
|
||||
description={<><Link level='body-sm' href='https://www.helicone.ai' target='_blank'>Helicone</Link>, <Link level='body-sm' href='https://developers.cloudflare.com/ai-gateway/' target='_blank'>Cloudflare</Link></>}
|
||||
placeholder={`e.g., ${HELICONE_OPENAI_HOST}, https://gateway.ai.cloudflare.com/v1/<ACCOUNT_TAG>/<GATEWAY_URL_SLUG>/openai, etc..`}
|
||||
value={oaiHost}
|
||||
onChange={text => updateSettings({ oaiHost: text })}
|
||||
autoCompleteId='openai-service-name'
|
||||
title='Custom Name'
|
||||
// tooltip='Custom name for this service. Useful when you have multiple OpenAI-compatible services configured.'
|
||||
placeholder='e.g., Fireworks, etc.'
|
||||
value={service?.label || ''}
|
||||
onChange={updateLabel}
|
||||
endDecorator={
|
||||
<IconButton size='sm' variant='plain' color='neutral' onClick={() => updateLabel('')}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>}
|
||||
|
||||
{showAdvanced && <FormTextField
|
||||
@@ -94,12 +110,12 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
|
||||
{!!heliKey && <Alert variant='soft' color={oaiHost?.includes(HELICONE_OPENAI_HOST) ? 'success' : 'warning'}>
|
||||
Advanced: You set the Helicone key. {!oaiHost?.includes(HELICONE_OPENAI_HOST)
|
||||
? `But you also need to set the OpenAI Host to ${HELICONE_OPENAI_HOST} to use Helicone.`
|
||||
? `But you also need to set the OpenAI Host to https://${HELICONE_OPENAI_HOST} to use Helicone.`
|
||||
: 'OpenAI traffic will now be routed through Helicone.'}
|
||||
</Alert>}
|
||||
|
||||
{showAdvanced && <FormSwitchControl
|
||||
title='Moderation' on='Enabled' fullWidth
|
||||
{showAdvanced && (!oaiHost || moderationCheck) && <FormSwitchControl
|
||||
title='OpenAI Moderation' on='Enabled'
|
||||
description={<>
|
||||
<Link level='body-sm' href='https://platform.openai.com/docs/guides/moderation/moderation' target='_blank'>Overview</Link>,
|
||||
{' '}<Link level='body-sm' href='https://openai.com/policies/usage-policies' target='_blank'>policy</Link>
|
||||
@@ -108,21 +124,7 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
onChange={on => updateSettings({ moderationCheck: on })}
|
||||
/>}
|
||||
|
||||
{showAdvanced && <FormTextField
|
||||
autoCompleteId='openai-service-name'
|
||||
title='Custom Name'
|
||||
// tooltip='Custom name for this service. Useful when you have multiple OpenAI-compatible services configured.'
|
||||
placeholder='e.g., Fireworks, Together AI, etc.'
|
||||
value={service?.label || ''}
|
||||
onChange={updateLabel}
|
||||
endDecorator={
|
||||
<IconButton size='sm' variant='plain' color='neutral' onClick={() => updateLabel('')}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>}
|
||||
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
{(showAdvanced || clientSideFetch) && <SetupFormClientSideToggle
|
||||
visible={!!oaiHost || !!oaiKey}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
|
||||
Reference in New Issue
Block a user