2
0

(openai) Add custom provider and custom models

Closes #532
This commit is contained in:
Baptiste Arnaud
2023-09-01 16:19:59 +02:00
parent 436fa251f8
commit 27a5f4eb74
21 changed files with 684 additions and 278 deletions

View File

@ -75,6 +75,7 @@
"nextjs-cors": "^2.1.2",
"nodemailer": "6.9.3",
"nprogress": "0.2.0",
"openai-edge": "1.2.2",
"papaparse": "5.4.1",
"posthog-js": "^1.77.1",
"posthog-node": "3.1.1",

View File

@ -24,7 +24,7 @@ import { MoreInfoTooltip } from '../MoreInfoTooltip'
import { env } from '@typebot.io/env'
type Props = {
items: string[]
items: string[] | undefined
value?: string
defaultValue?: string
debounceTimeout?: number
@ -77,9 +77,9 @@ export const AutocompleteInput = ({
const filteredItems = (
inputValue === ''
? items
? items ?? []
: [
...items.filter(
...(items ?? []).filter(
(item) =>
item.toLowerCase().startsWith((inputValue ?? '').toLowerCase()) &&
item.toLowerCase() !== inputValue.toLowerCase()
@ -186,7 +186,8 @@ export const AutocompleteInput = ({
onFocus={onOpen}
onBlur={updateCarretPosition}
onKeyDown={updateFocusedDropdownItem}
placeholder={placeholder}
placeholder={!items ? 'Loading...' : placeholder}
isDisabled={!items}
/>
</PopoverAnchor>
{filteredItems.length > 0 && (

View File

@ -35,7 +35,7 @@ type Item =
type Props<T extends Item> = {
isPopoverMatchingInputWidth?: boolean
selectedItem?: string
items: readonly T[]
items: readonly T[] | undefined
placeholder?: string
onSelect?: (value: string | undefined, item?: T) => void
}
@ -53,7 +53,7 @@ export const Select = <T extends Item>({
const { onOpen, onClose, isOpen } = useDisclosure()
const [inputValue, setInputValue] = useState(
getItemLabel(
items.find((item) =>
items?.find((item) =>
typeof item === 'string'
? selectedItem === item
: selectedItem === item.value
@ -72,13 +72,13 @@ export const Select = <T extends Item>({
const filteredItems = (
isTouched
? [
...items.filter((item) =>
...(items ?? []).filter((item) =>
getItemLabel(item)
.toLowerCase()
.includes((inputValue ?? '').toLowerCase())
),
]
: items
: items ?? []
).slice(0, 50)
const closeDropdown = () => {
@ -181,12 +181,17 @@ export const Select = <T extends Item>({
className="select-input"
value={isTouched ? inputValue : ''}
placeholder={
!isTouched && inputValue !== '' ? undefined : placeholder
!items
? 'Loading...'
: !isTouched && inputValue !== ''
? undefined
: placeholder
}
onChange={updateInputValue}
onFocus={onOpen}
onKeyDown={updateFocusedDropdownItem}
pr={selectedItem ? 16 : undefined}
isDisabled={!items}
/>
<InputRightElement

View File

@ -0,0 +1,134 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { Configuration, OpenAIApi, ResponseTypes } from 'openai-edge'
import { decrypt } from '@typebot.io/lib/api'
import { OpenAICredentials } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { IntegrationBlockType, typebotSchema } from '@typebot.io/schemas'
import { isNotEmpty } from '@typebot.io/lib/utils'
export const listModels = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/blocks/{blockId}/openai/models',
protect: true,
summary: 'List OpenAI models',
tags: ['OpenAI'],
},
})
.input(
z.object({
typebotId: z.string(),
blockId: z.string(),
credentialsId: z.string(),
workspaceId: z.string(),
})
)
.output(
z.object({
models: z.array(z.string()),
})
)
.query(
async ({
input: { credentialsId, workspaceId, typebotId, blockId },
ctx: { user },
}) => {
const workspace = await prisma.workspace.findFirst({
where: { id: workspaceId },
select: {
members: {
select: {
userId: true,
},
},
typebots: {
where: {
id: typebotId,
},
select: {
groups: true,
},
},
credentials: {
where: {
id: credentialsId,
},
select: {
id: true,
data: true,
iv: true,
},
},
},
})
if (!workspace || isReadWorkspaceFobidden(workspace, user))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No workspace found',
})
const credentials = workspace.credentials.at(0)
if (!credentials)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No credentials found',
})
const typebot = workspace.typebots.at(0)
if (!typebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
const block = typebotSchema._def.schema.shape.groups
.parse(workspace.typebots.at(0)?.groups)
.flatMap((group) => group.blocks)
.find((block) => block.id === blockId)
if (!block || block.type !== IntegrationBlockType.OPEN_AI)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'OpenAI block not found',
})
const data = (await decrypt(
credentials.data,
credentials.iv
)) as OpenAICredentials['data']
const config = new Configuration({
apiKey: data.apiKey,
basePath: block.options.baseUrl,
baseOptions: {
headers: {
'api-key': data.apiKey,
},
},
defaultQueryParams: isNotEmpty(block.options.apiVersion)
? new URLSearchParams({
'api-version': block.options.apiVersion,
})
: undefined,
})
const openai = new OpenAIApi(config)
const response = await openai.listModels()
const modelsData = (await response.json()) as ResponseTypes['listModels']
return {
models: modelsData.data
.sort((a, b) => b.created - a.created)
.map((model) => model.id),
}
}
)

View File

@ -0,0 +1,6 @@
import { router } from '@/helpers/server/trpc'
import { listModels } from './listModels'
export const openAIRouter = router({
listModels,
})

View File

@ -1,9 +1,19 @@
import { Stack, useDisclosure } from '@chakra-ui/react'
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Stack,
useDisclosure,
Text,
} from '@chakra-ui/react'
import React from 'react'
import { CredentialsDropdown } from '@/features/credentials/components/CredentialsDropdown'
import {
ChatCompletionOpenAIOptions,
CreateImageOpenAIOptions,
defaultBaseUrl,
defaultChatCompletionOptions,
OpenAIBlock,
openAITasks,
@ -13,15 +23,19 @@ import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { DropdownList } from '@/components/DropdownList'
import { OpenAIChatCompletionSettings } from './createChatCompletion/OpenAIChatCompletionSettings'
import { createId } from '@paralleldrive/cuid2'
import { TextInput } from '@/components/inputs'
type OpenAITask = (typeof openAITasks)[number]
type Props = {
options: OpenAIBlock['options']
block: OpenAIBlock
onOptionsChange: (options: OpenAIBlock['options']) => void
}
export const OpenAISettings = ({ options, onOptionsChange }: Props) => {
export const OpenAISettings = ({
block: { options, id },
onOptionsChange,
}: Props) => {
const { workspace } = useWorkspace()
const { isOpen, onOpen, onClose } = useDisclosure()
@ -44,6 +58,20 @@ export const OpenAISettings = ({ options, onOptionsChange }: Props) => {
}
}
const updateBaseUrl = (baseUrl: string) => {
onOptionsChange({
...options,
baseUrl,
})
}
const updateApiVersion = (apiVersion: string) => {
onOptionsChange({
...options,
apiVersion,
})
}
return (
<Stack>
{workspace && (
@ -56,22 +84,51 @@ export const OpenAISettings = ({ options, onOptionsChange }: Props) => {
credentialsName="OpenAI account"
/>
)}
<OpenAICredentialsModal
isOpen={isOpen}
onClose={onClose}
onNewCredentials={updateCredentialsId}
/>
<DropdownList
currentItem={options.task}
items={openAITasks.slice(0, -1)}
onItemSelect={updateTask}
placeholder="Select task"
/>
{options.task && (
<OpenAITaskSettings
options={options}
onOptionsChange={onOptionsChange}
/>
{options.credentialsId && (
<>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Customize provider
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel as={Stack} spacing={4}>
<TextInput
label="Base URL"
defaultValue={options.baseUrl}
onChange={updateBaseUrl}
/>
{options.baseUrl !== defaultBaseUrl && (
<TextInput
label="API version"
defaultValue={options.apiVersion}
onChange={updateApiVersion}
/>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
<OpenAICredentialsModal
isOpen={isOpen}
onClose={onClose}
onNewCredentials={updateCredentialsId}
/>
<DropdownList
currentItem={options.task}
items={openAITasks.slice(0, -1)}
onItemSelect={updateTask}
placeholder="Select task"
/>
{options.task && (
<OpenAITaskSettings
blockId={id}
options={options}
onOptionsChange={onOptionsChange}
/>
)}
</>
)}
</Stack>
)
@ -80,14 +137,17 @@ export const OpenAISettings = ({ options, onOptionsChange }: Props) => {
const OpenAITaskSettings = ({
options,
onOptionsChange,
blockId,
}: {
options: ChatCompletionOpenAIOptions | CreateImageOpenAIOptions
blockId: string
onOptionsChange: (options: OpenAIBlock['options']) => void
}) => {
switch (options.task) {
case 'Create chat completion': {
return (
<OpenAIChatCompletionSettings
blockId={blockId}
options={options}
onOptionsChange={onOptionsChange}
/>

View File

@ -0,0 +1,42 @@
import { Select } from '@/components/inputs/Select'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { trpc } from '@/lib/trpc'
type Props = {
credentialsId: string
blockId: string
defaultValue: string
onChange: (model: string | undefined) => void
}
export const ModelsDropdown = ({
defaultValue,
onChange,
credentialsId,
blockId,
}: Props) => {
const { typebot } = useTypebot()
const { workspace } = useWorkspace()
const { data } = trpc.openAI.listModels.useQuery(
{
credentialsId,
blockId,
typebotId: typebot?.id as string,
workspaceId: workspace?.id as string,
},
{
enabled: !!typebot && !!workspace,
}
)
return (
<Select
items={data?.models}
selectedItem={defaultValue}
onSelect={onChange}
placeholder="Select a model"
/>
)
}

View File

@ -1,9 +1,5 @@
import { TableList } from '@/components/TableList'
import {
chatCompletionModels,
ChatCompletionOpenAIOptions,
deprecatedCompletionModels,
} from '@typebot.io/schemas/features/blocks/integrations/openai'
import { ChatCompletionOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { ChatCompletionMessageItem } from './ChatCompletionMessageItem'
import {
Accordion,
@ -17,24 +13,23 @@ import {
import { TextLink } from '@/components/TextLink'
import { ChatCompletionResponseItem } from './ChatCompletionResponseItem'
import { NumberInput } from '@/components/inputs'
import { Select } from '@/components/inputs/Select'
import { ModelsDropdown } from './ModelsDropdown'
const apiReferenceUrl =
'https://platform.openai.com/docs/api-reference/chat/create'
type Props = {
blockId: string
options: ChatCompletionOpenAIOptions
onOptionsChange: (options: ChatCompletionOpenAIOptions) => void
}
export const OpenAIChatCompletionSettings = ({
blockId,
options,
onOptionsChange,
}: Props) => {
const updateModel = (
_: string | undefined,
model: (typeof chatCompletionModels)[number] | undefined
) => {
const updateModel = (model: string | undefined) => {
if (!model) return
onOptionsChange({
...options,
@ -79,68 +74,71 @@ export const OpenAIChatCompletionSettings = ({
</TextLink>{' '}
to better understand the available options.
</Text>
<Select
selectedItem={options.model}
items={chatCompletionModels.filter(
(model) => deprecatedCompletionModels.indexOf(model) === -1
)}
onSelect={updateModel}
/>
<Accordion allowMultiple>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Messages
</Text>
<AccordionIcon />
</AccordionButton>
{options.credentialsId && (
<>
<ModelsDropdown
credentialsId={options.credentialsId}
defaultValue={options.model}
onChange={updateModel}
blockId={blockId}
/>
<Accordion allowMultiple>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Messages
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pt="4">
<TableList
initialItems={options.messages}
Item={ChatCompletionMessageItem}
onItemsChange={updateMessages}
isOrdered
addLabel="Add message"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Advanced settings
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<NumberInput
label="Temperature"
placeholder="1"
max={2}
min={0}
step={0.1}
defaultValue={options.advancedSettings?.temperature}
onValueChange={updateTemperature}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Save answer
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pt="4">
<TableList
initialItems={options.responseMapping}
Item={ChatCompletionResponseItem}
onItemsChange={updateResponseMapping}
newItemDefaultProps={{ valueToExtract: 'Message content' }}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
<AccordionPanel pt="4">
<TableList
initialItems={options.messages}
Item={ChatCompletionMessageItem}
onItemsChange={updateMessages}
isOrdered
addLabel="Add message"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Advanced settings
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<NumberInput
label="Temperature"
placeholder="1"
max={2}
min={0}
step={0.1}
defaultValue={options.advancedSettings?.temperature}
onValueChange={updateTemperature}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Save answer
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pt="4">
<TableList
initialItems={options.responseMapping}
Item={ChatCompletionResponseItem}
onItemsChange={updateResponseMapping}
newItemDefaultProps={{ valueToExtract: 'Message content' }}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</>
)}
</Stack>
)
}

View File

@ -3,6 +3,7 @@ import { createTypebots } from '@typebot.io/lib/playwright/databaseActions'
import { createId } from '@paralleldrive/cuid2'
import { IntegrationBlockType } from '@typebot.io/schemas'
import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseHelpers'
import { defaultBaseUrl } from '@typebot.io/schemas/features/blocks/integrations/openai'
const typebotId = createId()
@ -12,7 +13,9 @@ test('should be configurable', async ({ page }) => {
id: typebotId,
...parseDefaultGroupWithBlock({
type: IntegrationBlockType.OPEN_AI,
options: {},
options: {
baseUrl: defaultBaseUrl,
},
}),
},
])

View File

@ -69,8 +69,7 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
<PopoverContent onMouseDown={handleMouseDown} pos="relative">
<PopoverArrow bgColor={arrowColor} />
<PopoverBody
pt="3"
pb="6"
py="3"
overflowY="scroll"
maxH="400px"
ref={ref}
@ -305,12 +304,7 @@ export const BlockSettings = ({
)
}
case IntegrationBlockType.OPEN_AI: {
return (
<OpenAISettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
return <OpenAISettings block={block} onOptionsChange={updateOptions} />
}
case IntegrationBlockType.PIXEL: {
return (

View File

@ -14,6 +14,7 @@ import { analyticsRouter } from '@/features/analytics/api/router'
import { collaboratorsRouter } from '@/features/collaboration/api/router'
import { customDomainsRouter } from '@/features/customDomains/api/router'
import { whatsAppRouter } from '@/features/whatsapp/router'
import { openAIRouter } from '@/features/blocks/integrations/openai/api/router'
export const trpcRouter = router({
getAppVersionProcedure,
@ -31,6 +32,7 @@ export const trpcRouter = router({
collaborators: collaboratorsRouter,
customDomains: customDomainsRouter,
whatsApp: whatsAppRouter,
openAI: openAIRouter,
})
export type AppRouter = typeof trpcRouter