From b73df7b2ce775585cbadc161c943dc08065fd375 Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Mon, 19 Jan 2026 22:37:17 -0800 Subject: [PATCH] LLMs: OpenAI: Autocomplete + suggest hosts for Chutes, Fireworks, Novita. #921 --- .../vendors/openai/OpenAIHostAutocomplete.tsx | 150 ++++++++++++++++++ .../vendors/openai/OpenAIServiceSetup.tsx | 72 +++++---- 2 files changed, 187 insertions(+), 35 deletions(-) create mode 100644 src/modules/llms/vendors/openai/OpenAIHostAutocomplete.tsx diff --git a/src/modules/llms/vendors/openai/OpenAIHostAutocomplete.tsx b/src/modules/llms/vendors/openai/OpenAIHostAutocomplete.tsx new file mode 100644 index 000000000..4135a3735 --- /dev/null +++ b/src/modules/llms/vendors/openai/OpenAIHostAutocomplete.tsx @@ -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 + ? {matchedProvider.label} docs + : null; + + return ( + + + + API Endpoint + + + + + {rightLabel && {rightLabel}} + + + 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) => ( + + + {params.group} + + + {params.children} + + + )} + renderOption={(optionProps, option) => { + const { key, ...rest } = optionProps as any; + return ( + + {option.label} + {option.description} + + ); + }} + 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', + // }, + }, + }, + }} + /> + + ); +} diff --git a/src/modules/llms/vendors/openai/OpenAIServiceSetup.tsx b/src/modules/llms/vendors/openai/OpenAIServiceSetup.tsx index bb68b6e62..1e27347d0 100644 --- a/src/modules/llms/vendors/openai/OpenAIServiceSetup.tsx +++ b/src/modules/llms/vendors/openai/OpenAIServiceSetup.tsx @@ -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 }) { + + {(showAdvanced || !!oaiHost) && ( + updateSettings({ oaiHost: host })} + /> + )} + {needsUserKey - ? !oaiKey && create key - : - } {oaiKey && keyValid && check usage} + ? (!oaiKey && !oaiHost && create key) + : (!oaiHost && /* only show "Already set" when using default OpenAI, not custom endpoints */) + } {oaiKey && !oaiHost && keyValid && check usage} } value={oaiKey} onChange={value => updateSettings({ oaiKey: value })} - required={needsUserKey} isError={keyError} + required={needsUserKey || !!oaiHost} isError={keyError} placeholder='sk-...' /> + {showAdvanced && } + {showAdvanced && Helicone, Cloudflare} - placeholder={`e.g., ${HELICONE_OPENAI_HOST}, https://gateway.ai.cloudflare.com/v1///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={ + updateLabel('')}> + + + } />} {showAdvanced && 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.'} } - {showAdvanced && Overview, {' '}policy @@ -108,21 +124,7 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) { onChange={on => updateSettings({ moderationCheck: on })} />} - {showAdvanced && updateLabel('')}> - - - } - />} - - {showAdvanced && updateSettings({ csf: on })}