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

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.Avian,
LLMFactory.RAGcon,
LLMFactory.NewAPI,
];
export const LlmIcon = ({

View File

@@ -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> = {

View File

@@ -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>
)}

View File

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

View File

@@ -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',

View File

@@ -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,
]);
};

View File

@@ -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,
};
};

View File

@@ -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>
);
};