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:
Tim Wang
2026-06-26 18:47:20 +08:00
committed by GitHub
parent 10140b1d02
commit ca96d61e73
17 changed files with 231 additions and 36 deletions

View File

@@ -8058,6 +8058,14 @@
"model_type": "embedding" "model_type": "embedding"
} }
] ]
},
{
"name": "New API",
"logo": "",
"tags": "LLM,TEXT EMBEDDING,TEXT RE-RANK,IMAGE2TEXT,TTS,SPEECH2TEXT",
"status": "1",
"llm": [],
"rank": "885"
} }
] ]
} }

View File

@@ -2100,3 +2100,13 @@ class RAGconChat(Base):
base_url = "https://connect.ragcon.com/v1" base_url = "https://connect.ragcon.com/v1"
super().__init__(key, model_name, base_url, **kwargs) 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)

View File

@@ -1417,4 +1417,17 @@ class BedrockCV(Base):
return res.choices[0].message.content.strip(), total_token_count_from_response(res) return res.choices[0].message.content.strip(), total_token_count_from_response(res)
def describe(self, image): 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)

View File

@@ -1300,3 +1300,13 @@ class PerplexityEmbed(Base):
def encode_queries(self, text): def encode_queries(self, text):
embds, cnt = self.encode([text]) embds, cnt = self.encode([text])
return np.array(embds[0]), cnt 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]

View File

@@ -469,3 +469,16 @@ class VLLM(OpenAIAPICompatible):
class LMStudio(OpenAIAPICompatible): class LMStudio(OpenAIAPICompatible):
_FACTORY_NAME = "LM-Studio" _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

View File

@@ -623,3 +623,37 @@ class RAGconRerank(Base):
except Exception as _e: except Exception as _e:
log_exception(_e, res) log_exception(_e, res)
return rank, token_count 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

View File

@@ -433,3 +433,13 @@ class RAGconSeq2txt(Base):
# Return text and token count # Return text and token count
text = transcription.text.strip() text = transcription.text.strip()
return text, num_tokens_from_string(text) 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)

View File

@@ -543,3 +543,13 @@ class RAGconTTS(Base):
for chunk in response.iter_content(chunk_size=1024): for chunk in response.iter_content(chunk_size=1024):
if chunk: if chunk:
yield 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)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -88,6 +88,7 @@ const svgIcons = [
// LLMFactory.DeerAPI, // LLMFactory.DeerAPI,
LLMFactory.Avian, LLMFactory.Avian,
LLMFactory.RAGcon, LLMFactory.RAGcon,
LLMFactory.NewAPI,
]; ];
export const LlmIcon = ({ export const LlmIcon = ({

View File

@@ -72,6 +72,7 @@ export enum LLMFactory {
Avian = 'Avian', Avian = 'Avian',
RAGcon = 'RAGcon', RAGcon = 'RAGcon',
Perplexity = 'Perplexity', Perplexity = 'Perplexity',
NewAPI = 'New API',
} }
// Please lowercase the file name // Please lowercase the file name
@@ -143,6 +144,7 @@ export const IconMap = {
[LLMFactory.Avian]: 'avian', [LLMFactory.Avian]: 'avian',
[LLMFactory.RAGcon]: 'ragcon', [LLMFactory.RAGcon]: 'ragcon',
[LLMFactory.Perplexity]: 'perplexity', [LLMFactory.Perplexity]: 'perplexity',
[LLMFactory.NewAPI]: 'new-api',
}; };
export const ModelTypeToField: Record<string, string> = { export const ModelTypeToField: Record<string, string> = {

View File

@@ -265,11 +265,9 @@ function InstanceModelList({
</span> </span>
))} ))}
</div> </div>
{false && ( <Button size="icon" variant="ghost" onClick={handleSettingsClick}>
<Button size="icon" variant="ghost" onClick={handleSettingsClick}> <Settings size={12} />
<Settings size={12} /> </Button>
</Button>
)}
</div> </div>
)} )}

View File

@@ -23,6 +23,7 @@ export const LIST_MODEL_PROVIDERS = new Set<string>([
LLMFactory.Xinference, LLMFactory.Xinference,
LLMFactory.LocalAI, LLMFactory.LocalAI,
LLMFactory.BaiduYiYan, LLMFactory.BaiduYiYan,
LLMFactory.NewAPI,
// LLMFactory.HuggingFace, // LLMFactory.HuggingFace,
// LLMFactory.GoogleCloud, // LLMFactory.GoogleCloud,

View File

@@ -134,6 +134,15 @@ export const LocalLlmConfigs: Record<string, ProviderConfig> = {
undefined, undefined,
'https://docs.vllm.ai/en/latest/', '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]: buildLocalConfig(
// LLMFactory.TokenPony, // LLMFactory.TokenPony,
// 'TokenPony', // 'TokenPony',

View File

@@ -1,6 +1,7 @@
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { useTranslate } from '@/hooks/common-hooks'; import { useTranslate } from '@/hooks/common-hooks';
import { IProviderModelItem } from '@/interfaces/request/llm'; import { IProviderModelItem } from '@/interfaces/request/llm';
import { Pencil } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
interface UseListModelsOptionsParams { interface UseListModelsOptionsParams {
@@ -9,29 +10,16 @@ interface UseListModelsOptionsParams {
allSelected: boolean; allSelected: boolean;
handleSelectModel: (model: IProviderModelItem) => void; handleSelectModel: (model: IProviderModelItem) => void;
handleToggleAll: () => 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 = ({ export const useListModelsOptions = ({
models, models,
selectedModelItems, selectedModelItems,
allSelected, allSelected,
handleSelectModel, handleSelectModel,
handleToggleAll, handleToggleAll,
onEditModel,
}: UseListModelsOptionsParams) => { }: UseListModelsOptionsParams) => {
const { t } = useTranslate('setting'); const { t } = useTranslate('setting');
@@ -75,13 +63,29 @@ export const useListModelsOptions = ({
); );
})} })}
</div> </div>
<Checkbox <div className="flex items-center gap-1">
checked={checked} {onEditModel && (
onClick={(e) => { <button
e.stopPropagation(); type="button"
handleSelectModel(m); 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> </div>
), ),
onClick: () => handleSelectModel(m), onClick: () => handleSelectModel(m),
@@ -98,6 +102,7 @@ export const useListModelsOptions = ({
handleSelectModel, handleSelectModel,
allSelected, allSelected,
handleToggleAll, handleToggleAll,
onEditModel,
t, t,
]); ]);
}; };

View File

@@ -154,15 +154,16 @@ export const useListModelsPicker = ({
if (models.length > 0 || listLoading) return; if (models.length > 0 || listLoading) return;
setListLoading(true); setListLoading(true);
try { try {
const values = (formRef.current?.getValues() || {}) as Record< const rawValues = (formRef.current?.getValues() || {}) as Record<
string, string,
any any
>; >;
// Reuse the verifyTransform to build the request payload — it // In viewMode, pass instance_name so the backend looks up
// already knows how to flatten provider-specific auth (api_key, // stored credentials server-side (avoids exposing api_key).
// base_url, region, model_info). Pass the current modelInfoList // For new-instance mode, pass api_key/base_url from the form.
// (empty on first load, populated on re-opens) so the backend const values = viewMode && initialValues
// sees an array shape consistent with the verify/submit payloads. ? { ...initialValues, ...rawValues }
: rawValues;
const verifyArgs = config.verifyTransform const verifyArgs = config.verifyTransform
? config.verifyTransform({ ? config.verifyTransform({
...values, ...values,
@@ -171,10 +172,13 @@ export const useListModelsPicker = ({
: { apiKey: values.api_key ?? '', baseUrl: values.base_url }; : { apiKey: values.api_key ?? '', baseUrl: values.base_url };
const res = await listProviderModels({ const res = await listProviderModels({
provider_name: llmFactory, provider_name: llmFactory,
api_key: (verifyArgs as any).apiKey ?? '', api_key: viewMode ? '' : ((verifyArgs as any).apiKey ?? ''),
base_url: (verifyArgs as any).baseUrl, base_url: viewMode ? '' : ((verifyArgs as any).baseUrl ?? ''),
region: (verifyArgs as any).region, region: (verifyArgs as any).region,
model_info: (verifyArgs as any).modelInfo ?? modelInfoList, model_info: (verifyArgs as any).modelInfo ?? modelInfoList,
...(viewMode && initialValues?.instance_name
? { instance_name: initialValues.instance_name }
: {}),
}); });
if (res?.code === 0 && Array.isArray(res.data)) { if (res?.code === 0 && Array.isArray(res.data)) {
setModelsState(res.data); setModelsState(res.data);
@@ -210,6 +214,8 @@ export const useListModelsPicker = ({
llmFactory, llmFactory,
modelInfoList, modelInfoList,
formRef, 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 { return {
models, models,
listLoading, listLoading,
@@ -298,5 +328,10 @@ export const useListModelsPicker = ({
handleToggleAll, handleToggleAll,
setModels, setModels,
setSelectedModelItems, setSelectedModelItems,
editingModel,
editDialogOpen,
setEditDialogOpen,
handleEditModel,
handleSaveEditedModel,
}; };
}; };

View File

@@ -14,6 +14,7 @@ import { FieldValues } from 'react-hook-form';
import { LLMHeader } from '../../components/llm-header'; import { LLMHeader } from '../../components/llm-header';
import VerifyButton from '../verify-button'; import VerifyButton from '../verify-button';
import { AddableToggleList } from './components/addable-toggle-list'; import { AddableToggleList } from './components/addable-toggle-list';
import { AddCustomModelDialog } from './components/add-custom-model-dialog';
import { useCustomModelFields } from './components/use-custom-model-fields'; import { useCustomModelFields } from './components/use-custom-model-fields';
import { import {
useListModelsOptions, useListModelsOptions,
@@ -103,6 +104,11 @@ const ProviderModal = ({
handleSelectModel, handleSelectModel,
handleToggleAll, handleToggleAll,
setModels, setModels,
editingModel,
editDialogOpen,
setEditDialogOpen,
handleEditModel,
handleSaveEditedModel,
} = useListModelsPicker({ } = useListModelsPicker({
visible, visible,
hasModelNameField, hasModelNameField,
@@ -164,6 +170,7 @@ const ProviderModal = ({
allSelected, allSelected,
handleSelectModel, handleSelectModel,
handleToggleAll, handleToggleAll,
onEditModel: handleEditModel,
}); });
// Submit and verify handlers — branch on viewMode and on whether the // Submit and verify handlers — branch on viewMode and on whether the
@@ -270,6 +277,32 @@ const ProviderModal = ({
</div> </div>
</div> </div>
</DynamicForm.Root> </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> </Modal>
); );
}; };