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

View File

@@ -854,6 +854,14 @@
font-size: 11px; font-size: 11px;
flex-shrink: 0; 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 */
#chat-input { #chat-input {

View File

@@ -32,21 +32,13 @@ const I18N = {
models_clear_credential: '清除凭据', models_clear_credential: '清除凭据',
models_base_default_hint: '留空将使用官方默认地址', models_base_default_hint: '留空将使用官方默认地址',
models_base_default: '默认', models_base_default: '默认',
models_custom_section: '自定义厂商', models_custom_vendor_label: '自定义',
models_custom_section_desc: '配置多个 OpenAI 兼容厂商,自由切换',
models_custom_add: '添加自定义厂商',
models_custom_name: '名称', 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: '删除',
models_custom_delete_confirm_title: '删除自定义厂商', models_custom_delete_confirm_title: '删除自定义厂商',
models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。', models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。',
models_custom_name_required: '请填写名称', models_custom_name_required: '请填写名称',
models_custom_base_required: '请填写 API Base', models_custom_base_required: '请填写 API Base',
models_custom_empty: '尚未配置自定义厂商,点击添加',
models_custom_edit_title: '编辑自定义厂商', models_custom_edit_title: '编辑自定义厂商',
models_custom_add_title: '添加自定义厂商', models_custom_add_title: '添加自定义厂商',
models_capability_chat: '主模型', models_capability_chat: '主模型',
@@ -240,10 +232,10 @@ const I18N = {
menu_logs: 'Logs', menu_logs: 'Logs',
models_title: 'Models', models_title: 'Models',
models_desc: 'Manage chat, image, voice, embedding and search capabilities in one place', 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_vendors_desc: 'Configured once, shared by multiple model capabilities',
models_section_capabilities: 'Capabilities', models_section_capabilities: 'Capabilities',
models_add_vendor: 'Add Vendor', models_add_vendor: 'Add Provider',
models_provider: 'Provider', models_provider: 'Provider',
models_model: 'Model', models_model: 'Model',
models_voice: 'Voice', models_voice: 'Voice',
@@ -253,21 +245,13 @@ const I18N = {
models_clear_credential: 'Clear credentials', models_clear_credential: 'Clear credentials',
models_base_default_hint: 'Leave blank to use the official default base URL', models_base_default_hint: 'Leave blank to use the official default base URL',
models_base_default: 'Default', models_base_default: 'Default',
models_custom_section: 'Custom Providers', models_custom_vendor_label: 'Custom',
models_custom_section_desc: 'Configure multiple OpenAI-compatible providers and switch freely',
models_custom_add: 'Add custom provider',
models_custom_name: 'Name', models_custom_name: '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: 'Delete',
models_custom_delete_confirm_title: 'Delete custom provider', models_custom_delete_confirm_title: 'Delete custom provider',
models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.', models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.',
models_custom_name_required: 'Name is required', models_custom_name_required: 'Name is required',
models_custom_base_required: 'API Base 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_edit_title: 'Edit custom provider',
models_custom_add_title: 'Add custom provider', models_custom_add_title: 'Add custom provider',
models_capability_chat: 'Main Model', 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_msg: 'Send /memory rebuild-index in the chat to rebuild the index.',
models_embedding_saved_ok: 'Go', models_embedding_saved_ok: 'Go',
models_pick_provider: 'Pick a provider', models_pick_provider: 'Pick a provider',
models_clear_confirm_title: 'Clear vendor credentials', models_clear_confirm_title: 'Clear provider credentials',
models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.', models_clear_confirm_msg: 'Remove this provider\'s API Key and Base URL? Capabilities relying on it will stop working.',
cancel: 'Cancel', cancel: 'Cancel',
save: 'Save', save: 'Save',
ok: 'OK', ok: 'OK',
@@ -4766,12 +4750,12 @@ function renderModelsView() {
const container = document.getElementById('models-content'); const container = document.getElementById('models-content');
container.innerHTML = ''; container.innerHTML = '';
container.appendChild(renderVendorsSection()); container.appendChild(renderVendorsSection());
container.appendChild(renderCustomProvidersSection());
MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def))); MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def)));
} }
// True when a provider card is one of the expanded custom (OpenAI-compatible) // 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) { function isCustomProviderCard(p) {
return !!(p && p.is_custom && p.custom_name); return !!(p && p.is_custom && p.custom_name);
} }
@@ -4782,10 +4766,9 @@ function renderVendorsSection() {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; 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 // Custom providers always show once created (even without an api key,
// vendor grid focused on the canonical (field-based) providers. // e.g. a local vLLM/Ollama endpoint); built-in vendors show when configured.
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p)); const configured = modelsState.providers.filter(p => p.configured || isCustomProviderCard(p));
const configured = builtinProviders.filter(p => p.configured);
const header = ` const header = `
<div class="flex items-start gap-3 mb-5"> <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> <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> <p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_section_vendors_desc')}</p>
</div> </div>
<span class="text-xs text-slate-400 dark:text-slate-500 mt-2 flex-shrink-0">${configured.length}/${builtinProviders.length}</span>
</div>`; </div>`;
let body; let body;
@@ -4822,8 +4804,13 @@ function renderVendorsSection() {
function renderVendorChip(p) { function renderVendorChip(p) {
// The masked API key is intentionally not surfaced here; it is shown // The masked API key is intentionally not surfaced here; it is shown
// inside the edit modal so the chip stays uncluttered and scannable. // 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 ` 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 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 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"> cursor-pointer transition-colors duration-150 text-left">
@@ -4856,112 +4843,10 @@ function renderProviderLogo(p, sizePx) {
</span>`; </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() { function getCustomProviderCards() {
return modelsState.providers.filter(isCustomProviderCard); 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) --------------------------------- // ---------- Capability cards (Layer 2) ---------------------------------
function renderCapabilityCard(def) { function renderCapabilityCard(def) {
@@ -5776,6 +5661,14 @@ function decorateVendorModalPicker(ddEl, opts) {
// Tag the row so the global active-row ✓ rule is suppressed in CSS // Tag the row so the global active-row ✓ rule is suppressed in CSS
// (otherwise configured AND selected rows would render two checks). // (otherwise configured AND selected rows would render two checks).
item.classList.add('vendor-picker-item'); 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; if (!opt._configured) return;
const check = document.createElement('i'); const check = document.createElement('i');
check.className = 'fas fa-check vendor-picker-configured-mark'; 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 // currently selected vendor via its own background highlight, so we
// intentionally suppress the global active-row ✓ for this picker // intentionally suppress the global active-row ✓ for this picker
// (see CSS) — otherwise configured + selected rows would show two. // (see CSS) — otherwise configured + selected rows would show two.
// Custom (OpenAI-compatible) providers are managed in their own // Expanded custom provider cards ("custom:<id>") are edited via their
// section with a dedicated modal, so they are excluded from this // dedicated modal, so they are excluded from this picker. Picking the
// built-in vendor picker. // "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 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 => ({ const pickerOpts = builtinProviders.map(p => ({
value: p.id, value: p.id,
label: localizedLabel(p.label), label: localizedLabel(p.label),
_configured: !!p.configured, _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); decorateVendorModalPicker(pickerEl, pickerOpts);
fillVendorModalForProvider(defaultId); onPick(defaultId);
} else { } else {
pickerWrap.classList.add('hidden'); pickerWrap.classList.add('hidden');
fillVendorModalForProvider(providerId); fillVendorModalForProvider(providerId);
@@ -6276,11 +6189,9 @@ function openCustomProviderModal(providerId) {
const nameInput = document.getElementById('custom-provider-name'); const nameInput = document.getElementById('custom-provider-name');
const baseInput = document.getElementById('custom-provider-base'); const baseInput = document.getElementById('custom-provider-base');
const keyInput = document.getElementById('custom-provider-key'); const keyInput = document.getElementById('custom-provider-key');
const modelInput = document.getElementById('custom-provider-model');
nameInput.value = card ? (card.custom_name || '') : ''; nameInput.value = card ? (card.custom_name || '') : '';
baseInput.value = card ? (card.api_base || '') : ''; baseInput.value = card ? (card.api_base || '') : '';
modelInput.value = card ? (card.model || '') : '';
// Surface the masked key as the value for configured providers so the // Surface the masked key as the value for configured providers so the
// "already set" state is unambiguous; an untouched masked value means // "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-cancel').onclick = closeCustomProviderModal;
document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal; 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) { function onOverlayClick(e) {
if (e.target === overlay) { if (e.target === overlay) {
closeCustomProviderModal(); closeCustomProviderModal();
@@ -6325,7 +6243,6 @@ function closeCustomProviderModal() {
function saveCustomProviderModal() { function saveCustomProviderModal() {
const name = document.getElementById('custom-provider-name').value.trim(); const name = document.getElementById('custom-provider-name').value.trim();
const apiBase = document.getElementById('custom-provider-base').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'); const keyInput = document.getElementById('custom-provider-key');
if (!name) { if (!name) {
@@ -6350,7 +6267,6 @@ function saveCustomProviderModal() {
action: 'set_custom_provider', action: 'set_custom_provider',
name: name, name: name,
api_base: apiBase, api_base: apiBase,
model: model,
}; };
if (apiKey) payload.api_key = apiKey; if (apiKey) payload.api_key = apiKey;
if (editing) payload.id = customProviderModalState.editId; 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) { function deleteCustomProvider(providerId) {
showConfirmDialog({ showConfirmDialog({
title: t('models_custom_delete_confirm_title'), title: t('models_custom_delete_confirm_title'),
@@ -6397,7 +6303,10 @@ function deleteCustomProvider(providerId) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }), 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') {
closeCustomProviderModal();
loadModelsView();
}
}).catch(() => { /* noop */ }); }).catch(() => { /* noop */ });
} }
}); });

View File

@@ -1643,6 +1643,32 @@ class ConfigHandler:
"api_key_field": p.get("api_key_field"), "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 "") raw_pwd = str(local_config.get("web_password", "") or "")
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else "" masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
@@ -2189,6 +2215,17 @@ class ModelsHandler:
}) })
return cards 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 @classmethod
def _provider_overview(cls) -> List[dict]: def _provider_overview(cls) -> List[dict]:
"""All known providers (configured first, unconfigured after). """All known providers (configured first, unconfigured after).
@@ -2202,13 +2239,18 @@ class ModelsHandler:
""" """
local_config = conf() local_config = conf()
custom_cards = cls._custom_provider_cards(local_config) 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 = [] items = []
for pid, p in ConfigHandler.PROVIDER_MODELS.items(): for pid, p in ConfigHandler.PROVIDER_MODELS.items():
if pid == "custom" and custom_cards: if pid == "custom" and custom_cards:
# Multi-provider mode: emit the expanded cards instead of the # Multi-provider mode: emit the expanded cards, plus the
# single legacy custom card. # legacy card when it is still in use.
items.extend(custom_cards) items.extend(custom_cards)
continue if not keep_legacy_custom:
continue
key_field = p.get("api_key_field") key_field = p.get("api_key_field")
base_field = p.get("api_base_key") base_field = p.get("api_base_key")
raw_key = local_config.get(key_field, "") if key_field else "" raw_key = local_config.get(key_field, "") if key_field else ""
@@ -2253,11 +2295,15 @@ class ModelsHandler:
provider_id = "linkai" provider_id = "linkai"
# In multi-provider mode, replace the single "custom" entry with the # In multi-provider mode, replace the single "custom" entry with the
# expanded "custom:<id>" ids so the chat dropdown matches the cards. # 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 = [] provider_ids = []
custom_cards = cls._custom_provider_cards(local_config) 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(): for pid in ConfigHandler.PROVIDER_MODELS.keys():
if pid == "custom" and custom_cards: if pid == "custom" and custom_cards:
provider_ids.extend(c["id"] for c in custom_cards) provider_ids.extend(c["id"] for c in custom_cards)
if keep_legacy_custom:
provider_ids.append(pid)
else: else:
provider_ids.append(pid) provider_ids.append(pid)
return { return {
@@ -2881,11 +2927,14 @@ class ModelsHandler:
existing["api_base"] = api_base existing["api_base"] = api_base
if api_key: if api_key:
existing["api_key"] = 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
if model: # explicit empty string clears it, a missing key keeps it (the
existing["model"] = model # UI modal no longer sends model, so manual config survives edits).
else: if "model" in data:
existing.pop("model", None) if model:
existing["model"] = model
else:
existing.pop("model", None)
created = False created = False
# Decide bot_type — only switch when explicitly requested. # Decide bot_type — only switch when explicitly requested.

View File

@@ -1,69 +1,31 @@
--- ---
title: Custom title: カスタム
description: Custom vendor configuration for third-party API proxies and local models 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 - **サードパーティ API プロキシ**:統一された API アドレスで複数のモデルを呼び出す
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI - **ローカルモデル**OllamavLLM などのツールでローカルにデプロイしたモデル
- **Private deployments**: model services deployed inside an enterprise - **プライベートデプロイ**:企業内部にデプロイされたモデルサービス
<Note> ## Web コンソールでの設定
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>
## 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 | | ツール | デフォルト API Base |
| --- | --- |
| `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 |
| --- | --- | | --- | --- |
| [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 ## 設定ファイルでの設定
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: `config.json` を直接編集することもできます。`custom_providers` リストに複数のプロバイダーを定義し、`bot_type` を `"custom:<id>"` に設定していずれかを有効化します:
```
/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>"`:
```json ```json
{ {
@@ -71,34 +33,30 @@ If you need to configure several OpenAI-compatible third-party services at once
"custom_providers": [ "custom_providers": [
{ {
"id": "3f2a9c1b", "id": "3f2a9c1b",
"name": "siliconflow", "name": "プロバイダーA",
"api_key": "YOUR_SILICONFLOW_KEY", "api_key": "YOUR_API_KEY_A",
"api_base": "https://api.siliconflow.cn/v1", "api_base": "https://api.a.com/v1",
"model": "deepseek-ai/DeepSeek-V3" "model": "deepseek-v3"
}, },
{ {
"id": "a1b2c3d4", "id": "a1b2c3d4",
"name": "qiniu", "name": "プロバイダーB",
"api_key": "YOUR_QINIU_KEY", "api_key": "YOUR_API_KEY_B",
"api_base": "https://api.qnaigc.com/v1", "api_base": "https://api.b.com/v1",
"model": "deepseek-v3" "model": "qwen3-max"
} }
] ]
} }
``` ```
| Parameter | Description | | パラメータ | 説明 |
| --- | --- | | --- | --- |
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | | `custom_providers` | カスタムプロバイダーのリスト。各項目は `id``name``api_base`、`api_key`(任意)、`model`(任意)を含む |
| `bot_type` | Set to `"custom:<id>"` to activate a specific provider; `id` is the provider's unique identifier | | `bot_type` | `"custom:<id>"` に設定して対応するプロバイダーを有効化 |
| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider | | `id` | 一意の識別子8 桁の 16 進数。Web コンソールから追加すると自動生成され、手動設定の場合は重複しない任意の文字列でよい |
| `name` | User-facing display label — can be freely renamed without breaking anything | | `name` | 表示名。自由に変更可能 |
| `model` | Optional; when set, this model is used instead of the global `model` field | | `model` | このプロバイダーで使用するモデル。有効化時に適用される |
<Note> <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])"`). 従来の単一プロバイダー設定(`bot_type` を `"custom"` にし、`custom_api_key` / `custom_api_base` を使用)は引き続き互換性があり、変更なしでそのまま利用できます。
</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>

View File

@@ -1,51 +1,21 @@
--- ---
title: Custom 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 - **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 - **Private deployments**: model services deployed inside an enterprise
<Note> ## Web Console
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>
## 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 Default endpoints of common local deployment tools:
{
"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 | | 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` | | [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 ## Configuration File
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: 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:
```
/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>"`:
```json ```json
{ {
@@ -71,17 +33,17 @@ If you need to configure several OpenAI-compatible third-party services at once
"custom_providers": [ "custom_providers": [
{ {
"id": "3f2a9c1b", "id": "3f2a9c1b",
"name": "siliconflow", "name": "ProviderA",
"api_key": "YOUR_SILICONFLOW_KEY", "api_key": "YOUR_API_KEY_A",
"api_base": "https://api.siliconflow.cn/v1", "api_base": "https://api.a.com/v1",
"model": "deepseek-ai/DeepSeek-V3" "model": "deepseek-v3"
}, },
{ {
"id": "a1b2c3d4", "id": "a1b2c3d4",
"name": "qiniu", "name": "ProviderB",
"api_key": "YOUR_QINIU_KEY", "api_key": "YOUR_API_KEY_B",
"api_base": "https://api.qnaigc.com/v1", "api_base": "https://api.b.com/v1",
"model": "deepseek-v3" "model": "qwen3-max"
} }
] ]
} }
@@ -89,16 +51,12 @@ 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 `id`, `name`, `api_key`, `api_base`, and an optional `model` | | `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 a specific provider; `id` is the provider's unique identifier | | `bot_type` | Set to `"custom:<id>"` to activate the corresponding provider |
| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider | | `id` | Unique identifier (8-char hex); auto-generated when adding via the Web console, or any unique string when editing manually |
| `name` | User-facing display label can be freely renamed without breaking anything | | `name` | Display label, can be renamed freely |
| `model` | Optional; when set, this model is used instead of the global `model` field | | `model` | Model used by this provider, takes effect when activated |
<Note> <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])"`). 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>
<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>

View File

@@ -3,7 +3,7 @@ title: DeepSeek
description: DeepSeek model configuration (Text Chat + Thinking Mode) 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 ## Text Chat

View File

@@ -1,15 +1,15 @@
--- ---
title: Models Overview 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 ## 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 | ✅ | | | | | | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | | [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 | ✅ | ✅ | | | | | | [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | | [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | | [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 | ✅ | | | | | | | [Custom](/models/custom) | Local models / third-party proxies | ✅ | | | | | |
<Tip> <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> </Tip>
## How to Configure ## 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" /> <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 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> <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. 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: 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 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: 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 | | `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_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 | | `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 ## 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) 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> <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. 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 ## 💰 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. 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 - **Claude Opus 4.7**: Added `claude-opus-4-7` model support
- **GLM 5.1**: Added `glm-5.1` model support - **GLM 5.1**: Added `glm-5.1` model support
- **Kimi Coding Plan**: Support for Kimi Coding Plan mode - **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 ## 💬 Web Console Improvements

View File

@@ -1,69 +1,31 @@
--- ---
title: Custom title: 自定义
description: Custom vendor configuration for third-party API proxies and local models 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 - **第三方 API 代理**:通过统一的 API 地址调用多种模型
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI - **本地模型**:使用 OllamavLLM 等工具在本地部署的模型
- **Private deployments**: model services deployed inside an enterprise - **私有化部署**:企业内部部署的模型服务
<Note> ## Web 端配置
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>
## 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 | | 工具 | 默认 API Base |
| --- | --- |
| `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 |
| --- | --- | | --- | --- |
| [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 ## 配置文件配置
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: 也可以直接编辑 `config.json`,在 `custom_providers` 列表中定义多个厂商,并将 `bot_type` 设置为 `"custom:<id>"` 来激活其中一个:
```
/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>"`:
```json ```json
{ {
@@ -71,34 +33,30 @@ If you need to configure several OpenAI-compatible third-party services at once
"custom_providers": [ "custom_providers": [
{ {
"id": "3f2a9c1b", "id": "3f2a9c1b",
"name": "siliconflow", "name": "厂商A",
"api_key": "YOUR_SILICONFLOW_KEY", "api_key": "YOUR_API_KEY_A",
"api_base": "https://api.siliconflow.cn/v1", "api_base": "https://api.a.com/v1",
"model": "deepseek-ai/DeepSeek-V3" "model": "deepseek-v3"
}, },
{ {
"id": "a1b2c3d4", "id": "a1b2c3d4",
"name": "qiniu", "name": "厂商B",
"api_key": "YOUR_QINIU_KEY", "api_key": "YOUR_API_KEY_B",
"api_base": "https://api.qnaigc.com/v1", "api_base": "https://api.b.com/v1",
"model": "deepseek-v3" "model": "qwen3-max"
} }
] ]
} }
``` ```
| Parameter | Description | | 参数 | 说明 |
| --- | --- | | --- | --- |
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | | `custom_providers` | 自定义厂商列表,每项包含 `id``name``api_base`、`api_key`(可选)、`model`(可选) |
| `bot_type` | Set to `"custom:<id>"` to activate a specific provider; `id` is the provider's unique identifier | | `bot_type` | 设置为 `"custom:<id>"` 激活对应厂商 |
| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider | | `id` | 厂商唯一标识8 位十六进制),在 Web 端添加时自动生成,手动配置时填写任意不重复的标识即可 |
| `name` | User-facing display label — can be freely renamed without breaking anything | | `name` | 展示名称,可随意修改 |
| `model` | Optional; when set, this model is used instead of the global `model` field | | `model` | 该厂商使用的模型,激活时生效 |
<Note> <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])"`). 历史的单厂商配置(`bot_type` 为 `"custom"`,配合 `custom_api_key` / `custom_api_base`)仍然兼容,无需改动即可继续使用。
</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>