Path: blob/master/src/packages/frontend/account/user-defined-llm.tsx
1503 views
import {1Alert,2Button,3Flex,4Form,5Input,6List,7Modal,8Popconfirm,9Select,10Skeleton,11Space,12Tooltip,13} from "antd";14import { useWatch } from "antd/es/form/Form";15import { sortBy } from "lodash";16import { FormattedMessage, useIntl } from "react-intl";1718import {19useEffect,20useState,21useTypedRedux,22} from "@cocalc/frontend/app-framework";23import {24A,25HelpIcon,26Icon,27RawPrompt,28Text,29Title,30} from "@cocalc/frontend/components";31import { LanguageModelVendorAvatar } from "@cocalc/frontend/components/language-model-icon";32import { webapp_client } from "@cocalc/frontend/webapp-client";33import { OTHER_SETTINGS_USERDEFINED_LLM as KEY } from "@cocalc/util/db-schema/defaults";34import {35LLM_PROVIDER,36SERVICES,37UserDefinedLLM,38UserDefinedLLMService,39isLLMServiceName,40toUserLLMModelName,41} from "@cocalc/util/db-schema/llm-utils";42import { trunc, unreachable } from "@cocalc/util/misc";4344// @cspell:ignore mixtral userdefined4546interface Props {47on_change: (name: string, value: any) => void;48}4950export function UserDefinedLLMComponent({ on_change }: Props) {51const intl = useIntl();52const user_defined_llm = useTypedRedux("customize", "user_defined_llm");53const other_settings = useTypedRedux("account", "other_settings");54const [form] = Form.useForm();55const [editLLM, setEditLLM] = useState<UserDefinedLLM | null>(null);56const [tmpLLM, setTmpLLM] = useState<UserDefinedLLM | null>(null);57const [loading, setLoading] = useState(false);58const [llms, setLLMs] = useState<UserDefinedLLM[]>([]);59const [error, setError] = useState<string | null>(null);6061const [needAPIKey, setNeedAPIKey] = useState(false);62const [needEndpoint, setNeedEndpoint] = useState(false);6364const service: UserDefinedLLMService = useWatch("service", form);65useEffect(() => {66const v = service === "custom_openai" || service === "ollama";67setNeedAPIKey(!v);68setNeedEndpoint(v);69}, [service]);7071useEffect(() => {72setLoading(true);73const val = other_settings?.get(KEY) ?? "[]";74try {75const data: UserDefinedLLM[] = JSON.parse(val);76setLLMs(sortBy(data, "id"));77} catch (e) {78setError(`Error parsing custom LLMs: ${e}`);79setLLMs([]);80}81setLoading(false);82}, [other_settings?.get(KEY)]);8384useEffect(() => {85if (editLLM != null) {86form.setFieldsValue(editLLM);87} else {88form.resetFields();89}90}, [editLLM]);9192function getNextID(): number {93let id = 0;94llms.forEach((m) => (m.id > id ? (id = m.id) : null));95return id + 1;96}9798function save(next: UserDefinedLLM, oldID: number) {99// trim each field in next100for (const key in next) {101if (typeof next[key] === "string") {102next[key] = next[key].trim();103}104}105// set id if not set106next.id ??= getNextID();107108const { service, display, model, endpoint } = next;109if (110!display ||111!model ||112(needEndpoint && !endpoint) ||113(needAPIKey && !next.apiKey)114) {115setError("Please fill all fields – click the add button and fix it!");116return;117}118if (!SERVICES.includes(service as any)) {119setError(`Invalid service: ${service}`);120return;121}122try {123// replace an entry with the same ID, if it exists124const newModels = llms.filter((m) => m.id !== oldID);125newModels.push(next);126on_change(KEY, JSON.stringify(newModels));127setEditLLM(null);128} catch (err) {129setError(`Error saving custom LLM: ${err}`);130}131}132133function deleteLLM(model: string) {134try {135const newModels = llms.filter((m) => m.model !== model);136on_change(KEY, JSON.stringify(newModels));137} catch (err) {138setError(`Error deleting custom LLM: ${err}`);139}140}141142function addLLM() {143return (144<Button145block146icon={<Icon name="plus-circle-o" />}147onClick={() => {148if (!error) {149setEditLLM({150id: getNextID(),151service: "custom_openai",152display: "",153endpoint: "",154model: "",155apiKey: "",156});157} else {158setEditLLM(tmpLLM);159setError(null);160}161}}162>163<FormattedMessage164id="account.user-defined-llm.add_button.label"165defaultMessage="Add your own Language Model"166/>167</Button>168);169}170171async function test(llm: UserDefinedLLM) {172setLoading(true);173Modal.info({174closable: true,175title: `Test ${llm.display} (${llm.model})`,176content: <TestCustomLLM llm={llm} />,177okText: "Close",178});179setLoading(false);180}181182function renderList() {183return (184<List185loading={loading}186itemLayout="horizontal"187dataSource={llms}188renderItem={(item: UserDefinedLLM) => {189const { display, model, endpoint, service } = item;190if (!isLLMServiceName(service)) return null;191192return (193<List.Item194actions={[195<Button196icon={<Icon name="pen" />}197type="link"198onClick={() => {199setEditLLM(item);200}}201>202Edit203</Button>,204<Popconfirm205title={`Are you sure you want to delete the LLM ${display} (${model})?`}206onConfirm={() => deleteLLM(model)}207okText="Yes"208cancelText="No"209>210<Button icon={<Icon name="trash" />} type="link" danger>211Delete212</Button>213</Popconfirm>,214<Button215icon={<Icon name="play-circle" />}216type="link"217onClick={() => test(item)}218>219Test220</Button>,221]}222>223<Skeleton avatar title={false} loading={false} active>224<Tooltip225title={226<>227Model: {model}228<br />229Endpoint: {endpoint}230<br />231Service: {service}232</>233}234>235<List.Item.Meta236avatar={237<LanguageModelVendorAvatar238model={toUserLLMModelName(item)}239/>240}241title={display}242/>243</Tooltip>244</Skeleton>245</List.Item>246);247}}248/>249);250}251252function renderExampleModel() {253switch (service) {254case "custom_openai":255case "openai":256return "'gpt-4o'";257case "ollama":258return "'llama3:latest', 'phi3:instruct', ...";259case "anthropic":260return "'claude-3-sonnet-20240229'";261case "mistralai":262return "'open-mixtral-8x22b'";263case "google":264return "'gemini-2.0-flash'";265default:266unreachable(service);267return "'llama3:latest'";268}269}270271function renderForm() {272if (!editLLM) return null;273return (274<Modal275open={editLLM != null}276title="Edit Language Model"277onOk={() => {278const vals = form.getFieldsValue(true);279setTmpLLM(vals);280save(vals, editLLM.id);281setEditLLM(null);282}}283onCancel={() => {284setEditLLM(null);285}}286>287<Form288form={form}289layout="horizontal"290labelCol={{ span: 8 }}291wrapperCol={{ span: 16 }}292>293<Form.Item294label="Display Name"295name="display"296rules={[{ required: true }]}297help="e.g. 'MyLLM'"298>299<Input />300</Form.Item>301<Form.Item302label="Service"303name="service"304rules={[{ required: true }]}305help="Select the kind of server to talk to. Probably 'OpenAI API' or 'Ollama'"306>307<Select popupMatchSelectWidth={false}>308{SERVICES.map((option) => {309const { name, desc } = LLM_PROVIDER[option];310return (311<Select.Option key={option} value={option}>312<Tooltip title={desc} placement="right">313<Text strong>{name}</Text>: {trunc(desc, 50)}314</Tooltip>315</Select.Option>316);317})}318</Select>319</Form.Item>320<Form.Item321label="Model Name"322name="model"323rules={[{ required: true }]}324help={`This depends on the available models. e.g. ${renderExampleModel()}.`}325>326<Input />327</Form.Item>328<Form.Item329label="Endpoint URL"330name="endpoint"331rules={[{ required: needEndpoint }]}332help={333needEndpoint334? "e.g. 'https://your.ollama.server:11434/' or 'https://api.openai.com/v1'"335: "This setting is ignored."336}337>338<Input disabled={!needEndpoint} />339</Form.Item>340<Form.Item341label="API Key"342name="apiKey"343help="A secret string, which you got from the service provider."344rules={[{ required: needAPIKey }]}345>346<Input />347</Form.Item>348</Form>349</Modal>350);351}352353function renderError() {354if (!error) return null;355return <Alert message={error} type="error" closable />;356}357358const title = intl.formatMessage({359id: "account.user-defined-llm.title",360defaultMessage: "Bring your own Language Model",361});362363function renderContent() {364if (user_defined_llm) {365return (366<>367{renderForm()}368{renderList()}369{addLLM()}370{renderError()}371</>372);373} else {374return <Alert banner type="info" message="This feature is disabled." />;375}376}377378return (379<>380<Title level={5}>381{title}{" "}382<HelpIcon style={{ float: "right" }} maxWidth="300px" title={title}>383<FormattedMessage384id="account.user-defined-llm.info"385defaultMessage={`This allows you to call a {llm} of your own.386You either need an API key or run it on your own server.387Make sure to click on "Test" to check, that the communication to the API actually works.388Most likely, the type you are looking for is "Custom OpenAI" or "Ollama".`}389values={{390llm: (391<A href={"https://en.wikipedia.org/wiki/Large_language_model"}>392Large Language Model393</A>394),395}}396/>397</HelpIcon>398</Title>399400{renderContent()}401</>402);403}404405function TestCustomLLM({ llm }: { llm: UserDefinedLLM }) {406const [querying, setQuerying] = useState<boolean>(false);407const [prompt, setPrompt] = useState<string>("Capital city of Australia?");408const [reply, setReply] = useState<string>("");409const [error, setError] = useState<string>("");410411async function doQuery() {412setQuerying(true);413setError("");414setReply("");415try {416const llmStream = webapp_client.openai_client.queryStream({417input: prompt,418project_id: null,419tag: "userdefined-llm-test",420model: toUserLLMModelName(llm),421system: "This is a test. Reply briefly.",422maxTokens: 100,423});424425let reply = "";426llmStream.on("token", (token) => {427if (token) {428reply += token;429setReply(reply);430} else {431setQuerying(false);432}433});434435llmStream.on("error", (err) => {436setError(err?.toString());437setQuerying(false);438});439} catch (e) {440setError(e.message);441setReply("");442setQuerying(false);443}444}445446// TODO implement a button (or whatever) to query the backend and show the response in real time447return (448<Space direction="vertical">449<Flex vertical={false} align="center" gap={5}>450<Flex>Prompt: </Flex>451<Input452value={prompt}453onChange={(e) => setPrompt(e.target.value)}454onPressEnter={doQuery}455/>456<Button loading={querying} type="primary" onClick={doQuery}>457Test458</Button>459</Flex>460{reply ? (461<>462Reply:463<RawPrompt input={reply} />464</>465) : null}466{error ? <Alert banner message={error} type="error" /> : null}467</Space>468);469}470471472