mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(web): manage multiple custom (OpenAI-compatible) providers in console UI
Adds first-class support for configuring more than one custom OpenAI-compatible provider (e.g. SiliconFlow, DeepSeek, local vLLM) and switching the active one from the web console, addressing #2838. Backend: - config: new `custom_providers` (list) and `custom_active_provider` fields, fully backward compatible with the legacy single `open_ai_api_base`/`model` fields (used as fallback). - models/custom_provider.py: centralized resolver `resolve_custom_credentials()` returning (api_key, api_base, model), with active-provider selection and graceful fallback. - chat_gpt_bot.py wired to use the resolver. - web_channel.py: `_provider_overview` expands `custom_providers` into one card per provider (id `custom:<name>`, active flag, masked key); new POST actions `set_custom_provider`, `delete_custom_provider`, `set_active_custom_provider` with hermetic persistence + bridge reset. Frontend: - console.js: dedicated "Custom providers" section with add / edit / delete / set-active actions, masked-key keep-existing handling, and ~20 new zh/en i18n strings. - chat.html: custom provider modal. Tests: - tests/test_custom_provider.py (11) - resolver/config behavior. - tests/test_custom_provider_handlers.py (18) - write-side handlers and overview expansion, including duplicate-name rejection. All 29 unit tests pass.
This commit is contained in:
@@ -1165,6 +1165,72 @@
|
||||
</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-violet-50 dark:bg-violet-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-sliders text-violet-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-violet-500 transition-colors"
|
||||
placeholder="siliconflow">
|
||||
</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-violet-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-violet-500 font-mono transition-colors"
|
||||
placeholder="sk-...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_default_model">默认模型(可选)</label>
|
||||
<input id="custom-provider-model" 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-violet-500 font-mono transition-colors"
|
||||
placeholder="deepseek-ai/DeepSeek-V3">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||
<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-violet-500 hover:bg-violet-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>
|
||||
|
||||
@@ -32,6 +32,24 @@ const I18N = {
|
||||
models_clear_credential: '清除凭据',
|
||||
models_base_default_hint: '留空将使用官方默认地址',
|
||||
models_base_default: '默认',
|
||||
models_custom_section: '自定义厂商',
|
||||
models_custom_section_desc: '配置多个 OpenAI 兼容厂商,自由切换',
|
||||
models_custom_add: '添加自定义厂商',
|
||||
models_custom_name: '名称',
|
||||
models_custom_name_placeholder: '例如 siliconflow',
|
||||
models_custom_default_model: '默认模型(可选)',
|
||||
models_custom_default_model_placeholder: '例如 deepseek-ai/DeepSeek-V3',
|
||||
models_custom_active: '使用中',
|
||||
models_custom_set_active: '设为使用中',
|
||||
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_name_exists: '该名称已存在',
|
||||
models_custom_empty: '尚未配置自定义厂商,点击添加',
|
||||
models_custom_edit_title: '编辑自定义厂商',
|
||||
models_custom_add_title: '添加自定义厂商',
|
||||
models_capability_chat: '主模型',
|
||||
models_capability_chat_desc: '用于基础对话和 Agent 推理',
|
||||
models_capability_vision: '图像理解',
|
||||
@@ -236,6 +254,24 @@ 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_section: 'Custom Providers',
|
||||
models_custom_section_desc: 'Configure multiple OpenAI-compatible providers and switch freely',
|
||||
models_custom_add: 'Add custom provider',
|
||||
models_custom_name: 'Name',
|
||||
models_custom_name_placeholder: 'e.g. siliconflow',
|
||||
models_custom_default_model: 'Default model (optional)',
|
||||
models_custom_default_model_placeholder: 'e.g. deepseek-ai/DeepSeek-V3',
|
||||
models_custom_active: 'Active',
|
||||
models_custom_set_active: 'Set active',
|
||||
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_name_exists: 'A provider with this name already exists',
|
||||
models_custom_empty: 'No custom providers yet, click to add',
|
||||
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',
|
||||
@@ -4732,16 +4768,26 @@ function renderModelsView() {
|
||||
const container = document.getElementById('models-content');
|
||||
container.innerHTML = '';
|
||||
container.appendChild(renderVendorsSection());
|
||||
container.appendChild(renderCustomProvidersSection());
|
||||
MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def)));
|
||||
}
|
||||
|
||||
// True when a provider card is one of the expanded custom (OpenAI-compatible)
|
||||
// providers — these are managed in their own section, not the vendor grid.
|
||||
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);
|
||||
// Expanded custom providers live in their own section; keep the built-in
|
||||
// vendor grid focused on the canonical (field-based) providers.
|
||||
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
|
||||
const configured = builtinProviders.filter(p => p.configured);
|
||||
|
||||
const header = `
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
@@ -4752,7 +4798,7 @@ 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>
|
||||
<span class="text-xs text-slate-400 dark:text-slate-500 mt-2 flex-shrink-0">${configured.length}/${builtinProviders.length}</span>
|
||||
</div>`;
|
||||
|
||||
let body;
|
||||
@@ -4812,6 +4858,98 @@ function renderProviderLogo(p, sizePx) {
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// ---------- Custom providers section (multiple OpenAI-compatible) -------
|
||||
// Renders the user-defined OpenAI-compatible providers as a dedicated,
|
||||
// independently managed list: add / edit / delete / activate. The backend
|
||||
// expands `custom_providers` into provider cards with id="custom:<name>",
|
||||
// is_custom=true, custom_name and an `active` flag (see
|
||||
// ModelsHandler._custom_provider_cards / _provider_overview).
|
||||
|
||||
function getCustomProviderCards() {
|
||||
return modelsState.providers.filter(isCustomProviderCard);
|
||||
}
|
||||
|
||||
function renderCustomProvidersSection() {
|
||||
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 customs = getCustomProviderCards();
|
||||
|
||||
const header = `
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
<div class="w-9 h-9 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-sliders text-violet-500 text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_custom_section')}</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_custom_section_desc')}</p>
|
||||
</div>
|
||||
<button onclick="openCustomProviderModal('')"
|
||||
class="mt-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-violet-50 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 hover:bg-violet-100 dark:hover:bg-violet-900/50 cursor-pointer transition-colors flex-shrink-0">
|
||||
<i class="fas fa-plus text-[10px] mr-1"></i>${t('models_custom_add')}
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
let body;
|
||||
if (customs.length === 0) {
|
||||
body = `
|
||||
<div class="flex flex-col items-center justify-center py-8 px-4 rounded-lg border border-dashed border-slate-200 dark:border-white/10">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 text-center">${t('models_custom_empty')}</p>
|
||||
</div>`;
|
||||
} else {
|
||||
body = `<div class="space-y-2.5">
|
||||
${customs.map(renderCustomProviderRow).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
wrap.innerHTML = header + body;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderCustomProviderRow(p) {
|
||||
const name = p.custom_name || '';
|
||||
const nameEsc = escapeHtml(name);
|
||||
// The active provider gets a highlighted ring + badge; others show a
|
||||
// "set active" affordance.
|
||||
const activeBadge = p.active
|
||||
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 flex-shrink-0">
|
||||
<i class="fas fa-check text-[9px] mr-0.5"></i>${t('models_custom_active')}</span>`
|
||||
: `<button onclick="setActiveCustomProvider('${nameEsc}')"
|
||||
class="px-2 py-0.5 rounded-full text-[10px] font-medium border border-slate-200 dark:border-white/10 text-slate-500 dark:text-slate-400 hover:border-emerald-300 hover:text-emerald-500 cursor-pointer transition-colors flex-shrink-0">
|
||||
${t('models_custom_set_active')}</button>`;
|
||||
|
||||
const ring = p.active
|
||||
? 'border-emerald-300 dark:border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-900/10'
|
||||
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5';
|
||||
|
||||
const model = p.model
|
||||
? `<span class="text-[11px] text-slate-400 dark:text-slate-500 font-mono truncate">${escapeHtml(p.model)}</span>`
|
||||
: '';
|
||||
const base = p.api_base
|
||||
? `<span class="text-[11px] text-slate-400 dark:text-slate-500 font-mono truncate">${escapeHtml(p.api_base)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border ${ring} transition-colors">
|
||||
${renderProviderLogo(p, 28)}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 truncate">${nameEsc}</span>
|
||||
${activeBadge}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">${base}${model ? '<span class="text-slate-300 dark:text-slate-600">·</span>' + model : ''}</div>
|
||||
</div>
|
||||
<button onclick="openCustomProviderModal('${nameEsc}')" title="${t('models_custom_edit_title')}"
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 dark:text-slate-500 hover:text-primary-500 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors flex-shrink-0">
|
||||
<i class="fas fa-pen-to-square text-[12px]"></i>
|
||||
</button>
|
||||
<button onclick="deleteCustomProvider('${nameEsc}')" title="${t('models_custom_delete')}"
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 dark:text-slate-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer transition-colors flex-shrink-0">
|
||||
<i class="fas fa-trash-can text-[12px]"></i>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- Capability cards (Layer 2) ---------------------------------
|
||||
|
||||
function renderCapabilityCard(def) {
|
||||
@@ -5933,11 +6071,15 @@ 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) || '';
|
||||
// Custom (OpenAI-compatible) providers are managed in their own
|
||||
// section with a dedicated modal, so they are excluded from this
|
||||
// built-in vendor picker.
|
||||
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
|
||||
const unconfigured = builtinProviders.filter(p => !p.configured);
|
||||
const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || '';
|
||||
pickerWrap.classList.remove('hidden');
|
||||
const pickerEl = document.getElementById('vendor-modal-picker');
|
||||
const pickerOpts = modelsState.providers.map(p => ({
|
||||
const pickerOpts = builtinProviders.map(p => ({
|
||||
value: p.id,
|
||||
label: localizedLabel(p.label),
|
||||
_configured: !!p.configured,
|
||||
@@ -6100,6 +6242,159 @@ function clearVendorModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Custom (OpenAI-compatible) provider modal — add / edit
|
||||
// =====================================================================
|
||||
// State for the dedicated custom-provider modal. `originalName` is empty when
|
||||
// adding and set to the provider name when editing (so the backend can rename
|
||||
// without losing the entry).
|
||||
let customProviderModalState = { originalName: '' };
|
||||
|
||||
function openCustomProviderModal(name) {
|
||||
const editing = !!name;
|
||||
customProviderModalState = { originalName: editing ? name : '' };
|
||||
|
||||
const card = editing ? getCustomProviderCards().find(p => p.custom_name === name) : 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');
|
||||
const modelInput = document.getElementById('custom-provider-model');
|
||||
|
||||
nameInput.value = card ? (card.custom_name || '') : '';
|
||||
baseInput.value = card ? (card.api_base || '') : '';
|
||||
modelInput.value = card ? (card.model || '') : '';
|
||||
|
||||
// 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;
|
||||
|
||||
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 model = document.getElementById('custom-provider-model').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.originalName;
|
||||
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,
|
||||
model: model,
|
||||
};
|
||||
if (apiKey) payload.api_key = apiKey;
|
||||
if (editing) payload.original_name = customProviderModalState.originalName;
|
||||
|
||||
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 {
|
||||
// Surface the most useful known error; fall back to generic save fail.
|
||||
const msg = (data.message || '').includes('already exists')
|
||||
? 'models_custom_name_exists' : 'models_save_failed';
|
||||
showStatus('custom-provider-modal-status', msg, true);
|
||||
}
|
||||
}).catch(() => {
|
||||
btn.disabled = false;
|
||||
showStatus('custom-provider-modal-status', 'models_save_failed', true);
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveCustomProvider(name) {
|
||||
fetch('/api/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set_active_custom_provider', name: name }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') loadModelsView();
|
||||
}).catch(() => { /* noop */ });
|
||||
}
|
||||
|
||||
function deleteCustomProvider(name) {
|
||||
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', name: name }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') loadModelsView();
|
||||
}).catch(() => { /* noop */ });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Channels View
|
||||
// =====================================================================
|
||||
|
||||
@@ -1601,6 +1601,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", "custom_active_provider",
|
||||
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
||||
"enable_thinking", "self_evolution_enabled", "web_password",
|
||||
}
|
||||
@@ -2131,13 +2132,85 @@ 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
|
||||
a synthetic id ``custom:<name>`` 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 (delete button, "set active" affordance).
|
||||
|
||||
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
|
||||
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 []
|
||||
|
||||
active_name = (local_config.get("custom_active_provider") or "").strip()
|
||||
# When no valid active name is set, the resolver treats the first entry
|
||||
# as active; mirror that here so exactly one card is highlighted.
|
||||
names = [p.get("name") for p in providers]
|
||||
if active_name not in names:
|
||||
active_name = names[0] if names else ""
|
||||
|
||||
meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {}
|
||||
cards = []
|
||||
for p in providers:
|
||||
name = p.get("name") or ""
|
||||
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:{name}",
|
||||
"label": {"zh": name, "en": name},
|
||||
"configured": configured,
|
||||
"is_custom": True,
|
||||
"custom_name": name,
|
||||
"active": (name == active_name),
|
||||
"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 _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)
|
||||
items = []
|
||||
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
|
||||
if pid == "custom" and custom_cards:
|
||||
# Multi-provider mode: emit the expanded cards instead of the
|
||||
# single legacy custom card.
|
||||
items.extend(custom_cards)
|
||||
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 ""
|
||||
@@ -2147,6 +2220,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 "",
|
||||
@@ -2155,7 +2229,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
|
||||
@@ -2603,6 +2689,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":
|
||||
@@ -2686,6 +2778,163 @@ 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 and the
|
||||
# ``custom_active_provider`` selector. They are the write-side companion to
|
||||
# ``_custom_provider_cards`` and let the console add / edit / delete /
|
||||
# activate user-defined OpenAI-compatible providers individually.
|
||||
|
||||
@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("name") or "").strip():
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
def _persist_custom_providers(self, providers: List[dict], active_name) -> None:
|
||||
"""Write the providers list + active selector to both in-memory conf
|
||||
and the on-disk config, then reset the bridge so bots rebuild."""
|
||||
local_config = conf()
|
||||
file_cfg = self._read_file_config()
|
||||
local_config["custom_providers"] = providers
|
||||
file_cfg["custom_providers"] = providers
|
||||
if active_name is not None:
|
||||
local_config["custom_active_provider"] = active_name
|
||||
file_cfg["custom_active_provider"] = active_name
|
||||
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",
|
||||
"name": "siliconflow", # required, unique
|
||||
"api_base": "https://...", # required when creating
|
||||
"api_key": "sk-...", # optional on edit (keep existing)
|
||||
"model": "deepseek-ai/...", # optional default model
|
||||
"original_name": "old-name", # optional, set when renaming
|
||||
"make_active": true # optional, also activate it
|
||||
}
|
||||
"""
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return json.dumps({"status": "error", "message": "name is required"})
|
||||
|
||||
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()
|
||||
# ``original_name`` is supplied only when editing an existing entry
|
||||
# (so it can be renamed). Its absence means "create a new provider";
|
||||
# we must keep that distinction explicit, otherwise a create request
|
||||
# for an already-taken name would be misread as an in-place edit.
|
||||
original_name = (data.get("original_name") or "").strip()
|
||||
is_edit = bool(original_name)
|
||||
make_active = bool(data.get("make_active"))
|
||||
|
||||
local_config = conf()
|
||||
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||
|
||||
# Reject a name collision unless it is the very entry being edited.
|
||||
for p in providers:
|
||||
if p.get("name") == name and p.get("name") != original_name:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": f"a custom provider named {name!r} already exists",
|
||||
})
|
||||
|
||||
existing = next((p for p in providers if p.get("name") == original_name), None) if is_edit 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"})
|
||||
entry = {"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
|
||||
# model is always overwritten (empty clears the default model).
|
||||
if model:
|
||||
existing["model"] = model
|
||||
else:
|
||||
existing.pop("model", None)
|
||||
created = False
|
||||
|
||||
# Decide the active selector.
|
||||
active_name = (local_config.get("custom_active_provider") or "").strip()
|
||||
if make_active or created and not active_name:
|
||||
# Activate on explicit request, or auto-activate the very first
|
||||
# provider so the resolver has a definite target.
|
||||
active_name = name
|
||||
elif active_name == original_name and original_name != name:
|
||||
# The active provider was renamed; keep it pointed at the new name.
|
||||
active_name = name
|
||||
|
||||
self._persist_custom_providers(providers, active_name)
|
||||
logger.info(
|
||||
f"[ModelsHandler] custom provider {name!r} "
|
||||
f"{'created' if created else 'updated'} (active={active_name!r})"
|
||||
)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"name": name,
|
||||
"created": created,
|
||||
"active": active_name,
|
||||
})
|
||||
|
||||
def _handle_delete_custom_provider(self, data: dict) -> str:
|
||||
"""Remove a custom provider by name."""
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return json.dumps({"status": "error", "message": "name is required"})
|
||||
|
||||
local_config = conf()
|
||||
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||
remaining = [p for p in providers if p.get("name") != name]
|
||||
if len(remaining) == len(providers):
|
||||
return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"})
|
||||
|
||||
active_name = (local_config.get("custom_active_provider") or "").strip()
|
||||
if active_name == name:
|
||||
# The active provider was removed — fall back to the first
|
||||
# remaining entry (resolver does the same when the name is stale).
|
||||
active_name = remaining[0]["name"] if remaining else ""
|
||||
|
||||
self._persist_custom_providers(remaining, active_name)
|
||||
logger.info(f"[ModelsHandler] custom provider {name!r} deleted (active={active_name!r})")
|
||||
return json.dumps({"status": "success", "name": name, "active": active_name})
|
||||
|
||||
def _handle_set_active_custom_provider(self, data: dict) -> str:
|
||||
"""Mark one of the existing custom providers as active."""
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return json.dumps({"status": "error", "message": "name is required"})
|
||||
|
||||
local_config = conf()
|
||||
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||
if not any(p.get("name") == name for p in providers):
|
||||
return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"})
|
||||
|
||||
self._persist_custom_providers(providers, name)
|
||||
logger.info(f"[ModelsHandler] active custom provider set to {name!r}")
|
||||
return json.dumps({"status": "success", "active": name})
|
||||
|
||||
def _handle_set_capability(self, data: dict) -> str:
|
||||
capability = (data.get("capability") or "").strip()
|
||||
provider_id = (data.get("provider_id") or "").strip()
|
||||
|
||||
@@ -24,8 +24,12 @@ available_setting = {
|
||||
"open_ai_api_base": "https://api.openai.com/v1",
|
||||
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
||||
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
|
||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom")
|
||||
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom")
|
||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
|
||||
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
|
||||
# Multiple custom (OpenAI-compatible) providers. When non-empty, supersedes the legacy custom_api_key/base above.
|
||||
# Each item: {"name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
|
||||
"custom_providers": [],
|
||||
"custom_active_provider": "", # name of the active provider in custom_providers; empty = use the first entry / legacy fields
|
||||
"proxy": "", # proxy used by openai
|
||||
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
||||
"model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
|
||||
|
||||
@@ -60,3 +60,37 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
|
||||
```
|
||||
/config model qwen3.5:27b
|
||||
```
|
||||
|
||||
## 複数のカスタムベンダーを設定する
|
||||
|
||||
複数の OpenAI 互換サードパーティサービス(例:SiliconFlow、Qiniu)を同時に設定したい場合は、`custom_providers` リストを使用し、`custom_active_provider` で現在有効なベンダーを指定します:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "custom",
|
||||
"custom_active_provider": "siliconflow",
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "siliconflow",
|
||||
"api_key": "YOUR_SILICONFLOW_KEY",
|
||||
"api_base": "https://api.siliconflow.cn/v1",
|
||||
"model": "deepseek-ai/DeepSeek-V3"
|
||||
},
|
||||
{
|
||||
"name": "qiniu",
|
||||
"api_key": "YOUR_QINIU_KEY",
|
||||
"api_base": "https://api.qnaigc.com/v1",
|
||||
"model": "deepseek-v3"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `custom_providers` | カスタムベンダーのリスト。各項目に `name`、`api_key`、`api_base`、任意の `model` を含む |
|
||||
| `custom_active_provider` | 有効なベンダーの `name`。空の場合はリストの最初の項目が使用される |
|
||||
|
||||
<Note>
|
||||
`custom_providers` が空の場合、上記の単一ベンダー設定 `custom_api_key` / `custom_api_base` に自動的にフォールバックするため、既存の設定はそのまま動作します。
|
||||
</Note>
|
||||
|
||||
@@ -60,3 +60,37 @@ Switching models under a custom vendor only changes `model` — `bot_type` and t
|
||||
```
|
||||
/config model qwen3.5:27b
|
||||
```
|
||||
|
||||
## Configuring Multiple Custom Providers
|
||||
|
||||
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and select the active one with `custom_active_provider`:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "custom",
|
||||
"custom_active_provider": "siliconflow",
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "siliconflow",
|
||||
"api_key": "YOUR_SILICONFLOW_KEY",
|
||||
"api_base": "https://api.siliconflow.cn/v1",
|
||||
"model": "deepseek-ai/DeepSeek-V3"
|
||||
},
|
||||
{
|
||||
"name": "qiniu",
|
||||
"api_key": "YOUR_QINIU_KEY",
|
||||
"api_base": "https://api.qnaigc.com/v1",
|
||||
"model": "deepseek-v3"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `custom_providers` | List of custom providers; each item has `name`, `api_key`, `api_base`, and an optional `model` |
|
||||
| `custom_active_provider` | The `name` of the active provider; when empty, the first entry in the list is used |
|
||||
|
||||
<Note>
|
||||
When `custom_providers` is empty, CowAgent automatically falls back to the single-provider `custom_api_key` / `custom_api_base` fields above, so existing configurations keep working without any changes.
|
||||
</Note>
|
||||
|
||||
@@ -60,3 +60,37 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
|
||||
```
|
||||
/config model qwen3.5:27b
|
||||
```
|
||||
|
||||
## 配置多个自定义厂商
|
||||
|
||||
如果需要同时配置多个第三方 OpenAI 兼容服务(例如硅基流动、七牛云等),可以使用 `custom_providers` 列表,并通过 `custom_active_provider` 指定当前生效的厂商:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "custom",
|
||||
"custom_active_provider": "siliconflow",
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "siliconflow",
|
||||
"api_key": "YOUR_SILICONFLOW_KEY",
|
||||
"api_base": "https://api.siliconflow.cn/v1",
|
||||
"model": "deepseek-ai/DeepSeek-V3"
|
||||
},
|
||||
{
|
||||
"name": "qiniu",
|
||||
"api_key": "YOUR_QINIU_KEY",
|
||||
"api_base": "https://api.qnaigc.com/v1",
|
||||
"model": "deepseek-v3"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `custom_providers` | 自定义厂商列表,每项包含 `name`、`api_key`、`api_base`、可选的 `model` |
|
||||
| `custom_active_provider` | 当前生效厂商的 `name`;留空时默认使用列表中的第一个厂商 |
|
||||
|
||||
<Note>
|
||||
当 `custom_providers` 为空时,将自动回退到上文的 `custom_api_key` / `custom_api_base` 单厂商配置,已有配置无需改动即可正常工作。
|
||||
</Note>
|
||||
|
||||
@@ -17,6 +17,7 @@ from common import const
|
||||
from common.i18n import t as _t
|
||||
from models.bot import Bot
|
||||
from models.openai_compatible_bot import OpenAICompatibleBot
|
||||
from models.custom_provider import resolve_custom_credentials
|
||||
from models.chatgpt.chat_gpt_session import ChatGPTSession
|
||||
from models.openai.open_ai_image import OpenAIImage
|
||||
from models.session_manager import SessionManager
|
||||
@@ -33,8 +34,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
super().__init__()
|
||||
# Resolve api key / base from config (no global SDK state anymore).
|
||||
if conf().get("bot_type") == "custom":
|
||||
self._api_key = conf().get("custom_api_key", "")
|
||||
self._api_base = conf().get("custom_api_base") or None
|
||||
# Supports multiple custom providers (custom_providers) with
|
||||
# automatic fallback to the legacy custom_api_key/base fields.
|
||||
self._api_key, self._api_base, _ = resolve_custom_credentials()
|
||||
else:
|
||||
self._api_key = conf().get("open_ai_api_key")
|
||||
self._api_base = conf().get("open_ai_api_base") or None
|
||||
@@ -71,10 +73,19 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
def get_api_config(self):
|
||||
"""Get API configuration for OpenAI-compatible base class"""
|
||||
is_custom = conf().get("bot_type") == "custom"
|
||||
if is_custom:
|
||||
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
||||
api_key = custom_key
|
||||
api_base = custom_base
|
||||
model = custom_model or conf().get("model", "gpt-3.5-turbo")
|
||||
else:
|
||||
api_key = conf().get("open_ai_api_key")
|
||||
api_base = conf().get("open_ai_api_base")
|
||||
model = conf().get("model", "gpt-3.5-turbo")
|
||||
return {
|
||||
'api_key': conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"),
|
||||
'api_base': conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base"),
|
||||
'model': conf().get("model", "gpt-3.5-turbo"),
|
||||
'api_key': api_key,
|
||||
'api_base': api_base,
|
||||
'model': model,
|
||||
'default_temperature': conf().get("temperature", 0.9),
|
||||
'default_top_p': conf().get("top_p", 1.0),
|
||||
'default_frequency_penalty': conf().get("frequency_penalty", 0.0),
|
||||
@@ -186,9 +197,15 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
|
||||
# Get model and API config
|
||||
is_custom = conf().get("bot_type") == "custom"
|
||||
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
|
||||
api_key = context.get("openai_api_key") or (conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"))
|
||||
api_base = conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base")
|
||||
if is_custom:
|
||||
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
||||
model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o")
|
||||
api_key = context.get("openai_api_key") or custom_key
|
||||
api_base = custom_base
|
||||
else:
|
||||
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
|
||||
api_key = context.get("openai_api_key") or conf().get("open_ai_api_key")
|
||||
api_base = conf().get("open_ai_api_base")
|
||||
|
||||
# Build vision request
|
||||
messages = [
|
||||
|
||||
84
models/custom_provider.py
Normal file
84
models/custom_provider.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# encoding:utf-8
|
||||
|
||||
"""
|
||||
Centralized resolver for custom (OpenAI-compatible) provider credentials.
|
||||
|
||||
CowAgent historically supported only a *single* custom provider via the flat
|
||||
config keys ``custom_api_key`` / ``custom_api_base``. This module adds support
|
||||
for *multiple* custom providers (see issue #2838) while remaining 100%
|
||||
backward compatible.
|
||||
|
||||
Config model
|
||||
------------
|
||||
- ``custom_providers``: list of dicts, each describing one custom provider::
|
||||
|
||||
{
|
||||
"name": "siliconflow", # unique, user-facing identifier
|
||||
"api_key": "sk-...", # required
|
||||
"api_base": "https://...", # required, must be OpenAI-compatible
|
||||
"model": "deepseek-ai/DeepSeek-V3" # optional default model
|
||||
}
|
||||
|
||||
- ``custom_active_provider``: the ``name`` of the provider to use. When empty
|
||||
(or pointing to a non-existent name) we fall back to the first provider in
|
||||
the list, and finally to the legacy ``custom_api_key`` / ``custom_api_base``.
|
||||
|
||||
Backward-compatibility contract
|
||||
-------------------------------
|
||||
When ``custom_providers`` is empty, ``resolve_custom_credentials`` returns
|
||||
exactly the legacy ``custom_api_key`` / ``custom_api_base`` values, so existing
|
||||
deployments behave byte-for-byte identically.
|
||||
"""
|
||||
|
||||
from config import conf
|
||||
from common.log import logger
|
||||
|
||||
|
||||
def get_custom_providers():
|
||||
"""Return the list of configured custom providers (always a list)."""
|
||||
providers = conf().get("custom_providers")
|
||||
if not isinstance(providers, list):
|
||||
return []
|
||||
# Keep only well-formed entries with a name.
|
||||
return [p for p in providers if isinstance(p, dict) and p.get("name")]
|
||||
|
||||
|
||||
def _find_active_provider(providers):
|
||||
"""Pick the active provider from the list, or None when list is empty."""
|
||||
if not providers:
|
||||
return None
|
||||
active_name = conf().get("custom_active_provider") or ""
|
||||
if active_name:
|
||||
for p in providers:
|
||||
if p.get("name") == active_name:
|
||||
return p
|
||||
logger.warning(
|
||||
"[CUSTOM] active provider '%s' not found in custom_providers, "
|
||||
"falling back to the first entry", active_name
|
||||
)
|
||||
return providers[0]
|
||||
|
||||
|
||||
def resolve_custom_credentials():
|
||||
"""Resolve the effective (api_key, api_base, model) for custom mode.
|
||||
|
||||
Resolution order:
|
||||
1. The active entry in ``custom_providers`` (multi-provider mode).
|
||||
2. The legacy flat keys ``custom_api_key`` / ``custom_api_base``.
|
||||
|
||||
:return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model``
|
||||
may be ``None`` / empty when not configured.
|
||||
"""
|
||||
provider = _find_active_provider(get_custom_providers())
|
||||
if provider is not None:
|
||||
return (
|
||||
provider.get("api_key", ""),
|
||||
provider.get("api_base") or None,
|
||||
provider.get("model") or None,
|
||||
)
|
||||
# Legacy single-provider fallback — unchanged behavior.
|
||||
return (
|
||||
conf().get("custom_api_key", ""),
|
||||
conf().get("custom_api_base") or None,
|
||||
None,
|
||||
)
|
||||
170
tests/test_custom_provider.py
Normal file
170
tests/test_custom_provider.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
Unit tests for multiple custom (OpenAI-compatible) provider support (issue #2838).
|
||||
|
||||
Covers models/custom_provider.py:
|
||||
- Backward compatibility: legacy custom_api_key / custom_api_base fallback
|
||||
- Multi-provider selection via custom_providers / custom_active_provider
|
||||
- Robustness against malformed config (missing name, non-dict, non-list)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import config as config_module
|
||||
from config import Config
|
||||
|
||||
|
||||
def set_conf(d):
|
||||
"""Install a fresh Config as the global config used by conf()."""
|
||||
config_module.config = Config(d)
|
||||
|
||||
|
||||
class TestResolveCustomCredentials(unittest.TestCase):
|
||||
"""resolve_custom_credentials() resolution order and fallbacks."""
|
||||
|
||||
def setUp(self):
|
||||
# Import here so the module picks up our config-swapping helper.
|
||||
from models.custom_provider import resolve_custom_credentials, get_custom_providers
|
||||
self.resolve = resolve_custom_credentials
|
||||
self.get_providers = get_custom_providers
|
||||
|
||||
# --- Backward compatibility ---
|
||||
|
||||
def test_legacy_fallback_when_no_providers(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_api_key": "legacy-key",
|
||||
"custom_api_base": "https://legacy.example.com/v1",
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
def test_empty_config(self):
|
||||
set_conf({"bot_type": "custom"})
|
||||
self.assertEqual(self.resolve(), ("", None, None))
|
||||
|
||||
# --- Multi-provider selection ---
|
||||
|
||||
def test_multi_providers_no_active_uses_first(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_providers": [
|
||||
{"name": "siliconflow", "api_key": "sf-key",
|
||||
"api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"},
|
||||
{"name": "qiniu", "api_key": "qn-key",
|
||||
"api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("sf-key", "https://api.siliconflow.cn/v1", "deepseek-ai/DeepSeek-V3"),
|
||||
)
|
||||
|
||||
def test_active_provider_selected(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_active_provider": "qiniu",
|
||||
"custom_providers": [
|
||||
{"name": "siliconflow", "api_key": "sf-key",
|
||||
"api_base": "https://api.siliconflow.cn/v1", "model": "m1"},
|
||||
{"name": "qiniu", "api_key": "qn-key",
|
||||
"api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"),
|
||||
)
|
||||
|
||||
def test_active_name_missing_falls_back_to_first(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_active_provider": "ghost",
|
||||
"custom_providers": [
|
||||
{"name": "siliconflow", "api_key": "sf-key",
|
||||
"api_base": "https://api.siliconflow.cn/v1"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("sf-key", "https://api.siliconflow.cn/v1", None),
|
||||
)
|
||||
|
||||
def test_provider_without_model_returns_none_model(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_providers": [
|
||||
{"name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("", "http://localhost:11434/v1", None),
|
||||
)
|
||||
|
||||
# --- Robustness against malformed config ---
|
||||
|
||||
def test_malformed_entries_filtered_and_fallback(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_api_key": "legacy-key",
|
||||
"custom_api_base": "https://legacy.example.com/v1",
|
||||
"custom_providers": [
|
||||
{"api_key": "no-name-key"}, # invalid: no name
|
||||
"not-a-dict", # invalid: wrong type
|
||||
],
|
||||
})
|
||||
# All entries invalid -> treated as empty -> legacy fallback
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
def test_get_custom_providers_filters_invalid(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_providers": [
|
||||
{"name": "ok", "api_key": "k", "api_base": "https://x/v1"},
|
||||
{"api_key": "no-name"}, # dropped
|
||||
123, # dropped
|
||||
],
|
||||
})
|
||||
providers = self.get_providers()
|
||||
self.assertEqual(len(providers), 1)
|
||||
self.assertEqual(providers[0]["name"], "ok")
|
||||
|
||||
def test_custom_providers_not_a_list_falls_back(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_api_key": "legacy-key",
|
||||
"custom_api_base": "https://legacy.example.com/v1",
|
||||
"custom_providers": "oops-a-string",
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
|
||||
class TestConfigDefaults(unittest.TestCase):
|
||||
"""The new config fields must exist with safe defaults."""
|
||||
|
||||
def test_default_config_has_custom_providers(self):
|
||||
from config import available_setting
|
||||
self.assertIn("custom_providers", available_setting)
|
||||
self.assertEqual(available_setting["custom_providers"], [])
|
||||
|
||||
def test_default_config_has_active_provider(self):
|
||||
from config import available_setting
|
||||
self.assertIn("custom_active_provider", available_setting)
|
||||
self.assertEqual(available_setting["custom_active_provider"], "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
258
tests/test_custom_provider_handlers.py
Normal file
258
tests/test_custom_provider_handlers.py
Normal file
@@ -0,0 +1,258 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
Unit tests for the multi custom-provider management API (issue #2838, web UI).
|
||||
|
||||
Covers channel/web/web_channel.py::ModelsHandler:
|
||||
- _custom_provider_cards / _provider_overview expansion
|
||||
- _handle_set_custom_provider (create / edit / rename / activate)
|
||||
- _handle_delete_custom_provider
|
||||
- _handle_set_active_custom_provider
|
||||
|
||||
These handlers are normally driven by the `web.py` framework, which isn't
|
||||
available in the headless test environment, so we stub the `web` module before
|
||||
import. The on-disk config read/write and the Bridge reset are patched to keep
|
||||
the tests hermetic (no file I/O, no live bot routing).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
# Add project root to path.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# Stub the web.py framework so web_channel imports without the dependency.
|
||||
if "web" not in sys.modules:
|
||||
_web_stub = types.ModuleType("web")
|
||||
_web_stub.header = lambda *a, **k: None
|
||||
_web_stub.data = lambda: b"{}"
|
||||
_web_stub.ctx = types.SimpleNamespace()
|
||||
sys.modules["web"] = _web_stub
|
||||
|
||||
import config as config_module
|
||||
from config import Config
|
||||
from channel.web.web_channel import ModelsHandler
|
||||
|
||||
|
||||
def set_conf(d):
|
||||
"""Install a fresh Config as the global config used by conf()."""
|
||||
config_module.config = Config(d)
|
||||
|
||||
|
||||
class _HandlerHarness:
|
||||
"""Test double around ModelsHandler that captures persisted config in
|
||||
memory instead of touching config.json, and no-ops the Bridge reset."""
|
||||
|
||||
def __init__(self):
|
||||
self.handler = ModelsHandler.__new__(ModelsHandler)
|
||||
self._file_cfg = {}
|
||||
self.bridge_resets = 0
|
||||
# Patch the disk + bridge boundary on this instance.
|
||||
self.handler._read_file_config = lambda: dict(self._file_cfg)
|
||||
self.handler._write_file_config = self._capture_write
|
||||
self.handler._reset_bridge = self._capture_reset
|
||||
|
||||
def _capture_write(self, data):
|
||||
self._file_cfg = dict(data)
|
||||
|
||||
def _capture_reset(self):
|
||||
self.bridge_resets += 1
|
||||
|
||||
def call(self, **payload):
|
||||
# Resolve the bound method by action for convenience.
|
||||
action = payload.get("action")
|
||||
method = {
|
||||
"set_custom_provider": self.handler._handle_set_custom_provider,
|
||||
"delete_custom_provider": self.handler._handle_delete_custom_provider,
|
||||
"set_active_custom_provider": self.handler._handle_set_active_custom_provider,
|
||||
}[action]
|
||||
return json.loads(method(payload))
|
||||
|
||||
|
||||
class TestSetCustomProvider(unittest.TestCase):
|
||||
def setUp(self):
|
||||
set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""})
|
||||
self.h = _HandlerHarness()
|
||||
|
||||
def test_create_first_provider_auto_activates(self):
|
||||
res = self.h.call(action="set_custom_provider", name="siliconflow",
|
||||
api_base="https://api.siliconflow.cn/v1", api_key="sf-key")
|
||||
self.assertEqual(res["status"], "success")
|
||||
self.assertTrue(res["created"])
|
||||
self.assertEqual(res["active"], "siliconflow")
|
||||
providers = config_module.conf().get("custom_providers")
|
||||
self.assertEqual(len(providers), 1)
|
||||
self.assertEqual(providers[0]["name"], "siliconflow")
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "siliconflow")
|
||||
self.assertEqual(self.h.bridge_resets, 1)
|
||||
|
||||
def test_create_requires_api_base(self):
|
||||
res = self.h.call(action="set_custom_provider", name="x", api_key="k")
|
||||
self.assertEqual(res["status"], "error")
|
||||
self.assertIn("api_base", res["message"])
|
||||
|
||||
def test_create_requires_name(self):
|
||||
res = self.h.call(action="set_custom_provider", name="", api_base="https://x/v1")
|
||||
self.assertEqual(res["status"], "error")
|
||||
|
||||
def test_second_provider_does_not_steal_active(self):
|
||||
self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="ak")
|
||||
res = self.h.call(action="set_custom_provider", name="b",
|
||||
api_base="https://b/v1", api_key="bk")
|
||||
self.assertTrue(res["created"])
|
||||
# First provider stays active unless make_active is requested.
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "a")
|
||||
|
||||
def test_make_active_flag(self):
|
||||
self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="ak")
|
||||
self.h.call(action="set_custom_provider", name="b",
|
||||
api_base="https://b/v1", api_key="bk", make_active=True)
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "b")
|
||||
|
||||
def test_duplicate_name_rejected(self):
|
||||
self.h.call(action="set_custom_provider", name="dup",
|
||||
api_base="https://a/v1", api_key="ak")
|
||||
res = self.h.call(action="set_custom_provider", name="dup",
|
||||
api_base="https://b/v1", api_key="bk")
|
||||
self.assertEqual(res["status"], "error")
|
||||
self.assertIn("already exists", res["message"])
|
||||
# The original entry must be untouched.
|
||||
providers = config_module.conf().get("custom_providers")
|
||||
self.assertEqual(len(providers), 1)
|
||||
self.assertEqual(providers[0]["api_base"], "https://a/v1")
|
||||
|
||||
def test_edit_keeps_key_when_omitted(self):
|
||||
self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="secret")
|
||||
# Edit only the base; omit api_key.
|
||||
res = self.h.call(action="set_custom_provider", name="a",
|
||||
original_name="a", api_base="https://a2/v1")
|
||||
self.assertEqual(res["status"], "success")
|
||||
self.assertFalse(res["created"])
|
||||
providers = config_module.conf().get("custom_providers")
|
||||
self.assertEqual(providers[0]["api_base"], "https://a2/v1")
|
||||
self.assertEqual(providers[0]["api_key"], "secret") # preserved
|
||||
|
||||
def test_rename_updates_active_pointer(self):
|
||||
self.h.call(action="set_custom_provider", name="old",
|
||||
api_base="https://a/v1", api_key="ak")
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "old")
|
||||
res = self.h.call(action="set_custom_provider", name="new",
|
||||
original_name="old", api_base="https://a/v1")
|
||||
self.assertEqual(res["status"], "success")
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "new")
|
||||
names = [p["name"] for p in config_module.conf().get("custom_providers")]
|
||||
self.assertEqual(names, ["new"])
|
||||
|
||||
def test_edit_clears_model_when_empty(self):
|
||||
self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="ak", model="m1")
|
||||
self.assertEqual(config_module.conf().get("custom_providers")[0]["model"], "m1")
|
||||
self.h.call(action="set_custom_provider", name="a", original_name="a",
|
||||
api_base="https://a/v1", model="")
|
||||
self.assertNotIn("model", config_module.conf().get("custom_providers")[0])
|
||||
|
||||
|
||||
class TestDeleteCustomProvider(unittest.TestCase):
|
||||
def setUp(self):
|
||||
set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""})
|
||||
self.h = _HandlerHarness()
|
||||
self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak")
|
||||
self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk")
|
||||
|
||||
def test_delete_unknown(self):
|
||||
res = self.h.call(action="delete_custom_provider", name="ghost")
|
||||
self.assertEqual(res["status"], "error")
|
||||
|
||||
def test_delete_non_active(self):
|
||||
res = self.h.call(action="delete_custom_provider", name="b")
|
||||
self.assertEqual(res["status"], "success")
|
||||
names = [p["name"] for p in config_module.conf().get("custom_providers")]
|
||||
self.assertEqual(names, ["a"])
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "a")
|
||||
|
||||
def test_delete_active_falls_back_to_first_remaining(self):
|
||||
# 'a' is active (created first); deleting it should re-point to 'b'.
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "a")
|
||||
res = self.h.call(action="delete_custom_provider", name="a")
|
||||
self.assertEqual(res["status"], "success")
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "b")
|
||||
|
||||
def test_delete_last_clears_active(self):
|
||||
self.h.call(action="delete_custom_provider", name="a")
|
||||
self.h.call(action="delete_custom_provider", name="b")
|
||||
self.assertEqual(config_module.conf().get("custom_providers"), [])
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "")
|
||||
|
||||
|
||||
class TestSetActiveCustomProvider(unittest.TestCase):
|
||||
def setUp(self):
|
||||
set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""})
|
||||
self.h = _HandlerHarness()
|
||||
self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak")
|
||||
self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk")
|
||||
|
||||
def test_set_active_valid(self):
|
||||
res = self.h.call(action="set_active_custom_provider", name="b")
|
||||
self.assertEqual(res["status"], "success")
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "b")
|
||||
|
||||
def test_set_active_unknown(self):
|
||||
res = self.h.call(action="set_active_custom_provider", name="ghost")
|
||||
self.assertEqual(res["status"], "error")
|
||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "a")
|
||||
|
||||
|
||||
class TestProviderOverviewExpansion(unittest.TestCase):
|
||||
"""_provider_overview / _custom_provider_cards should expand the list."""
|
||||
|
||||
def test_no_custom_providers_keeps_single_card(self):
|
||||
set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""})
|
||||
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
||||
self.assertEqual(cards, [])
|
||||
overview = ModelsHandler._provider_overview()
|
||||
custom_cards = [c for c in overview if c.get("id") == "custom"]
|
||||
# Legacy single custom card remains present.
|
||||
self.assertEqual(len(custom_cards), 1)
|
||||
self.assertTrue(custom_cards[0].get("is_custom"))
|
||||
|
||||
def test_multi_providers_expand_into_cards(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_active_provider": "b",
|
||||
"custom_providers": [
|
||||
{"name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
||||
{"name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"},
|
||||
],
|
||||
})
|
||||
overview = ModelsHandler._provider_overview()
|
||||
custom_cards = [c for c in overview if c.get("is_custom")]
|
||||
self.assertEqual(len(custom_cards), 2)
|
||||
by_name = {c["custom_name"]: c for c in custom_cards}
|
||||
self.assertEqual(by_name["a"]["id"], "custom:a")
|
||||
self.assertFalse(by_name["a"]["active"])
|
||||
self.assertTrue(by_name["b"]["active"])
|
||||
self.assertEqual(by_name["b"]["model"], "m")
|
||||
# No single legacy "custom" card when expanded.
|
||||
self.assertFalse(any(c.get("id") == "custom" for c in overview))
|
||||
|
||||
def test_active_defaults_to_first_when_unset(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_active_provider": "",
|
||||
"custom_providers": [
|
||||
{"name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
||||
{"name": "b", "api_key": "bk", "api_base": "https://b/v1"},
|
||||
],
|
||||
})
|
||||
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
||||
by_name = {c["custom_name"]: c for c in cards}
|
||||
self.assertTrue(by_name["a"]["active"])
|
||||
self.assertFalse(by_name["b"]["active"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user