From ca96d61e73a05fc1d55624198a908a78cb5ab145 Mon Sep 17 00:00:00 2001 From: Tim Wang <38489718+wanghualoong@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:47:20 +0800 Subject: [PATCH] Feat: Add New API model provider for OpenAI-compatible gateways (#15991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add support for **"New API"** as a model provider, enabling connection to [New API](https://github.com/QuantumNous/new-api) / [one-api](https://github.com/songquanpeng/one-api) compatible gateways that aggregate multiple LLM backends behind a unified OpenAI-compatible `/v1` endpoint. ### Features - **All model types**: Chat, Embedding, Rerank, Image2Text, TTS, Speech2Text - **List Models discovery**: `NewAPI(OpenAIAPICompatible)` class in `model_meta.py` queries the gateway's `/v1/models` to auto-discover available models via the native `GET /api/v1/providers//models` endpoint - **Model parameter editing**: Pencil icon on each discovered model row to edit `model_type`, `max_tokens`, and `features` (e.g. tool call support) before submitting - **Custom model addition**: "Add Custom Model" button at the bottom of the List Models dropdown for models not returned by the API - **Gear icon settings**: Enabled the Settings gear button on provider instances to manage models on existing instances (viewMode) - **viewMode credential passthrough**: Fixed List Models in viewMode — merges `initialValues` credentials when `api_key`/`base_url` fields are hidden by `hideWhenInstanceExists` ### Changes **Backend** (8 files): - `rag/llm/chat_model.py` — `NewAPIChat(Base)` class - `rag/llm/embedding_model.py` — `NewAPIEmbed(OpenAIEmbed)` class (no auto `/v1` append) - `rag/llm/rerank_model.py` — `NewAPIRerank(Base)` class (uses `/rerank` endpoint) - `rag/llm/cv_model.py` — `NewAPICv(GptV4)` class - `rag/llm/tts_model.py` — `NewAPITTS(OpenAITTS)` class - `rag/llm/sequence2txt_model.py` — `NewAPISeq2txt(GPTSeq2txt)` class - `rag/llm/model_meta.py` — `NewAPI(OpenAIAPICompatible)` class for List Models discovery - `conf/llm_factories.json` — New API factory entry with all model type tags **Frontend** (8 files + 1 new SVG): - `web/src/assets/svg/llm/new-api.svg` — New API logo icon - `web/src/constants/llm.ts` — `LLMFactory.NewAPI` enum + `IconMap` entry - `web/src/components/svg-icon.tsx` — `NewAPI` added to `svgIcons` - `web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/local-llm-configs.ts` — New API `buildLocalConfig` - `web/src/pages/user-setting/setting-model/modal/provider-modal/constants.ts` — `LIST_MODEL_PROVIDERS` includes NewAPI - `web/src/pages/user-setting/setting-model/components/used-model.tsx` — Enable Settings gear button - `web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts` — viewMode credential merge + model editing state/handlers - `web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-options.tsx` — Pencil edit icon per model row - `web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx` — `AddCustomModelDialog` import + edit dialog rendering **Note on Go implementation**: A Go model driver (`NewAPIModel` delegating to `OpenAIModel`) has been prepared but is deferred until the Go runtime is enabled in a future release (current v0.26.0 images use `API_PROXY_SCHEME=python` and do not compile Go binaries). Will submit as a follow-up PR. ## Related - Depends on: #15996 (provider instance API improvements — server-side credential lookup, idempotent `add_model`, security fixes — required for viewMode gear icon and batch model submission) ## Test plan - [ ] Add New API provider with api_key and base_url pointing to an OpenAI-compatible gateway - [ ] Click "List Models" — should discover and display available models from `/v1/models` - [ ] Click pencil icon on a model — should open edit dialog to change model_type, max_tokens, features - [ ] Select multiple models and click OK — should add all selected models - [ ] Click gear icon on the added instance — should open viewMode with List Models working - [ ] In viewMode, select new models including pre-existing ones, click OK — should succeed (requires #15996) - [ ] Verify all model types work: create a Chat assistant, Embedding KB, Rerank setting 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Tim Wang Co-authored-by: Claude Opus 4.6 --- conf/llm_factories.json | 8 +++ rag/llm/chat_model.py | 10 ++++ rag/llm/cv_model.py | 15 +++++- rag/llm/embedding_model.py | 10 ++++ rag/llm/model_meta.py | 13 +++++ rag/llm/rerank_model.py | 34 +++++++++++++ rag/llm/sequence2txt_model.py | 10 ++++ rag/llm/tts_model.py | 10 ++++ web/src/assets/svg/llm/new-api.svg | 3 ++ web/src/components/svg-icon.tsx | 1 + web/src/constants/llm.ts | 2 + .../setting-model/components/used-model.tsx | 8 ++- .../modal/provider-modal/constants.ts | 1 + .../field-config/local-llm-configs.ts | 9 ++++ .../hooks/use-list-models-options.tsx | 49 ++++++++++-------- .../hooks/use-list-models-picker.ts | 51 ++++++++++++++++--- .../modal/provider-modal/index.tsx | 33 ++++++++++++ 17 files changed, 231 insertions(+), 36 deletions(-) create mode 100644 web/src/assets/svg/llm/new-api.svg diff --git a/conf/llm_factories.json b/conf/llm_factories.json index 681cb406a8..18c30e3abe 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -8058,6 +8058,14 @@ "model_type": "embedding" } ] + }, + { + "name": "New API", + "logo": "", + "tags": "LLM,TEXT EMBEDDING,TEXT RE-RANK,IMAGE2TEXT,TTS,SPEECH2TEXT", + "status": "1", + "llm": [], + "rank": "885" } ] } diff --git a/rag/llm/chat_model.py b/rag/llm/chat_model.py index bc452b1bbe..dd98e48ab5 100644 --- a/rag/llm/chat_model.py +++ b/rag/llm/chat_model.py @@ -2100,3 +2100,13 @@ class RAGconChat(Base): base_url = "https://connect.ragcon.com/v1" super().__init__(key, model_name, base_url, **kwargs) + + +class NewAPIChat(Base): + _FACTORY_NAME = "New API" + + def __init__(self, key, model_name, base_url, **kwargs): + if not base_url: + raise ValueError("url cannot be None") + model_name = model_name.split("___")[0] + super().__init__(key, model_name, base_url, **kwargs) diff --git a/rag/llm/cv_model.py b/rag/llm/cv_model.py index a832ad6c1a..5aae7c9641 100644 --- a/rag/llm/cv_model.py +++ b/rag/llm/cv_model.py @@ -1417,4 +1417,17 @@ class BedrockCV(Base): return res.choices[0].message.content.strip(), total_token_count_from_response(res) def describe(self, image): - return self.describe_with_prompt(image) \ No newline at end of file + return self.describe_with_prompt(image) + + +class NewAPICv(GptV4): + _FACTORY_NAME = "New API" + + def __init__(self, key, model_name, lang="Chinese", base_url="", **kwargs): + if not base_url: + raise ValueError("url cannot be None") + self.client = OpenAI(api_key=key, base_url=base_url) + self.async_client = AsyncOpenAI(api_key=key, base_url=base_url) + self.model_name = model_name.split("___")[0] + self.lang = lang + Base.__init__(self, **kwargs) diff --git a/rag/llm/embedding_model.py b/rag/llm/embedding_model.py index c8ed17b34a..13025bb2d0 100644 --- a/rag/llm/embedding_model.py +++ b/rag/llm/embedding_model.py @@ -1300,3 +1300,13 @@ class PerplexityEmbed(Base): def encode_queries(self, text): embds, cnt = self.encode([text]) return np.array(embds[0]), cnt + + +class NewAPIEmbed(OpenAIEmbed): + _FACTORY_NAME = "New API" + + def __init__(self, key, model_name, base_url): + if not base_url: + raise ValueError("url cannot be None") + self.client = OpenAI(api_key=key, base_url=base_url) + self.model_name = model_name.split("___")[0] diff --git a/rag/llm/model_meta.py b/rag/llm/model_meta.py index 8dbd3ba5f5..6923cc65f5 100644 --- a/rag/llm/model_meta.py +++ b/rag/llm/model_meta.py @@ -469,3 +469,16 @@ class VLLM(OpenAIAPICompatible): class LMStudio(OpenAIAPICompatible): _FACTORY_NAME = "LM-Studio" + + +class NewAPI(OpenAIAPICompatible): + _FACTORY_NAME = "New API" + + def _get_api_key(self): + try: + parsed = json.loads(self.api_key) + if isinstance(parsed, dict): + return parsed.get("api_key", self.api_key) + except (JSONDecodeError, TypeError): + pass + return self.api_key diff --git a/rag/llm/rerank_model.py b/rag/llm/rerank_model.py index 9705d5c66a..8067615141 100644 --- a/rag/llm/rerank_model.py +++ b/rag/llm/rerank_model.py @@ -623,3 +623,37 @@ class RAGconRerank(Base): except Exception as _e: log_exception(_e, res) return rank, token_count + + +class NewAPIRerank(Base): + _FACTORY_NAME = "New API" + + def __init__(self, key, model_name, base_url): + normalized_base_url = (base_url or "").strip() + if "/rerank" in normalized_base_url: + self.base_url = normalized_base_url.rstrip("/") + else: + self.base_url = urljoin(f"{normalized_base_url.rstrip('/')}/", "rerank").rstrip("/") + self.headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {key}", + } + self.model_name = model_name.split("___")[0] + + def _compute_rank(self, query: str, texts: list): + texts = [truncate(t, 500) for t in texts] + data = { + "model": self.model_name, + "query": query, + "documents": texts, + "top_n": len(texts), + } + token_count = sum(num_tokens_from_string(t) for t in texts) + res = requests.post(self.base_url, headers=self.headers, json=data).json() + rank = np.zeros(len(texts), dtype=float) + try: + for d in res["results"]: + rank[d["index"]] = d["relevance_score"] + except Exception as _e: + log_exception(_e, res) + return rank, token_count diff --git a/rag/llm/sequence2txt_model.py b/rag/llm/sequence2txt_model.py index 4624a2911a..1642314dcb 100644 --- a/rag/llm/sequence2txt_model.py +++ b/rag/llm/sequence2txt_model.py @@ -433,3 +433,13 @@ class RAGconSeq2txt(Base): # Return text and token count text = transcription.text.strip() return text, num_tokens_from_string(text) + + +class NewAPISeq2txt(GPTSeq2txt): + _FACTORY_NAME = "New API" + + def __init__(self, key, model_name="whisper-1", base_url="", **kwargs): + if not base_url: + raise ValueError("url cannot be None") + model_name = model_name.split("___")[0] + super().__init__(key, model_name=model_name, base_url=base_url, **kwargs) diff --git a/rag/llm/tts_model.py b/rag/llm/tts_model.py index f37cd89c25..4f95b2cb6b 100644 --- a/rag/llm/tts_model.py +++ b/rag/llm/tts_model.py @@ -543,3 +543,13 @@ class RAGconTTS(Base): for chunk in response.iter_content(chunk_size=1024): if chunk: yield chunk + + +class NewAPITTS(OpenAITTS): + _FACTORY_NAME = "New API" + + def __init__(self, key, model_name, base_url="", **kwargs): + if not base_url: + raise ValueError("url cannot be None") + model_name = model_name.split("___")[0] + super().__init__(key, model_name, base_url, **kwargs) diff --git a/web/src/assets/svg/llm/new-api.svg b/web/src/assets/svg/llm/new-api.svg new file mode 100644 index 0000000000..5af0227fbd --- /dev/null +++ b/web/src/assets/svg/llm/new-api.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/components/svg-icon.tsx b/web/src/components/svg-icon.tsx index 3e3ee85cb3..b51cc51fac 100644 --- a/web/src/components/svg-icon.tsx +++ b/web/src/components/svg-icon.tsx @@ -88,6 +88,7 @@ const svgIcons = [ // LLMFactory.DeerAPI, LLMFactory.Avian, LLMFactory.RAGcon, + LLMFactory.NewAPI, ]; export const LlmIcon = ({ diff --git a/web/src/constants/llm.ts b/web/src/constants/llm.ts index 4157de1fbe..dea7096ebf 100644 --- a/web/src/constants/llm.ts +++ b/web/src/constants/llm.ts @@ -72,6 +72,7 @@ export enum LLMFactory { Avian = 'Avian', RAGcon = 'RAGcon', Perplexity = 'Perplexity', + NewAPI = 'New API', } // Please lowercase the file name @@ -143,6 +144,7 @@ export const IconMap = { [LLMFactory.Avian]: 'avian', [LLMFactory.RAGcon]: 'ragcon', [LLMFactory.Perplexity]: 'perplexity', + [LLMFactory.NewAPI]: 'new-api', }; export const ModelTypeToField: Record = { diff --git a/web/src/pages/user-setting/setting-model/components/used-model.tsx b/web/src/pages/user-setting/setting-model/components/used-model.tsx index ca91d52211..fa8365c1e8 100644 --- a/web/src/pages/user-setting/setting-model/components/used-model.tsx +++ b/web/src/pages/user-setting/setting-model/components/used-model.tsx @@ -265,11 +265,9 @@ function InstanceModelList({ ))} - {false && ( - - )} + )} diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/constants.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/constants.ts index e340911419..bd77e06452 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/constants.ts +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/constants.ts @@ -23,6 +23,7 @@ export const LIST_MODEL_PROVIDERS = new Set([ LLMFactory.Xinference, LLMFactory.LocalAI, LLMFactory.BaiduYiYan, + LLMFactory.NewAPI, // LLMFactory.HuggingFace, // LLMFactory.GoogleCloud, diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/local-llm-configs.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/local-llm-configs.ts index 8ccbe84833..8fd8967fd9 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/local-llm-configs.ts +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/local-llm-configs.ts @@ -134,6 +134,15 @@ export const LocalLlmConfigs: Record = { undefined, 'https://docs.vllm.ai/en/latest/', ), + [LLMFactory.NewAPI]: buildLocalConfig( + LLMFactory.NewAPI, + 'New API', + ['chat', 'embedding', 'rerank', 'image2text', 'tts', 'speech2text'], + undefined, + false, + undefined, + 'https://github.com/QuantumNous/new-api', + ), // [LLMFactory.TokenPony]: buildLocalConfig( // LLMFactory.TokenPony, // 'TokenPony', diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-options.tsx b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-options.tsx index 0f9973dbd5..abf52103d2 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-options.tsx +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-options.tsx @@ -1,6 +1,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { useTranslate } from '@/hooks/common-hooks'; import { IProviderModelItem } from '@/interfaces/request/llm'; +import { Pencil } from 'lucide-react'; import { useMemo } from 'react'; interface UseListModelsOptionsParams { @@ -9,29 +10,16 @@ interface UseListModelsOptionsParams { allSelected: boolean; handleSelectModel: (model: IProviderModelItem) => void; handleToggleAll: () => void; + onEditModel?: (model: IProviderModelItem) => void; } -/** - * Build ToggleList options from the fetched model list. The first item is - * a sentinel "All models" row that toggles the full selection. - * - * Why the Checkbox uses `onClick` (not `onCheckedChange`): - * Radix Checkbox calls `event.stopPropagation()` internally on its onClick - * when the Checkbox lives inside a form, so the row's `onClick` (attached - * by ToggleList) never fires when the user clicks the Checkbox itself. - * To make the Checkbox click toggle selection, we handle it in our own - * `onClick` and re-stop propagation to (a) prevent the row's onClick from - * double-firing and (b) make Radix's CheckboxBubbleInput dispatch a - * non-bubbling synthetic click on its hidden form input — without this, - * the dispatched click would bubble back to the row and re-trigger the - * toggle, causing "Maximum update depth exceeded". - */ export const useListModelsOptions = ({ models, selectedModelItems, allSelected, handleSelectModel, handleToggleAll, + onEditModel, }: UseListModelsOptionsParams) => { const { t } = useTranslate('setting'); @@ -75,13 +63,29 @@ export const useListModelsOptions = ({ ); })} - { - e.stopPropagation(); - handleSelectModel(m); - }} - /> +
+ {onEditModel && ( + + )} + { + e.stopPropagation(); + handleSelectModel(m); + }} + /> +
), onClick: () => handleSelectModel(m), @@ -98,6 +102,7 @@ export const useListModelsOptions = ({ handleSelectModel, allSelected, handleToggleAll, + onEditModel, t, ]); }; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts index 004c7c16b0..d68aa3acb7 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/use-list-models-picker.ts @@ -154,15 +154,16 @@ export const useListModelsPicker = ({ if (models.length > 0 || listLoading) return; setListLoading(true); try { - const values = (formRef.current?.getValues() || {}) as Record< + const rawValues = (formRef.current?.getValues() || {}) as Record< string, any >; - // Reuse the verifyTransform to build the request payload — it - // already knows how to flatten provider-specific auth (api_key, - // base_url, region, model_info). Pass the current modelInfoList - // (empty on first load, populated on re-opens) so the backend - // sees an array shape consistent with the verify/submit payloads. + // In viewMode, pass instance_name so the backend looks up + // stored credentials server-side (avoids exposing api_key). + // For new-instance mode, pass api_key/base_url from the form. + const values = viewMode && initialValues + ? { ...initialValues, ...rawValues } + : rawValues; const verifyArgs = config.verifyTransform ? config.verifyTransform({ ...values, @@ -171,10 +172,13 @@ export const useListModelsPicker = ({ : { apiKey: values.api_key ?? '', baseUrl: values.base_url }; const res = await listProviderModels({ provider_name: llmFactory, - api_key: (verifyArgs as any).apiKey ?? '', - base_url: (verifyArgs as any).baseUrl, + api_key: viewMode ? '' : ((verifyArgs as any).apiKey ?? ''), + base_url: viewMode ? '' : ((verifyArgs as any).baseUrl ?? ''), region: (verifyArgs as any).region, model_info: (verifyArgs as any).modelInfo ?? modelInfoList, + ...(viewMode && initialValues?.instance_name + ? { instance_name: initialValues.instance_name } + : {}), }); if (res?.code === 0 && Array.isArray(res.data)) { setModelsState(res.data); @@ -210,6 +214,8 @@ export const useListModelsPicker = ({ llmFactory, modelInfoList, formRef, + viewMode, + initialValues, ], ); @@ -287,6 +293,30 @@ export const useListModelsPicker = ({ [], ); + // --- Model editing --- + const [editingModel, setEditingModel] = + useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + + const handleEditModel = useCallback((model: IProviderModelItem) => { + setEditingModel(model); + setEditDialogOpen(true); + }, []); + + const handleSaveEditedModel = useCallback( + (updated: IProviderModelItem) => { + const oldName = editingModel?.name; + if (!oldName) return; + const replace = (items: IProviderModelItem[]) => + items.map((m) => (m.name === oldName ? updated : m)); + setModelsState(replace); + setSelectedModelItemsState(replace); + setEditDialogOpen(false); + setEditingModel(null); + }, + [editingModel], + ); + return { models, listLoading, @@ -298,5 +328,10 @@ export const useListModelsPicker = ({ handleToggleAll, setModels, setSelectedModelItems, + editingModel, + editDialogOpen, + setEditDialogOpen, + handleEditModel, + handleSaveEditedModel, }; }; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx index 53f5cb3ea9..a6d15a18c5 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx +++ b/web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx @@ -14,6 +14,7 @@ import { FieldValues } from 'react-hook-form'; import { LLMHeader } from '../../components/llm-header'; import VerifyButton from '../verify-button'; import { AddableToggleList } from './components/addable-toggle-list'; +import { AddCustomModelDialog } from './components/add-custom-model-dialog'; import { useCustomModelFields } from './components/use-custom-model-fields'; import { useListModelsOptions, @@ -103,6 +104,11 @@ const ProviderModal = ({ handleSelectModel, handleToggleAll, setModels, + editingModel, + editDialogOpen, + setEditDialogOpen, + handleEditModel, + handleSaveEditedModel, } = useListModelsPicker({ visible, hasModelNameField, @@ -164,6 +170,7 @@ const ProviderModal = ({ allSelected, handleSelectModel, handleToggleAll, + onEditModel: handleEditModel, }); // Submit and verify handlers — branch on viewMode and on whether the @@ -270,6 +277,32 @@ const ProviderModal = ({ + {editingModel && ( + ({ + ...f, + defaultValue: + f.name === 'name' + ? editingModel.name + : f.name === 'model_types' + ? editingModel.model_types ?? [] + : f.name === 'max_tokens' + ? editingModel.max_tokens ?? 0 + : f.name === 'features' + ? editingModel.features ?? [] + : f.defaultValue, + }))} + onSubmit={handleSaveEditedModel} + submitText={tc('ok')} + cancelText={tc('cancel')} + existingNames={existingNames.filter( + (n) => n !== editingModel.name, + )} + /> + )} ); };