mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 15:31:05 +08:00
Feat: Add New API model provider for OpenAI-compatible gateways (#15991)
## 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/<name>/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 <wanghualoong@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
3
web/src/assets/svg/llm/new-api.svg
Normal file
3
web/src/assets/svg/llm/new-api.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
@@ -88,6 +88,7 @@ const svgIcons = [
|
||||
// LLMFactory.DeerAPI,
|
||||
LLMFactory.Avian,
|
||||
LLMFactory.RAGcon,
|
||||
LLMFactory.NewAPI,
|
||||
];
|
||||
|
||||
export const LlmIcon = ({
|
||||
|
||||
@@ -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<string, string> = {
|
||||
|
||||
@@ -265,11 +265,9 @@ function InstanceModelList({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{false && (
|
||||
<Button size="icon" variant="ghost" onClick={handleSettingsClick}>
|
||||
<Settings size={12} />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" onClick={handleSettingsClick}>
|
||||
<Settings size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const LIST_MODEL_PROVIDERS = new Set<string>([
|
||||
LLMFactory.Xinference,
|
||||
LLMFactory.LocalAI,
|
||||
LLMFactory.BaiduYiYan,
|
||||
LLMFactory.NewAPI,
|
||||
|
||||
// LLMFactory.HuggingFace,
|
||||
// LLMFactory.GoogleCloud,
|
||||
|
||||
@@ -134,6 +134,15 @@ export const LocalLlmConfigs: Record<string, ProviderConfig> = {
|
||||
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',
|
||||
|
||||
@@ -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 = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectModel(m);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{onEditModel && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Edit model"
|
||||
title="Edit model"
|
||||
className="p-1 rounded hover:bg-bg-card text-text-secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditModel(m);
|
||||
}}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectModel(m);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handleSelectModel(m),
|
||||
@@ -98,6 +102,7 @@ export const useListModelsOptions = ({
|
||||
handleSelectModel,
|
||||
allSelected,
|
||||
handleToggleAll,
|
||||
onEditModel,
|
||||
t,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -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<IProviderModelItem | null>(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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 = ({
|
||||
</div>
|
||||
</div>
|
||||
</DynamicForm.Root>
|
||||
{editingModel && (
|
||||
<AddCustomModelDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
title={editingModel.name}
|
||||
fields={customModelDialogFields.map((f) => ({
|
||||
...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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user