Merge remote-tracking branch 'upstream/master'

This commit is contained in:
yangziyu-hhh
2026-06-15 13:17:12 +08:00
42 changed files with 2230 additions and 234 deletions

View File

@@ -519,7 +519,10 @@ class ChatChannel(Channel):
def cancel_session(self, session_id):
with self.lock:
if session_id in self.sessions:
for future in self.futures[session_id]:
# futures[session_id] is only created in consume() when a task is
# dispatched, so it may be absent if cancel happens right after
# produce() but before the first dispatch. Default to [].
for future in self.futures.get(session_id, []):
future.cancel()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:
@@ -529,7 +532,7 @@ class ChatChannel(Channel):
def cancel_all_session(self):
with self.lock:
for session_id in self.sessions:
for future in self.futures[session_id]:
for future in self.futures.get(session_id, []):
future.cancel()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:

View File

@@ -1198,6 +1198,66 @@
</div>
</div>
<!-- Custom Provider Modal (multiple OpenAI-compatible providers) -->
<div id="custom-provider-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
w-full max-w-md mx-4">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas fa-sliders text-primary-500"></i>
</div>
<div class="min-w-0 flex-1">
<h3 id="custom-provider-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_name">名称</label>
<input id="custom-provider-name" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
<input id="custom-provider-base" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="https://...../v1">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
<input id="custom-provider-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors">
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
<button id="custom-provider-modal-delete"
class="px-3 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
cursor-pointer transition-colors duration-150 hidden"
data-i18n="models_custom_delete">删除</button>
<span id="custom-provider-modal-status"
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
<button id="custom-provider-modal-cancel"
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
text-slate-600 dark:text-slate-300 text-sm font-medium
hover:bg-slate-50 dark:hover:bg-white/5
cursor-pointer transition-colors duration-150"
data-i18n="cancel">取消</button>
<button id="custom-provider-modal-save"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
data-i18n="save">保存</button>
</div>
</div>
</div>
<script defer src="assets/js/console.js"></script>
</body>
</html>

View File

@@ -866,6 +866,14 @@
font-size: 11px;
flex-shrink: 0;
}
/* "Custom" row is an add-new action: trailing + instead of ✓. */
.vendor-picker-add-mark {
margin-left: auto;
padding-left: 12px;
color: #94a3b8;
font-size: 11px;
flex-shrink: 0;
}
/* Chat Input */
#chat-input {

View File

@@ -32,6 +32,15 @@ const I18N = {
models_clear_credential: '清除凭据',
models_base_default_hint: '留空将使用官方默认地址',
models_base_default: '默认',
models_custom_vendor_label: '自定义',
models_custom_name: '名称',
models_custom_delete: '删除',
models_custom_delete_confirm_title: '删除自定义厂商',
models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。',
models_custom_name_required: '请填写名称',
models_custom_base_required: '请填写 API Base',
models_custom_edit_title: '编辑自定义厂商',
models_custom_add_title: '添加自定义厂商',
models_capability_chat: '主模型',
models_capability_chat_desc: '用于基础对话和 Agent 推理',
models_capability_vision: '图像理解',
@@ -226,10 +235,10 @@ const I18N = {
menu_logs: 'Logs',
models_title: 'Models',
models_desc: 'Manage chat, image, voice, embedding and search capabilities in one place',
models_section_vendors: 'Vendor Credentials',
models_section_vendors: 'Provider Credentials',
models_section_vendors_desc: 'Configured once, shared by multiple model capabilities',
models_section_capabilities: 'Capabilities',
models_add_vendor: 'Add Vendor',
models_add_vendor: 'Add Provider',
models_provider: 'Provider',
models_model: 'Model',
models_voice: 'Voice',
@@ -239,6 +248,15 @@ const I18N = {
models_clear_credential: 'Clear credentials',
models_base_default_hint: 'Leave blank to use the official default base URL',
models_base_default: 'Default',
models_custom_vendor_label: 'Custom',
models_custom_name: 'Name',
models_custom_delete: 'Delete',
models_custom_delete_confirm_title: 'Delete custom provider',
models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.',
models_custom_name_required: 'Name is required',
models_custom_base_required: 'API Base is required',
models_custom_edit_title: 'Edit custom provider',
models_custom_add_title: 'Add custom provider',
models_capability_chat: 'Main Model',
models_capability_chat_desc: 'Used for basic chat and agent reasoning',
models_capability_vision: 'Image Understanding',
@@ -280,8 +298,8 @@ const I18N = {
models_embedding_saved_msg: 'Send /memory rebuild-index in the chat to rebuild the index.',
models_embedding_saved_ok: 'Go',
models_pick_provider: 'Pick a provider',
models_clear_confirm_title: 'Clear vendor credentials',
models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.',
models_clear_confirm_title: 'Clear provider credentials',
models_clear_confirm_msg: 'Remove this provider\'s API Key and Base URL? Capabilities relying on it will stop working.',
cancel: 'Cancel',
save: 'Save',
ok: 'OK',
@@ -4799,13 +4817,22 @@ function renderModelsView() {
MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def)));
}
// True when a provider card is one of the expanded custom (OpenAI-compatible)
// providers (id "custom:<id>") — shown in the vendor grid alongside built-in
// vendors, but edited via the dedicated custom-provider modal.
function isCustomProviderCard(p) {
return !!(p && p.is_custom && p.custom_name);
}
// ---------- Vendor section (Layer 1) -----------------------------------
function renderVendorsSection() {
const wrap = document.createElement('div');
wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
const configured = modelsState.providers.filter(p => p.configured);
// Custom providers always show once created (even without an api key,
// e.g. a local vLLM/Ollama endpoint); built-in vendors show when configured.
const configured = modelsState.providers.filter(p => p.configured || isCustomProviderCard(p));
const header = `
<div class="flex items-start gap-3 mb-5">
@@ -4816,7 +4843,6 @@ function renderVendorsSection() {
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_section_vendors')}</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_section_vendors_desc')}</p>
</div>
<span class="text-xs text-slate-400 dark:text-slate-500 mt-2 flex-shrink-0">${configured.length}/${modelsState.providers.length}</span>
</div>`;
let body;
@@ -4842,8 +4868,13 @@ function renderVendorsSection() {
function renderVendorChip(p) {
// The masked API key is intentionally not surfaced here; it is shown
// inside the edit modal so the chip stays uncluttered and scannable.
// Custom providers open their dedicated modal (name + base + key);
// their ids are server-generated hex, safe to inline.
const onclick = isCustomProviderCard(p)
? `openCustomProviderModal('${escapeHtml(p.custom_id)}')`
: `openVendorModal('${escapeHtml(p.id)}')`;
return `
<button onclick="openVendorModal('${escapeHtml(p.id)}')"
<button onclick="${onclick}"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border border-slate-200 dark:border-white/10
bg-slate-50 dark:bg-white/5 hover:border-primary-300 dark:hover:border-primary-500/50
cursor-pointer transition-colors duration-150 text-left">
@@ -4876,6 +4907,10 @@ function renderProviderLogo(p, sizePx) {
</span>`;
}
function getCustomProviderCards() {
return modelsState.providers.filter(isCustomProviderCard);
}
// ---------- Capability cards (Layer 2) ---------------------------------
function renderCapabilityCard(def) {
@@ -5690,6 +5725,14 @@ function decorateVendorModalPicker(ddEl, opts) {
// Tag the row so the global active-row ✓ rule is suppressed in CSS
// (otherwise configured AND selected rows would render two checks).
item.classList.add('vendor-picker-item');
if (opt._isAddNew) {
// "Custom" is an add-new action (multiple entries allowed),
// so show a trailing + instead of the configured ✓.
const plus = document.createElement('i');
plus.className = 'fas fa-plus vendor-picker-add-mark';
item.appendChild(plus);
return;
}
if (!opt._configured) return;
const check = document.createElement('i');
check.className = 'fas fa-check vendor-picker-configured-mark';
@@ -5997,18 +6040,42 @@ function openVendorModal(providerId, onSaved) {
// currently selected vendor via its own background highlight, so we
// intentionally suppress the global active-row ✓ for this picker
// (see CSS) — otherwise configured + selected rows would show two.
const unconfigured = modelsState.providers.filter(p => !p.configured);
const defaultId = (unconfigured[0] && unconfigured[0].id) || (modelsState.providers[0] && modelsState.providers[0].id) || '';
pickerWrap.classList.remove('hidden');
const pickerEl = document.getElementById('vendor-modal-picker');
const pickerOpts = modelsState.providers.map(p => ({
// Expanded custom provider cards ("custom:<id>") are edited via their
// dedicated modal, so they are excluded from this picker. Picking the
// "custom" entry creates a *new* custom provider via that modal —
// this is how multiple OpenAI-compatible endpoints are added.
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
const pickerOpts = builtinProviders.map(p => ({
value: p.id,
label: localizedLabel(p.label),
_configured: !!p.configured,
}));
initDropdown(pickerEl, pickerOpts, defaultId, (val) => fillVendorModalForProvider(val));
// In multi-provider mode the backend replaces the bare "custom" card
// with the expanded ones; re-add it here so the entry stays available.
if (!pickerOpts.some(o => o.value === 'custom')) {
pickerOpts.push({ value: 'custom', label: t('models_custom_vendor_label'), _configured: false });
}
// "Custom" always behaves as an add-new action (multiple entries
// allowed), so it shows a + mark instead of the configured ✓.
pickerOpts.forEach(o => { if (o.value === 'custom') { o._isAddNew = true; o._configured = false; } });
const unconfigured = builtinProviders.filter(p => !p.configured);
const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || 'custom';
pickerWrap.classList.remove('hidden');
const pickerEl = document.getElementById('vendor-modal-picker');
const onPick = (val) => {
if (val === 'custom') {
// "Custom" in the add flow always creates a new
// OpenAI-compatible provider entry via the dedicated modal
// (name + base + key), supporting multiple custom endpoints.
closeVendorModal();
openCustomProviderModal('');
return;
}
fillVendorModalForProvider(val);
};
initDropdown(pickerEl, pickerOpts, defaultId, onPick);
decorateVendorModalPicker(pickerEl, pickerOpts);
fillVendorModalForProvider(defaultId);
onPick(defaultId);
} else {
pickerWrap.classList.add('hidden');
fillVendorModalForProvider(providerId);
@@ -6164,6 +6231,151 @@ function clearVendorModal() {
});
}
// =====================================================================
// Custom (OpenAI-compatible) provider modal — add / edit
// =====================================================================
// State for the dedicated custom-provider modal. `editId` is empty when
// adding and set to the provider id when editing.
let customProviderModalState = { editId: '' };
function openCustomProviderModal(providerId) {
const editing = !!providerId;
customProviderModalState = { editId: editing ? providerId : '' };
const card = editing ? getCustomProviderCards().find(p => p.custom_id === providerId) : null;
const overlay = document.getElementById('custom-provider-modal-overlay');
if (!overlay) return;
document.getElementById('custom-provider-modal-title').textContent =
editing ? t('models_custom_edit_title') : t('models_custom_add_title');
const nameInput = document.getElementById('custom-provider-name');
const baseInput = document.getElementById('custom-provider-base');
const keyInput = document.getElementById('custom-provider-key');
nameInput.value = card ? (card.custom_name || '') : '';
baseInput.value = card ? (card.api_base || '') : '';
// Surface the masked key as the value for configured providers so the
// "already set" state is unambiguous; an untouched masked value means
// "keep the existing key" on save (mirrors the vendor modal contract).
if (card && card.configured && card.api_key_masked) {
keyInput.value = card.api_key_masked;
keyInput.dataset.masked = '1';
keyInput.dataset.maskedVal = card.api_key_masked;
} else {
keyInput.value = '';
keyInput.dataset.masked = '';
keyInput.dataset.maskedVal = '';
}
keyInput.oninput = function () {
if (keyInput.dataset.masked === '1' && keyInput.value !== keyInput.dataset.maskedVal) {
keyInput.dataset.masked = '';
}
};
const statusEl = document.getElementById('custom-provider-modal-status');
if (statusEl) { statusEl.textContent = ''; statusEl.classList.add('opacity-0'); }
overlay.classList.remove('hidden');
document.getElementById('custom-provider-modal-cancel').onclick = closeCustomProviderModal;
document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal;
// Delete is only available when editing an existing provider.
const deleteBtn = document.getElementById('custom-provider-modal-delete');
if (deleteBtn) {
deleteBtn.classList.toggle('hidden', !editing);
deleteBtn.onclick = editing ? () => deleteCustomProvider(providerId) : null;
}
function onOverlayClick(e) {
if (e.target === overlay) {
closeCustomProviderModal();
overlay.removeEventListener('click', onOverlayClick);
}
}
overlay.addEventListener('click', onOverlayClick);
nameInput.focus();
}
function closeCustomProviderModal() {
const overlay = document.getElementById('custom-provider-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
function saveCustomProviderModal() {
const name = document.getElementById('custom-provider-name').value.trim();
const apiBase = document.getElementById('custom-provider-base').value.trim();
const keyInput = document.getElementById('custom-provider-key');
if (!name) {
showStatus('custom-provider-modal-status', 'models_custom_name_required', true);
document.getElementById('custom-provider-name').focus();
return;
}
const editing = !!customProviderModalState.editId;
if (!editing && !apiBase) {
showStatus('custom-provider-modal-status', 'models_custom_base_required', true);
document.getElementById('custom-provider-base').focus();
return;
}
// Untouched masked key => no change (omit from payload).
let apiKey = keyInput.value.trim();
if (keyInput.dataset.masked === '1' && apiKey === (keyInput.dataset.maskedVal || '')) {
apiKey = '';
}
const payload = {
action: 'set_custom_provider',
name: name,
api_base: apiBase,
};
if (apiKey) payload.api_key = apiKey;
if (editing) payload.id = customProviderModalState.editId;
const btn = document.getElementById('custom-provider-modal-save');
btn.disabled = true;
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(r => r.json()).then(data => {
btn.disabled = false;
if (data.status === 'success') {
closeCustomProviderModal();
loadModelsView();
} else {
showStatus('custom-provider-modal-status', 'models_save_failed', true);
}
}).catch(() => {
btn.disabled = false;
showStatus('custom-provider-modal-status', 'models_save_failed', true);
});
}
function deleteCustomProvider(providerId) {
showConfirmDialog({
title: t('models_custom_delete_confirm_title'),
message: t('models_custom_delete_confirm_msg'),
okText: t('models_custom_delete'),
cancelText: t('cancel'),
onConfirm: () => {
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
closeCustomProviderModal();
loadModelsView();
}
}).catch(() => { /* noop */ });
}
});
}
// =====================================================================
// Channels View
// =====================================================================

View File

@@ -1471,10 +1471,9 @@ class ConfigHandler:
_RECOMMENDED_MODELS = [
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
# claude-fable-5 is intentionally placed at the end of the Claude
# group here: it is expensive, so avoid surfacing it too early in
# the LinkAI dropdown.
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_FABLE_5,
# claude-fable-5 is placed after claude-opus-4-7 (not as the Claude
# default) since it is often unavailable due to policy restrictions.
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
@@ -1519,7 +1518,7 @@ class ConfigHandler:
"api_base_key": "claude_api_base",
"api_base_default": "https://api.anthropic.com/v1",
"api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
}),
("gemini", {
"label": "Gemini",
@@ -1611,6 +1610,7 @@ class ConfigHandler:
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
"custom_providers",
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
"enable_thinking", "self_evolution_enabled", "web_password",
}
@@ -1652,6 +1652,32 @@ class ConfigHandler:
"api_key_field": p.get("api_key_field"),
}
# Expose user-defined custom providers as "custom:<id>" entries so
# the legacy config page can display and select them. Credentials
# are managed on the Models page, hence the null key/base fields.
# Mirrors the Models page: when expanded entries exist, the bare
# legacy "custom" entry is hidden — unless the flat single-provider
# custom config is still active or filled in.
try:
from models.custom_provider import get_custom_providers
custom_list = get_custom_providers()
legacy_custom_in_use = ModelsHandler._legacy_custom_in_use(local_config)
if custom_list and not legacy_custom_in_use:
providers.pop("custom", None)
for cp in custom_list:
cid = f"custom:{cp.get('id')}"
cname = cp.get("name") or cp.get("id")
providers[cid] = {
"label": {"zh": cname, "en": cname},
"models": [cp["model"]] if cp.get("model") else [],
"api_base_key": None,
"api_base_default": None,
"api_base_placeholder": "",
"api_key_field": None,
}
except Exception as cp_err:
logger.warning(f"[ConfigHandler] failed to expand custom providers: {cp_err}")
raw_pwd = str(local_config.get("web_password", "") or "")
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
@@ -2141,13 +2167,99 @@ class ModelsHandler:
def _is_real_key(value: str) -> bool:
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
@classmethod
def _custom_provider_cards(cls, local_config: dict) -> List[dict]:
"""Expand ``custom_providers`` into one card per provider.
Each user-defined OpenAI-compatible provider becomes its own card with
id ``custom:<id>`` so the frontend can render, edit, delete and
activate them independently. The card carries ``is_custom=True`` and
``active`` flags that the UI uses to render the extra controls.
Returns an empty list when no multi-providers are configured, in which
case the caller keeps the single legacy ``custom`` card untouched —
guaranteeing backward compatibility with the flat
``custom_api_key`` / ``custom_api_base`` config.
"""
try:
from models.custom_provider import get_custom_providers, parse_custom_bot_type
providers = get_custom_providers()
except Exception as e: # pragma: no cover - defensive
logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}")
providers = []
if not providers:
return []
# Determine the currently active provider id from bot_type.
bot_type = local_config.get("bot_type") or ""
_, active_id = parse_custom_bot_type(bot_type)
meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {}
cards = []
for p in providers:
pid = p.get("id") or ""
name = p.get("name") or pid
raw_key = p.get("api_key") or ""
raw_base = p.get("api_base") or ""
configured = cls._is_real_key(raw_key)
cards.append({
"id": f"custom:{pid}",
"label": {"zh": name, "en": name},
"configured": configured,
"is_custom": True,
"custom_id": pid,
"custom_name": name,
"active": (pid == active_id),
"model": p.get("model") or "",
# Custom cards are edited via the dedicated set_custom_provider
# action, not the field-based set_provider flow, so the field
# names are intentionally null.
"api_key_field": None,
"api_base_field": None,
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
"api_base": raw_base,
"api_base_default": "",
"api_base_placeholder": meta.get("api_base_placeholder") or "",
"models": [p.get("model")] if p.get("model") else [],
})
return cards
@classmethod
def _legacy_custom_in_use(cls, local_config: dict) -> bool:
"""True when the flat single-provider custom config is still relevant:
either it is the active bot_type, or its key/base fields are filled.
In that case the legacy "custom" card must stay visible even when
multi ``custom_providers`` entries exist."""
if (local_config.get("bot_type") or "") == "custom":
return True
return (cls._is_real_key(local_config.get("custom_api_key") or "")
or bool(local_config.get("custom_api_base")))
@classmethod
def _provider_overview(cls) -> List[dict]:
"""All known providers (configured first, unconfigured after).
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list."""
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list.
When the user has defined multiple custom (OpenAI-compatible)
providers via ``custom_providers``, the single built-in ``custom``
card is replaced by one card per provider (see
``_custom_provider_cards``). Otherwise the legacy single ``custom``
card is shown unchanged.
"""
local_config = conf()
custom_cards = cls._custom_provider_cards(local_config)
# Keep the legacy single "custom" card visible alongside the expanded
# ones when the flat custom_api_key/base config is active or filled,
# so existing single-provider setups never disappear from the UI.
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
items = []
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
if pid == "custom" and custom_cards:
# Multi-provider mode: emit the expanded cards, plus the
# legacy card when it is still in use.
items.extend(custom_cards)
if not keep_legacy_custom:
continue
key_field = p.get("api_key_field")
base_field = p.get("api_base_key")
raw_key = local_config.get(key_field, "") if key_field else ""
@@ -2157,6 +2269,7 @@ class ModelsHandler:
"id": pid,
"label": p["label"],
"configured": configured,
"is_custom": (pid == "custom"),
"api_key_field": key_field,
"api_base_field": base_field,
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
@@ -2165,7 +2278,19 @@ class ModelsHandler:
"api_base_placeholder": p.get("api_base_placeholder") or "",
"models": list(p.get("models") or []),
})
items.sort(key=lambda it: (0 if it["configured"] else 1, list(ConfigHandler.PROVIDER_MODELS.keys()).index(it["id"])))
def _sort_key(it):
pid = it["id"]
# Custom expanded cards share the sort weight of the base "custom"
# entry so they cluster where the single custom card used to be.
base_id = "custom" if it.get("is_custom") else pid
try:
order = list(ConfigHandler.PROVIDER_MODELS.keys()).index(base_id)
except ValueError:
order = len(ConfigHandler.PROVIDER_MODELS)
return (0 if it["configured"] else 1, order)
items.sort(key=_sort_key)
return items
@classmethod
@@ -2173,13 +2298,28 @@ class ModelsHandler:
"""Main chat model — drives the agent. bot_type maps to a provider id."""
bot_type = local_config.get("bot_type") or ""
provider_id = "openai" if bot_type == "chatGPT" else bot_type
if provider_id not in ConfigHandler.PROVIDER_MODELS and local_config.get("use_linkai"):
is_custom_id = provider_id.startswith("custom:")
if (provider_id not in ConfigHandler.PROVIDER_MODELS and not is_custom_id
and local_config.get("use_linkai")):
provider_id = "linkai"
# In multi-provider mode, replace the single "custom" entry with the
# expanded "custom:<id>" ids so the chat dropdown matches the cards.
# The legacy "custom" entry stays when its flat config is still used.
provider_ids = []
custom_cards = cls._custom_provider_cards(local_config)
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
for pid in ConfigHandler.PROVIDER_MODELS.keys():
if pid == "custom" and custom_cards:
provider_ids.extend(c["id"] for c in custom_cards)
if keep_legacy_custom:
provider_ids.append(pid)
else:
provider_ids.append(pid)
return {
"editable": True,
"current_provider": provider_id,
"current_model": local_config.get("model", ""),
"providers": list(ConfigHandler.PROVIDER_MODELS.keys()),
"providers": provider_ids,
"use_linkai": bool(local_config.get("use_linkai", False)),
}
@@ -2613,6 +2753,12 @@ class ModelsHandler:
return self._handle_set_provider(data)
if action == "delete_provider":
return self._handle_delete_provider(data)
if action == "set_custom_provider":
return self._handle_set_custom_provider(data)
if action == "delete_custom_provider":
return self._handle_delete_custom_provider(data)
if action == "set_active_custom_provider":
return self._handle_set_active_custom_provider(data)
if action == "set_capability":
return self._handle_set_capability(data)
if action == "set_voice_reply_mode":
@@ -2696,6 +2842,170 @@ class ModelsHandler:
self._reset_bridge()
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
# ------------------------------------------------------------------
# Multiple custom (OpenAI-compatible) providers
# ------------------------------------------------------------------
# These actions manage the ``custom_providers`` list. Activation is done
# by setting ``bot_type`` to ``"custom:<id>"``. There is no separate
# ``custom_active_provider`` field — a single source of truth.
@staticmethod
def _normalize_custom_providers(raw) -> List[dict]:
"""Return a clean list of provider dicts (drops malformed entries)."""
if not isinstance(raw, list):
return []
out = []
for p in raw:
if isinstance(p, dict) and (p.get("id") or "").strip():
out.append(p)
return out
def _persist_custom_providers(self, providers: List[dict], bot_type=None) -> None:
"""Write the providers list to both in-memory conf and the on-disk
config, then reset the bridge so bots rebuild.
If ``bot_type`` is given, also update ``bot_type``. When activating a
provider (bot_type is ``custom:<id>``), also write the provider's
``model`` into the global ``model`` field so that all paths (chat,
agent, vision) automatically use the correct model."""
from models.custom_provider import parse_custom_bot_type
local_config = conf()
file_cfg = self._read_file_config()
local_config["custom_providers"] = providers
file_cfg["custom_providers"] = providers
if bot_type is not None:
local_config["bot_type"] = bot_type
file_cfg["bot_type"] = bot_type
# Sync the provider's model into the global model field.
_, pid = parse_custom_bot_type(bot_type)
if pid:
provider = next((p for p in providers if p.get("id") == pid), None)
if provider and provider.get("model"):
local_config["model"] = provider["model"]
file_cfg["model"] = provider["model"]
self._write_file_config(file_cfg)
self._reset_bridge()
def _handle_set_custom_provider(self, data: dict) -> str:
"""Add a new custom provider or update an existing one.
Payload::
{
"action": "set_custom_provider",
"id": "3f2a9c1b", # required for edit; omit for create
"name": "siliconflow", # required, display label
"api_base": "https://...", # required when creating
"api_key": "sk-...", # optional on edit (keep existing)
"model": "deepseek-ai/...", # optional default model
"make_active": true # optional, also activate it
}
"""
from models.custom_provider import generate_provider_id, parse_custom_bot_type
name = (data.get("name") or "").strip()
if not name:
return json.dumps({"status": "error", "message": "name is required"})
provider_id = (data.get("id") or "").strip()
api_base = (data.get("api_base") or "").strip()
# api_key omitted/empty on edit => keep the existing one.
api_key_raw = data.get("api_key")
api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else ""
model = (data.get("model") or "").strip()
make_active = bool(data.get("make_active"))
local_config = conf()
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
existing = next((p for p in providers if p.get("id") == provider_id), None) if provider_id else None
if existing is None:
# Creating a new provider — api_base is mandatory.
if not api_base:
return json.dumps({"status": "error", "message": "api_base is required"})
provider_id = generate_provider_id()
entry = {"id": provider_id, "name": name, "api_key": api_key, "api_base": api_base}
if model:
entry["model"] = model
providers.append(entry)
created = True
else:
existing["name"] = name
if api_base:
existing["api_base"] = api_base
if api_key:
existing["api_key"] = api_key
# Only touch model when explicitly provided in the payload; an
# explicit empty string clears it, a missing key keeps it (the
# UI modal no longer sends model, so manual config survives edits).
if "model" in data:
if model:
existing["model"] = model
else:
existing.pop("model", None)
created = False
# Decide bot_type — only switch when explicitly requested.
new_bot_type = None
if make_active:
new_bot_type = f"custom:{provider_id}"
self._persist_custom_providers(providers, new_bot_type)
logger.info(
f"[ModelsHandler] custom provider {name!r} (id={provider_id}) "
f"{'created' if created else 'updated'}"
)
return json.dumps({
"status": "success",
"id": provider_id,
"name": name,
"created": created,
})
def _handle_delete_custom_provider(self, data: dict) -> str:
"""Remove a custom provider by id."""
from models.custom_provider import parse_custom_bot_type
provider_id = (data.get("id") or "").strip()
if not provider_id:
return json.dumps({"status": "error", "message": "id is required"})
local_config = conf()
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
remaining = [p for p in providers if p.get("id") != provider_id]
if len(remaining) == len(providers):
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
# If the deleted provider was active, fall back to the first remaining.
_, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "")
new_bot_type = None
if current_active_id == provider_id:
if remaining:
new_bot_type = f"custom:{remaining[0]['id']}"
else:
new_bot_type = "custom" # revert to legacy
self._persist_custom_providers(remaining, new_bot_type)
logger.info(f"[ModelsHandler] custom provider id={provider_id} deleted")
return json.dumps({"status": "success", "id": provider_id})
def _handle_set_active_custom_provider(self, data: dict) -> str:
"""Activate a custom provider by setting bot_type to 'custom:<id>'."""
provider_id = (data.get("id") or "").strip()
if not provider_id:
return json.dumps({"status": "error", "message": "id is required"})
local_config = conf()
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
if not any(p.get("id") == provider_id for p in providers):
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
new_bot_type = f"custom:{provider_id}"
self._persist_custom_providers(providers, new_bot_type)
logger.info(f"[ModelsHandler] active custom provider set to id={provider_id}")
return json.dumps({"status": "success", "active_id": provider_id})
def _handle_set_capability(self, data: dict) -> str:
capability = (data.get("capability") or "").strip()
provider_id = (data.get("provider_id") or "").strip()
@@ -2760,13 +3070,28 @@ class ModelsHandler:
})
def _set_chat(self, provider_id: str, model: str) -> str:
if provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
# Accept expanded custom provider ids ("custom:<id>") as well as the
# built-in vendors, so the chat capability card and the custom
# providers section behave consistently.
custom_provider = None
if provider_id.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type
_, custom_id = parse_custom_bot_type(provider_id)
providers = self._normalize_custom_providers(conf().get("custom_providers"))
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
if custom_provider is None:
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
elif provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
applied = {}
local_config = conf()
file_cfg = self._read_file_config()
# Fall back to the custom provider's default model when none is given.
if not model and custom_provider:
model = custom_provider.get("model") or ""
if provider_id:
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
local_config["bot_type"] = bot_type_value