Path: blob/master/src/packages/frontend/account/user-defined-llm.tsx
5977 views
import {1Alert,2Button,3Flex,4Form,5Input,6InputNumber,7List,8Modal,9Popconfirm,10Select,11Skeleton,12Space,13Tooltip,14} from "antd";15import { useWatch } from "antd/es/form/Form";16import { sortBy } from "lodash";17import { FormattedMessage, useIntl } from "react-intl";1819import {20CSS,21useEffect,22useState,23useTypedRedux,24} from "@cocalc/frontend/app-framework";25import {26A,27HelpIcon,28Icon,29RawPrompt,30Text,31} from "@cocalc/frontend/components";32import { LanguageModelVendorAvatar } from "@cocalc/frontend/components/language-model-icon";33import { webapp_client } from "@cocalc/frontend/webapp-client";34import { OTHER_SETTINGS_USER_DEFINED_LLM as KEY } from "@cocalc/util/db-schema/defaults";35import {36FALLBACK_MAX_TOKENS,37LLM_PROVIDER,38SERVICES,39UserDefinedLLM,40UserDefinedLLMService,41isLLMServiceName,42toUserLLMModelName,43} from "@cocalc/util/db-schema/llm-utils";44import { trunc, unreachable } from "@cocalc/util/misc";45import { Panel } from "@cocalc/frontend/antd-bootstrap";4647// @cspell:ignore mixtral userdefined4849interface Props {50style?: CSS;51on_change: (name: string, value: any) => void;52}5354export function UserDefinedLLMComponent({ style, on_change }: Props) {55const intl = useIntl();56const user_defined_llm = useTypedRedux("customize", "user_defined_llm");57const other_settings = useTypedRedux("account", "other_settings");58const [form] = Form.useForm();59const [editLLM, setEditLLM] = useState<UserDefinedLLM | null>(null);60const [tmpLLM, setTmpLLM] = useState<UserDefinedLLM | null>(null);61const [loading, setLoading] = useState(false);62const [llms, setLLMs] = useState<UserDefinedLLM[]>([]);63const [error, setError] = useState<string | null>(null);6465const [needAPIKey, setNeedAPIKey] = useState(false);66const [needEndpoint, setNeedEndpoint] = useState(false);6768const service: UserDefinedLLMService = useWatch("service", form);69useEffect(() => {70const v = service === "custom_openai" || service === "ollama";71setNeedAPIKey(!v);72setNeedEndpoint(v);73}, [service]);7475useEffect(() => {76setLoading(true);77const val = other_settings?.get(KEY) ?? "[]";78try {79const data: UserDefinedLLM[] = JSON.parse(val);80setLLMs(sortBy(data, "id"));81} catch (e) {82setError(`Error parsing custom LLMs: ${e}`);83setLLMs([]);84}85setLoading(false);86}, [other_settings?.get(KEY)]);8788useEffect(() => {89if (editLLM != null) {90form.setFieldsValue(editLLM);91} else {92form.resetFields();93}94}, [editLLM]);9596function getNextID(): number {97let id = 0;98llms.forEach((m) => (m.id > id ? (id = m.id) : null));99return id + 1;100}101102function save(next: UserDefinedLLM, oldID: number) {103// trim each field in next104for (const key in next) {105if (typeof next[key] === "string") {106next[key] = next[key].trim();107}108}109// set id if not set110next.id ??= getNextID();111112const { service, display, model, endpoint } = next;113if (114!display ||115!model ||116(needEndpoint && !endpoint) ||117(needAPIKey && !next.apiKey)118) {119setError("Please fill all fields – click the add button and fix it!");120return;121}122if (!SERVICES.includes(service as any)) {123setError(`Invalid service: ${service}`);124return;125}126try {127// replace an entry with the same ID, if it exists128const newModels = llms.filter((m) => m.id !== oldID);129newModels.push(next);130on_change(KEY, JSON.stringify(newModels));131setEditLLM(null);132} catch (err) {133setError(`Error saving custom LLM: ${err}`);134}135}136137function deleteLLM(model: string) {138try {139const newModels = llms.filter((m) => m.model !== model);140on_change(KEY, JSON.stringify(newModels));141} catch (err) {142setError(`Error deleting custom LLM: ${err}`);143}144}145146function addLLM() {147return (148<Button149block150icon={<Icon name="plus-circle-o" />}151onClick={() => {152if (!error) {153setEditLLM({154id: getNextID(),155service: "custom_openai",156display: "",157endpoint: "",158model: "",159apiKey: "",160});161} else {162setEditLLM(tmpLLM);163setError(null);164}165}}166>167<FormattedMessage168id="account.user-defined-llm.add_button.label"169defaultMessage="Add your own Language Model"170/>171</Button>172);173}174175async function test(llm: UserDefinedLLM) {176setLoading(true);177Modal.info({178closable: true,179title: `Test ${llm.display} (${llm.model})`,180content: <TestCustomLLM llm={llm} />,181okText: "Close",182});183setLoading(false);184}185186function renderList() {187return (188<List189loading={loading}190itemLayout="horizontal"191dataSource={llms}192renderItem={(item: UserDefinedLLM) => {193const { display, model, endpoint, service } = item;194if (!isLLMServiceName(service)) return null;195196return (197<List.Item198actions={[199<Button200icon={<Icon name="pen" />}201type="link"202onClick={() => {203setEditLLM(item);204}}205>206Edit207</Button>,208<Popconfirm209title={`Are you sure you want to delete the LLM ${display} (${model})?`}210onConfirm={() => deleteLLM(model)}211okText="Yes"212cancelText="No"213>214<Button icon={<Icon name="trash" />} type="link" danger>215Delete216</Button>217</Popconfirm>,218<Button219icon={<Icon name="play-circle" />}220type="link"221onClick={() => test(item)}222>223Test224</Button>,225]}226>227<Skeleton avatar title={false} loading={false} active>228<Tooltip229title={230<>231Model: {model}232<br />233Endpoint: {endpoint}234<br />235Service: {service}236</>237}238>239<List.Item.Meta240avatar={241<LanguageModelVendorAvatar242model={toUserLLMModelName(item)}243/>244}245title={display}246/>247</Tooltip>248</Skeleton>249</List.Item>250);251}}252/>253);254}255256function renderExampleModel() {257switch (service) {258case "custom_openai":259case "openai":260return "'gpt-4o'";261case "ollama":262return "'llama3:latest', 'phi3:instruct', ...";263case "anthropic":264return "'claude-3-sonnet-20240229'";265case "mistralai":266return "'open-mixtral-8x22b'";267case "google":268return "'gemini-2.0-flash'";269case "xai":270return "'grok-4-1-fast-non-reasoning-16k'";271default:272unreachable(service);273return "'llama3:latest'";274}275}276277function renderForm() {278if (!editLLM) return null;279return (280<Modal281open={editLLM != null}282title="Edit Language Model"283onOk={() => {284const vals = form.getFieldsValue(true);285setTmpLLM(vals);286save(vals, editLLM.id);287setEditLLM(null);288}}289onCancel={() => {290setEditLLM(null);291}}292>293<Form294form={form}295layout="horizontal"296labelCol={{ span: 8 }}297wrapperCol={{ span: 16 }}298>299<Form.Item300label="Display Name"301name="display"302rules={[{ required: true }]}303help="e.g. 'MyLLM'"304>305<Input />306</Form.Item>307<Form.Item308label="Service"309name="service"310rules={[{ required: true }]}311help="Select the kind of server to talk to. Probably 'OpenAI API' or 'Ollama'"312>313<Select popupMatchSelectWidth={false}>314{SERVICES.map((option) => {315const { name, desc } = LLM_PROVIDER[option];316return (317<Select.Option key={option} value={option}>318<Tooltip title={desc} placement="right">319<Text strong>{name}</Text>: {trunc(desc, 50)}320</Tooltip>321</Select.Option>322);323})}324</Select>325</Form.Item>326<Form.Item327label="Model Name"328name="model"329rules={[{ required: true }]}330help={`This depends on the available models. e.g. ${renderExampleModel()}.`}331>332<Input />333</Form.Item>334<Form.Item335label="Endpoint URL"336name="endpoint"337rules={[{ required: needEndpoint }]}338help={339needEndpoint340? "e.g. 'https://your.ollama.server:11434/' or 'https://api.openai.com/v1'"341: "This setting is ignored."342}343>344<Input disabled={!needEndpoint} />345</Form.Item>346<Form.Item347label="API Key"348name="apiKey"349help="A secret string, which you got from the service provider."350rules={[{ required: needAPIKey }]}351>352<Input />353</Form.Item>354<Form.Item355label="Max Tokens"356name="max_tokens"357help={`Context window size in tokens. Leave empty to use default (${FALLBACK_MAX_TOKENS}). Valid range: 1000-2000000.`}358rules={[359{360type: "number",361min: 1000,362max: 2000000,363message: "Must be between 1000 and 2000000",364},365]}366>367<InputNumber368min={1000}369max={2000000}370placeholder={`${FALLBACK_MAX_TOKENS} (default)`}371style={{ width: "100%" }}372/>373</Form.Item>374</Form>375</Modal>376);377}378379function renderError() {380if (!error) return null;381return <Alert message={error} type="error" closable />;382}383384const title = intl.formatMessage({385id: "account.user-defined-llm.title",386defaultMessage: "Bring your own Language Model",387});388389function renderContent() {390if (user_defined_llm) {391return (392<>393{renderForm()}394{renderList()}395{addLLM()}396{renderError()}397</>398);399} else {400return <Alert banner type="info" message="This feature is disabled." />;401}402}403404function renderHelpIcon() {405return (406<HelpIcon style={{ float: "right" }} maxWidth="300px" title={title}>407<FormattedMessage408id="account.user-defined-llm.info"409defaultMessage={`This allows you to call a {llm} of your own.410You either need an API key or run it on your own server.411Make sure to click on "Test" to check, that the communication to the API actually works.412Most likely, the type you are looking for is "Custom OpenAI" or "Ollama".`}413values={{414llm: (415<A href={"https://en.wikipedia.org/wiki/Large_language_model"}>416Large Language Model417</A>418),419}}420/>421</HelpIcon>422);423}424425return (426<Panel427style={style}428size={"small"}429header={430<>431{title}432{renderHelpIcon()}433</>434}435>436{renderContent()}437</Panel>438);439}440441function TestCustomLLM({ llm }: { llm: UserDefinedLLM }) {442const [querying, setQuerying] = useState<boolean>(false);443const [prompt, setPrompt] = useState<string>("Capital city of Australia?");444const [reply, setReply] = useState<string>("");445const [error, setError] = useState<string>("");446447async function doQuery() {448setQuerying(true);449setError("");450setReply("");451try {452const llmStream = webapp_client.openai_client.queryStream({453input: prompt,454project_id: null,455tag: "userdefined-llm-test",456model: toUserLLMModelName(llm),457system: "This is a test. Reply briefly.",458maxTokens: 100,459});460461let reply = "";462llmStream.on("token", (token) => {463if (token) {464reply += token;465setReply(reply);466} else {467setQuerying(false);468}469});470471llmStream.on("error", (err) => {472setError(err?.toString());473setQuerying(false);474});475} catch (e) {476setError(e.message);477setReply("");478setQuerying(false);479}480}481482// TODO implement a button (or whatever) to query the backend and show the response in real time483return (484<Space direction="vertical">485<Flex vertical={false} align="center" gap={5}>486<Flex>Prompt: </Flex>487<Input488value={prompt}489onChange={(e) => setPrompt(e.target.value)}490onPressEnter={doQuery}491/>492<Button loading={querying} type="primary" onClick={doQuery}>493Test494</Button>495</Flex>496{reply ? (497<>498Reply:499<RawPrompt input={reply} />500</>501) : null}502{error ? <Alert banner message={error} type="error" /> : null}503</Space>504);505}506507508