mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 21:07:28 +08:00
refactor(custom): id-based routing, single source of truth, security fixes
Rework the multi custom-provider design per maintainer review: 1. Data model: use server-generated uuid4 short id as primary key; 'name' is now a pure display label that can be freely renamed. 2. Routing: drop 'custom_active_provider'; activate a provider by setting bot_type to 'custom:<id>'. Single source of truth — no pointer drift between bot_type and a separate active selector. 3. Security: drag_sensitive() now recursively masks api_key/secret in nested structures (custom_providers list); previously only top-level string fields were masked. 4. Per-provider model: the provider's 'model' field now takes effect on the main chat path and agent path (was silently ignored before). 5. XSS fix: replace all inline onclick handlers in custom-provider UI with data-* attributes + event delegation. Provider names never appear in executable HTML contexts. Legacy compatibility: bot_type='custom' (no colon) still reads the flat custom_api_key/custom_api_base fields byte-for-byte identically. Closes: consolidates #2876 into this PR as requested. Ref: #2838
This commit is contained in:
@@ -4861,9 +4861,11 @@ function renderProviderLogo(p, sizePx) {
|
|||||||
// ---------- Custom providers section (multiple OpenAI-compatible) -------
|
// ---------- Custom providers section (multiple OpenAI-compatible) -------
|
||||||
// Renders the user-defined OpenAI-compatible providers as a dedicated,
|
// Renders the user-defined OpenAI-compatible providers as a dedicated,
|
||||||
// independently managed list: add / edit / delete / activate. The backend
|
// independently managed list: add / edit / delete / activate. The backend
|
||||||
// expands `custom_providers` into provider cards with id="custom:<name>",
|
// expands `custom_providers` into provider cards with id="custom:<id>",
|
||||||
// is_custom=true, custom_name and an `active` flag (see
|
// is_custom=true, custom_id, custom_name and an `active` flag (see
|
||||||
// ModelsHandler._custom_provider_cards / _provider_overview).
|
// ModelsHandler._custom_provider_cards / _provider_overview).
|
||||||
|
// All button interactions use data-* attributes + event delegation (no inline
|
||||||
|
// onclick) to avoid XSS via user-supplied names.
|
||||||
|
|
||||||
function getCustomProviderCards() {
|
function getCustomProviderCards() {
|
||||||
return modelsState.providers.filter(isCustomProviderCard);
|
return modelsState.providers.filter(isCustomProviderCard);
|
||||||
@@ -4884,7 +4886,7 @@ function renderCustomProvidersSection() {
|
|||||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_custom_section')}</h3>
|
<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>
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_custom_section_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="openCustomProviderModal('')"
|
<button data-action="add-custom"
|
||||||
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">
|
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')}
|
<i class="fas fa-plus text-[10px] mr-1"></i>${t('models_custom_add')}
|
||||||
</button>
|
</button>
|
||||||
@@ -4903,18 +4905,30 @@ function renderCustomProvidersSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wrap.innerHTML = header + body;
|
wrap.innerHTML = header + body;
|
||||||
|
// Event delegation — handles all custom-provider actions via data-action attrs.
|
||||||
|
wrap.addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.getAttribute('data-action');
|
||||||
|
const providerId = btn.getAttribute('data-provider-id') || '';
|
||||||
|
if (action === 'add-custom') openCustomProviderModal('');
|
||||||
|
else if (action === 'edit-custom') openCustomProviderModal(providerId);
|
||||||
|
else if (action === 'delete-custom') deleteCustomProvider(providerId);
|
||||||
|
else if (action === 'set-active-custom') setActiveCustomProvider(providerId);
|
||||||
|
});
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCustomProviderRow(p) {
|
function renderCustomProviderRow(p) {
|
||||||
|
const id = p.custom_id || '';
|
||||||
const name = p.custom_name || '';
|
const name = p.custom_name || '';
|
||||||
const nameEsc = escapeHtml(name);
|
const nameEsc = escapeHtml(name);
|
||||||
// The active provider gets a highlighted ring + badge; others show a
|
// The active provider gets a highlighted ring + badge; others show a
|
||||||
// "set active" affordance.
|
// "set active" affordance via data-attributes (no inline onclick — XSS safe).
|
||||||
const activeBadge = p.active
|
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">
|
? `<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>`
|
<i class="fas fa-check text-[9px] mr-0.5"></i>${t('models_custom_active')}</span>`
|
||||||
: `<button onclick="setActiveCustomProvider('${nameEsc}')"
|
: `<button data-action="set-active-custom" data-provider-id="${escapeHtml(id)}"
|
||||||
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">
|
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>`;
|
${t('models_custom_set_active')}</button>`;
|
||||||
|
|
||||||
@@ -4930,7 +4944,7 @@ function renderCustomProviderRow(p) {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border ${ring} transition-colors">
|
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border ${ring} transition-colors" data-provider-id="${escapeHtml(id)}">
|
||||||
${renderProviderLogo(p, 28)}
|
${renderProviderLogo(p, 28)}
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -4939,11 +4953,11 @@ function renderCustomProviderRow(p) {
|
|||||||
</div>
|
</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 class="flex items-center gap-2 mt-0.5">${base}${model ? '<span class="text-slate-300 dark:text-slate-600">·</span>' + model : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="openCustomProviderModal('${nameEsc}')" title="${t('models_custom_edit_title')}"
|
<button data-action="edit-custom" data-provider-id="${escapeHtml(id)}" 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">
|
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>
|
<i class="fas fa-pen-to-square text-[12px]"></i>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="deleteCustomProvider('${nameEsc}')" title="${t('models_custom_delete')}"
|
<button data-action="delete-custom" data-provider-id="${escapeHtml(id)}" 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">
|
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>
|
<i class="fas fa-trash-can text-[12px]"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -6245,16 +6259,15 @@ function clearVendorModal() {
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Custom (OpenAI-compatible) provider modal — add / edit
|
// Custom (OpenAI-compatible) provider modal — add / edit
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// State for the dedicated custom-provider modal. `originalName` is empty when
|
// State for the dedicated custom-provider modal. `editId` is empty when
|
||||||
// adding and set to the provider name when editing (so the backend can rename
|
// adding and set to the provider id when editing.
|
||||||
// without losing the entry).
|
let customProviderModalState = { editId: '' };
|
||||||
let customProviderModalState = { originalName: '' };
|
|
||||||
|
|
||||||
function openCustomProviderModal(name) {
|
function openCustomProviderModal(providerId) {
|
||||||
const editing = !!name;
|
const editing = !!providerId;
|
||||||
customProviderModalState = { originalName: editing ? name : '' };
|
customProviderModalState = { editId: editing ? providerId : '' };
|
||||||
|
|
||||||
const card = editing ? getCustomProviderCards().find(p => p.custom_name === name) : null;
|
const card = editing ? getCustomProviderCards().find(p => p.custom_id === providerId) : null;
|
||||||
|
|
||||||
const overlay = document.getElementById('custom-provider-modal-overlay');
|
const overlay = document.getElementById('custom-provider-modal-overlay');
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
@@ -6322,7 +6335,7 @@ function saveCustomProviderModal() {
|
|||||||
document.getElementById('custom-provider-name').focus();
|
document.getElementById('custom-provider-name').focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const editing = !!customProviderModalState.originalName;
|
const editing = !!customProviderModalState.editId;
|
||||||
if (!editing && !apiBase) {
|
if (!editing && !apiBase) {
|
||||||
showStatus('custom-provider-modal-status', 'models_custom_base_required', true);
|
showStatus('custom-provider-modal-status', 'models_custom_base_required', true);
|
||||||
document.getElementById('custom-provider-base').focus();
|
document.getElementById('custom-provider-base').focus();
|
||||||
@@ -6342,7 +6355,7 @@ function saveCustomProviderModal() {
|
|||||||
model: model,
|
model: model,
|
||||||
};
|
};
|
||||||
if (apiKey) payload.api_key = apiKey;
|
if (apiKey) payload.api_key = apiKey;
|
||||||
if (editing) payload.original_name = customProviderModalState.originalName;
|
if (editing) payload.id = customProviderModalState.editId;
|
||||||
|
|
||||||
const btn = document.getElementById('custom-provider-modal-save');
|
const btn = document.getElementById('custom-provider-modal-save');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -6356,10 +6369,7 @@ function saveCustomProviderModal() {
|
|||||||
closeCustomProviderModal();
|
closeCustomProviderModal();
|
||||||
loadModelsView();
|
loadModelsView();
|
||||||
} else {
|
} else {
|
||||||
// Surface the most useful known error; fall back to generic save fail.
|
showStatus('custom-provider-modal-status', 'models_save_failed', true);
|
||||||
const msg = (data.message || '').includes('already exists')
|
|
||||||
? 'models_custom_name_exists' : 'models_save_failed';
|
|
||||||
showStatus('custom-provider-modal-status', msg, true);
|
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -6367,17 +6377,17 @@ function saveCustomProviderModal() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveCustomProvider(name) {
|
function setActiveCustomProvider(providerId) {
|
||||||
fetch('/api/models', {
|
fetch('/api/models', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action: 'set_active_custom_provider', name: name }),
|
body: JSON.stringify({ action: 'set_active_custom_provider', id: providerId }),
|
||||||
}).then(r => r.json()).then(data => {
|
}).then(r => r.json()).then(data => {
|
||||||
if (data.status === 'success') loadModelsView();
|
if (data.status === 'success') loadModelsView();
|
||||||
}).catch(() => { /* noop */ });
|
}).catch(() => { /* noop */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteCustomProvider(name) {
|
function deleteCustomProvider(providerId) {
|
||||||
showConfirmDialog({
|
showConfirmDialog({
|
||||||
title: t('models_custom_delete_confirm_title'),
|
title: t('models_custom_delete_confirm_title'),
|
||||||
message: t('models_custom_delete_confirm_msg'),
|
message: t('models_custom_delete_confirm_msg'),
|
||||||
@@ -6387,7 +6397,7 @@ function deleteCustomProvider(name) {
|
|||||||
fetch('/api/models', {
|
fetch('/api/models', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action: 'delete_custom_provider', name: name }),
|
body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }),
|
||||||
}).then(r => r.json()).then(data => {
|
}).then(r => r.json()).then(data => {
|
||||||
if (data.status === 'success') loadModelsView();
|
if (data.status === 'success') loadModelsView();
|
||||||
}).catch(() => { /* noop */ });
|
}).catch(() => { /* noop */ });
|
||||||
|
|||||||
@@ -1601,7 +1601,7 @@ class ConfigHandler:
|
|||||||
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
|
"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",
|
"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",
|
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
|
||||||
"custom_providers", "custom_active_provider",
|
"custom_providers",
|
||||||
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
||||||
"enable_thinking", "self_evolution_enabled", "web_password",
|
"enable_thinking", "self_evolution_enabled", "web_password",
|
||||||
}
|
}
|
||||||
@@ -2137,10 +2137,9 @@ class ModelsHandler:
|
|||||||
"""Expand ``custom_providers`` into one card per provider.
|
"""Expand ``custom_providers`` into one card per provider.
|
||||||
|
|
||||||
Each user-defined OpenAI-compatible provider becomes its own card with
|
Each user-defined OpenAI-compatible provider becomes its own card with
|
||||||
a synthetic id ``custom:<name>`` so the frontend can render, edit,
|
id ``custom:<id>`` so the frontend can render, edit, delete and
|
||||||
delete and activate them independently. The card carries
|
activate them independently. The card carries ``is_custom=True`` and
|
||||||
``is_custom=True`` and ``active`` flags that the UI uses to render the
|
``active`` flags that the UI uses to render the extra controls.
|
||||||
extra controls (delete button, "set active" affordance).
|
|
||||||
|
|
||||||
Returns an empty list when no multi-providers are configured, in which
|
Returns an empty list when no multi-providers are configured, in which
|
||||||
case the caller keeps the single legacy ``custom`` card untouched —
|
case the caller keeps the single legacy ``custom`` card untouched —
|
||||||
@@ -2148,7 +2147,7 @@ class ModelsHandler:
|
|||||||
``custom_api_key`` / ``custom_api_base`` config.
|
``custom_api_key`` / ``custom_api_base`` config.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from models.custom_provider import get_custom_providers
|
from models.custom_provider import get_custom_providers, parse_custom_bot_type
|
||||||
providers = get_custom_providers()
|
providers = get_custom_providers()
|
||||||
except Exception as e: # pragma: no cover - defensive
|
except Exception as e: # pragma: no cover - defensive
|
||||||
logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}")
|
logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}")
|
||||||
@@ -2156,27 +2155,26 @@ class ModelsHandler:
|
|||||||
if not providers:
|
if not providers:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
active_name = (local_config.get("custom_active_provider") or "").strip()
|
# Determine the currently active provider id from bot_type.
|
||||||
# When no valid active name is set, the resolver treats the first entry
|
bot_type = local_config.get("bot_type") or ""
|
||||||
# as active; mirror that here so exactly one card is highlighted.
|
_, active_id = parse_custom_bot_type(bot_type)
|
||||||
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 {}
|
meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {}
|
||||||
cards = []
|
cards = []
|
||||||
for p in providers:
|
for p in providers:
|
||||||
name = p.get("name") or ""
|
pid = p.get("id") or ""
|
||||||
|
name = p.get("name") or pid
|
||||||
raw_key = p.get("api_key") or ""
|
raw_key = p.get("api_key") or ""
|
||||||
raw_base = p.get("api_base") or ""
|
raw_base = p.get("api_base") or ""
|
||||||
configured = cls._is_real_key(raw_key)
|
configured = cls._is_real_key(raw_key)
|
||||||
cards.append({
|
cards.append({
|
||||||
"id": f"custom:{name}",
|
"id": f"custom:{pid}",
|
||||||
"label": {"zh": name, "en": name},
|
"label": {"zh": name, "en": name},
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
"is_custom": True,
|
"is_custom": True,
|
||||||
|
"custom_id": pid,
|
||||||
"custom_name": name,
|
"custom_name": name,
|
||||||
"active": (name == active_name),
|
"active": (pid == active_id),
|
||||||
"model": p.get("model") or "",
|
"model": p.get("model") or "",
|
||||||
# Custom cards are edited via the dedicated set_custom_provider
|
# Custom cards are edited via the dedicated set_custom_provider
|
||||||
# action, not the field-based set_provider flow, so the field
|
# action, not the field-based set_provider flow, so the field
|
||||||
@@ -2781,10 +2779,9 @@ class ModelsHandler:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Multiple custom (OpenAI-compatible) providers
|
# Multiple custom (OpenAI-compatible) providers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# These actions manage the ``custom_providers`` list and the
|
# These actions manage the ``custom_providers`` list. Activation is done
|
||||||
# ``custom_active_provider`` selector. They are the write-side companion to
|
# by setting ``bot_type`` to ``"custom:<id>"``. There is no separate
|
||||||
# ``_custom_provider_cards`` and let the console add / edit / delete /
|
# ``custom_active_provider`` field — a single source of truth.
|
||||||
# activate user-defined OpenAI-compatible providers individually.
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_custom_providers(raw) -> List[dict]:
|
def _normalize_custom_providers(raw) -> List[dict]:
|
||||||
@@ -2793,20 +2790,22 @@ class ModelsHandler:
|
|||||||
return []
|
return []
|
||||||
out = []
|
out = []
|
||||||
for p in raw:
|
for p in raw:
|
||||||
if isinstance(p, dict) and (p.get("name") or "").strip():
|
if isinstance(p, dict) and (p.get("id") or "").strip():
|
||||||
out.append(p)
|
out.append(p)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _persist_custom_providers(self, providers: List[dict], active_name) -> None:
|
def _persist_custom_providers(self, providers: List[dict], bot_type=None) -> None:
|
||||||
"""Write the providers list + active selector to both in-memory conf
|
"""Write the providers list to both in-memory conf and the on-disk
|
||||||
and the on-disk config, then reset the bridge so bots rebuild."""
|
config, then reset the bridge so bots rebuild.
|
||||||
|
|
||||||
|
If ``bot_type`` is given, also update ``bot_type``."""
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
file_cfg = self._read_file_config()
|
file_cfg = self._read_file_config()
|
||||||
local_config["custom_providers"] = providers
|
local_config["custom_providers"] = providers
|
||||||
file_cfg["custom_providers"] = providers
|
file_cfg["custom_providers"] = providers
|
||||||
if active_name is not None:
|
if bot_type is not None:
|
||||||
local_config["custom_active_provider"] = active_name
|
local_config["bot_type"] = bot_type
|
||||||
file_cfg["custom_active_provider"] = active_name
|
file_cfg["bot_type"] = bot_type
|
||||||
self._write_file_config(file_cfg)
|
self._write_file_config(file_cfg)
|
||||||
self._reset_bridge()
|
self._reset_bridge()
|
||||||
|
|
||||||
@@ -2817,48 +2816,38 @@ class ModelsHandler:
|
|||||||
|
|
||||||
{
|
{
|
||||||
"action": "set_custom_provider",
|
"action": "set_custom_provider",
|
||||||
"name": "siliconflow", # required, unique
|
"id": "3f2a9c1b", # required for edit; omit for create
|
||||||
|
"name": "siliconflow", # required, display label
|
||||||
"api_base": "https://...", # required when creating
|
"api_base": "https://...", # required when creating
|
||||||
"api_key": "sk-...", # optional on edit (keep existing)
|
"api_key": "sk-...", # optional on edit (keep existing)
|
||||||
"model": "deepseek-ai/...", # optional default model
|
"model": "deepseek-ai/...", # optional default model
|
||||||
"original_name": "old-name", # optional, set when renaming
|
|
||||||
"make_active": true # optional, also activate it
|
"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()
|
name = (data.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return json.dumps({"status": "error", "message": "name is required"})
|
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_base = (data.get("api_base") or "").strip()
|
||||||
# api_key omitted/empty on edit => keep the existing one.
|
# api_key omitted/empty on edit => keep the existing one.
|
||||||
api_key_raw = data.get("api_key")
|
api_key_raw = data.get("api_key")
|
||||||
api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else ""
|
api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else ""
|
||||||
model = (data.get("model") or "").strip()
|
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"))
|
make_active = bool(data.get("make_active"))
|
||||||
|
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
|
||||||
# Reject a name collision unless it is the very entry being edited.
|
existing = next((p for p in providers if p.get("id") == provider_id), None) if provider_id else None
|
||||||
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:
|
if existing is None:
|
||||||
# Creating a new provider — api_base is mandatory.
|
# Creating a new provider — api_base is mandatory.
|
||||||
if not api_base:
|
if not api_base:
|
||||||
return json.dumps({"status": "error", "message": "api_base is required"})
|
return json.dumps({"status": "error", "message": "api_base is required"})
|
||||||
entry = {"name": name, "api_key": api_key, "api_base": api_base}
|
provider_id = generate_provider_id()
|
||||||
|
entry = {"id": provider_id, "name": name, "api_key": api_key, "api_base": api_base}
|
||||||
if model:
|
if model:
|
||||||
entry["model"] = model
|
entry["model"] = model
|
||||||
providers.append(entry)
|
providers.append(entry)
|
||||||
@@ -2876,64 +2865,68 @@ class ModelsHandler:
|
|||||||
existing.pop("model", None)
|
existing.pop("model", None)
|
||||||
created = False
|
created = False
|
||||||
|
|
||||||
# Decide the active selector.
|
# Decide bot_type.
|
||||||
active_name = (local_config.get("custom_active_provider") or "").strip()
|
_, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "")
|
||||||
if make_active or created and not active_name:
|
new_bot_type = None
|
||||||
|
if make_active or (created and not current_active_id):
|
||||||
# Activate on explicit request, or auto-activate the very first
|
# Activate on explicit request, or auto-activate the very first
|
||||||
# provider so the resolver has a definite target.
|
# provider so the resolver has a definite target.
|
||||||
active_name = name
|
new_bot_type = f"custom:{provider_id}"
|
||||||
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)
|
self._persist_custom_providers(providers, new_bot_type)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[ModelsHandler] custom provider {name!r} "
|
f"[ModelsHandler] custom provider {name!r} (id={provider_id}) "
|
||||||
f"{'created' if created else 'updated'} (active={active_name!r})"
|
f"{'created' if created else 'updated'}"
|
||||||
)
|
)
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
"id": provider_id,
|
||||||
"name": name,
|
"name": name,
|
||||||
"created": created,
|
"created": created,
|
||||||
"active": active_name,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def _handle_delete_custom_provider(self, data: dict) -> str:
|
def _handle_delete_custom_provider(self, data: dict) -> str:
|
||||||
"""Remove a custom provider by name."""
|
"""Remove a custom provider by id."""
|
||||||
name = (data.get("name") or "").strip()
|
from models.custom_provider import parse_custom_bot_type
|
||||||
if not name:
|
|
||||||
return json.dumps({"status": "error", "message": "name is required"})
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
if not provider_id:
|
||||||
|
return json.dumps({"status": "error", "message": "id is required"})
|
||||||
|
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
remaining = [p for p in providers if p.get("name") != name]
|
remaining = [p for p in providers if p.get("id") != provider_id]
|
||||||
if len(remaining) == len(providers):
|
if len(remaining) == len(providers):
|
||||||
return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"})
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||||
|
|
||||||
active_name = (local_config.get("custom_active_provider") or "").strip()
|
# If the deleted provider was active, fall back to the first remaining.
|
||||||
if active_name == name:
|
_, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "")
|
||||||
# The active provider was removed — fall back to the first
|
new_bot_type = None
|
||||||
# remaining entry (resolver does the same when the name is stale).
|
if current_active_id == provider_id:
|
||||||
active_name = remaining[0]["name"] if remaining else ""
|
if remaining:
|
||||||
|
new_bot_type = f"custom:{remaining[0]['id']}"
|
||||||
|
else:
|
||||||
|
new_bot_type = "custom" # revert to legacy
|
||||||
|
|
||||||
self._persist_custom_providers(remaining, active_name)
|
self._persist_custom_providers(remaining, new_bot_type)
|
||||||
logger.info(f"[ModelsHandler] custom provider {name!r} deleted (active={active_name!r})")
|
logger.info(f"[ModelsHandler] custom provider id={provider_id} deleted")
|
||||||
return json.dumps({"status": "success", "name": name, "active": active_name})
|
return json.dumps({"status": "success", "id": provider_id})
|
||||||
|
|
||||||
def _handle_set_active_custom_provider(self, data: dict) -> str:
|
def _handle_set_active_custom_provider(self, data: dict) -> str:
|
||||||
"""Mark one of the existing custom providers as active."""
|
"""Activate a custom provider by setting bot_type to 'custom:<id>'."""
|
||||||
name = (data.get("name") or "").strip()
|
provider_id = (data.get("id") or "").strip()
|
||||||
if not name:
|
if not provider_id:
|
||||||
return json.dumps({"status": "error", "message": "name is required"})
|
return json.dumps({"status": "error", "message": "id is required"})
|
||||||
|
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
if not any(p.get("name") == name for p in providers):
|
if not any(p.get("id") == provider_id for p in providers):
|
||||||
return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"})
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||||
|
|
||||||
self._persist_custom_providers(providers, name)
|
new_bot_type = f"custom:{provider_id}"
|
||||||
logger.info(f"[ModelsHandler] active custom provider set to {name!r}")
|
self._persist_custom_providers(providers, new_bot_type)
|
||||||
return json.dumps({"status": "success", "active": name})
|
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:
|
def _handle_set_capability(self, data: dict) -> str:
|
||||||
capability = (data.get("capability") or "").strip()
|
capability = (data.get("capability") or "").strip()
|
||||||
|
|||||||
40
config.py
40
config.py
@@ -26,10 +26,9 @@ available_setting = {
|
|||||||
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini 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"); legacy single-provider field
|
"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
|
"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.
|
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
|
||||||
# Each item: {"name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
|
# Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
|
||||||
"custom_providers": [],
|
"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
|
"proxy": "", # proxy used by openai
|
||||||
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
# 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
|
"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
|
||||||
@@ -325,24 +324,37 @@ class Config(dict):
|
|||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_value(val):
|
||||||
|
"""Mask a sensitive string value, keeping first 3 and last 3 chars."""
|
||||||
|
if not isinstance(val, str) or len(val) <= 8:
|
||||||
|
return val
|
||||||
|
return val[0:3] + "*" * 5 + val[-3:]
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_sensitive_recursive(obj):
|
||||||
|
"""Recursively mask values whose keys contain 'key' or 'secret'."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
masked = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
if ("key" in k or "secret" in k) and isinstance(v, str):
|
||||||
|
masked[k] = _mask_value(v)
|
||||||
|
else:
|
||||||
|
masked[k] = _mask_sensitive_recursive(v)
|
||||||
|
return masked
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [_mask_sensitive_recursive(item) for item in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def drag_sensitive(config):
|
def drag_sensitive(config):
|
||||||
try:
|
try:
|
||||||
if isinstance(config, str):
|
if isinstance(config, str):
|
||||||
conf_dict: dict = json.loads(config)
|
conf_dict: dict = json.loads(config)
|
||||||
conf_dict_copy = copy.deepcopy(conf_dict)
|
conf_dict_copy = _mask_sensitive_recursive(conf_dict)
|
||||||
for key in conf_dict_copy:
|
|
||||||
if "key" in key or "secret" in key:
|
|
||||||
if isinstance(conf_dict_copy[key], str):
|
|
||||||
conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:]
|
|
||||||
return json.dumps(conf_dict_copy, indent=4)
|
return json.dumps(conf_dict_copy, indent=4)
|
||||||
|
|
||||||
elif isinstance(config, dict):
|
elif isinstance(config, dict):
|
||||||
config_copy = copy.deepcopy(config)
|
return _mask_sensitive_recursive(config)
|
||||||
for key in config:
|
|
||||||
if "key" in key or "secret" in key:
|
|
||||||
if isinstance(config_copy[key], str):
|
|
||||||
config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:]
|
|
||||||
return config_copy
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
---
|
---
|
||||||
title: カスタム
|
title: Custom
|
||||||
description: カスタムベンダー設定。サードパーティ API プロキシやローカルモデル向け
|
description: Custom vendor configuration for third-party API proxies and local models
|
||||||
---
|
---
|
||||||
|
|
||||||
OpenAI 互換プロトコルで接続するサードパーティのモデルサービスや、ローカルにデプロイしたモデルに適しています。例えば:
|
For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as:
|
||||||
|
|
||||||
- **サードパーティ API プロキシ**:統一された API Base から複数のモデルを呼び出す
|
- **Third-party API proxies**: call multiple models through a unified API base
|
||||||
- **ローカルモデル**:Ollama、vLLM、LocalAI などのツールでローカルにデプロイしたモデル
|
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI
|
||||||
- **プライベートデプロイ**:企業内部にデプロイしたモデルサービス
|
- **Private deployments**: model services deployed inside an enterprise
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
`openai` ベンダーとの違い:カスタムベンダーを選択した場合、`/config model` でモデルを切り替えてもベンダータイプは自動で切り替わらず、常にカスタムの API アドレスを使用します。
|
Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
## テキスト対話
|
## Text Chat
|
||||||
|
|
||||||
### サードパーティ API プロキシ
|
### Third-party API proxy
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -26,16 +26,16 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `bot_type` | `custom` に設定する必要があります |
|
| `bot_type` | Must be set to `custom` |
|
||||||
| `model` | モデル名。プロキシサービスがサポートする任意のモデル名を指定 |
|
| `model` | Model name; any model name supported by the proxy service |
|
||||||
| `custom_api_key` | API キー。プロキシサービスから提供されます |
|
| `custom_api_key` | API key provided by the proxy service |
|
||||||
| `custom_api_base` | API アドレス。プロキシサービスから提供され、OpenAI プロトコル互換である必要があります |
|
| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible |
|
||||||
|
|
||||||
### ローカルモデル
|
### Local models
|
||||||
|
|
||||||
ローカルモデルは通常 API Key が不要で、API Base のみ設定します:
|
Local models usually do not require an API key — only the API base needs to be filled in:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -45,38 +45,39 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
一般的なローカルデプロイツールとデフォルトアドレス:
|
Common local deployment tools and their default endpoints:
|
||||||
|
|
||||||
| ツール | デフォルト API Base |
|
| Tool | Default API Base |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [Ollama](https://ollama.com) | `http://localhost:11434/v1` |
|
| [Ollama](https://ollama.com) | `http://localhost:11434/v1` |
|
||||||
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
||||||
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
||||||
|
|
||||||
### モデル切り替え
|
### Switching Models
|
||||||
|
|
||||||
カスタムベンダーでモデルを切り替える際は `model` のみが変更され、`bot_type` と API アドレスは変わりません:
|
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged:
|
||||||
|
|
||||||
```
|
```
|
||||||
/config model qwen3.5:27b
|
/config model qwen3.5:27b
|
||||||
```
|
```
|
||||||
|
|
||||||
## 複数のカスタムベンダーを設定する
|
## Configuring Multiple Custom Providers
|
||||||
|
|
||||||
複数の OpenAI 互換サードパーティサービス(例:SiliconFlow、Qiniu)を同時に設定したい場合は、`custom_providers` リストを使用し、`custom_active_provider` で現在有効なベンダーを指定します:
|
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:<id>"`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "custom",
|
"bot_type": "custom:3f2a9c1b",
|
||||||
"custom_active_provider": "siliconflow",
|
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{
|
{
|
||||||
|
"id": "3f2a9c1b",
|
||||||
"name": "siliconflow",
|
"name": "siliconflow",
|
||||||
"api_key": "YOUR_SILICONFLOW_KEY",
|
"api_key": "YOUR_SILICONFLOW_KEY",
|
||||||
"api_base": "https://api.siliconflow.cn/v1",
|
"api_base": "https://api.siliconflow.cn/v1",
|
||||||
"model": "deepseek-ai/DeepSeek-V3"
|
"model": "deepseek-ai/DeepSeek-V3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "a1b2c3d4",
|
||||||
"name": "qiniu",
|
"name": "qiniu",
|
||||||
"api_key": "YOUR_QINIU_KEY",
|
"api_key": "YOUR_QINIU_KEY",
|
||||||
"api_base": "https://api.qnaigc.com/v1",
|
"api_base": "https://api.qnaigc.com/v1",
|
||||||
@@ -86,11 +87,18 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `custom_providers` | カスタムベンダーのリスト。各項目に `name`、`api_key`、`api_base`、任意の `model` を含む |
|
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` |
|
||||||
| `custom_active_provider` | 有効なベンダーの `name`。空の場合はリストの最初の項目が使用される |
|
| `bot_type` | Set to `"custom:<id>"` to activate a specific provider; `id` is the provider's unique identifier |
|
||||||
|
| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider |
|
||||||
|
| `name` | User-facing display label — can be freely renamed without breaking anything |
|
||||||
|
| `model` | Optional; when set, this model is used instead of the global `model` field |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
`custom_providers` が空の場合、上記の単一ベンダー設定 `custom_api_key` / `custom_api_base` に自動的にフォールバックするため、既存の設定はそのまま動作します。
|
The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`).
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes.
|
||||||
</Note>
|
</Note>
|
||||||
|
|||||||
@@ -63,20 +63,21 @@ Switching models under a custom vendor only changes `model` — `bot_type` and t
|
|||||||
|
|
||||||
## Configuring Multiple Custom Providers
|
## 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`:
|
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:<id>"`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "custom",
|
"bot_type": "custom:3f2a9c1b",
|
||||||
"custom_active_provider": "siliconflow",
|
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{
|
{
|
||||||
|
"id": "3f2a9c1b",
|
||||||
"name": "siliconflow",
|
"name": "siliconflow",
|
||||||
"api_key": "YOUR_SILICONFLOW_KEY",
|
"api_key": "YOUR_SILICONFLOW_KEY",
|
||||||
"api_base": "https://api.siliconflow.cn/v1",
|
"api_base": "https://api.siliconflow.cn/v1",
|
||||||
"model": "deepseek-ai/DeepSeek-V3"
|
"model": "deepseek-ai/DeepSeek-V3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "a1b2c3d4",
|
||||||
"name": "qiniu",
|
"name": "qiniu",
|
||||||
"api_key": "YOUR_QINIU_KEY",
|
"api_key": "YOUR_QINIU_KEY",
|
||||||
"api_base": "https://api.qnaigc.com/v1",
|
"api_base": "https://api.qnaigc.com/v1",
|
||||||
@@ -88,9 +89,16 @@ If you need to configure several OpenAI-compatible third-party services at once
|
|||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `custom_providers` | List of custom providers; each item has `name`, `api_key`, `api_base`, and an optional `model` |
|
| `custom_providers` | List of custom providers; each item has `id`, `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 |
|
| `bot_type` | Set to `"custom:<id>"` to activate a specific provider; `id` is the provider's unique identifier |
|
||||||
|
| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider |
|
||||||
|
| `name` | User-facing display label — can be freely renamed without breaking anything |
|
||||||
|
| `model` | Optional; when set, this model is used instead of the global `model` field |
|
||||||
|
|
||||||
<Note>
|
<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.
|
The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`).
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes.
|
||||||
</Note>
|
</Note>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
---
|
---
|
||||||
title: 自定义
|
title: Custom
|
||||||
description: 自定义厂商配置,适用于第三方 API 代理和本地模型
|
description: Custom vendor configuration for third-party API proxies and local models
|
||||||
---
|
---
|
||||||
|
|
||||||
适用于通过 OpenAI 兼容协议接入的第三方模型服务或本地部署的模型,例如:
|
For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as:
|
||||||
|
|
||||||
- **第三方 API 代理**:使用统一的 API Base 调用多种模型
|
- **Third-party API proxies**: call multiple models through a unified API base
|
||||||
- **本地模型**:通过 Ollama、vLLM、LocalAI 等工具在本地部署的模型
|
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI
|
||||||
- **私有化部署**:企业内部部署的模型服务
|
- **Private deployments**: model services deployed inside an enterprise
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
与 `openai` 厂商的区别:选择自定义厂商后,通过 `/config model` 切换模型时,不会自动切换厂商类型,始终使用自定义的 API 地址。
|
Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
## 文本对话
|
## Text Chat
|
||||||
|
|
||||||
### 第三方 API 代理
|
### Third-party API proxy
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -26,16 +26,16 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `bot_type` | 必须设为 `custom` |
|
| `bot_type` | Must be set to `custom` |
|
||||||
| `model` | 模型名称,填写代理服务支持的任意模型名 |
|
| `model` | Model name; any model name supported by the proxy service |
|
||||||
| `custom_api_key` | API 密钥,由代理服务提供 |
|
| `custom_api_key` | API key provided by the proxy service |
|
||||||
| `custom_api_base` | API 地址,由代理服务提供,需兼容 OpenAI 协议 |
|
| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible |
|
||||||
|
|
||||||
### 本地模型
|
### Local models
|
||||||
|
|
||||||
本地模型通常不需要 API Key,只需填写 API Base:
|
Local models usually do not require an API key — only the API base needs to be filled in:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -45,38 +45,39 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
常见的本地部署工具及默认地址:
|
Common local deployment tools and their default endpoints:
|
||||||
|
|
||||||
| 工具 | 默认 API Base |
|
| Tool | Default API Base |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [Ollama](https://ollama.com) | `http://localhost:11434/v1` |
|
| [Ollama](https://ollama.com) | `http://localhost:11434/v1` |
|
||||||
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
||||||
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
||||||
|
|
||||||
### 切换模型
|
### Switching Models
|
||||||
|
|
||||||
自定义厂商下切换模型时,只会修改 `model`,不会改变 `bot_type` 和 API 地址:
|
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged:
|
||||||
|
|
||||||
```
|
```
|
||||||
/config model qwen3.5:27b
|
/config model qwen3.5:27b
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置多个自定义厂商
|
## Configuring Multiple Custom Providers
|
||||||
|
|
||||||
如果需要同时配置多个第三方 OpenAI 兼容服务(例如硅基流动、七牛云等),可以使用 `custom_providers` 列表,并通过 `custom_active_provider` 指定当前生效的厂商:
|
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:<id>"`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "custom",
|
"bot_type": "custom:3f2a9c1b",
|
||||||
"custom_active_provider": "siliconflow",
|
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{
|
{
|
||||||
|
"id": "3f2a9c1b",
|
||||||
"name": "siliconflow",
|
"name": "siliconflow",
|
||||||
"api_key": "YOUR_SILICONFLOW_KEY",
|
"api_key": "YOUR_SILICONFLOW_KEY",
|
||||||
"api_base": "https://api.siliconflow.cn/v1",
|
"api_base": "https://api.siliconflow.cn/v1",
|
||||||
"model": "deepseek-ai/DeepSeek-V3"
|
"model": "deepseek-ai/DeepSeek-V3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "a1b2c3d4",
|
||||||
"name": "qiniu",
|
"name": "qiniu",
|
||||||
"api_key": "YOUR_QINIU_KEY",
|
"api_key": "YOUR_QINIU_KEY",
|
||||||
"api_base": "https://api.qnaigc.com/v1",
|
"api_base": "https://api.qnaigc.com/v1",
|
||||||
@@ -86,11 +87,18 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `custom_providers` | 自定义厂商列表,每项包含 `name`、`api_key`、`api_base`、可选的 `model` |
|
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` |
|
||||||
| `custom_active_provider` | 当前生效厂商的 `name`;留空时默认使用列表中的第一个厂商 |
|
| `bot_type` | Set to `"custom:<id>"` to activate a specific provider; `id` is the provider's unique identifier |
|
||||||
|
| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider |
|
||||||
|
| `name` | User-facing display label — can be freely renamed without breaking anything |
|
||||||
|
| `model` | Optional; when set, this model is used instead of the global `model` field |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
当 `custom_providers` 为空时,将自动回退到上文的 `custom_api_key` / `custom_api_base` 单厂商配置,已有配置无需改动即可正常工作。
|
The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`).
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes.
|
||||||
</Note>
|
</Note>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def create_bot(bot_type):
|
|||||||
from models.mimo.mimo_bot import MimoBot
|
from models.mimo.mimo_bot import MimoBot
|
||||||
return MimoBot()
|
return MimoBot()
|
||||||
|
|
||||||
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM): # OpenAI-compatible API
|
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM) or bot_type.startswith("custom:"): # OpenAI-compatible API
|
||||||
from models.chatgpt.chat_gpt_bot import ChatGPTBot
|
from models.chatgpt.chat_gpt_bot import ChatGPTBot
|
||||||
return ChatGPTBot()
|
return ChatGPTBot()
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from common import const
|
|||||||
from common.i18n import t as _t
|
from common.i18n import t as _t
|
||||||
from models.bot import Bot
|
from models.bot import Bot
|
||||||
from models.openai_compatible_bot import OpenAICompatibleBot
|
from models.openai_compatible_bot import OpenAICompatibleBot
|
||||||
from models.custom_provider import resolve_custom_credentials
|
from models.custom_provider import resolve_custom_credentials, parse_custom_bot_type
|
||||||
from models.chatgpt.chat_gpt_session import ChatGPTSession
|
from models.chatgpt.chat_gpt_session import ChatGPTSession
|
||||||
from models.openai.open_ai_image import OpenAIImage
|
from models.openai.open_ai_image import OpenAIImage
|
||||||
from models.session_manager import SessionManager
|
from models.session_manager import SessionManager
|
||||||
@@ -33,10 +33,12 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Resolve api key / base from config (no global SDK state anymore).
|
# Resolve api key / base from config (no global SDK state anymore).
|
||||||
if conf().get("bot_type") == "custom":
|
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
|
||||||
# Supports multiple custom providers (custom_providers) with
|
custom_model = None
|
||||||
# automatic fallback to the legacy custom_api_key/base fields.
|
if is_custom:
|
||||||
self._api_key, self._api_base, _ = resolve_custom_credentials()
|
# Supports multiple custom providers via bot_type "custom:<id>"
|
||||||
|
# with automatic fallback to the legacy custom_api_key/base fields.
|
||||||
|
self._api_key, self._api_base, custom_model = resolve_custom_credentials()
|
||||||
else:
|
else:
|
||||||
self._api_key = conf().get("open_ai_api_key")
|
self._api_key = conf().get("open_ai_api_key")
|
||||||
self._api_base = conf().get("open_ai_api_base") or None
|
self._api_base = conf().get("open_ai_api_base") or None
|
||||||
@@ -48,8 +50,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
|||||||
)
|
)
|
||||||
if conf().get("rate_limit_chatgpt"):
|
if conf().get("rate_limit_chatgpt"):
|
||||||
self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20))
|
self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20))
|
||||||
conf_model = conf().get("model") or "gpt-3.5-turbo"
|
# Per-provider model takes precedence over global model.
|
||||||
self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")
|
conf_model = custom_model or conf().get("model") or "gpt-3.5-turbo"
|
||||||
|
self.sessions = SessionManager(ChatGPTSession, model=conf_model)
|
||||||
# o1相关模型不支持system prompt,暂时用文心模型的session
|
# o1相关模型不支持system prompt,暂时用文心模型的session
|
||||||
|
|
||||||
self.args = {
|
self.args = {
|
||||||
@@ -72,7 +75,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
|||||||
|
|
||||||
def get_api_config(self):
|
def get_api_config(self):
|
||||||
"""Get API configuration for OpenAI-compatible base class"""
|
"""Get API configuration for OpenAI-compatible base class"""
|
||||||
is_custom = conf().get("bot_type") == "custom"
|
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
|
||||||
if is_custom:
|
if is_custom:
|
||||||
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
||||||
api_key = custom_key
|
api_key = custom_key
|
||||||
@@ -196,7 +199,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
|||||||
mime_type = mime_type_map.get(extension, "image/jpeg")
|
mime_type = mime_type_map.get(extension, "image/jpeg")
|
||||||
|
|
||||||
# Get model and API config
|
# Get model and API config
|
||||||
is_custom = conf().get("bot_type") == "custom"
|
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
|
||||||
if is_custom:
|
if is_custom:
|
||||||
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
||||||
model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o")
|
model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o")
|
||||||
|
|||||||
@@ -13,69 +13,110 @@ Config model
|
|||||||
- ``custom_providers``: list of dicts, each describing one custom provider::
|
- ``custom_providers``: list of dicts, each describing one custom provider::
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "siliconflow", # unique, user-facing identifier
|
"id": "3f2a9c1b", # server-generated short uuid (primary key)
|
||||||
|
"name": "siliconflow", # user-facing display label (not a key)
|
||||||
"api_key": "sk-...", # required
|
"api_key": "sk-...", # required
|
||||||
"api_base": "https://...", # required, must be OpenAI-compatible
|
"api_base": "https://...", # required, must be OpenAI-compatible
|
||||||
"model": "deepseek-ai/DeepSeek-V3" # optional default model
|
"model": "deepseek-ai/DeepSeek-V3" # optional default model
|
||||||
}
|
}
|
||||||
|
|
||||||
- ``custom_active_provider``: the ``name`` of the provider to use. When empty
|
Routing
|
||||||
(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``.
|
- ``bot_type: "custom"`` (legacy): reads the flat ``custom_api_key`` / ``custom_api_base``.
|
||||||
|
- ``bot_type: "custom:<id>"`` (multi-provider): looks up the provider by id in
|
||||||
|
``custom_providers``. There is a single source of truth — no separate
|
||||||
|
``custom_active_provider`` field.
|
||||||
|
|
||||||
Backward-compatibility contract
|
Backward-compatibility contract
|
||||||
-------------------------------
|
-------------------------------
|
||||||
When ``custom_providers`` is empty, ``resolve_custom_credentials`` returns
|
When ``bot_type`` is exactly ``"custom"`` (no colon suffix), behaviour is
|
||||||
exactly the legacy ``custom_api_key`` / ``custom_api_base`` values, so existing
|
unchanged: we return ``custom_api_key`` / ``custom_api_base`` values.
|
||||||
deployments behave byte-for-byte identically.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
from config import conf
|
from config import conf
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
def generate_provider_id() -> str:
|
||||||
|
"""Generate a short random id for a new custom provider."""
|
||||||
|
return uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
|
||||||
def get_custom_providers():
|
def get_custom_providers():
|
||||||
"""Return the list of configured custom providers (always a list)."""
|
"""Return the list of configured custom providers (always a list)."""
|
||||||
providers = conf().get("custom_providers")
|
providers = conf().get("custom_providers")
|
||||||
if not isinstance(providers, list):
|
if not isinstance(providers, list):
|
||||||
return []
|
return []
|
||||||
# Keep only well-formed entries with a name.
|
# Keep only well-formed entries with an id.
|
||||||
return [p for p in providers if isinstance(p, dict) and p.get("name")]
|
return [p for p in providers if isinstance(p, dict) and p.get("id")]
|
||||||
|
|
||||||
|
|
||||||
def _find_active_provider(providers):
|
def _find_provider_by_id(providers, provider_id):
|
||||||
"""Pick the active provider from the list, or None when list is empty."""
|
"""Look up a provider by its id, or None if not found."""
|
||||||
if not providers:
|
if not providers or not provider_id:
|
||||||
return None
|
return None
|
||||||
active_name = conf().get("custom_active_provider") or ""
|
|
||||||
if active_name:
|
|
||||||
for p in providers:
|
for p in providers:
|
||||||
if p.get("name") == active_name:
|
if p.get("id") == provider_id:
|
||||||
return p
|
return p
|
||||||
logger.warning(
|
return None
|
||||||
"[CUSTOM] active provider '%s' not found in custom_providers, "
|
|
||||||
"falling back to the first entry", active_name
|
|
||||||
)
|
def parse_custom_bot_type(bot_type):
|
||||||
return providers[0]
|
"""Parse bot_type to extract custom provider id.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_custom, provider_id) where:
|
||||||
|
- is_custom: True if bot_type starts with "custom"
|
||||||
|
- provider_id: the id suffix (e.g. "3f2a9c1b") or empty string for legacy mode
|
||||||
|
"""
|
||||||
|
if not bot_type or not isinstance(bot_type, str):
|
||||||
|
return False, ""
|
||||||
|
if bot_type == "custom":
|
||||||
|
return True, ""
|
||||||
|
if bot_type.startswith("custom:"):
|
||||||
|
return True, bot_type[7:] # len("custom:") == 7
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
def resolve_custom_credentials():
|
def resolve_custom_credentials():
|
||||||
"""Resolve the effective (api_key, api_base, model) for custom mode.
|
"""Resolve the effective (api_key, api_base, model) for custom mode.
|
||||||
|
|
||||||
Resolution order:
|
Resolution order:
|
||||||
1. The active entry in ``custom_providers`` (multi-provider mode).
|
1. If ``bot_type`` is ``"custom:<id>"``, look up that id in
|
||||||
2. The legacy flat keys ``custom_api_key`` / ``custom_api_base``.
|
``custom_providers``.
|
||||||
|
2. If ``bot_type`` is exactly ``"custom"`` (legacy), return the flat
|
||||||
|
``custom_api_key`` / ``custom_api_base``.
|
||||||
|
|
||||||
:return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model``
|
:return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model``
|
||||||
may be ``None`` / empty when not configured.
|
may be ``None`` / empty when not configured.
|
||||||
"""
|
"""
|
||||||
provider = _find_active_provider(get_custom_providers())
|
bot_type = conf().get("bot_type", "")
|
||||||
|
is_custom, provider_id = parse_custom_bot_type(bot_type)
|
||||||
|
|
||||||
|
if not is_custom:
|
||||||
|
# Not custom at all — should not happen but be defensive.
|
||||||
|
return (
|
||||||
|
conf().get("open_ai_api_key", ""),
|
||||||
|
conf().get("open_ai_api_base") or None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if provider_id:
|
||||||
|
# Multi-provider mode: look up by id.
|
||||||
|
providers = get_custom_providers()
|
||||||
|
provider = _find_provider_by_id(providers, provider_id)
|
||||||
if provider is not None:
|
if provider is not None:
|
||||||
return (
|
return (
|
||||||
provider.get("api_key", ""),
|
provider.get("api_key", ""),
|
||||||
provider.get("api_base") or None,
|
provider.get("api_base") or None,
|
||||||
provider.get("model") or None,
|
provider.get("model") or None,
|
||||||
)
|
)
|
||||||
|
logger.warning(
|
||||||
|
"[CUSTOM] provider id '%s' not found in custom_providers, "
|
||||||
|
"falling back to legacy fields", provider_id
|
||||||
|
)
|
||||||
|
|
||||||
# Legacy single-provider fallback — unchanged behavior.
|
# Legacy single-provider fallback — unchanged behavior.
|
||||||
return (
|
return (
|
||||||
conf().get("custom_api_key", ""),
|
conf().get("custom_api_key", ""),
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ Unit tests for multiple custom (OpenAI-compatible) provider support (issue #2838
|
|||||||
|
|
||||||
Covers models/custom_provider.py:
|
Covers models/custom_provider.py:
|
||||||
- Backward compatibility: legacy custom_api_key / custom_api_base fallback
|
- Backward compatibility: legacy custom_api_key / custom_api_base fallback
|
||||||
- Multi-provider selection via custom_providers / custom_active_provider
|
- Multi-provider selection via bot_type "custom:<id>" routing
|
||||||
- Robustness against malformed config (missing name, non-dict, non-list)
|
- parse_custom_bot_type helper
|
||||||
|
- Robustness against malformed config (missing id, non-dict, non-list)
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -23,11 +24,43 @@ def set_conf(d):
|
|||||||
config_module.config = Config(d)
|
config_module.config = Config(d)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCustomBotType(unittest.TestCase):
|
||||||
|
"""parse_custom_bot_type() parsing logic."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
self.parse = parse_custom_bot_type
|
||||||
|
|
||||||
|
def test_legacy_custom(self):
|
||||||
|
is_custom, pid = self.parse("custom")
|
||||||
|
self.assertTrue(is_custom)
|
||||||
|
self.assertEqual(pid, "")
|
||||||
|
|
||||||
|
def test_custom_with_id(self):
|
||||||
|
is_custom, pid = self.parse("custom:3f2a9c1b")
|
||||||
|
self.assertTrue(is_custom)
|
||||||
|
self.assertEqual(pid, "3f2a9c1b")
|
||||||
|
|
||||||
|
def test_non_custom(self):
|
||||||
|
is_custom, pid = self.parse("openai")
|
||||||
|
self.assertFalse(is_custom)
|
||||||
|
self.assertEqual(pid, "")
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
is_custom, pid = self.parse("")
|
||||||
|
self.assertFalse(is_custom)
|
||||||
|
self.assertEqual(pid, "")
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
is_custom, pid = self.parse(None)
|
||||||
|
self.assertFalse(is_custom)
|
||||||
|
self.assertEqual(pid, "")
|
||||||
|
|
||||||
|
|
||||||
class TestResolveCustomCredentials(unittest.TestCase):
|
class TestResolveCustomCredentials(unittest.TestCase):
|
||||||
"""resolve_custom_credentials() resolution order and fallbacks."""
|
"""resolve_custom_credentials() resolution order and fallbacks."""
|
||||||
|
|
||||||
def setUp(self):
|
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
|
from models.custom_provider import resolve_custom_credentials, get_custom_providers
|
||||||
self.resolve = resolve_custom_credentials
|
self.resolve = resolve_custom_credentials
|
||||||
self.get_providers = get_custom_providers
|
self.get_providers = get_custom_providers
|
||||||
@@ -49,31 +82,15 @@ class TestResolveCustomCredentials(unittest.TestCase):
|
|||||||
set_conf({"bot_type": "custom"})
|
set_conf({"bot_type": "custom"})
|
||||||
self.assertEqual(self.resolve(), ("", None, None))
|
self.assertEqual(self.resolve(), ("", None, None))
|
||||||
|
|
||||||
# --- Multi-provider selection ---
|
# --- Multi-provider selection via bot_type ---
|
||||||
|
|
||||||
def test_multi_providers_no_active_uses_first(self):
|
def test_provider_selected_by_id(self):
|
||||||
set_conf({
|
set_conf({
|
||||||
"bot_type": "custom",
|
"bot_type": "custom:abc12345",
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{"name": "siliconflow", "api_key": "sf-key",
|
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
|
||||||
"api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"},
|
"api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"},
|
||||||
{"name": "qiniu", "api_key": "qn-key",
|
{"id": "abc12345", "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"},
|
"api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -82,25 +99,26 @@ class TestResolveCustomCredentials(unittest.TestCase):
|
|||||||
("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"),
|
("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_active_name_missing_falls_back_to_first(self):
|
def test_id_not_found_falls_back_to_legacy(self):
|
||||||
set_conf({
|
set_conf({
|
||||||
"bot_type": "custom",
|
"bot_type": "custom:ghost",
|
||||||
"custom_active_provider": "ghost",
|
"custom_api_key": "legacy-key",
|
||||||
|
"custom_api_base": "https://legacy.example.com/v1",
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{"name": "siliconflow", "api_key": "sf-key",
|
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
|
||||||
"api_base": "https://api.siliconflow.cn/v1"},
|
"api_base": "https://api.siliconflow.cn/v1"},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.resolve(),
|
self.resolve(),
|
||||||
("sf-key", "https://api.siliconflow.cn/v1", None),
|
("legacy-key", "https://legacy.example.com/v1", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_provider_without_model_returns_none_model(self):
|
def test_provider_without_model_returns_none_model(self):
|
||||||
set_conf({
|
set_conf({
|
||||||
"bot_type": "custom",
|
"bot_type": "custom:local01",
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{"name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"},
|
{"id": "local01", "name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -112,11 +130,11 @@ class TestResolveCustomCredentials(unittest.TestCase):
|
|||||||
|
|
||||||
def test_malformed_entries_filtered_and_fallback(self):
|
def test_malformed_entries_filtered_and_fallback(self):
|
||||||
set_conf({
|
set_conf({
|
||||||
"bot_type": "custom",
|
"bot_type": "custom:nope",
|
||||||
"custom_api_key": "legacy-key",
|
"custom_api_key": "legacy-key",
|
||||||
"custom_api_base": "https://legacy.example.com/v1",
|
"custom_api_base": "https://legacy.example.com/v1",
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{"api_key": "no-name-key"}, # invalid: no name
|
{"name": "no-id", "api_key": "no-id-key"}, # invalid: no id
|
||||||
"not-a-dict", # invalid: wrong type
|
"not-a-dict", # invalid: wrong type
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -130,14 +148,14 @@ class TestResolveCustomCredentials(unittest.TestCase):
|
|||||||
set_conf({
|
set_conf({
|
||||||
"bot_type": "custom",
|
"bot_type": "custom",
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{"name": "ok", "api_key": "k", "api_base": "https://x/v1"},
|
{"id": "ok1", "name": "ok", "api_key": "k", "api_base": "https://x/v1"},
|
||||||
{"api_key": "no-name"}, # dropped
|
{"name": "no-id", "api_key": "no-id"}, # dropped: no id
|
||||||
123, # dropped
|
123, # dropped
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
providers = self.get_providers()
|
providers = self.get_providers()
|
||||||
self.assertEqual(len(providers), 1)
|
self.assertEqual(len(providers), 1)
|
||||||
self.assertEqual(providers[0]["name"], "ok")
|
self.assertEqual(providers[0]["id"], "ok1")
|
||||||
|
|
||||||
def test_custom_providers_not_a_list_falls_back(self):
|
def test_custom_providers_not_a_list_falls_back(self):
|
||||||
set_conf({
|
set_conf({
|
||||||
@@ -152,6 +170,22 @@ class TestResolveCustomCredentials(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateProviderId(unittest.TestCase):
|
||||||
|
"""generate_provider_id() produces valid short ids."""
|
||||||
|
|
||||||
|
def test_length_and_hex(self):
|
||||||
|
from models.custom_provider import generate_provider_id
|
||||||
|
pid = generate_provider_id()
|
||||||
|
self.assertEqual(len(pid), 8)
|
||||||
|
# Must be valid hex characters
|
||||||
|
int(pid, 16)
|
||||||
|
|
||||||
|
def test_uniqueness(self):
|
||||||
|
from models.custom_provider import generate_provider_id
|
||||||
|
ids = {generate_provider_id() for _ in range(100)}
|
||||||
|
self.assertEqual(len(ids), 100)
|
||||||
|
|
||||||
|
|
||||||
class TestConfigDefaults(unittest.TestCase):
|
class TestConfigDefaults(unittest.TestCase):
|
||||||
"""The new config fields must exist with safe defaults."""
|
"""The new config fields must exist with safe defaults."""
|
||||||
|
|
||||||
@@ -160,10 +194,46 @@ class TestConfigDefaults(unittest.TestCase):
|
|||||||
self.assertIn("custom_providers", available_setting)
|
self.assertIn("custom_providers", available_setting)
|
||||||
self.assertEqual(available_setting["custom_providers"], [])
|
self.assertEqual(available_setting["custom_providers"], [])
|
||||||
|
|
||||||
def test_default_config_has_active_provider(self):
|
def test_default_config_no_custom_active_provider(self):
|
||||||
|
"""custom_active_provider was removed — replaced by bot_type routing."""
|
||||||
from config import available_setting
|
from config import available_setting
|
||||||
self.assertIn("custom_active_provider", available_setting)
|
self.assertNotIn("custom_active_provider", available_setting)
|
||||||
self.assertEqual(available_setting["custom_active_provider"], "")
|
|
||||||
|
|
||||||
|
class TestDragSensitiveNested(unittest.TestCase):
|
||||||
|
"""drag_sensitive() must mask api_key in nested structures."""
|
||||||
|
|
||||||
|
def test_nested_api_key_masked(self):
|
||||||
|
from config import drag_sensitive
|
||||||
|
import json
|
||||||
|
test_config = {
|
||||||
|
"open_ai_api_key": "sk-1234567890abcdef",
|
||||||
|
"custom_providers": [
|
||||||
|
{"id": "x1", "name": "test", "api_key": "sk-nested-secret-key-long", "api_base": "https://x/v1"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = drag_sensitive(test_config)
|
||||||
|
# Top-level key should be masked
|
||||||
|
self.assertNotIn("1234567890abcdef", str(result))
|
||||||
|
# Nested key should also be masked
|
||||||
|
self.assertNotIn("nested-secret-key-long", str(result))
|
||||||
|
# But the id/name/api_base should not be masked
|
||||||
|
self.assertIn("x1", str(result))
|
||||||
|
self.assertIn("test", str(result))
|
||||||
|
self.assertIn("https://x/v1", str(result))
|
||||||
|
|
||||||
|
def test_string_config_masked(self):
|
||||||
|
from config import drag_sensitive
|
||||||
|
import json
|
||||||
|
test_str = json.dumps({
|
||||||
|
"open_ai_api_key": "sk-1234567890abcdef",
|
||||||
|
"custom_providers": [
|
||||||
|
{"id": "x1", "api_key": "sk-nested-very-long-secret"}
|
||||||
|
],
|
||||||
|
})
|
||||||
|
result = drag_sensitive(test_str)
|
||||||
|
self.assertNotIn("1234567890abcdef", result)
|
||||||
|
self.assertNotIn("nested-very-long-secret", result)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ Unit tests for the multi custom-provider management API (issue #2838, web UI).
|
|||||||
|
|
||||||
Covers channel/web/web_channel.py::ModelsHandler:
|
Covers channel/web/web_channel.py::ModelsHandler:
|
||||||
- _custom_provider_cards / _provider_overview expansion
|
- _custom_provider_cards / _provider_overview expansion
|
||||||
- _handle_set_custom_provider (create / edit / rename / activate)
|
- _handle_set_custom_provider (create / edit / activate)
|
||||||
- _handle_delete_custom_provider
|
- _handle_delete_custom_provider
|
||||||
- _handle_set_active_custom_provider
|
- _handle_set_active_custom_provider
|
||||||
|
|
||||||
These handlers are normally driven by the `web.py` framework, which isn't
|
Uses id-based routing (bot_type: "custom:<id>") — no custom_active_provider.
|
||||||
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 json
|
||||||
import os
|
import os
|
||||||
@@ -72,7 +69,7 @@ class _HandlerHarness:
|
|||||||
|
|
||||||
class TestSetCustomProvider(unittest.TestCase):
|
class TestSetCustomProvider(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""})
|
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||||
self.h = _HandlerHarness()
|
self.h = _HandlerHarness()
|
||||||
|
|
||||||
def test_create_first_provider_auto_activates(self):
|
def test_create_first_provider_auto_activates(self):
|
||||||
@@ -80,11 +77,15 @@ class TestSetCustomProvider(unittest.TestCase):
|
|||||||
api_base="https://api.siliconflow.cn/v1", api_key="sf-key")
|
api_base="https://api.siliconflow.cn/v1", api_key="sf-key")
|
||||||
self.assertEqual(res["status"], "success")
|
self.assertEqual(res["status"], "success")
|
||||||
self.assertTrue(res["created"])
|
self.assertTrue(res["created"])
|
||||||
self.assertEqual(res["active"], "siliconflow")
|
self.assertIn("id", res)
|
||||||
|
# bot_type should be updated to "custom:<id>"
|
||||||
|
bot_type = config_module.conf().get("bot_type")
|
||||||
|
self.assertTrue(bot_type.startswith("custom:"))
|
||||||
|
self.assertEqual(bot_type, f"custom:{res['id']}")
|
||||||
providers = config_module.conf().get("custom_providers")
|
providers = config_module.conf().get("custom_providers")
|
||||||
self.assertEqual(len(providers), 1)
|
self.assertEqual(len(providers), 1)
|
||||||
|
self.assertEqual(providers[0]["id"], res["id"])
|
||||||
self.assertEqual(providers[0]["name"], "siliconflow")
|
self.assertEqual(providers[0]["name"], "siliconflow")
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "siliconflow")
|
|
||||||
self.assertEqual(self.h.bridge_resets, 1)
|
self.assertEqual(self.h.bridge_resets, 1)
|
||||||
|
|
||||||
def test_create_requires_api_base(self):
|
def test_create_requires_api_base(self):
|
||||||
@@ -97,161 +98,158 @@ class TestSetCustomProvider(unittest.TestCase):
|
|||||||
self.assertEqual(res["status"], "error")
|
self.assertEqual(res["status"], "error")
|
||||||
|
|
||||||
def test_second_provider_does_not_steal_active(self):
|
def test_second_provider_does_not_steal_active(self):
|
||||||
self.h.call(action="set_custom_provider", name="a",
|
res1 = self.h.call(action="set_custom_provider", name="a",
|
||||||
api_base="https://a/v1", api_key="ak")
|
api_base="https://a/v1", api_key="ak")
|
||||||
res = self.h.call(action="set_custom_provider", name="b",
|
res2 = self.h.call(action="set_custom_provider", name="b",
|
||||||
api_base="https://b/v1", api_key="bk")
|
api_base="https://b/v1", api_key="bk")
|
||||||
self.assertTrue(res["created"])
|
self.assertTrue(res2["created"])
|
||||||
# First provider stays active unless make_active is requested.
|
# First provider stays active unless make_active is requested.
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "a")
|
bot_type = config_module.conf().get("bot_type")
|
||||||
|
self.assertEqual(bot_type, f"custom:{res1['id']}")
|
||||||
|
|
||||||
def test_make_active_flag(self):
|
def test_make_active_flag(self):
|
||||||
self.h.call(action="set_custom_provider", name="a",
|
self.h.call(action="set_custom_provider", name="a",
|
||||||
api_base="https://a/v1", api_key="ak")
|
api_base="https://a/v1", api_key="ak")
|
||||||
self.h.call(action="set_custom_provider", name="b",
|
res2 = self.h.call(action="set_custom_provider", name="b",
|
||||||
api_base="https://b/v1", api_key="bk", make_active=True)
|
api_base="https://b/v1", api_key="bk", make_active=True)
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "b")
|
bot_type = config_module.conf().get("bot_type")
|
||||||
|
self.assertEqual(bot_type, f"custom:{res2['id']}")
|
||||||
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):
|
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",
|
res = self.h.call(action="set_custom_provider", name="a",
|
||||||
original_name="a", api_base="https://a2/v1")
|
api_base="https://a/v1", api_key="secret")
|
||||||
self.assertEqual(res["status"], "success")
|
pid = res["id"]
|
||||||
self.assertFalse(res["created"])
|
# Edit only the base; omit api_key.
|
||||||
|
res2 = self.h.call(action="set_custom_provider", name="a",
|
||||||
|
id=pid, api_base="https://a2/v1")
|
||||||
|
self.assertEqual(res2["status"], "success")
|
||||||
|
self.assertFalse(res2["created"])
|
||||||
providers = config_module.conf().get("custom_providers")
|
providers = config_module.conf().get("custom_providers")
|
||||||
self.assertEqual(providers[0]["api_base"], "https://a2/v1")
|
self.assertEqual(providers[0]["api_base"], "https://a2/v1")
|
||||||
self.assertEqual(providers[0]["api_key"], "secret") # preserved
|
self.assertEqual(providers[0]["api_key"], "secret") # preserved
|
||||||
|
|
||||||
def test_rename_updates_active_pointer(self):
|
def test_edit_can_rename(self):
|
||||||
self.h.call(action="set_custom_provider", name="old",
|
res = self.h.call(action="set_custom_provider", name="old",
|
||||||
api_base="https://a/v1", api_key="ak")
|
api_base="https://a/v1", api_key="ak")
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "old")
|
pid = res["id"]
|
||||||
res = self.h.call(action="set_custom_provider", name="new",
|
res2 = self.h.call(action="set_custom_provider", name="new",
|
||||||
original_name="old", api_base="https://a/v1")
|
id=pid, api_base="https://a/v1")
|
||||||
self.assertEqual(res["status"], "success")
|
self.assertEqual(res2["status"], "success")
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "new")
|
providers = config_module.conf().get("custom_providers")
|
||||||
names = [p["name"] for p in config_module.conf().get("custom_providers")]
|
self.assertEqual(providers[0]["name"], "new")
|
||||||
self.assertEqual(names, ["new"])
|
# ID stays the same
|
||||||
|
self.assertEqual(providers[0]["id"], pid)
|
||||||
|
|
||||||
def test_edit_clears_model_when_empty(self):
|
def test_edit_clears_model_when_empty(self):
|
||||||
self.h.call(action="set_custom_provider", name="a",
|
res = self.h.call(action="set_custom_provider", name="a",
|
||||||
api_base="https://a/v1", api_key="ak", model="m1")
|
api_base="https://a/v1", api_key="ak", model="m1")
|
||||||
|
pid = res["id"]
|
||||||
self.assertEqual(config_module.conf().get("custom_providers")[0]["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",
|
self.h.call(action="set_custom_provider", name="a", id=pid,
|
||||||
api_base="https://a/v1", model="")
|
api_base="https://a/v1", model="")
|
||||||
self.assertNotIn("model", config_module.conf().get("custom_providers")[0])
|
self.assertNotIn("model", config_module.conf().get("custom_providers")[0])
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteCustomProvider(unittest.TestCase):
|
class TestDeleteCustomProvider(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""})
|
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||||
self.h = _HandlerHarness()
|
self.h = _HandlerHarness()
|
||||||
self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak")
|
self.res_a = self.h.call(action="set_custom_provider", name="a",
|
||||||
self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk")
|
api_base="https://a/v1", api_key="ak")
|
||||||
|
self.res_b = self.h.call(action="set_custom_provider", name="b",
|
||||||
|
api_base="https://b/v1", api_key="bk")
|
||||||
|
|
||||||
def test_delete_unknown(self):
|
def test_delete_unknown(self):
|
||||||
res = self.h.call(action="delete_custom_provider", name="ghost")
|
res = self.h.call(action="delete_custom_provider", id="ghost")
|
||||||
self.assertEqual(res["status"], "error")
|
self.assertEqual(res["status"], "error")
|
||||||
|
|
||||||
def test_delete_non_active(self):
|
def test_delete_non_active(self):
|
||||||
res = self.h.call(action="delete_custom_provider", name="b")
|
res = self.h.call(action="delete_custom_provider", id=self.res_b["id"])
|
||||||
self.assertEqual(res["status"], "success")
|
self.assertEqual(res["status"], "success")
|
||||||
names = [p["name"] for p in config_module.conf().get("custom_providers")]
|
ids = [p["id"] for p in config_module.conf().get("custom_providers")]
|
||||||
self.assertEqual(names, ["a"])
|
self.assertEqual(ids, [self.res_a["id"]])
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "a")
|
# bot_type unchanged (still pointing to a)
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
|
||||||
|
|
||||||
def test_delete_active_falls_back_to_first_remaining(self):
|
def test_delete_active_falls_back_to_first_remaining(self):
|
||||||
# 'a' is active (created first); deleting it should re-point to 'b'.
|
# 'a' is active (created first); deleting it should re-point to 'b'.
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "a")
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
|
||||||
res = self.h.call(action="delete_custom_provider", name="a")
|
res = self.h.call(action="delete_custom_provider", id=self.res_a["id"])
|
||||||
self.assertEqual(res["status"], "success")
|
self.assertEqual(res["status"], "success")
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "b")
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}")
|
||||||
|
|
||||||
def test_delete_last_clears_active(self):
|
def test_delete_last_reverts_to_legacy(self):
|
||||||
self.h.call(action="delete_custom_provider", name="a")
|
self.h.call(action="delete_custom_provider", id=self.res_a["id"])
|
||||||
self.h.call(action="delete_custom_provider", name="b")
|
self.h.call(action="delete_custom_provider", id=self.res_b["id"])
|
||||||
self.assertEqual(config_module.conf().get("custom_providers"), [])
|
self.assertEqual(config_module.conf().get("custom_providers"), [])
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "")
|
# When all providers deleted, reverts to legacy "custom"
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), "custom")
|
||||||
|
|
||||||
|
|
||||||
class TestSetActiveCustomProvider(unittest.TestCase):
|
class TestSetActiveCustomProvider(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""})
|
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||||
self.h = _HandlerHarness()
|
self.h = _HandlerHarness()
|
||||||
self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak")
|
self.res_a = self.h.call(action="set_custom_provider", name="a",
|
||||||
self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk")
|
api_base="https://a/v1", api_key="ak")
|
||||||
|
self.res_b = self.h.call(action="set_custom_provider", name="b",
|
||||||
|
api_base="https://b/v1", api_key="bk")
|
||||||
|
|
||||||
def test_set_active_valid(self):
|
def test_set_active_valid(self):
|
||||||
res = self.h.call(action="set_active_custom_provider", name="b")
|
res = self.h.call(action="set_active_custom_provider", id=self.res_b["id"])
|
||||||
self.assertEqual(res["status"], "success")
|
self.assertEqual(res["status"], "success")
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "b")
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}")
|
||||||
|
|
||||||
def test_set_active_unknown(self):
|
def test_set_active_unknown(self):
|
||||||
res = self.h.call(action="set_active_custom_provider", name="ghost")
|
res = self.h.call(action="set_active_custom_provider", id="ghost")
|
||||||
self.assertEqual(res["status"], "error")
|
self.assertEqual(res["status"], "error")
|
||||||
self.assertEqual(config_module.conf().get("custom_active_provider"), "a")
|
# bot_type unchanged
|
||||||
|
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
|
||||||
|
|
||||||
|
|
||||||
class TestProviderOverviewExpansion(unittest.TestCase):
|
class TestProviderOverviewExpansion(unittest.TestCase):
|
||||||
"""_provider_overview / _custom_provider_cards should expand the list."""
|
"""_provider_overview / _custom_provider_cards should expand the list."""
|
||||||
|
|
||||||
def test_no_custom_providers_keeps_single_card(self):
|
def test_no_custom_providers_keeps_single_card(self):
|
||||||
set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""})
|
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||||
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
||||||
self.assertEqual(cards, [])
|
self.assertEqual(cards, [])
|
||||||
overview = ModelsHandler._provider_overview()
|
overview = ModelsHandler._provider_overview()
|
||||||
custom_cards = [c for c in overview if c.get("id") == "custom"]
|
custom_cards = [c for c in overview if c.get("id") == "custom"]
|
||||||
# Legacy single custom card remains present.
|
# Legacy single custom card remains present.
|
||||||
self.assertEqual(len(custom_cards), 1)
|
self.assertEqual(len(custom_cards), 1)
|
||||||
self.assertTrue(custom_cards[0].get("is_custom"))
|
|
||||||
|
|
||||||
def test_multi_providers_expand_into_cards(self):
|
def test_multi_providers_expand_into_cards(self):
|
||||||
set_conf({
|
set_conf({
|
||||||
"bot_type": "custom",
|
"bot_type": "custom:id_b",
|
||||||
"custom_active_provider": "b",
|
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{"name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
{"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
||||||
{"name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"},
|
{"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
overview = ModelsHandler._provider_overview()
|
overview = ModelsHandler._provider_overview()
|
||||||
custom_cards = [c for c in overview if c.get("is_custom")]
|
custom_cards = [c for c in overview if c.get("is_custom")]
|
||||||
self.assertEqual(len(custom_cards), 2)
|
self.assertEqual(len(custom_cards), 2)
|
||||||
by_name = {c["custom_name"]: c for c in custom_cards}
|
by_id = {c["custom_id"]: c for c in custom_cards}
|
||||||
self.assertEqual(by_name["a"]["id"], "custom:a")
|
self.assertEqual(by_id["id_a"]["id"], "custom:id_a")
|
||||||
self.assertFalse(by_name["a"]["active"])
|
self.assertFalse(by_id["id_a"]["active"])
|
||||||
self.assertTrue(by_name["b"]["active"])
|
self.assertTrue(by_id["id_b"]["active"])
|
||||||
self.assertEqual(by_name["b"]["model"], "m")
|
self.assertEqual(by_id["id_b"]["model"], "m")
|
||||||
# No single legacy "custom" card when expanded.
|
# No single legacy "custom" card when expanded.
|
||||||
self.assertFalse(any(c.get("id") == "custom" for c in overview))
|
self.assertFalse(any(c.get("id") == "custom" for c in overview))
|
||||||
|
|
||||||
def test_active_defaults_to_first_when_unset(self):
|
def test_no_active_shows_none_active(self):
|
||||||
|
"""When bot_type is plain 'custom', no card is marked active."""
|
||||||
set_conf({
|
set_conf({
|
||||||
"bot_type": "custom",
|
"bot_type": "custom",
|
||||||
"custom_active_provider": "",
|
|
||||||
"custom_providers": [
|
"custom_providers": [
|
||||||
{"name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
{"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
||||||
{"name": "b", "api_key": "bk", "api_base": "https://b/v1"},
|
{"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1"},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
||||||
by_name = {c["custom_name"]: c for c in cards}
|
active_cards = [c for c in cards if c.get("active")]
|
||||||
self.assertTrue(by_name["a"]["active"])
|
self.assertEqual(len(active_cards), 0)
|
||||||
self.assertFalse(by_name["b"]["active"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user