feat(web): integrate custom providers into the provider credentials section

- Merge the separate custom-providers section into the unified provider
  grid; "Custom" in the add-provider picker now acts as an add-new action
  (trailing + mark) and opens the dedicated modal, supporting multiple
  OpenAI-compatible endpoints
- Simplify the custom provider modal: drop the default-model field, add
  an inline delete button, align colors with the theme
- Keep the legacy single "custom" card visible (models page, chat
  dropdown and legacy config page) while custom_api_key/custom_api_base
  is still in use, so existing single-provider setups don't disappear
- Unify user-facing wording from "vendor" to "provider" in UI and docs
- Restructure custom provider docs (zh/en/ja) around Web console and
  config file usage
This commit is contained in:
zhayujie
2026-06-12 18:03:33 +08:00
parent 63bfab03f6
commit 6b5ee245ae
15 changed files with 234 additions and 400 deletions

View File

@@ -1171,8 +1171,8 @@
w-full max-w-md mx-4">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-violet-50 dark:bg-violet-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas fa-sliders text-violet-500"></i>
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas fa-sliders text-primary-500"></i>
</div>
<div class="min-w-0 flex-1">
<h3 id="custom-provider-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
@@ -1185,15 +1185,14 @@
<input id="custom-provider-name" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-violet-500 transition-colors"
placeholder="siliconflow">
focus:outline-none focus:border-primary-500 transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
<input id="custom-provider-base" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-violet-500 font-mono transition-colors"
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="https://...../v1">
</div>
<div>
@@ -1201,20 +1200,15 @@
<input id="custom-provider-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-violet-500 font-mono transition-colors"
placeholder="sk-...">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_default_model">默认模型(可选)</label>
<input id="custom-provider-model" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-violet-500 font-mono transition-colors"
placeholder="deepseek-ai/DeepSeek-V3">
focus:outline-none focus:border-primary-500 font-mono transition-colors">
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
<button id="custom-provider-modal-delete"
class="px-3 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
cursor-pointer transition-colors duration-150 hidden"
data-i18n="models_custom_delete">删除</button>
<span id="custom-provider-modal-status"
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
<button id="custom-provider-modal-cancel"
@@ -1224,7 +1218,7 @@
cursor-pointer transition-colors duration-150"
data-i18n="cancel">取消</button>
<button id="custom-provider-modal-save"
class="px-4 py-2 rounded-lg bg-violet-500 hover:bg-violet-600 text-white text-sm font-medium
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
data-i18n="save">保存</button>
</div>

View File

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

View File

@@ -32,21 +32,13 @@ const I18N = {
models_clear_credential: '清除凭据',
models_base_default_hint: '留空将使用官方默认地址',
models_base_default: '默认',
models_custom_section: '自定义厂商',
models_custom_section_desc: '配置多个 OpenAI 兼容厂商,自由切换',
models_custom_add: '添加自定义厂商',
models_custom_vendor_label: '自定义',
models_custom_name: '名称',
models_custom_name_placeholder: '例如 siliconflow',
models_custom_default_model: '默认模型(可选)',
models_custom_default_model_placeholder: '例如 deepseek-ai/DeepSeek-V3',
models_custom_active: '使用中',
models_custom_set_active: '设为使用中',
models_custom_delete: '删除',
models_custom_delete_confirm_title: '删除自定义厂商',
models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。',
models_custom_name_required: '请填写名称',
models_custom_base_required: '请填写 API Base',
models_custom_empty: '尚未配置自定义厂商,点击添加',
models_custom_edit_title: '编辑自定义厂商',
models_custom_add_title: '添加自定义厂商',
models_capability_chat: '主模型',
@@ -240,10 +232,10 @@ const I18N = {
menu_logs: 'Logs',
models_title: 'Models',
models_desc: 'Manage chat, image, voice, embedding and search capabilities in one place',
models_section_vendors: 'Vendor Credentials',
models_section_vendors: 'Provider Credentials',
models_section_vendors_desc: 'Configured once, shared by multiple model capabilities',
models_section_capabilities: 'Capabilities',
models_add_vendor: 'Add Vendor',
models_add_vendor: 'Add Provider',
models_provider: 'Provider',
models_model: 'Model',
models_voice: 'Voice',
@@ -253,21 +245,13 @@ const I18N = {
models_clear_credential: 'Clear credentials',
models_base_default_hint: 'Leave blank to use the official default base URL',
models_base_default: 'Default',
models_custom_section: 'Custom Providers',
models_custom_section_desc: 'Configure multiple OpenAI-compatible providers and switch freely',
models_custom_add: 'Add custom provider',
models_custom_vendor_label: 'Custom',
models_custom_name: 'Name',
models_custom_name_placeholder: 'e.g. siliconflow',
models_custom_default_model: 'Default model (optional)',
models_custom_default_model_placeholder: 'e.g. deepseek-ai/DeepSeek-V3',
models_custom_active: 'Active',
models_custom_set_active: 'Set active',
models_custom_delete: 'Delete',
models_custom_delete_confirm_title: 'Delete custom provider',
models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.',
models_custom_name_required: 'Name is required',
models_custom_base_required: 'API Base is required',
models_custom_empty: 'No custom providers yet, click to add',
models_custom_edit_title: 'Edit custom provider',
models_custom_add_title: 'Add custom provider',
models_capability_chat: 'Main Model',
@@ -311,8 +295,8 @@ const I18N = {
models_embedding_saved_msg: 'Send /memory rebuild-index in the chat to rebuild the index.',
models_embedding_saved_ok: 'Go',
models_pick_provider: 'Pick a provider',
models_clear_confirm_title: 'Clear vendor credentials',
models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.',
models_clear_confirm_title: 'Clear provider credentials',
models_clear_confirm_msg: 'Remove this provider\'s API Key and Base URL? Capabilities relying on it will stop working.',
cancel: 'Cancel',
save: 'Save',
ok: 'OK',
@@ -4766,12 +4750,12 @@ function renderModelsView() {
const container = document.getElementById('models-content');
container.innerHTML = '';
container.appendChild(renderVendorsSection());
container.appendChild(renderCustomProvidersSection());
MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def)));
}
// True when a provider card is one of the expanded custom (OpenAI-compatible)
// providers — these are managed in their own section, not the vendor grid.
// providers (id "custom:<id>") — shown in the vendor grid alongside built-in
// vendors, but edited via the dedicated custom-provider modal.
function isCustomProviderCard(p) {
return !!(p && p.is_custom && p.custom_name);
}
@@ -4782,10 +4766,9 @@ function renderVendorsSection() {
const wrap = document.createElement('div');
wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
// Expanded custom providers live in their own section; keep the built-in
// vendor grid focused on the canonical (field-based) providers.
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
const configured = builtinProviders.filter(p => p.configured);
// Custom providers always show once created (even without an api key,
// e.g. a local vLLM/Ollama endpoint); built-in vendors show when configured.
const configured = modelsState.providers.filter(p => p.configured || isCustomProviderCard(p));
const header = `
<div class="flex items-start gap-3 mb-5">
@@ -4796,7 +4779,6 @@ function renderVendorsSection() {
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_section_vendors')}</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_section_vendors_desc')}</p>
</div>
<span class="text-xs text-slate-400 dark:text-slate-500 mt-2 flex-shrink-0">${configured.length}/${builtinProviders.length}</span>
</div>`;
let body;
@@ -4822,8 +4804,13 @@ function renderVendorsSection() {
function renderVendorChip(p) {
// The masked API key is intentionally not surfaced here; it is shown
// inside the edit modal so the chip stays uncluttered and scannable.
// Custom providers open their dedicated modal (name + base + key);
// their ids are server-generated hex, safe to inline.
const onclick = isCustomProviderCard(p)
? `openCustomProviderModal('${escapeHtml(p.custom_id)}')`
: `openVendorModal('${escapeHtml(p.id)}')`;
return `
<button onclick="openVendorModal('${escapeHtml(p.id)}')"
<button onclick="${onclick}"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border border-slate-200 dark:border-white/10
bg-slate-50 dark:bg-white/5 hover:border-primary-300 dark:hover:border-primary-500/50
cursor-pointer transition-colors duration-150 text-left">
@@ -4856,112 +4843,10 @@ function renderProviderLogo(p, sizePx) {
</span>`;
}
// ---------- Custom providers section (multiple OpenAI-compatible) -------
// Renders the user-defined OpenAI-compatible providers as a dedicated,
// independently managed list: add / edit / delete / activate. The backend
// expands `custom_providers` into provider cards with id="custom:<id>",
// is_custom=true, custom_id, custom_name and an `active` flag (see
// 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() {
return modelsState.providers.filter(isCustomProviderCard);
}
function renderCustomProvidersSection() {
const wrap = document.createElement('div');
wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
const customs = getCustomProviderCards();
const header = `
<div class="flex items-start gap-3 mb-5">
<div class="w-9 h-9 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center flex-shrink-0">
<i class="fas fa-sliders text-violet-500 text-sm"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_custom_section')}</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_custom_section_desc')}</p>
</div>
<button 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">
<i class="fas fa-plus text-[10px] mr-1"></i>${t('models_custom_add')}
</button>
</div>`;
let body;
if (customs.length === 0) {
body = `
<div class="flex flex-col items-center justify-center py-8 px-4 rounded-lg border border-dashed border-slate-200 dark:border-white/10">
<p class="text-sm text-slate-500 dark:text-slate-400 text-center">${t('models_custom_empty')}</p>
</div>`;
} else {
body = `<div class="space-y-2.5">
${customs.map(renderCustomProviderRow).join('')}
</div>`;
}
wrap.innerHTML = header + body;
// 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;
}
function renderCustomProviderRow(p) {
const id = p.custom_id || '';
const name = p.custom_name || '';
const nameEsc = escapeHtml(name);
// The active provider gets a highlighted ring + badge; others show a
// "set active" affordance via data-attributes (no inline onclick — XSS safe).
const activeBadge = p.active
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 flex-shrink-0">
<i class="fas fa-check text-[9px] mr-0.5"></i>${t('models_custom_active')}</span>`
: `<button 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">
${t('models_custom_set_active')}</button>`;
const ring = p.active
? 'border-emerald-300 dark:border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-900/10'
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5';
const model = p.model
? `<span class="text-[11px] text-slate-400 dark:text-slate-500 font-mono truncate">${escapeHtml(p.model)}</span>`
: '';
const base = p.api_base
? `<span class="text-[11px] text-slate-400 dark:text-slate-500 font-mono truncate">${escapeHtml(p.api_base)}</span>`
: '';
return `
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border ${ring} transition-colors" data-provider-id="${escapeHtml(id)}">
${renderProviderLogo(p, 28)}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 truncate">${nameEsc}</span>
${activeBadge}
</div>
<div class="flex items-center gap-2 mt-0.5">${base}${model ? '<span class="text-slate-300 dark:text-slate-600">·</span>' + model : ''}</div>
</div>
<button 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">
<i class="fas fa-pen-to-square text-[12px]"></i>
</button>
<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">
<i class="fas fa-trash-can text-[12px]"></i>
</button>
</div>`;
}
// ---------- Capability cards (Layer 2) ---------------------------------
function renderCapabilityCard(def) {
@@ -5776,6 +5661,14 @@ function decorateVendorModalPicker(ddEl, opts) {
// Tag the row so the global active-row ✓ rule is suppressed in CSS
// (otherwise configured AND selected rows would render two checks).
item.classList.add('vendor-picker-item');
if (opt._isAddNew) {
// "Custom" is an add-new action (multiple entries allowed),
// so show a trailing + instead of the configured ✓.
const plus = document.createElement('i');
plus.className = 'fas fa-plus vendor-picker-add-mark';
item.appendChild(plus);
return;
}
if (!opt._configured) return;
const check = document.createElement('i');
check.className = 'fas fa-check vendor-picker-configured-mark';
@@ -6083,22 +5976,42 @@ function openVendorModal(providerId, onSaved) {
// currently selected vendor via its own background highlight, so we
// intentionally suppress the global active-row ✓ for this picker
// (see CSS) — otherwise configured + selected rows would show two.
// Custom (OpenAI-compatible) providers are managed in their own
// section with a dedicated modal, so they are excluded from this
// built-in vendor picker.
// Expanded custom provider cards ("custom:<id>") are edited via their
// dedicated modal, so they are excluded from this picker. Picking the
// "custom" entry creates a *new* custom provider via that modal —
// this is how multiple OpenAI-compatible endpoints are added.
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
const unconfigured = builtinProviders.filter(p => !p.configured);
const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || '';
pickerWrap.classList.remove('hidden');
const pickerEl = document.getElementById('vendor-modal-picker');
const pickerOpts = builtinProviders.map(p => ({
value: p.id,
label: localizedLabel(p.label),
_configured: !!p.configured,
}));
initDropdown(pickerEl, pickerOpts, defaultId, (val) => fillVendorModalForProvider(val));
// In multi-provider mode the backend replaces the bare "custom" card
// with the expanded ones; re-add it here so the entry stays available.
if (!pickerOpts.some(o => o.value === 'custom')) {
pickerOpts.push({ value: 'custom', label: t('models_custom_vendor_label'), _configured: false });
}
// "Custom" always behaves as an add-new action (multiple entries
// allowed), so it shows a + mark instead of the configured ✓.
pickerOpts.forEach(o => { if (o.value === 'custom') { o._isAddNew = true; o._configured = false; } });
const unconfigured = builtinProviders.filter(p => !p.configured);
const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || 'custom';
pickerWrap.classList.remove('hidden');
const pickerEl = document.getElementById('vendor-modal-picker');
const onPick = (val) => {
if (val === 'custom') {
// "Custom" in the add flow always creates a new
// OpenAI-compatible provider entry via the dedicated modal
// (name + base + key), supporting multiple custom endpoints.
closeVendorModal();
openCustomProviderModal('');
return;
}
fillVendorModalForProvider(val);
};
initDropdown(pickerEl, pickerOpts, defaultId, onPick);
decorateVendorModalPicker(pickerEl, pickerOpts);
fillVendorModalForProvider(defaultId);
onPick(defaultId);
} else {
pickerWrap.classList.add('hidden');
fillVendorModalForProvider(providerId);
@@ -6276,11 +6189,9 @@ function openCustomProviderModal(providerId) {
const nameInput = document.getElementById('custom-provider-name');
const baseInput = document.getElementById('custom-provider-base');
const keyInput = document.getElementById('custom-provider-key');
const modelInput = document.getElementById('custom-provider-model');
nameInput.value = card ? (card.custom_name || '') : '';
baseInput.value = card ? (card.api_base || '') : '';
modelInput.value = card ? (card.model || '') : '';
// Surface the masked key as the value for configured providers so the
// "already set" state is unambiguous; an untouched masked value means
@@ -6307,6 +6218,13 @@ function openCustomProviderModal(providerId) {
document.getElementById('custom-provider-modal-cancel').onclick = closeCustomProviderModal;
document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal;
// Delete is only available when editing an existing provider.
const deleteBtn = document.getElementById('custom-provider-modal-delete');
if (deleteBtn) {
deleteBtn.classList.toggle('hidden', !editing);
deleteBtn.onclick = editing ? () => deleteCustomProvider(providerId) : null;
}
function onOverlayClick(e) {
if (e.target === overlay) {
closeCustomProviderModal();
@@ -6325,7 +6243,6 @@ function closeCustomProviderModal() {
function saveCustomProviderModal() {
const name = document.getElementById('custom-provider-name').value.trim();
const apiBase = document.getElementById('custom-provider-base').value.trim();
const model = document.getElementById('custom-provider-model').value.trim();
const keyInput = document.getElementById('custom-provider-key');
if (!name) {
@@ -6350,7 +6267,6 @@ function saveCustomProviderModal() {
action: 'set_custom_provider',
name: name,
api_base: apiBase,
model: model,
};
if (apiKey) payload.api_key = apiKey;
if (editing) payload.id = customProviderModalState.editId;
@@ -6375,16 +6291,6 @@ function saveCustomProviderModal() {
});
}
function setActiveCustomProvider(providerId) {
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_active_custom_provider', id: providerId }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') loadModelsView();
}).catch(() => { /* noop */ });
}
function deleteCustomProvider(providerId) {
showConfirmDialog({
title: t('models_custom_delete_confirm_title'),
@@ -6397,7 +6303,10 @@ function deleteCustomProvider(providerId) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') loadModelsView();
if (data.status === 'success') {
closeCustomProviderModal();
loadModelsView();
}
}).catch(() => { /* noop */ });
}
});

View File

@@ -1643,6 +1643,32 @@ class ConfigHandler:
"api_key_field": p.get("api_key_field"),
}
# Expose user-defined custom providers as "custom:<id>" entries so
# the legacy config page can display and select them. Credentials
# are managed on the Models page, hence the null key/base fields.
# Mirrors the Models page: when expanded entries exist, the bare
# legacy "custom" entry is hidden — unless the flat single-provider
# custom config is still active or filled in.
try:
from models.custom_provider import get_custom_providers
custom_list = get_custom_providers()
legacy_custom_in_use = ModelsHandler._legacy_custom_in_use(local_config)
if custom_list and not legacy_custom_in_use:
providers.pop("custom", None)
for cp in custom_list:
cid = f"custom:{cp.get('id')}"
cname = cp.get("name") or cp.get("id")
providers[cid] = {
"label": {"zh": cname, "en": cname},
"models": [cp["model"]] if cp.get("model") else [],
"api_base_key": None,
"api_base_default": None,
"api_base_placeholder": "",
"api_key_field": None,
}
except Exception as cp_err:
logger.warning(f"[ConfigHandler] failed to expand custom providers: {cp_err}")
raw_pwd = str(local_config.get("web_password", "") or "")
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
@@ -2189,6 +2215,17 @@ class ModelsHandler:
})
return cards
@classmethod
def _legacy_custom_in_use(cls, local_config: dict) -> bool:
"""True when the flat single-provider custom config is still relevant:
either it is the active bot_type, or its key/base fields are filled.
In that case the legacy "custom" card must stay visible even when
multi ``custom_providers`` entries exist."""
if (local_config.get("bot_type") or "") == "custom":
return True
return (cls._is_real_key(local_config.get("custom_api_key") or "")
or bool(local_config.get("custom_api_base")))
@classmethod
def _provider_overview(cls) -> List[dict]:
"""All known providers (configured first, unconfigured after).
@@ -2202,12 +2239,17 @@ class ModelsHandler:
"""
local_config = conf()
custom_cards = cls._custom_provider_cards(local_config)
# Keep the legacy single "custom" card visible alongside the expanded
# ones when the flat custom_api_key/base config is active or filled,
# so existing single-provider setups never disappear from the UI.
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
items = []
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
if pid == "custom" and custom_cards:
# Multi-provider mode: emit the expanded cards instead of the
# single legacy custom card.
# Multi-provider mode: emit the expanded cards, plus the
# legacy card when it is still in use.
items.extend(custom_cards)
if not keep_legacy_custom:
continue
key_field = p.get("api_key_field")
base_field = p.get("api_base_key")
@@ -2253,11 +2295,15 @@ class ModelsHandler:
provider_id = "linkai"
# In multi-provider mode, replace the single "custom" entry with the
# expanded "custom:<id>" ids so the chat dropdown matches the cards.
# The legacy "custom" entry stays when its flat config is still used.
provider_ids = []
custom_cards = cls._custom_provider_cards(local_config)
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
for pid in ConfigHandler.PROVIDER_MODELS.keys():
if pid == "custom" and custom_cards:
provider_ids.extend(c["id"] for c in custom_cards)
if keep_legacy_custom:
provider_ids.append(pid)
else:
provider_ids.append(pid)
return {
@@ -2881,7 +2927,10 @@ class ModelsHandler:
existing["api_base"] = api_base
if api_key:
existing["api_key"] = api_key
# model is always overwritten (empty clears the default model).
# Only touch model when explicitly provided in the payload; an
# explicit empty string clears it, a missing key keeps it (the
# UI modal no longer sends model, so manual config survives edits).
if "model" in data:
if model:
existing["model"] = model
else:

View File

@@ -1,69 +1,31 @@
---
title: Custom
description: Custom vendor configuration for third-party API proxies and local models
title: カスタム
description: サードパーティ API プロキシやローカルモデル向けのカスタムプロバイダー設定
---
For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as:
OpenAI 互換プロトコルで接続するモデルサービス向けの設定です。例えば:
- **Third-party API proxies**: call multiple models through a unified API base
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI
- **Private deployments**: model services deployed inside an enterprise
- **サードパーティ API プロキシ**:統一された API アドレスで複数のモデルを呼び出す
- **ローカルモデル**OllamavLLM などのツールでローカルにデプロイしたモデル
- **プライベートデプロイ**:企業内部にデプロイされたモデルサービス
<Note>
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>
## Web コンソールでの設定
## Text Chat
推奨方法です。Web コンソールの「モデル」ページで「プロバイダーを追加」をクリックし、「カスタム」を選択して、名称・API Base・API Key を入力します。複数のカスタムプロバイダーを追加でき、追加後は「メインモデル」でプロバイダーとモデルを選択すると有効になります。
### Third-party API proxy
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-custom-model-config.png" />
```json
{
"bot_type": "custom",
"model": "",
"custom_api_key": "YOUR_API_KEY",
"custom_api_base": "https://{your-proxy.com}/v1"
}
```
主なローカルデプロイツールのデフォルトアドレス:
| Parameter | Description |
| --- | --- |
| `bot_type` | Must be set to `custom` |
| `model` | Model name; any model name supported by the proxy service |
| `custom_api_key` | API key provided by the proxy service |
| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible |
### Local models
Local models usually do not require an API key — only the API base needs to be filled in:
```json
{
"bot_type": "custom",
"model": "qwen3.5:27b",
"custom_api_base": "http://localhost:11434/v1"
}
```
Common local deployment tools and their default endpoints:
| Tool | Default API Base |
| ツール | デフォルト API Base |
| --- | --- |
| [Ollama](https://ollama.com) | `http://localhost:11434/v1` |
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
### Switching Models
## 設定ファイルでの設定
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged:
```
/config model qwen3.5:27b
```
## Configuring Multiple Custom Providers
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:<id>"`:
`config.json` を直接編集することもできます。`custom_providers` リストに複数のプロバイダーを定義し、`bot_type` を `"custom:<id>"` に設定していずれかを有効化します:
```json
{
@@ -71,34 +33,30 @@ If you need to configure several OpenAI-compatible third-party services at once
"custom_providers": [
{
"id": "3f2a9c1b",
"name": "siliconflow",
"api_key": "YOUR_SILICONFLOW_KEY",
"api_base": "https://api.siliconflow.cn/v1",
"model": "deepseek-ai/DeepSeek-V3"
"name": "プロバイダーA",
"api_key": "YOUR_API_KEY_A",
"api_base": "https://api.a.com/v1",
"model": "deepseek-v3"
},
{
"id": "a1b2c3d4",
"name": "qiniu",
"api_key": "YOUR_QINIU_KEY",
"api_base": "https://api.qnaigc.com/v1",
"model": "deepseek-v3"
"name": "プロバイダーB",
"api_key": "YOUR_API_KEY_B",
"api_base": "https://api.b.com/v1",
"model": "qwen3-max"
}
]
}
```
| Parameter | Description |
| パラメータ | 説明 |
| --- | --- |
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` |
| `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 |
| `custom_providers` | カスタムプロバイダーのリスト。各項目は `id``name``api_base`、`api_key`(任意)、`model`(任意)を含む |
| `bot_type` | `"custom:<id>"` に設定して対応するプロバイダーを有効化 |
| `id` | 一意の識別子8 桁の 16 進数。Web コンソールから追加すると自動生成され、手動設定の場合は重複しない任意の文字列でよい |
| `name` | 表示名。自由に変更可能 |
| `model` | このプロバイダーで使用するモデル。有効化時に適用される |
<Note>
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.
従来の単一プロバイダー設定(`bot_type` を `"custom"` にし、`custom_api_key` / `custom_api_base` を使用)は引き続き互換性があり、変更なしでそのまま利用できます。
</Note>

View File

@@ -1,51 +1,21 @@
---
title: Custom
description: Custom vendor configuration for third-party API proxies and local models
description: Custom provider configuration for third-party API proxies and local models
---
For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as:
For model services accessed via the OpenAI-compatible protocol, such as:
- **Third-party API proxies**: call multiple models through a unified API base
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI
- **Local models**: models deployed locally with tools like Ollama, vLLM
- **Private deployments**: model services deployed inside an enterprise
<Note>
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>
## Web Console
## Text Chat
Recommended. On the "Models" page of the Web console, click "Add Provider" and pick "Custom", then fill in the name, API Base and API Key. Multiple custom providers can be added; after adding one, select it together with a model in the "Main Model" card to enable it.
### Third-party API proxy
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-custom-model-config.png" />
```json
{
"bot_type": "custom",
"model": "",
"custom_api_key": "YOUR_API_KEY",
"custom_api_base": "https://{your-proxy.com}/v1"
}
```
| Parameter | Description |
| --- | --- |
| `bot_type` | Must be set to `custom` |
| `model` | Model name; any model name supported by the proxy service |
| `custom_api_key` | API key provided by the proxy service |
| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible |
### Local models
Local models usually do not require an API key — only the API base needs to be filled in:
```json
{
"bot_type": "custom",
"model": "qwen3.5:27b",
"custom_api_base": "http://localhost:11434/v1"
}
```
Common local deployment tools and their default endpoints:
Default endpoints of common local deployment tools:
| Tool | Default API Base |
| --- | --- |
@@ -53,17 +23,9 @@ Common local deployment tools and their default endpoints:
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
### Switching Models
## Configuration File
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged:
```
/config model qwen3.5:27b
```
## Configuring Multiple Custom Providers
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:<id>"`:
You can also edit `config.json` directly: define multiple providers in the `custom_providers` list and set `bot_type` to `"custom:<id>"` to activate one of them:
```json
{
@@ -71,17 +33,17 @@ If you need to configure several OpenAI-compatible third-party services at once
"custom_providers": [
{
"id": "3f2a9c1b",
"name": "siliconflow",
"api_key": "YOUR_SILICONFLOW_KEY",
"api_base": "https://api.siliconflow.cn/v1",
"model": "deepseek-ai/DeepSeek-V3"
"name": "ProviderA",
"api_key": "YOUR_API_KEY_A",
"api_base": "https://api.a.com/v1",
"model": "deepseek-v3"
},
{
"id": "a1b2c3d4",
"name": "qiniu",
"api_key": "YOUR_QINIU_KEY",
"api_base": "https://api.qnaigc.com/v1",
"model": "deepseek-v3"
"name": "ProviderB",
"api_key": "YOUR_API_KEY_B",
"api_base": "https://api.b.com/v1",
"model": "qwen3-max"
}
]
}
@@ -89,16 +51,12 @@ If you need to configure several OpenAI-compatible third-party services at once
| Parameter | Description |
| --- | --- |
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` |
| `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 |
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_base`, `api_key` (optional) and `model` (optional) |
| `bot_type` | Set to `"custom:<id>"` to activate the corresponding provider |
| `id` | Unique identifier (8-char hex); auto-generated when adding via the Web console, or any unique string when editing manually |
| `name` | Display label, can be renamed freely |
| `model` | Model used by this provider, takes effect when activated |
<Note>
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.
The legacy single-provider configuration (`bot_type` set to `"custom"` with `custom_api_key` / `custom_api_base`) remains fully compatible and keeps working without any changes.
</Note>

View File

@@ -3,7 +3,7 @@ title: DeepSeek
description: DeepSeek model configuration (Text Chat + Thinking Mode)
---
DeepSeek is one of the default recommended vendors in Agent mode, focused on cost-effective text chat and task planning.
DeepSeek is one of the default recommended providers in Agent mode, focused on cost-effective text chat and task planning.
## Text Chat

View File

@@ -1,15 +1,15 @@
---
title: Models Overview
description: Model vendors supported by CowAgent and their capability matrix
description: Model providers supported by CowAgent and their capability matrix
---
CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several vendors also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow.
CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several providers also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow.
## Capability Matrix
A snapshot of each vendor's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the vendor can power.
A snapshot of each provider's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the provider can power.
| Vendor | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding |
| Provider | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding |
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
@@ -22,11 +22,11 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model;
| [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](/models/linkai) | 100+ models from multiple vendors | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Custom](/models/custom) | Local models / third-party proxies | ✅ | | | | | |
<Tip>
Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own vendor and model — there is no forced binding between them.
Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own provider and model — there is no forced binding between them.
</Tip>
## How to Configure
@@ -35,4 +35,4 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model;
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-models-config.png" />
**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected vendor. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`.
**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected provider. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`.

View File

@@ -3,7 +3,7 @@ title: LinkAI
description: Access text, vision, image, speech, and embedding capabilities through the LinkAI platform
---
A single `linkai_api_key` gives you access to all capabilities of mainstream vendors such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao.
A single `linkai_api_key` gives you access to all capabilities of mainstream providers such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao.
<Tip>
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.

View File

@@ -49,7 +49,7 @@ Use the global `enable_thinking` flag to toggle visibility (also switchable from
Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models:
- When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup.
- When the main model belongs to another vendor, the Vision tool falls back to `mimo-v2.5-pro` in order.
- When the main model belongs to another provider, the Vision tool falls back to `mimo-v2.5-pro` in order.
To force a specific Vision model, set it explicitly in the configuration:

View File

@@ -25,7 +25,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency |
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other vendors via the compatible protocol |
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
## Image Understanding

View File

@@ -3,7 +3,7 @@ title: Qwen
description: Qwen model configuration (Text / Image Understanding / Image Generation / Speech-to-Text / Text-to-Speech / Embedding)
---
Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`.
Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured providers. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`.
<Tip>
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.

View File

@@ -34,7 +34,7 @@ Related commits: [30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b)
## 💰 Coding Plan Support
Added integration with vendor Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported vendors include Aliyun, MiniMax, GLM, Kimi, and Volcengine.
Added integration with provider Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported providers include Aliyun, MiniMax, GLM, Kimi, and Volcengine.
See [Coding Plan docs](https://docs.cowagent.ai/en/models/coding-plan) for detailed configuration.

View File

@@ -22,7 +22,7 @@ Docs: [Image Generation Skill](https://docs.cowagent.ai/en/skills/image-generati
- **Claude Opus 4.7**: Added `claude-opus-4-7` model support
- **GLM 5.1**: Added `glm-5.1` model support
- **Kimi Coding Plan**: Support for Kimi Coding Plan mode
- **Custom model providers**: New custom model provider configuration for easier integration with additional vendors
- **Custom model providers**: New custom model provider configuration for easier integration with additional providers
## 💬 Web Console Improvements

View File

@@ -1,69 +1,31 @@
---
title: Custom
description: Custom vendor configuration for third-party API proxies and local models
title: 自定义
description: 自定义厂商配置,用于第三方 API 代理与本地模型
---
For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as:
适用于通过 OpenAI 兼容协议接入的模型服务,例如:
- **Third-party API proxies**: call multiple models through a unified API base
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI
- **Private deployments**: model services deployed inside an enterprise
- **第三方 API 代理**:通过统一的 API 地址调用多种模型
- **本地模型**:使用 OllamavLLM 等工具在本地部署的模型
- **私有化部署**:企业内部部署的模型服务
<Note>
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>
## Web 端配置
## Text Chat
推荐方式。在 Web 控制台「模型」页面点击「添加厂商」选择「自定义」填写名称、API Base 和 API Key 即可。支持添加多个自定义厂商,添加后在「主模型」中选择对应厂商和模型即可启用。
### Third-party API proxy
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-custom-model-config.png" />
```json
{
"bot_type": "custom",
"model": "",
"custom_api_key": "YOUR_API_KEY",
"custom_api_base": "https://{your-proxy.com}/v1"
}
```
本地部署工具的默认地址:
| Parameter | Description |
| --- | --- |
| `bot_type` | Must be set to `custom` |
| `model` | Model name; any model name supported by the proxy service |
| `custom_api_key` | API key provided by the proxy service |
| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible |
### Local models
Local models usually do not require an API key — only the API base needs to be filled in:
```json
{
"bot_type": "custom",
"model": "qwen3.5:27b",
"custom_api_base": "http://localhost:11434/v1"
}
```
Common local deployment tools and their default endpoints:
| Tool | Default API Base |
| 工具 | 默认 API Base |
| --- | --- |
| [Ollama](https://ollama.com) | `http://localhost:11434/v1` |
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
### Switching Models
## 配置文件配置
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged:
```
/config model qwen3.5:27b
```
## Configuring Multiple Custom Providers
If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:<id>"`:
也可以直接编辑 `config.json`,在 `custom_providers` 列表中定义多个厂商,并将 `bot_type` 设置为 `"custom:<id>"` 来激活其中一个:
```json
{
@@ -71,34 +33,30 @@ If you need to configure several OpenAI-compatible third-party services at once
"custom_providers": [
{
"id": "3f2a9c1b",
"name": "siliconflow",
"api_key": "YOUR_SILICONFLOW_KEY",
"api_base": "https://api.siliconflow.cn/v1",
"model": "deepseek-ai/DeepSeek-V3"
"name": "厂商A",
"api_key": "YOUR_API_KEY_A",
"api_base": "https://api.a.com/v1",
"model": "deepseek-v3"
},
{
"id": "a1b2c3d4",
"name": "qiniu",
"api_key": "YOUR_QINIU_KEY",
"api_base": "https://api.qnaigc.com/v1",
"model": "deepseek-v3"
"name": "厂商B",
"api_key": "YOUR_API_KEY_B",
"api_base": "https://api.b.com/v1",
"model": "qwen3-max"
}
]
}
```
| Parameter | Description |
| 参数 | 说明 |
| --- | --- |
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` |
| `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 |
| `custom_providers` | 自定义厂商列表,每项包含 `id``name``api_base`、`api_key`(可选)、`model`(可选) |
| `bot_type` | 设置为 `"custom:<id>"` 激活对应厂商 |
| `id` | 厂商唯一标识8 位十六进制),在 Web 端添加时自动生成,手动配置时填写任意不重复的标识即可 |
| `name` | 展示名称,可随意修改 |
| `model` | 该厂商使用的模型,激活时生效 |
<Note>
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.
历史的单厂商配置(`bot_type` 为 `"custom"`,配合 `custom_api_key` / `custom_api_base`)仍然兼容,无需改动即可继续使用。
</Note>