mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
@@ -48,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" width="750"/>
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" width="750"/>
|
||||
|
||||
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible.
|
||||
|
||||
|
||||
@@ -462,13 +462,12 @@ class MemoryFlushManager:
|
||||
daily_content=daily_content or "(no recent daily records)",
|
||||
)
|
||||
from agent.protocol.models import LLMRequest
|
||||
# Scale max_tokens based on input size to avoid truncating large MEMORY.md
|
||||
input_chars = len(memory_content) + len(daily_content)
|
||||
dream_max_tokens = max(2000, min(input_chars, 8000))
|
||||
# No output cap: the prompt already keeps MEMORY.md concise (~50
|
||||
# items), so a hard max_tokens would only risk truncating a large
|
||||
# rewrite. Let the model use its default output budget.
|
||||
request = LLMRequest(
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
temperature=0.3,
|
||||
max_tokens=dream_max_tokens,
|
||||
stream=False,
|
||||
system=_dream_system_prompt(),
|
||||
)
|
||||
|
||||
@@ -34,6 +34,27 @@ class SkillService:
|
||||
"""
|
||||
self.manager = skill_manager
|
||||
|
||||
def _safe_skill_dir(self, name: str) -> str:
|
||||
"""Derive and validate the skill directory path.
|
||||
|
||||
Ensures the resolved path stays within the custom_dir root,
|
||||
preventing path traversal via names like ``../escaped``.
|
||||
|
||||
:raises ValueError: if the name would escape the skills root.
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
raise ValueError("skill name is required")
|
||||
# Reject obvious traversal components.
|
||||
if ".." in name or name.startswith("/") or name.startswith("\\"):
|
||||
raise ValueError(f"invalid skill name (path traversal detected): {name!r}")
|
||||
skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name))
|
||||
root = os.path.realpath(self.manager.custom_dir)
|
||||
if not skill_dir.startswith(root + os.sep) and skill_dir != root:
|
||||
raise ValueError(
|
||||
f"skill name {name!r} resolves outside the skills directory"
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# query
|
||||
# ------------------------------------------------------------------
|
||||
@@ -107,7 +128,7 @@ class SkillService:
|
||||
if not files:
|
||||
raise ValueError("skill files list is empty")
|
||||
|
||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
||||
skill_dir = self._safe_skill_dir(name)
|
||||
|
||||
tmp_dir = skill_dir + ".tmp"
|
||||
if os.path.exists(tmp_dir):
|
||||
@@ -146,7 +167,7 @@ class SkillService:
|
||||
raise ValueError("package url is required")
|
||||
|
||||
url = files[0]["url"]
|
||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
||||
skill_dir = self._safe_skill_dir(name)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
zip_path = os.path.join(tmp_dir, "package.zip")
|
||||
@@ -217,7 +238,7 @@ class SkillService:
|
||||
if not name:
|
||||
raise ValueError("skill name is required")
|
||||
|
||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
||||
skill_dir = self._safe_skill_dir(name)
|
||||
if os.path.exists(skill_dir):
|
||||
shutil.rmtree(skill_dir)
|
||||
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
||||
|
||||
@@ -17,11 +17,14 @@ Provider resolution:
|
||||
"""
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
@@ -654,6 +657,40 @@ class Vision(BaseTool):
|
||||
return api_base
|
||||
return api_base.rstrip("/") + "/v1"
|
||||
|
||||
@staticmethod
|
||||
def _validate_url_safe(url: str) -> None:
|
||||
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
||||
|
||||
Resolves the hostname to its IP address(es) and blocks any that fall
|
||||
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||
schemes, or hosts that fail DNS resolution.
|
||||
|
||||
Raises:
|
||||
ValueError: if the URL targets a disallowed address.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise ValueError("URL has no hostname")
|
||||
|
||||
try:
|
||||
# Resolve all addresses for the hostname.
|
||||
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}")
|
||||
|
||||
for family, _, _, _, sockaddr in addr_infos:
|
||||
ip_str = sockaddr[0]
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
raise ValueError(
|
||||
f"URL resolves to a non-public address ({ip_str}), "
|
||||
f"request blocked for security"
|
||||
)
|
||||
|
||||
def _build_image_content(self, image: str) -> dict:
|
||||
"""
|
||||
Build the image_url content block.
|
||||
@@ -661,6 +698,7 @@ class Vision(BaseTool):
|
||||
so every bot backend can consume them without extra downloads.
|
||||
"""
|
||||
if image.startswith(("http://", "https://")):
|
||||
self._validate_url_safe(image)
|
||||
return self._download_to_data_url(image)
|
||||
|
||||
if not os.path.isfile(image):
|
||||
|
||||
@@ -519,7 +519,10 @@ class ChatChannel(Channel):
|
||||
def cancel_session(self, session_id):
|
||||
with self.lock:
|
||||
if session_id in self.sessions:
|
||||
for future in self.futures[session_id]:
|
||||
# futures[session_id] is only created in consume() when a task is
|
||||
# dispatched, so it may be absent if cancel happens right after
|
||||
# produce() but before the first dispatch. Default to [].
|
||||
for future in self.futures.get(session_id, []):
|
||||
future.cancel()
|
||||
cnt = self.sessions[session_id][0].qsize()
|
||||
if cnt > 0:
|
||||
@@ -529,7 +532,7 @@ class ChatChannel(Channel):
|
||||
def cancel_all_session(self):
|
||||
with self.lock:
|
||||
for session_id in self.sessions:
|
||||
for future in self.futures[session_id]:
|
||||
for future in self.futures.get(session_id, []):
|
||||
future.cancel()
|
||||
cnt = self.sessions[session_id][0].qsize()
|
||||
if cnt > 0:
|
||||
|
||||
@@ -1198,6 +1198,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Provider Modal (multiple OpenAI-compatible providers) -->
|
||||
<div id="custom-provider-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||
w-full max-w-md mx-4">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-sliders text-primary-500"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 id="custom-provider-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_name">名称</label>
|
||||
<input id="custom-provider-name" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
|
||||
<input id="custom-provider-base" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||
placeholder="https://...../v1">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
|
||||
<input id="custom-provider-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||
<button id="custom-provider-modal-delete"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
cursor-pointer transition-colors duration-150 hidden"
|
||||
data-i18n="models_custom_delete">删除</button>
|
||||
<span id="custom-provider-modal-status"
|
||||
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
|
||||
<button id="custom-provider-modal-cancel"
|
||||
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||
hover:bg-slate-50 dark:hover:bg-white/5
|
||||
cursor-pointer transition-colors duration-150"
|
||||
data-i18n="cancel">取消</button>
|
||||
<button id="custom-provider-modal-save"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-i18n="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="assets/js/console.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -866,6 +866,14 @@
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* "Custom" row is an add-new action: trailing + instead of ✓. */
|
||||
.vendor-picker-add-mark {
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Chat Input */
|
||||
#chat-input {
|
||||
|
||||
@@ -32,6 +32,15 @@ const I18N = {
|
||||
models_clear_credential: '清除凭据',
|
||||
models_base_default_hint: '留空将使用官方默认地址',
|
||||
models_base_default: '默认',
|
||||
models_custom_vendor_label: '自定义',
|
||||
models_custom_name: '名称',
|
||||
models_custom_delete: '删除',
|
||||
models_custom_delete_confirm_title: '删除自定义厂商',
|
||||
models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。',
|
||||
models_custom_name_required: '请填写名称',
|
||||
models_custom_base_required: '请填写 API Base',
|
||||
models_custom_edit_title: '编辑自定义厂商',
|
||||
models_custom_add_title: '添加自定义厂商',
|
||||
models_capability_chat: '主模型',
|
||||
models_capability_chat_desc: '用于基础对话和 Agent 推理',
|
||||
models_capability_vision: '图像理解',
|
||||
@@ -226,10 +235,10 @@ const I18N = {
|
||||
menu_logs: 'Logs',
|
||||
models_title: 'Models',
|
||||
models_desc: 'Manage chat, image, voice, embedding and search capabilities in one place',
|
||||
models_section_vendors: 'Vendor Credentials',
|
||||
models_section_vendors: 'Provider Credentials',
|
||||
models_section_vendors_desc: 'Configured once, shared by multiple model capabilities',
|
||||
models_section_capabilities: 'Capabilities',
|
||||
models_add_vendor: 'Add Vendor',
|
||||
models_add_vendor: 'Add Provider',
|
||||
models_provider: 'Provider',
|
||||
models_model: 'Model',
|
||||
models_voice: 'Voice',
|
||||
@@ -239,6 +248,15 @@ const I18N = {
|
||||
models_clear_credential: 'Clear credentials',
|
||||
models_base_default_hint: 'Leave blank to use the official default base URL',
|
||||
models_base_default: 'Default',
|
||||
models_custom_vendor_label: 'Custom',
|
||||
models_custom_name: 'Name',
|
||||
models_custom_delete: 'Delete',
|
||||
models_custom_delete_confirm_title: 'Delete custom provider',
|
||||
models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.',
|
||||
models_custom_name_required: 'Name is required',
|
||||
models_custom_base_required: 'API Base is required',
|
||||
models_custom_edit_title: 'Edit custom provider',
|
||||
models_custom_add_title: 'Add custom provider',
|
||||
models_capability_chat: 'Main Model',
|
||||
models_capability_chat_desc: 'Used for basic chat and agent reasoning',
|
||||
models_capability_vision: 'Image Understanding',
|
||||
@@ -280,8 +298,8 @@ const I18N = {
|
||||
models_embedding_saved_msg: 'Send /memory rebuild-index in the chat to rebuild the index.',
|
||||
models_embedding_saved_ok: 'Go',
|
||||
models_pick_provider: 'Pick a provider',
|
||||
models_clear_confirm_title: 'Clear vendor credentials',
|
||||
models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.',
|
||||
models_clear_confirm_title: 'Clear provider credentials',
|
||||
models_clear_confirm_msg: 'Remove this provider\'s API Key and Base URL? Capabilities relying on it will stop working.',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
ok: 'OK',
|
||||
@@ -4799,13 +4817,22 @@ function renderModelsView() {
|
||||
MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def)));
|
||||
}
|
||||
|
||||
// True when a provider card is one of the expanded custom (OpenAI-compatible)
|
||||
// providers (id "custom:<id>") — shown in the vendor grid alongside built-in
|
||||
// vendors, but edited via the dedicated custom-provider modal.
|
||||
function isCustomProviderCard(p) {
|
||||
return !!(p && p.is_custom && p.custom_name);
|
||||
}
|
||||
|
||||
// ---------- Vendor section (Layer 1) -----------------------------------
|
||||
|
||||
function renderVendorsSection() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
|
||||
|
||||
const configured = modelsState.providers.filter(p => p.configured);
|
||||
// Custom providers always show once created (even without an api key,
|
||||
// e.g. a local vLLM/Ollama endpoint); built-in vendors show when configured.
|
||||
const configured = modelsState.providers.filter(p => p.configured || isCustomProviderCard(p));
|
||||
|
||||
const header = `
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
@@ -4816,7 +4843,6 @@ function renderVendorsSection() {
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_section_vendors')}</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_section_vendors_desc')}</p>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400 dark:text-slate-500 mt-2 flex-shrink-0">${configured.length}/${modelsState.providers.length}</span>
|
||||
</div>`;
|
||||
|
||||
let body;
|
||||
@@ -4842,8 +4868,13 @@ function renderVendorsSection() {
|
||||
function renderVendorChip(p) {
|
||||
// The masked API key is intentionally not surfaced here; it is shown
|
||||
// inside the edit modal so the chip stays uncluttered and scannable.
|
||||
// Custom providers open their dedicated modal (name + base + key);
|
||||
// their ids are server-generated hex, safe to inline.
|
||||
const onclick = isCustomProviderCard(p)
|
||||
? `openCustomProviderModal('${escapeHtml(p.custom_id)}')`
|
||||
: `openVendorModal('${escapeHtml(p.id)}')`;
|
||||
return `
|
||||
<button onclick="openVendorModal('${escapeHtml(p.id)}')"
|
||||
<button onclick="${onclick}"
|
||||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border border-slate-200 dark:border-white/10
|
||||
bg-slate-50 dark:bg-white/5 hover:border-primary-300 dark:hover:border-primary-500/50
|
||||
cursor-pointer transition-colors duration-150 text-left">
|
||||
@@ -4876,6 +4907,10 @@ function renderProviderLogo(p, sizePx) {
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function getCustomProviderCards() {
|
||||
return modelsState.providers.filter(isCustomProviderCard);
|
||||
}
|
||||
|
||||
// ---------- Capability cards (Layer 2) ---------------------------------
|
||||
|
||||
function renderCapabilityCard(def) {
|
||||
@@ -5690,6 +5725,14 @@ function decorateVendorModalPicker(ddEl, opts) {
|
||||
// Tag the row so the global active-row ✓ rule is suppressed in CSS
|
||||
// (otherwise configured AND selected rows would render two checks).
|
||||
item.classList.add('vendor-picker-item');
|
||||
if (opt._isAddNew) {
|
||||
// "Custom" is an add-new action (multiple entries allowed),
|
||||
// so show a trailing + instead of the configured ✓.
|
||||
const plus = document.createElement('i');
|
||||
plus.className = 'fas fa-plus vendor-picker-add-mark';
|
||||
item.appendChild(plus);
|
||||
return;
|
||||
}
|
||||
if (!opt._configured) return;
|
||||
const check = document.createElement('i');
|
||||
check.className = 'fas fa-check vendor-picker-configured-mark';
|
||||
@@ -5997,18 +6040,42 @@ function openVendorModal(providerId, onSaved) {
|
||||
// currently selected vendor via its own background highlight, so we
|
||||
// intentionally suppress the global active-row ✓ for this picker
|
||||
// (see CSS) — otherwise configured + selected rows would show two.
|
||||
const unconfigured = modelsState.providers.filter(p => !p.configured);
|
||||
const defaultId = (unconfigured[0] && unconfigured[0].id) || (modelsState.providers[0] && modelsState.providers[0].id) || '';
|
||||
pickerWrap.classList.remove('hidden');
|
||||
const pickerEl = document.getElementById('vendor-modal-picker');
|
||||
const pickerOpts = modelsState.providers.map(p => ({
|
||||
// Expanded custom provider cards ("custom:<id>") are edited via their
|
||||
// dedicated modal, so they are excluded from this picker. Picking the
|
||||
// "custom" entry creates a *new* custom provider via that modal —
|
||||
// this is how multiple OpenAI-compatible endpoints are added.
|
||||
const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p));
|
||||
const pickerOpts = builtinProviders.map(p => ({
|
||||
value: p.id,
|
||||
label: localizedLabel(p.label),
|
||||
_configured: !!p.configured,
|
||||
}));
|
||||
initDropdown(pickerEl, pickerOpts, defaultId, (val) => fillVendorModalForProvider(val));
|
||||
// In multi-provider mode the backend replaces the bare "custom" card
|
||||
// with the expanded ones; re-add it here so the entry stays available.
|
||||
if (!pickerOpts.some(o => o.value === 'custom')) {
|
||||
pickerOpts.push({ value: 'custom', label: t('models_custom_vendor_label'), _configured: false });
|
||||
}
|
||||
// "Custom" always behaves as an add-new action (multiple entries
|
||||
// allowed), so it shows a + mark instead of the configured ✓.
|
||||
pickerOpts.forEach(o => { if (o.value === 'custom') { o._isAddNew = true; o._configured = false; } });
|
||||
const unconfigured = builtinProviders.filter(p => !p.configured);
|
||||
const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || 'custom';
|
||||
pickerWrap.classList.remove('hidden');
|
||||
const pickerEl = document.getElementById('vendor-modal-picker');
|
||||
const onPick = (val) => {
|
||||
if (val === 'custom') {
|
||||
// "Custom" in the add flow always creates a new
|
||||
// OpenAI-compatible provider entry via the dedicated modal
|
||||
// (name + base + key), supporting multiple custom endpoints.
|
||||
closeVendorModal();
|
||||
openCustomProviderModal('');
|
||||
return;
|
||||
}
|
||||
fillVendorModalForProvider(val);
|
||||
};
|
||||
initDropdown(pickerEl, pickerOpts, defaultId, onPick);
|
||||
decorateVendorModalPicker(pickerEl, pickerOpts);
|
||||
fillVendorModalForProvider(defaultId);
|
||||
onPick(defaultId);
|
||||
} else {
|
||||
pickerWrap.classList.add('hidden');
|
||||
fillVendorModalForProvider(providerId);
|
||||
@@ -6164,6 +6231,151 @@ function clearVendorModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Custom (OpenAI-compatible) provider modal — add / edit
|
||||
// =====================================================================
|
||||
// State for the dedicated custom-provider modal. `editId` is empty when
|
||||
// adding and set to the provider id when editing.
|
||||
let customProviderModalState = { editId: '' };
|
||||
|
||||
function openCustomProviderModal(providerId) {
|
||||
const editing = !!providerId;
|
||||
customProviderModalState = { editId: editing ? providerId : '' };
|
||||
|
||||
const card = editing ? getCustomProviderCards().find(p => p.custom_id === providerId) : null;
|
||||
|
||||
const overlay = document.getElementById('custom-provider-modal-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
document.getElementById('custom-provider-modal-title').textContent =
|
||||
editing ? t('models_custom_edit_title') : t('models_custom_add_title');
|
||||
|
||||
const nameInput = document.getElementById('custom-provider-name');
|
||||
const baseInput = document.getElementById('custom-provider-base');
|
||||
const keyInput = document.getElementById('custom-provider-key');
|
||||
|
||||
nameInput.value = card ? (card.custom_name || '') : '';
|
||||
baseInput.value = card ? (card.api_base || '') : '';
|
||||
|
||||
// Surface the masked key as the value for configured providers so the
|
||||
// "already set" state is unambiguous; an untouched masked value means
|
||||
// "keep the existing key" on save (mirrors the vendor modal contract).
|
||||
if (card && card.configured && card.api_key_masked) {
|
||||
keyInput.value = card.api_key_masked;
|
||||
keyInput.dataset.masked = '1';
|
||||
keyInput.dataset.maskedVal = card.api_key_masked;
|
||||
} else {
|
||||
keyInput.value = '';
|
||||
keyInput.dataset.masked = '';
|
||||
keyInput.dataset.maskedVal = '';
|
||||
}
|
||||
keyInput.oninput = function () {
|
||||
if (keyInput.dataset.masked === '1' && keyInput.value !== keyInput.dataset.maskedVal) {
|
||||
keyInput.dataset.masked = '';
|
||||
}
|
||||
};
|
||||
|
||||
const statusEl = document.getElementById('custom-provider-modal-status');
|
||||
if (statusEl) { statusEl.textContent = ''; statusEl.classList.add('opacity-0'); }
|
||||
|
||||
overlay.classList.remove('hidden');
|
||||
document.getElementById('custom-provider-modal-cancel').onclick = closeCustomProviderModal;
|
||||
document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal;
|
||||
|
||||
// Delete is only available when editing an existing provider.
|
||||
const deleteBtn = document.getElementById('custom-provider-modal-delete');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.classList.toggle('hidden', !editing);
|
||||
deleteBtn.onclick = editing ? () => deleteCustomProvider(providerId) : null;
|
||||
}
|
||||
|
||||
function onOverlayClick(e) {
|
||||
if (e.target === overlay) {
|
||||
closeCustomProviderModal();
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
}
|
||||
}
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function closeCustomProviderModal() {
|
||||
const overlay = document.getElementById('custom-provider-modal-overlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
function saveCustomProviderModal() {
|
||||
const name = document.getElementById('custom-provider-name').value.trim();
|
||||
const apiBase = document.getElementById('custom-provider-base').value.trim();
|
||||
const keyInput = document.getElementById('custom-provider-key');
|
||||
|
||||
if (!name) {
|
||||
showStatus('custom-provider-modal-status', 'models_custom_name_required', true);
|
||||
document.getElementById('custom-provider-name').focus();
|
||||
return;
|
||||
}
|
||||
const editing = !!customProviderModalState.editId;
|
||||
if (!editing && !apiBase) {
|
||||
showStatus('custom-provider-modal-status', 'models_custom_base_required', true);
|
||||
document.getElementById('custom-provider-base').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Untouched masked key => no change (omit from payload).
|
||||
let apiKey = keyInput.value.trim();
|
||||
if (keyInput.dataset.masked === '1' && apiKey === (keyInput.dataset.maskedVal || '')) {
|
||||
apiKey = '';
|
||||
}
|
||||
|
||||
const payload = {
|
||||
action: 'set_custom_provider',
|
||||
name: name,
|
||||
api_base: apiBase,
|
||||
};
|
||||
if (apiKey) payload.api_key = apiKey;
|
||||
if (editing) payload.id = customProviderModalState.editId;
|
||||
|
||||
const btn = document.getElementById('custom-provider-modal-save');
|
||||
btn.disabled = true;
|
||||
fetch('/api/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(r => r.json()).then(data => {
|
||||
btn.disabled = false;
|
||||
if (data.status === 'success') {
|
||||
closeCustomProviderModal();
|
||||
loadModelsView();
|
||||
} else {
|
||||
showStatus('custom-provider-modal-status', 'models_save_failed', true);
|
||||
}
|
||||
}).catch(() => {
|
||||
btn.disabled = false;
|
||||
showStatus('custom-provider-modal-status', 'models_save_failed', true);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteCustomProvider(providerId) {
|
||||
showConfirmDialog({
|
||||
title: t('models_custom_delete_confirm_title'),
|
||||
message: t('models_custom_delete_confirm_msg'),
|
||||
okText: t('models_custom_delete'),
|
||||
cancelText: t('cancel'),
|
||||
onConfirm: () => {
|
||||
fetch('/api/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') {
|
||||
closeCustomProviderModal();
|
||||
loadModelsView();
|
||||
}
|
||||
}).catch(() => { /* noop */ });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Channels View
|
||||
// =====================================================================
|
||||
|
||||
@@ -1471,10 +1471,9 @@ class ConfigHandler:
|
||||
_RECOMMENDED_MODELS = [
|
||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||
# claude-fable-5 is intentionally placed at the end of the Claude
|
||||
# group here: it is expensive, so avoid surfacing it too early in
|
||||
# the LinkAI dropdown.
|
||||
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_FABLE_5,
|
||||
# claude-fable-5 is placed after claude-opus-4-7 (not as the Claude
|
||||
# default) since it is often unavailable due to policy restrictions.
|
||||
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||
@@ -1519,7 +1518,7 @@ class ConfigHandler:
|
||||
"api_base_key": "claude_api_base",
|
||||
"api_base_default": "https://api.anthropic.com/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"models": [const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||
}),
|
||||
("gemini", {
|
||||
"label": "Gemini",
|
||||
@@ -1611,6 +1610,7 @@ class ConfigHandler:
|
||||
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
|
||||
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
||||
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
|
||||
"custom_providers",
|
||||
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
||||
"enable_thinking", "self_evolution_enabled", "web_password",
|
||||
}
|
||||
@@ -1652,6 +1652,32 @@ class ConfigHandler:
|
||||
"api_key_field": p.get("api_key_field"),
|
||||
}
|
||||
|
||||
# Expose user-defined custom providers as "custom:<id>" entries so
|
||||
# the legacy config page can display and select them. Credentials
|
||||
# are managed on the Models page, hence the null key/base fields.
|
||||
# Mirrors the Models page: when expanded entries exist, the bare
|
||||
# legacy "custom" entry is hidden — unless the flat single-provider
|
||||
# custom config is still active or filled in.
|
||||
try:
|
||||
from models.custom_provider import get_custom_providers
|
||||
custom_list = get_custom_providers()
|
||||
legacy_custom_in_use = ModelsHandler._legacy_custom_in_use(local_config)
|
||||
if custom_list and not legacy_custom_in_use:
|
||||
providers.pop("custom", None)
|
||||
for cp in custom_list:
|
||||
cid = f"custom:{cp.get('id')}"
|
||||
cname = cp.get("name") or cp.get("id")
|
||||
providers[cid] = {
|
||||
"label": {"zh": cname, "en": cname},
|
||||
"models": [cp["model"]] if cp.get("model") else [],
|
||||
"api_base_key": None,
|
||||
"api_base_default": None,
|
||||
"api_base_placeholder": "",
|
||||
"api_key_field": None,
|
||||
}
|
||||
except Exception as cp_err:
|
||||
logger.warning(f"[ConfigHandler] failed to expand custom providers: {cp_err}")
|
||||
|
||||
raw_pwd = str(local_config.get("web_password", "") or "")
|
||||
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
||||
|
||||
@@ -2141,13 +2167,99 @@ class ModelsHandler:
|
||||
def _is_real_key(value: str) -> bool:
|
||||
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
|
||||
|
||||
@classmethod
|
||||
def _custom_provider_cards(cls, local_config: dict) -> List[dict]:
|
||||
"""Expand ``custom_providers`` into one card per provider.
|
||||
|
||||
Each user-defined OpenAI-compatible provider becomes its own card with
|
||||
id ``custom:<id>`` so the frontend can render, edit, delete and
|
||||
activate them independently. The card carries ``is_custom=True`` and
|
||||
``active`` flags that the UI uses to render the extra controls.
|
||||
|
||||
Returns an empty list when no multi-providers are configured, in which
|
||||
case the caller keeps the single legacy ``custom`` card untouched —
|
||||
guaranteeing backward compatibility with the flat
|
||||
``custom_api_key`` / ``custom_api_base`` config.
|
||||
"""
|
||||
try:
|
||||
from models.custom_provider import get_custom_providers, parse_custom_bot_type
|
||||
providers = get_custom_providers()
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}")
|
||||
providers = []
|
||||
if not providers:
|
||||
return []
|
||||
|
||||
# Determine the currently active provider id from bot_type.
|
||||
bot_type = local_config.get("bot_type") or ""
|
||||
_, active_id = parse_custom_bot_type(bot_type)
|
||||
|
||||
meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {}
|
||||
cards = []
|
||||
for p in providers:
|
||||
pid = p.get("id") or ""
|
||||
name = p.get("name") or pid
|
||||
raw_key = p.get("api_key") or ""
|
||||
raw_base = p.get("api_base") or ""
|
||||
configured = cls._is_real_key(raw_key)
|
||||
cards.append({
|
||||
"id": f"custom:{pid}",
|
||||
"label": {"zh": name, "en": name},
|
||||
"configured": configured,
|
||||
"is_custom": True,
|
||||
"custom_id": pid,
|
||||
"custom_name": name,
|
||||
"active": (pid == active_id),
|
||||
"model": p.get("model") or "",
|
||||
# Custom cards are edited via the dedicated set_custom_provider
|
||||
# action, not the field-based set_provider flow, so the field
|
||||
# names are intentionally null.
|
||||
"api_key_field": None,
|
||||
"api_base_field": None,
|
||||
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
||||
"api_base": raw_base,
|
||||
"api_base_default": "",
|
||||
"api_base_placeholder": meta.get("api_base_placeholder") or "",
|
||||
"models": [p.get("model")] if p.get("model") else [],
|
||||
})
|
||||
return cards
|
||||
|
||||
@classmethod
|
||||
def _legacy_custom_in_use(cls, local_config: dict) -> bool:
|
||||
"""True when the flat single-provider custom config is still relevant:
|
||||
either it is the active bot_type, or its key/base fields are filled.
|
||||
In that case the legacy "custom" card must stay visible even when
|
||||
multi ``custom_providers`` entries exist."""
|
||||
if (local_config.get("bot_type") or "") == "custom":
|
||||
return True
|
||||
return (cls._is_real_key(local_config.get("custom_api_key") or "")
|
||||
or bool(local_config.get("custom_api_base")))
|
||||
|
||||
@classmethod
|
||||
def _provider_overview(cls) -> List[dict]:
|
||||
"""All known providers (configured first, unconfigured after).
|
||||
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list."""
|
||||
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list.
|
||||
|
||||
When the user has defined multiple custom (OpenAI-compatible)
|
||||
providers via ``custom_providers``, the single built-in ``custom``
|
||||
card is replaced by one card per provider (see
|
||||
``_custom_provider_cards``). Otherwise the legacy single ``custom``
|
||||
card is shown unchanged.
|
||||
"""
|
||||
local_config = conf()
|
||||
custom_cards = cls._custom_provider_cards(local_config)
|
||||
# Keep the legacy single "custom" card visible alongside the expanded
|
||||
# ones when the flat custom_api_key/base config is active or filled,
|
||||
# so existing single-provider setups never disappear from the UI.
|
||||
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
|
||||
items = []
|
||||
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
|
||||
if pid == "custom" and custom_cards:
|
||||
# Multi-provider mode: emit the expanded cards, plus the
|
||||
# legacy card when it is still in use.
|
||||
items.extend(custom_cards)
|
||||
if not keep_legacy_custom:
|
||||
continue
|
||||
key_field = p.get("api_key_field")
|
||||
base_field = p.get("api_base_key")
|
||||
raw_key = local_config.get(key_field, "") if key_field else ""
|
||||
@@ -2157,6 +2269,7 @@ class ModelsHandler:
|
||||
"id": pid,
|
||||
"label": p["label"],
|
||||
"configured": configured,
|
||||
"is_custom": (pid == "custom"),
|
||||
"api_key_field": key_field,
|
||||
"api_base_field": base_field,
|
||||
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
||||
@@ -2165,7 +2278,19 @@ class ModelsHandler:
|
||||
"api_base_placeholder": p.get("api_base_placeholder") or "",
|
||||
"models": list(p.get("models") or []),
|
||||
})
|
||||
items.sort(key=lambda it: (0 if it["configured"] else 1, list(ConfigHandler.PROVIDER_MODELS.keys()).index(it["id"])))
|
||||
|
||||
def _sort_key(it):
|
||||
pid = it["id"]
|
||||
# Custom expanded cards share the sort weight of the base "custom"
|
||||
# entry so they cluster where the single custom card used to be.
|
||||
base_id = "custom" if it.get("is_custom") else pid
|
||||
try:
|
||||
order = list(ConfigHandler.PROVIDER_MODELS.keys()).index(base_id)
|
||||
except ValueError:
|
||||
order = len(ConfigHandler.PROVIDER_MODELS)
|
||||
return (0 if it["configured"] else 1, order)
|
||||
|
||||
items.sort(key=_sort_key)
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
@@ -2173,13 +2298,28 @@ class ModelsHandler:
|
||||
"""Main chat model — drives the agent. bot_type maps to a provider id."""
|
||||
bot_type = local_config.get("bot_type") or ""
|
||||
provider_id = "openai" if bot_type == "chatGPT" else bot_type
|
||||
if provider_id not in ConfigHandler.PROVIDER_MODELS and local_config.get("use_linkai"):
|
||||
is_custom_id = provider_id.startswith("custom:")
|
||||
if (provider_id not in ConfigHandler.PROVIDER_MODELS and not is_custom_id
|
||||
and local_config.get("use_linkai")):
|
||||
provider_id = "linkai"
|
||||
# In multi-provider mode, replace the single "custom" entry with the
|
||||
# expanded "custom:<id>" ids so the chat dropdown matches the cards.
|
||||
# The legacy "custom" entry stays when its flat config is still used.
|
||||
provider_ids = []
|
||||
custom_cards = cls._custom_provider_cards(local_config)
|
||||
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
|
||||
for pid in ConfigHandler.PROVIDER_MODELS.keys():
|
||||
if pid == "custom" and custom_cards:
|
||||
provider_ids.extend(c["id"] for c in custom_cards)
|
||||
if keep_legacy_custom:
|
||||
provider_ids.append(pid)
|
||||
else:
|
||||
provider_ids.append(pid)
|
||||
return {
|
||||
"editable": True,
|
||||
"current_provider": provider_id,
|
||||
"current_model": local_config.get("model", ""),
|
||||
"providers": list(ConfigHandler.PROVIDER_MODELS.keys()),
|
||||
"providers": provider_ids,
|
||||
"use_linkai": bool(local_config.get("use_linkai", False)),
|
||||
}
|
||||
|
||||
@@ -2613,6 +2753,12 @@ class ModelsHandler:
|
||||
return self._handle_set_provider(data)
|
||||
if action == "delete_provider":
|
||||
return self._handle_delete_provider(data)
|
||||
if action == "set_custom_provider":
|
||||
return self._handle_set_custom_provider(data)
|
||||
if action == "delete_custom_provider":
|
||||
return self._handle_delete_custom_provider(data)
|
||||
if action == "set_active_custom_provider":
|
||||
return self._handle_set_active_custom_provider(data)
|
||||
if action == "set_capability":
|
||||
return self._handle_set_capability(data)
|
||||
if action == "set_voice_reply_mode":
|
||||
@@ -2696,6 +2842,170 @@ class ModelsHandler:
|
||||
self._reset_bridge()
|
||||
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Multiple custom (OpenAI-compatible) providers
|
||||
# ------------------------------------------------------------------
|
||||
# These actions manage the ``custom_providers`` list. Activation is done
|
||||
# by setting ``bot_type`` to ``"custom:<id>"``. There is no separate
|
||||
# ``custom_active_provider`` field — a single source of truth.
|
||||
|
||||
@staticmethod
|
||||
def _normalize_custom_providers(raw) -> List[dict]:
|
||||
"""Return a clean list of provider dicts (drops malformed entries)."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out = []
|
||||
for p in raw:
|
||||
if isinstance(p, dict) and (p.get("id") or "").strip():
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
def _persist_custom_providers(self, providers: List[dict], bot_type=None) -> None:
|
||||
"""Write the providers list to both in-memory conf and the on-disk
|
||||
config, then reset the bridge so bots rebuild.
|
||||
|
||||
If ``bot_type`` is given, also update ``bot_type``. When activating a
|
||||
provider (bot_type is ``custom:<id>``), also write the provider's
|
||||
``model`` into the global ``model`` field so that all paths (chat,
|
||||
agent, vision) automatically use the correct model."""
|
||||
from models.custom_provider import parse_custom_bot_type
|
||||
|
||||
local_config = conf()
|
||||
file_cfg = self._read_file_config()
|
||||
local_config["custom_providers"] = providers
|
||||
file_cfg["custom_providers"] = providers
|
||||
if bot_type is not None:
|
||||
local_config["bot_type"] = bot_type
|
||||
file_cfg["bot_type"] = bot_type
|
||||
# Sync the provider's model into the global model field.
|
||||
_, pid = parse_custom_bot_type(bot_type)
|
||||
if pid:
|
||||
provider = next((p for p in providers if p.get("id") == pid), None)
|
||||
if provider and provider.get("model"):
|
||||
local_config["model"] = provider["model"]
|
||||
file_cfg["model"] = provider["model"]
|
||||
self._write_file_config(file_cfg)
|
||||
self._reset_bridge()
|
||||
|
||||
def _handle_set_custom_provider(self, data: dict) -> str:
|
||||
"""Add a new custom provider or update an existing one.
|
||||
|
||||
Payload::
|
||||
|
||||
{
|
||||
"action": "set_custom_provider",
|
||||
"id": "3f2a9c1b", # required for edit; omit for create
|
||||
"name": "siliconflow", # required, display label
|
||||
"api_base": "https://...", # required when creating
|
||||
"api_key": "sk-...", # optional on edit (keep existing)
|
||||
"model": "deepseek-ai/...", # optional default model
|
||||
"make_active": true # optional, also activate it
|
||||
}
|
||||
"""
|
||||
from models.custom_provider import generate_provider_id, parse_custom_bot_type
|
||||
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return json.dumps({"status": "error", "message": "name is required"})
|
||||
|
||||
provider_id = (data.get("id") or "").strip()
|
||||
api_base = (data.get("api_base") or "").strip()
|
||||
# api_key omitted/empty on edit => keep the existing one.
|
||||
api_key_raw = data.get("api_key")
|
||||
api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else ""
|
||||
model = (data.get("model") or "").strip()
|
||||
make_active = bool(data.get("make_active"))
|
||||
|
||||
local_config = conf()
|
||||
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||
|
||||
existing = next((p for p in providers if p.get("id") == provider_id), None) if provider_id else None
|
||||
if existing is None:
|
||||
# Creating a new provider — api_base is mandatory.
|
||||
if not api_base:
|
||||
return json.dumps({"status": "error", "message": "api_base is required"})
|
||||
provider_id = generate_provider_id()
|
||||
entry = {"id": provider_id, "name": name, "api_key": api_key, "api_base": api_base}
|
||||
if model:
|
||||
entry["model"] = model
|
||||
providers.append(entry)
|
||||
created = True
|
||||
else:
|
||||
existing["name"] = name
|
||||
if api_base:
|
||||
existing["api_base"] = api_base
|
||||
if api_key:
|
||||
existing["api_key"] = api_key
|
||||
# Only touch model when explicitly provided in the payload; an
|
||||
# explicit empty string clears it, a missing key keeps it (the
|
||||
# UI modal no longer sends model, so manual config survives edits).
|
||||
if "model" in data:
|
||||
if model:
|
||||
existing["model"] = model
|
||||
else:
|
||||
existing.pop("model", None)
|
||||
created = False
|
||||
|
||||
# Decide bot_type — only switch when explicitly requested.
|
||||
new_bot_type = None
|
||||
if make_active:
|
||||
new_bot_type = f"custom:{provider_id}"
|
||||
|
||||
self._persist_custom_providers(providers, new_bot_type)
|
||||
logger.info(
|
||||
f"[ModelsHandler] custom provider {name!r} (id={provider_id}) "
|
||||
f"{'created' if created else 'updated'}"
|
||||
)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"id": provider_id,
|
||||
"name": name,
|
||||
"created": created,
|
||||
})
|
||||
|
||||
def _handle_delete_custom_provider(self, data: dict) -> str:
|
||||
"""Remove a custom provider by id."""
|
||||
from models.custom_provider import parse_custom_bot_type
|
||||
|
||||
provider_id = (data.get("id") or "").strip()
|
||||
if not provider_id:
|
||||
return json.dumps({"status": "error", "message": "id is required"})
|
||||
|
||||
local_config = conf()
|
||||
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||
remaining = [p for p in providers if p.get("id") != provider_id]
|
||||
if len(remaining) == len(providers):
|
||||
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||
|
||||
# If the deleted provider was active, fall back to the first remaining.
|
||||
_, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "")
|
||||
new_bot_type = None
|
||||
if current_active_id == provider_id:
|
||||
if remaining:
|
||||
new_bot_type = f"custom:{remaining[0]['id']}"
|
||||
else:
|
||||
new_bot_type = "custom" # revert to legacy
|
||||
|
||||
self._persist_custom_providers(remaining, new_bot_type)
|
||||
logger.info(f"[ModelsHandler] custom provider id={provider_id} deleted")
|
||||
return json.dumps({"status": "success", "id": provider_id})
|
||||
|
||||
def _handle_set_active_custom_provider(self, data: dict) -> str:
|
||||
"""Activate a custom provider by setting bot_type to 'custom:<id>'."""
|
||||
provider_id = (data.get("id") or "").strip()
|
||||
if not provider_id:
|
||||
return json.dumps({"status": "error", "message": "id is required"})
|
||||
|
||||
local_config = conf()
|
||||
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||
if not any(p.get("id") == provider_id for p in providers):
|
||||
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||
|
||||
new_bot_type = f"custom:{provider_id}"
|
||||
self._persist_custom_providers(providers, new_bot_type)
|
||||
logger.info(f"[ModelsHandler] active custom provider set to id={provider_id}")
|
||||
return json.dumps({"status": "success", "active_id": provider_id})
|
||||
|
||||
def _handle_set_capability(self, data: dict) -> str:
|
||||
capability = (data.get("capability") or "").strip()
|
||||
provider_id = (data.get("provider_id") or "").strip()
|
||||
@@ -2760,13 +3070,28 @@ class ModelsHandler:
|
||||
})
|
||||
|
||||
def _set_chat(self, provider_id: str, model: str) -> str:
|
||||
if provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
|
||||
# Accept expanded custom provider ids ("custom:<id>") as well as the
|
||||
# built-in vendors, so the chat capability card and the custom
|
||||
# providers section behave consistently.
|
||||
custom_provider = None
|
||||
if provider_id.startswith("custom:"):
|
||||
from models.custom_provider import parse_custom_bot_type
|
||||
_, custom_id = parse_custom_bot_type(provider_id)
|
||||
providers = self._normalize_custom_providers(conf().get("custom_providers"))
|
||||
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
|
||||
if custom_provider is None:
|
||||
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
|
||||
elif provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
|
||||
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||
|
||||
applied = {}
|
||||
local_config = conf()
|
||||
file_cfg = self._read_file_config()
|
||||
|
||||
# Fall back to the custom provider's default model when none is given.
|
||||
if not model and custom_provider:
|
||||
model = custom_provider.get("model") or ""
|
||||
|
||||
if provider_id:
|
||||
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
|
||||
local_config["bot_type"] = bot_type_value
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import click
|
||||
from cli import __version__
|
||||
from cli.commands.skill import skill
|
||||
from cli.commands.process import start, stop, restart, update, status, logs
|
||||
from cli.commands.process import start, stop, restart, self_restart, update, status, logs
|
||||
from cli.commands.context import context
|
||||
from cli.commands.install import install_browser
|
||||
from cli.commands.knowledge import knowledge
|
||||
@@ -68,6 +68,7 @@ main.add_command(skill)
|
||||
main.add_command(start)
|
||||
main.add_command(stop)
|
||||
main.add_command(restart)
|
||||
main.add_command(self_restart)
|
||||
main.add_command(update)
|
||||
main.add_command(status)
|
||||
main.add_command(logs)
|
||||
|
||||
@@ -78,12 +78,13 @@ def _is_china_network() -> bool:
|
||||
|
||||
|
||||
def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
||||
"""Install a package, retrying with --user on permission failure."""
|
||||
"""Install a package, preferring prebuilt wheels; retry with --user on perm error."""
|
||||
python = sys.executable
|
||||
ret = subprocess.call([python, "-m", "pip", "install", package_spec])
|
||||
base = [python, "-m", "pip", "install", "--prefer-binary"]
|
||||
ret = subprocess.call(base + [package_spec])
|
||||
if ret != 0:
|
||||
stream(" Retrying with --user flag...", "yellow")
|
||||
ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec])
|
||||
ret = subprocess.call(base + ["--user", package_spec])
|
||||
return ret
|
||||
|
||||
|
||||
@@ -155,6 +156,22 @@ def run_install_browser(
|
||||
|
||||
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
|
||||
|
||||
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||||
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||||
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||||
if sys.platform == "win32":
|
||||
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||
ret = subprocess.call(
|
||||
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||
)
|
||||
if ret != 0:
|
||||
stream(
|
||||
" Could not pre-install a prebuilt greenlet wheel.\n"
|
||||
" playwright may try to build greenlet from source, which needs\n"
|
||||
" Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/",
|
||||
"yellow",
|
||||
)
|
||||
|
||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||
|
||||
@@ -195,6 +195,120 @@ def restart(ctx, no_logs):
|
||||
ctx.invoke(start, no_logs=no_logs)
|
||||
|
||||
|
||||
# Detached relay that survives the caller's process tree. Run via `python -c`
|
||||
# with start_new_session=True so it keeps going after the agent's bash child
|
||||
# (and the main app it kills) both die. Flow: self-check the new code FIRST
|
||||
# (import app); abort without touching the old process if it fails. Only when
|
||||
# the new code is loadable does it SIGTERM the old PID, wait for exit (SIGKILL
|
||||
# fallback), then start a fresh app.py and write the pid.
|
||||
_RELAY_SCRIPT = r"""
|
||||
import os, sys, time, signal, subprocess
|
||||
|
||||
root, python, app_py, pid_file, log_file = sys.argv[1:6]
|
||||
old_pid = int(sys.argv[6]) if len(sys.argv) > 6 and sys.argv[6] else 0
|
||||
|
||||
|
||||
def alive(pid):
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def log(msg):
|
||||
try:
|
||||
with open(log_file, "a") as f:
|
||||
f.write("[self-restart] " + msg + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# 0) Self-check: make sure the new code actually loads BEFORE killing anything.
|
||||
# `import app` exercises top-level imports / syntax of the entry module. If it
|
||||
# fails, abort and leave the running service untouched — never end up with the
|
||||
# old process killed and the new one unable to start.
|
||||
check = subprocess.run(
|
||||
[python, "-c", "import app"], cwd=root,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
detail = (check.stdout or b"").decode("utf-8", "replace").strip()
|
||||
log("self-check FAILED, aborting restart; service left running:\n" + detail)
|
||||
sys.exit(1)
|
||||
log("self-check passed")
|
||||
|
||||
# 1) Ask the old process to exit gracefully (its SIGTERM handler persists state).
|
||||
if alive(old_pid):
|
||||
try:
|
||||
os.kill(old_pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
# 2) Wait up to ~15s for it to go, then force-kill as a backstop.
|
||||
for _ in range(150):
|
||||
if not alive(old_pid):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
try:
|
||||
os.kill(old_pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
# 3) Start a fresh instance, detached, logging to the same file.
|
||||
with open(log_file, "a") as f:
|
||||
proc = subprocess.Popen(
|
||||
[python, app_py], cwd=root,
|
||||
stdout=f, stderr=f, start_new_session=True,
|
||||
)
|
||||
with open(pid_file, "w") as f:
|
||||
f.write(str(proc.pid))
|
||||
log("restarted, new pid=" + str(proc.pid))
|
||||
"""
|
||||
|
||||
|
||||
@click.command(name="self-restart", hidden=True)
|
||||
def self_restart():
|
||||
"""Restart from inside the running agent (detached; survives parent death).
|
||||
|
||||
Intended to be invoked by the agent itself (e.g. via bash after editing its
|
||||
own code), not by users — so it is hidden from `cow help`. Unlike `restart`,
|
||||
the actual stop+start runs in a detached relay process that outlives the
|
||||
agent's bash child, which would otherwise die together with the main app it
|
||||
kills.
|
||||
"""
|
||||
if _IS_WIN:
|
||||
click.echo("self-restart is not supported on Windows; use `cow restart`.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
root = get_project_root()
|
||||
app_py = os.path.join(root, "app.py")
|
||||
if not os.path.exists(app_py):
|
||||
click.echo("Error: app.py not found in project root.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
python = sys.executable
|
||||
pid = _read_pid() or 0
|
||||
|
||||
subprocess.Popen(
|
||||
[
|
||||
python, "-c", _RELAY_SCRIPT,
|
||||
root, python, app_py, _get_pid_file(), _get_log_file(), str(pid),
|
||||
],
|
||||
cwd=root,
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
click.echo(click.style(
|
||||
"✓ Restart scheduled. The service will stop and come back in a few seconds.",
|
||||
fg="green",
|
||||
))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
def update(ctx):
|
||||
|
||||
@@ -172,6 +172,11 @@ class CloudClient(LinkAIClient):
|
||||
if key in available_setting and config.get(key) is not None:
|
||||
local_config[key] = config.get(key)
|
||||
|
||||
# Self-evolution switch: normalize remote value (bool / "Y"/"N" / "true")
|
||||
# to a real bool so the evolution config parser reads it correctly.
|
||||
if config.get("self_evolution_enabled") is not None:
|
||||
local_config["self_evolution_enabled"] = self._to_bool(config.get("self_evolution_enabled"))
|
||||
|
||||
# Voice settings
|
||||
reply_voice_mode = config.get("reply_voice_mode")
|
||||
if reply_voice_mode:
|
||||
@@ -341,6 +346,20 @@ class CloudClient(LinkAIClient):
|
||||
except Exception as e:
|
||||
logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# value helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _to_bool(value) -> bool:
|
||||
"""Normalize a remote config value to bool (bool / "Y"/"N" / "true"/"1")."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("y", "yes", "true", "1", "on")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# channel credentials helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -855,6 +874,10 @@ def _build_config():
|
||||
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
|
||||
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
|
||||
"agent_max_steps": local_conf.get("agent_max_steps"),
|
||||
# Self-evolution switch reported so the cloud console can reflect state
|
||||
"self_evolution_enabled": "Y" if local_conf.get("self_evolution_enabled") else "N",
|
||||
"self_evolution_idle_minutes": local_conf.get("self_evolution_idle_minutes"),
|
||||
"self_evolution_min_turns": local_conf.get("self_evolution_min_turns"),
|
||||
"channelType": local_conf.get("channel_type"),
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to t
|
||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
||||
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - Agent recommended model
|
||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
|
||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
||||
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||
@@ -194,7 +194,7 @@ MODEL_LIST = [
|
||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||
|
||||
# Claude
|
||||
CLAUDE3, CLAUDE_FABLE_5, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||
|
||||
|
||||
@@ -27,10 +27,14 @@ def compress_imgfile(file, max_size):
|
||||
img = Image.open(file)
|
||||
rgb_image = img.convert("RGB")
|
||||
quality = 95
|
||||
min_quality = 10
|
||||
while True:
|
||||
out_buf = io.BytesIO()
|
||||
rgb_image.save(out_buf, "JPEG", quality=quality)
|
||||
if fsize(out_buf) <= max_size:
|
||||
if fsize(out_buf) <= max_size or quality <= min_quality:
|
||||
# Stop at min_quality: further decrements would pass an invalid
|
||||
# quality (<1) to PIL and the loop would otherwise never terminate
|
||||
# for images that cannot be compressed below max_size.
|
||||
return out_buf
|
||||
quality -= 5
|
||||
|
||||
|
||||
42
config.py
42
config.py
@@ -24,8 +24,11 @@ available_setting = {
|
||||
"open_ai_api_base": "https://api.openai.com/v1",
|
||||
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
||||
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
|
||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom")
|
||||
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom")
|
||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
|
||||
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
|
||||
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
|
||||
# Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
|
||||
"custom_providers": [],
|
||||
"proxy": "", # proxy used by openai
|
||||
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
||||
"model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
|
||||
@@ -321,24 +324,37 @@ class Config(dict):
|
||||
config = Config()
|
||||
|
||||
|
||||
def _mask_value(val):
|
||||
"""Mask a sensitive string value, keeping first 3 and last 3 chars."""
|
||||
if not isinstance(val, str) or len(val) <= 8:
|
||||
return val
|
||||
return val[0:3] + "*" * 5 + val[-3:]
|
||||
|
||||
|
||||
def _mask_sensitive_recursive(obj):
|
||||
"""Recursively mask values whose keys contain 'key' or 'secret'."""
|
||||
if isinstance(obj, dict):
|
||||
masked = {}
|
||||
for k, v in obj.items():
|
||||
if ("key" in k or "secret" in k) and isinstance(v, str):
|
||||
masked[k] = _mask_value(v)
|
||||
else:
|
||||
masked[k] = _mask_sensitive_recursive(v)
|
||||
return masked
|
||||
elif isinstance(obj, list):
|
||||
return [_mask_sensitive_recursive(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def drag_sensitive(config):
|
||||
try:
|
||||
if isinstance(config, str):
|
||||
conf_dict: dict = json.loads(config)
|
||||
conf_dict_copy = copy.deepcopy(conf_dict)
|
||||
for key in conf_dict_copy:
|
||||
if "key" in key or "secret" in key:
|
||||
if isinstance(conf_dict_copy[key], str):
|
||||
conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:]
|
||||
conf_dict_copy = _mask_sensitive_recursive(conf_dict)
|
||||
return json.dumps(conf_dict_copy, indent=4)
|
||||
|
||||
elif isinstance(config, dict):
|
||||
config_copy = copy.deepcopy(config)
|
||||
for key in config:
|
||||
if "key" in key or "secret" in key:
|
||||
if isinstance(config_copy[key], str):
|
||||
config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:]
|
||||
return config_copy
|
||||
return _mask_sensitive_recursive(config)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return config
|
||||
|
||||
@@ -9,7 +9,7 @@ CowAgent 2.0 has evolved from a simple chatbot into a super intelligent assistan
|
||||
|
||||
CowAgent's architecture consists of the following core modules:
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" />
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" />
|
||||
|
||||
| Module | Description |
|
||||
| --- | --- |
|
||||
|
||||
@@ -48,7 +48,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています
|
||||
|
||||
## 🏗️ アーキテクチャ
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" width="750"/>
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" width="750"/>
|
||||
|
||||
CowAgent は完全な **Agent Harness** です:メッセージは各種**チャネル**から流入し、**Agent Core** が記憶・知識・利用可能なツール/Skill を組み合わせてタスクを計画・判断、**モデル**が応答を生成し、結果は元のチャネルに返されます。各レイヤーは疎結合で、独立して拡張可能です。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ CowAgent 2.0 は、シンプルなチャットボットから、自律的な思
|
||||
|
||||
CowAgent のアーキテクチャは以下のコアモジュールで構成されています:
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" />
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" />
|
||||
|
||||
| モジュール | 説明 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -1,51 +1,21 @@
|
||||
---
|
||||
title: カスタム
|
||||
description: カスタムベンダー設定。サードパーティ API プロキシやローカルモデル向け
|
||||
description: サードパーティ API プロキシやローカルモデル向けのカスタムプロバイダー設定
|
||||
---
|
||||
|
||||
OpenAI 互換プロトコルで接続するサードパーティのモデルサービスや、ローカルにデプロイしたモデルに適しています。例えば:
|
||||
OpenAI 互換プロトコルで接続するモデルサービス向けの設定です。例えば:
|
||||
|
||||
- **サードパーティ API プロキシ**:統一された API Base から複数のモデルを呼び出す
|
||||
- **ローカルモデル**:Ollama、vLLM、LocalAI などのツールでローカルにデプロイしたモデル
|
||||
- **プライベートデプロイ**:企業内部にデプロイしたモデルサービス
|
||||
- **サードパーティ API プロキシ**:統一された API アドレスで複数のモデルを呼び出す
|
||||
- **ローカルモデル**:Ollama、vLLM などのツールでローカルにデプロイしたモデル
|
||||
- **プライベートデプロイ**:企業内部にデプロイされたモデルサービス
|
||||
|
||||
<Note>
|
||||
`openai` ベンダーとの違い:カスタムベンダーを選択した場合、`/config model` でモデルを切り替えてもベンダータイプは自動で切り替わらず、常にカスタムの API アドレスを使用します。
|
||||
</Note>
|
||||
## Web コンソールでの設定
|
||||
|
||||
## テキスト対話
|
||||
推奨方法です。Web コンソールの「モデル」ページで「プロバイダーを追加」をクリックし、「カスタム」を選択して、名称・API Base・API Key を入力します。複数のカスタムプロバイダーを追加でき、追加後は「メインモデル」でプロバイダーとモデルを選択すると有効になります。
|
||||
|
||||
### サードパーティ API プロキシ
|
||||
<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"
|
||||
}
|
||||
```
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `bot_type` | `custom` に設定する必要があります |
|
||||
| `model` | モデル名。プロキシサービスがサポートする任意のモデル名を指定 |
|
||||
| `custom_api_key` | API キー。プロキシサービスから提供されます |
|
||||
| `custom_api_base` | API アドレス。プロキシサービスから提供され、OpenAI プロトコル互換である必要があります |
|
||||
|
||||
### ローカルモデル
|
||||
|
||||
ローカルモデルは通常 API Key が不要で、API Base のみ設定します:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "custom",
|
||||
"model": "qwen3.5:27b",
|
||||
"custom_api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
```
|
||||
|
||||
一般的なローカルデプロイツールとデフォルトアドレス:
|
||||
主なローカルデプロイツールのデフォルトアドレス:
|
||||
|
||||
| ツール | デフォルト API Base |
|
||||
| --- | --- |
|
||||
@@ -53,10 +23,40 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
|
||||
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
||||
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
||||
|
||||
### モデル切り替え
|
||||
## 設定ファイルでの設定
|
||||
|
||||
カスタムベンダーでモデルを切り替える際は `model` のみが変更され、`bot_type` と API アドレスは変わりません:
|
||||
`config.json` を直接編集することもできます。`custom_providers` リストに複数のプロバイダーを定義し、`bot_type` を `"custom:<id>"` に設定していずれかを有効化します:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "custom:3f2a9c1b",
|
||||
"custom_providers": [
|
||||
{
|
||||
"id": "3f2a9c1b",
|
||||
"name": "プロバイダーA",
|
||||
"api_key": "YOUR_API_KEY_A",
|
||||
"api_base": "https://api.a.com/v1",
|
||||
"model": "deepseek-v3"
|
||||
},
|
||||
{
|
||||
"id": "a1b2c3d4",
|
||||
"name": "プロバイダーB",
|
||||
"api_key": "YOUR_API_KEY_B",
|
||||
"api_base": "https://api.b.com/v1",
|
||||
"model": "qwen3-max"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
/config model qwen3.5:27b
|
||||
```
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `custom_providers` | カスタムプロバイダーのリスト。各項目は `id`、`name`、`api_base`、`api_key`(任意)、`model`(任意)を含む |
|
||||
| `bot_type` | `"custom:<id>"` に設定して対応するプロバイダーを有効化 |
|
||||
| `id` | 一意の識別子(8 桁の 16 進数)。Web コンソールから追加すると自動生成され、手動設定の場合は重複しない任意の文字列でよい |
|
||||
| `name` | 表示名。自由に変更可能 |
|
||||
| `model` | このプロバイダーで使用するモデル。有効化時に適用される |
|
||||
|
||||
<Note>
|
||||
従来の単一プロバイダー設定(`bot_type` を `"custom"` にし、`custom_api_key` / `custom_api_base` を使用)は引き続き互換性があり、変更なしでそのまま利用できます。
|
||||
</Note>
|
||||
|
||||
@@ -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,10 +23,40 @@ 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:
|
||||
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
|
||||
{
|
||||
"bot_type": "custom:3f2a9c1b",
|
||||
"custom_providers": [
|
||||
{
|
||||
"id": "3f2a9c1b",
|
||||
"name": "ProviderA",
|
||||
"api_key": "YOUR_API_KEY_A",
|
||||
"api_base": "https://api.a.com/v1",
|
||||
"model": "deepseek-v3"
|
||||
},
|
||||
{
|
||||
"id": "a1b2c3d4",
|
||||
"name": "ProviderB",
|
||||
"api_key": "YOUR_API_KEY_B",
|
||||
"api_base": "https://api.b.com/v1",
|
||||
"model": "qwen3-max"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
/config model qwen3.5:27b
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `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 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,51 +1,21 @@
|
||||
---
|
||||
title: 自定义
|
||||
description: 自定义厂商配置,适用于第三方 API 代理和本地模型
|
||||
description: 自定义厂商配置,用于第三方 API 代理与本地模型
|
||||
---
|
||||
|
||||
适用于通过 OpenAI 兼容协议接入的第三方模型服务或本地部署的模型,例如:
|
||||
适用于通过 OpenAI 兼容协议接入的模型服务,例如:
|
||||
|
||||
- **第三方 API 代理**:使用统一的 API Base 调用多种模型
|
||||
- **本地模型**:通过 Ollama、vLLM、LocalAI 等工具在本地部署的模型
|
||||
- **第三方 API 代理**:通过统一的 API 地址调用多种模型
|
||||
- **本地模型**:使用 Ollama、vLLM 等工具在本地部署的模型
|
||||
- **私有化部署**:企业内部部署的模型服务
|
||||
|
||||
<Note>
|
||||
与 `openai` 厂商的区别:选择自定义厂商后,通过 `/config model` 切换模型时,不会自动切换厂商类型,始终使用自定义的 API 地址。
|
||||
</Note>
|
||||
## Web 端配置
|
||||
|
||||
## 文本对话
|
||||
推荐方式。在 Web 控制台「模型」页面点击「添加厂商」,选择「自定义」,填写名称、API Base 和 API Key 即可。支持添加多个自定义厂商,添加后在「主模型」中选择对应厂商和模型即可启用。
|
||||
|
||||
### 第三方 API 代理
|
||||
<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"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `bot_type` | 必须设为 `custom` |
|
||||
| `model` | 模型名称,填写代理服务支持的任意模型名 |
|
||||
| `custom_api_key` | API 密钥,由代理服务提供 |
|
||||
| `custom_api_base` | API 地址,由代理服务提供,需兼容 OpenAI 协议 |
|
||||
|
||||
### 本地模型
|
||||
|
||||
本地模型通常不需要 API Key,只需填写 API Base:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "custom",
|
||||
"model": "qwen3.5:27b",
|
||||
"custom_api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
```
|
||||
|
||||
常见的本地部署工具及默认地址:
|
||||
本地部署工具的默认地址:
|
||||
|
||||
| 工具 | 默认 API Base |
|
||||
| --- | --- |
|
||||
@@ -53,10 +23,40 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
|
||||
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
||||
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
||||
|
||||
### 切换模型
|
||||
## 配置文件配置
|
||||
|
||||
自定义厂商下切换模型时,只会修改 `model`,不会改变 `bot_type` 和 API 地址:
|
||||
也可以直接编辑 `config.json`,在 `custom_providers` 列表中定义多个厂商,并将 `bot_type` 设置为 `"custom:<id>"` 来激活其中一个:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "custom:3f2a9c1b",
|
||||
"custom_providers": [
|
||||
{
|
||||
"id": "3f2a9c1b",
|
||||
"name": "厂商A",
|
||||
"api_key": "YOUR_API_KEY_A",
|
||||
"api_base": "https://api.a.com/v1",
|
||||
"model": "deepseek-v3"
|
||||
},
|
||||
{
|
||||
"id": "a1b2c3d4",
|
||||
"name": "厂商B",
|
||||
"api_key": "YOUR_API_KEY_B",
|
||||
"api_base": "https://api.b.com/v1",
|
||||
"model": "qwen3-max"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
/config model qwen3.5:27b
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `custom_providers` | 自定义厂商列表,每项包含 `id`、`name`、`api_base`、`api_key`(可选)、`model`(可选) |
|
||||
| `bot_type` | 设置为 `"custom:<id>"` 激活对应厂商 |
|
||||
| `id` | 厂商唯一标识(8 位十六进制),在 Web 端添加时自动生成,手动配置时填写任意不重复的标识即可 |
|
||||
| `name` | 展示名称,可随意修改 |
|
||||
| `model` | 该厂商使用的模型,激活时生效 |
|
||||
|
||||
<Note>
|
||||
历史的单厂商配置(`bot_type` 为 `"custom"`,配合 `custom_api_key` / `custom_api_base`)仍然兼容,无需改动即可继续使用。
|
||||
</Note>
|
||||
|
||||
@@ -29,7 +29,7 @@ def create_bot(bot_type):
|
||||
from models.mimo.mimo_bot import MimoBot
|
||||
return MimoBot()
|
||||
|
||||
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM): # OpenAI-compatible API
|
||||
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM) or bot_type.startswith("custom:"): # OpenAI-compatible API
|
||||
from models.chatgpt.chat_gpt_bot import ChatGPTBot
|
||||
return ChatGPTBot()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from common import const
|
||||
from common.i18n import t as _t
|
||||
from models.bot import Bot
|
||||
from models.openai_compatible_bot import OpenAICompatibleBot
|
||||
from models.custom_provider import resolve_custom_credentials, parse_custom_bot_type
|
||||
from models.chatgpt.chat_gpt_session import ChatGPTSession
|
||||
from models.openai.open_ai_image import OpenAIImage
|
||||
from models.session_manager import SessionManager
|
||||
@@ -32,9 +33,12 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Resolve api key / base from config (no global SDK state anymore).
|
||||
if conf().get("bot_type") == "custom":
|
||||
self._api_key = conf().get("custom_api_key", "")
|
||||
self._api_base = conf().get("custom_api_base") or None
|
||||
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
|
||||
custom_model = None
|
||||
if is_custom:
|
||||
# Supports multiple custom providers via bot_type "custom:<id>"
|
||||
# with automatic fallback to the legacy custom_api_key/base fields.
|
||||
self._api_key, self._api_base, custom_model = resolve_custom_credentials()
|
||||
else:
|
||||
self._api_key = conf().get("open_ai_api_key")
|
||||
self._api_base = conf().get("open_ai_api_base") or None
|
||||
@@ -46,8 +50,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
)
|
||||
if conf().get("rate_limit_chatgpt"):
|
||||
self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20))
|
||||
conf_model = conf().get("model") or "gpt-3.5-turbo"
|
||||
self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")
|
||||
# Per-provider model takes precedence over global model.
|
||||
conf_model = custom_model or conf().get("model") or "gpt-3.5-turbo"
|
||||
self.sessions = SessionManager(ChatGPTSession, model=conf_model)
|
||||
# o1相关模型不支持system prompt,暂时用文心模型的session
|
||||
|
||||
self.args = {
|
||||
@@ -70,11 +75,20 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
|
||||
def get_api_config(self):
|
||||
"""Get API configuration for OpenAI-compatible base class"""
|
||||
is_custom = conf().get("bot_type") == "custom"
|
||||
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
|
||||
if is_custom:
|
||||
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
||||
api_key = custom_key
|
||||
api_base = custom_base
|
||||
model = custom_model or conf().get("model", "gpt-3.5-turbo")
|
||||
else:
|
||||
api_key = conf().get("open_ai_api_key")
|
||||
api_base = conf().get("open_ai_api_base")
|
||||
model = conf().get("model", "gpt-3.5-turbo")
|
||||
return {
|
||||
'api_key': conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"),
|
||||
'api_base': conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base"),
|
||||
'model': conf().get("model", "gpt-3.5-turbo"),
|
||||
'api_key': api_key,
|
||||
'api_base': api_base,
|
||||
'model': model,
|
||||
'default_temperature': conf().get("temperature", 0.9),
|
||||
'default_top_p': conf().get("top_p", 1.0),
|
||||
'default_frequency_penalty': conf().get("frequency_penalty", 0.0),
|
||||
@@ -185,10 +199,16 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
mime_type = mime_type_map.get(extension, "image/jpeg")
|
||||
|
||||
# Get model and API config
|
||||
is_custom = conf().get("bot_type") == "custom"
|
||||
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
|
||||
api_key = context.get("openai_api_key") or (conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"))
|
||||
api_base = conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base")
|
||||
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
|
||||
if is_custom:
|
||||
custom_key, custom_base, custom_model = resolve_custom_credentials()
|
||||
model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o")
|
||||
api_key = context.get("openai_api_key") or custom_key
|
||||
api_base = custom_base
|
||||
else:
|
||||
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
|
||||
api_key = context.get("openai_api_key") or conf().get("open_ai_api_key")
|
||||
api_base = conf().get("open_ai_api_base")
|
||||
|
||||
# Build vision request
|
||||
messages = [
|
||||
|
||||
125
models/custom_provider.py
Normal file
125
models/custom_provider.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# encoding:utf-8
|
||||
|
||||
"""
|
||||
Centralized resolver for custom (OpenAI-compatible) provider credentials.
|
||||
|
||||
CowAgent historically supported only a *single* custom provider via the flat
|
||||
config keys ``custom_api_key`` / ``custom_api_base``. This module adds support
|
||||
for *multiple* custom providers (see issue #2838) while remaining 100%
|
||||
backward compatible.
|
||||
|
||||
Config model
|
||||
------------
|
||||
- ``custom_providers``: list of dicts, each describing one custom provider::
|
||||
|
||||
{
|
||||
"id": "3f2a9c1b", # server-generated short uuid (primary key)
|
||||
"name": "siliconflow", # user-facing display label (not a key)
|
||||
"api_key": "sk-...", # required
|
||||
"api_base": "https://...", # required, must be OpenAI-compatible
|
||||
"model": "deepseek-ai/DeepSeek-V3" # optional default model
|
||||
}
|
||||
|
||||
Routing
|
||||
-------
|
||||
- ``bot_type: "custom"`` (legacy): reads the flat ``custom_api_key`` / ``custom_api_base``.
|
||||
- ``bot_type: "custom:<id>"`` (multi-provider): looks up the provider by id in
|
||||
``custom_providers``. There is a single source of truth — no separate
|
||||
``custom_active_provider`` field.
|
||||
|
||||
Backward-compatibility contract
|
||||
-------------------------------
|
||||
When ``bot_type`` is exactly ``"custom"`` (no colon suffix), behaviour is
|
||||
unchanged: we return ``custom_api_key`` / ``custom_api_base`` values.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from config import conf
|
||||
from common.log import logger
|
||||
|
||||
|
||||
def generate_provider_id() -> str:
|
||||
"""Generate a short random id for a new custom provider."""
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
|
||||
def get_custom_providers():
|
||||
"""Return the list of configured custom providers (always a list)."""
|
||||
providers = conf().get("custom_providers")
|
||||
if not isinstance(providers, list):
|
||||
return []
|
||||
# Keep only well-formed entries with an id.
|
||||
return [p for p in providers if isinstance(p, dict) and p.get("id")]
|
||||
|
||||
|
||||
def _find_provider_by_id(providers, provider_id):
|
||||
"""Look up a provider by its id, or None if not found."""
|
||||
if not providers or not provider_id:
|
||||
return None
|
||||
for p in providers:
|
||||
if p.get("id") == provider_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def parse_custom_bot_type(bot_type):
|
||||
"""Parse bot_type to extract custom provider id.
|
||||
|
||||
Returns:
|
||||
(is_custom, provider_id) where:
|
||||
- is_custom: True if bot_type starts with "custom"
|
||||
- provider_id: the id suffix (e.g. "3f2a9c1b") or empty string for legacy mode
|
||||
"""
|
||||
if not bot_type or not isinstance(bot_type, str):
|
||||
return False, ""
|
||||
if bot_type == "custom":
|
||||
return True, ""
|
||||
if bot_type.startswith("custom:"):
|
||||
return True, bot_type[7:] # len("custom:") == 7
|
||||
return False, ""
|
||||
|
||||
|
||||
def resolve_custom_credentials():
|
||||
"""Resolve the effective (api_key, api_base, model) for custom mode.
|
||||
|
||||
Resolution order:
|
||||
1. If ``bot_type`` is ``"custom:<id>"``, look up that id in
|
||||
``custom_providers``.
|
||||
2. If ``bot_type`` is exactly ``"custom"`` (legacy), return the flat
|
||||
``custom_api_key`` / ``custom_api_base``.
|
||||
|
||||
:return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model``
|
||||
may be ``None`` / empty when not configured.
|
||||
"""
|
||||
bot_type = conf().get("bot_type", "")
|
||||
is_custom, provider_id = parse_custom_bot_type(bot_type)
|
||||
|
||||
if not is_custom:
|
||||
# Not custom at all — should not happen but be defensive.
|
||||
return (
|
||||
conf().get("open_ai_api_key", ""),
|
||||
conf().get("open_ai_api_base") or None,
|
||||
None,
|
||||
)
|
||||
|
||||
if provider_id:
|
||||
# Multi-provider mode: look up by id.
|
||||
providers = get_custom_providers()
|
||||
provider = _find_provider_by_id(providers, provider_id)
|
||||
if provider is not None:
|
||||
return (
|
||||
provider.get("api_key", ""),
|
||||
provider.get("api_base") or None,
|
||||
provider.get("model") or None,
|
||||
)
|
||||
logger.warning(
|
||||
"[CUSTOM] provider id '%s' not found in custom_providers, "
|
||||
"falling back to legacy fields", provider_id
|
||||
)
|
||||
|
||||
# Legacy single-provider fallback — unchanged behavior.
|
||||
return (
|
||||
conf().get("custom_api_key", ""),
|
||||
conf().get("custom_api_base") or None,
|
||||
None,
|
||||
)
|
||||
@@ -147,6 +147,7 @@ class CowCliPlugin(Plugin):
|
||||
else: # "typo"
|
||||
reply_text = self._typo_hint(token, result[1])
|
||||
|
||||
reply_text = self._harden_line_breaks(reply_text, e_context)
|
||||
e_context["reply"] = Reply(ReplyType.TEXT, reply_text)
|
||||
e_context.action = EventAction.BREAK_PASS
|
||||
|
||||
@@ -1516,6 +1517,43 @@ class CowCliPlugin(Plugin):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _harden_line_breaks(text: str, e_context) -> str:
|
||||
"""WeChat PC renders bot messages as Markdown, where a lone '\\n' is
|
||||
collapsed into a space, so plain-text CLI output gets squashed onto
|
||||
one line. Prefix consecutive text lines with '- ' so the Markdown
|
||||
list keeps each on its own line (the only form WeChat respects).
|
||||
WeChat-only; other channels are untouched. Blank lines, code fences,
|
||||
and lines that are already list items are left intact."""
|
||||
if e_context is None or not text or "\n" not in text:
|
||||
return text
|
||||
try:
|
||||
if e_context["context"].kwargs.get("channel_type") != "weixin":
|
||||
return text
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
out = []
|
||||
in_code = False
|
||||
lines = text.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if line.lstrip().startswith("```"):
|
||||
in_code = not in_code
|
||||
out.append(line)
|
||||
continue
|
||||
stripped = line.lstrip()
|
||||
prev_packed = i > 0 and lines[i - 1].strip() != ""
|
||||
next_packed = i < len(lines) - 1 and lines[i + 1].strip() != ""
|
||||
# Only convert lines inside a multi-line block (a neighbour line is
|
||||
# non-blank); standalone paragraphs separated by blank lines, code
|
||||
# blocks, blank lines, and existing list items are left intact.
|
||||
if (in_code or not stripped or stripped.startswith(("- ", "* ", "+ "))
|
||||
or not (prev_packed or next_packed)):
|
||||
out.append(line)
|
||||
else:
|
||||
out.append("- " + stripped)
|
||||
return "\n".join(out)
|
||||
|
||||
@staticmethod
|
||||
def _build_dream_result(flush_mgr, is_web: bool) -> str:
|
||||
"""Build dream completion message with diary content."""
|
||||
|
||||
@@ -4,19 +4,41 @@
|
||||
- `$角色/$role <角色名>` - 让AI扮演该角色,角色名支持模糊匹配。
|
||||
- `$停止扮演` - 停止角色扮演。
|
||||
|
||||
添加自定义角色请在`roles/roles.json`中添加。
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
plugins/role/
|
||||
├── README.md
|
||||
├── __init__.py
|
||||
├── role.py # 插件主逻辑
|
||||
├── roles.json # 内置角色库(所有默认角色)
|
||||
├── schema/
|
||||
│ └── role_template.json # 角色模板,新增角色时复制此文件
|
||||
└── roles/ # 可选:放自定义角色文件,自动加载
|
||||
└── 我的角色.json # 同title会覆盖内置角色,不同则追加
|
||||
```
|
||||
|
||||
## 添加自定义角色
|
||||
|
||||
1. 新建roles目录
|
||||
2. 复制 下面模板 到 `roles/` 目录并重命名(如 `我的角色.json`)。
|
||||
3. 编辑文件内容,填写角色信息。
|
||||
4. 重启Bot即可生效,插件会自动扫描 `roles/` 目录下的所有 `.json` 文件。
|
||||
|
||||
> **覆盖规则**:如果自定义角色的 `title` 与内置角色同名,则覆盖内置角色;否则作为新角色追加。无需修改任何映射文件。
|
||||
|
||||
(大部分prompt来自https://github.com/rockbenben/ChatGPT-Shortcut/blob/main/src/data/users.tsx)
|
||||
|
||||
以下为例子:
|
||||
以下为角色文件内容例子:
|
||||
```json
|
||||
{
|
||||
"title": "写作助理",
|
||||
"description": "As a writing improvement assistant, your task is to improve the spelling, grammar, clarity, concision, and overall readability of the text I provided, while breaking down long sentences, reducing repetition, and providing suggestions for improvement. Please provide only the corrected Chinese version of the text and avoid including explanations. Please treat every message I send later as text content.",
|
||||
"descn": "作为一名中文写作改进助理,你的任务是改进所提供文本的拼写、语法、清晰、简洁和整体可读性,同时分解长句,减少重复,并提供改进建议。请只提供文本的更正版本,避免包括解释。请把我之后的每一条消息都当作文本内容。",
|
||||
"wrapper": "内容是:\n\"%s\"",
|
||||
"remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。"
|
||||
}
|
||||
{
|
||||
"title": "写作助理",
|
||||
"description": "As a writing improvement assistant...",
|
||||
"descn": "作为一名中文写作改进助理...",
|
||||
"wrapper": "内容是:\n\"%s\"",
|
||||
"remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。",
|
||||
"tags": ["favorite", "write"]
|
||||
}
|
||||
```
|
||||
|
||||
- `title`: 角色名。
|
||||
@@ -24,3 +46,16 @@
|
||||
- `descn`: 使用`$角色`触发时,使用中文prompt。
|
||||
- `wrapper`: 用于包装用户消息,可起到强调作用,避免回复离题。
|
||||
- `remark`: 简短描述该角色,在打印帮助文档时显示。
|
||||
- `tags`: 角色分类标签,可用`$角色类型 <标签>`查看。
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "",
|
||||
"description": "",
|
||||
"descn": "",
|
||||
"wrapper": "",
|
||||
"remark": "",
|
||||
"tags": []
|
||||
}
|
||||
```
|
||||
@@ -57,6 +57,14 @@ class Role(Plugin):
|
||||
logger.warning(f"[Role] unknown tag {tag} ")
|
||||
self.tags[tag] = (tag, [])
|
||||
self.tags[tag][1].append(role)
|
||||
|
||||
# 扫描 roles/ 目录,加载可选的扩展/覆盖角色文件
|
||||
roles_dir = os.path.join(curdir, "roles")
|
||||
if os.path.isdir(roles_dir):
|
||||
for fname in sorted(os.listdir(roles_dir)):
|
||||
if fname.endswith(".json"):
|
||||
self._load_role_file(os.path.join(roles_dir, fname))
|
||||
|
||||
for tag in list(self.tags.keys()):
|
||||
if len(self.tags[tag][1]) == 0:
|
||||
logger.debug(f"[Role] no role found for tag {tag} ")
|
||||
@@ -74,6 +82,27 @@ class Role(Plugin):
|
||||
logger.warn("[Role] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/role .")
|
||||
raise e
|
||||
|
||||
def _load_role_file(self, filepath):
|
||||
"""从roles/目录加载单个角色文件,同title则覆盖内置角色,否则追加"""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
role = json.load(f)
|
||||
title_lower = role["title"].lower()
|
||||
if title_lower in self.roles:
|
||||
old_tags = self.roles[title_lower].get("tags", [])
|
||||
for tag in old_tags:
|
||||
if tag in self.tags:
|
||||
self.tags[tag][1] = [r for r in self.tags[tag][1] if r["title"].lower() != title_lower]
|
||||
logger.info(f"[Role] overridden: {role['title']}")
|
||||
self.roles[title_lower] = role
|
||||
for tag in role.get("tags", []):
|
||||
if tag not in self.tags:
|
||||
self.tags[tag] = (tag, [])
|
||||
self.tags[tag][1].append(role)
|
||||
logger.debug(f"[Role] loaded: {role['title']} from {os.path.basename(filepath)}")
|
||||
except Exception as e:
|
||||
logger.warn(f"[Role] failed to load {filepath}: {e}")
|
||||
|
||||
def get_role(self, name, find_closest=True, min_sim=0.35):
|
||||
name = name.lower()
|
||||
found_role = None
|
||||
|
||||
19
run.sh
19
run.sh
@@ -597,7 +597,7 @@ select_model() {
|
||||
# The 12th option is "skip" -> configure later in the web console.
|
||||
select_menu sel "$title" \
|
||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \
|
||||
"Claude (claude-fable-5, claude-opus-4-8, etc.)" \
|
||||
"Claude (claude-opus-4-8, claude-fable-5, etc.)" \
|
||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
|
||||
"OpenAI (gpt-5.5, etc.)" \
|
||||
"MiniMax (MiniMax-M3, etc.)" \
|
||||
@@ -629,7 +629,7 @@ read_model_config() {
|
||||
configure_model() {
|
||||
case "$model_choice" in
|
||||
1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;;
|
||||
2) read_model_config "Claude" "claude-fable-5" "CLAUDE_KEY" ;;
|
||||
2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;;
|
||||
3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;;
|
||||
4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;;
|
||||
5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;;
|
||||
@@ -869,8 +869,10 @@ base = {
|
||||
'mimo_api_key': e('MIMO_KEY', ''),
|
||||
'deepseek_api_key': e('DEEPSEEK_KEY', ''),
|
||||
'deepseek_api_base': e('DEEPSEEK_BASE'),
|
||||
'voice_to_text': 'openai',
|
||||
'text_to_voice': 'openai',
|
||||
# Leave ASR/TTS provider empty so the web console auto-suggests the vendor
|
||||
# whose API key is actually configured (e.g. LinkAI), not always OpenAI.
|
||||
'voice_to_text': '',
|
||||
'text_to_voice': '',
|
||||
'voice_reply_voice': False,
|
||||
'speech_recognition': True,
|
||||
'group_speech_recognition': False,
|
||||
@@ -878,9 +880,9 @@ base = {
|
||||
'linkai_api_key': e('LINKAI_KEY', ''),
|
||||
'linkai_app_code': '',
|
||||
'agent': True,
|
||||
'agent_max_context_tokens': 40000,
|
||||
'agent_max_context_turns': 30,
|
||||
'agent_max_steps': 15,
|
||||
'agent_max_context_tokens': 50000,
|
||||
'agent_max_context_turns': 20,
|
||||
'agent_max_steps': 20,
|
||||
# New installs opt into self-evolution; existing users (no key) keep the
|
||||
# code default (off) so an upgrade never silently changes their behavior.
|
||||
'self_evolution_enabled': True,
|
||||
@@ -978,7 +980,10 @@ start_project() {
|
||||
echo -e " ${GREEN}./run.sh status${NC} $(t "查看状态" "Check status")"
|
||||
echo -e " ${GREEN}./run.sh logs${NC} $(t "查看日志" "View logs")"
|
||||
echo -e " ${GREEN}./run.sh update${NC} $(t "更新并重启" "Update and restart")"
|
||||
echo -e " ${GREEN}cow install-browser${NC} $(t "安装浏览器工具" "Install browser tool")"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${YELLOW}$(t "提示:需要让 Agent 浏览网页时,运行 cow install-browser 安装浏览器工具" "Tip: to let the Agent browse the web, run 'cow install-browser' to install the browser tool")${NC}"
|
||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -32,6 +32,44 @@ try {
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
|
||||
# ── disable console QuickEdit mode ───────────────────────────────
|
||||
# On Windows, QuickEdit is ON by default: a stray click/selection in the
|
||||
# window FREEZES all output until the user presses a key. During long steps
|
||||
# (git clone, pip install) this looks like a hang, and the wake-up key gets
|
||||
# buffered and later auto-confirms a menu default. Turning QuickEdit off
|
||||
# removes both the freeze and the phantom keystrokes.
|
||||
try {
|
||||
if (-not ([Console]::IsInputRedirected)) {
|
||||
$quickEditType = @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class ConsoleQuickEdit {
|
||||
const int STD_INPUT_HANDLE = -10;
|
||||
const uint ENABLE_QUICK_EDIT = 0x0040;
|
||||
const uint ENABLE_EXTENDED = 0x0080;
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern IntPtr GetStdHandle(int nStdHandle);
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
|
||||
public static void Disable() {
|
||||
IntPtr handle = GetStdHandle(STD_INPUT_HANDLE);
|
||||
uint mode;
|
||||
if (!GetConsoleMode(handle, out mode)) { return; }
|
||||
mode &= ~ENABLE_QUICK_EDIT; // clear QuickEdit
|
||||
mode |= ENABLE_EXTENDED; // keep extended flags valid
|
||||
SetConsoleMode(handle, mode);
|
||||
}
|
||||
}
|
||||
'@
|
||||
if (-not ([System.Management.Automation.PSTypeName]'ConsoleQuickEdit').Type) {
|
||||
Add-Type -TypeDefinition $quickEditType -ErrorAction Stop
|
||||
}
|
||||
[ConsoleQuickEdit]::Disable()
|
||||
}
|
||||
} catch {}
|
||||
|
||||
# ── colours ──────────────────────────────────────────────────────
|
||||
function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green }
|
||||
function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow }
|
||||
@@ -91,6 +129,17 @@ function Initialize-UiLang {
|
||||
}
|
||||
}
|
||||
|
||||
# Drain any buffered console keystrokes. During long steps (git clone, pip
|
||||
# install) users often hit Enter to "wake up" a seemingly stuck console; those
|
||||
# keys sit in the input buffer and would otherwise be consumed by the next
|
||||
# ReadKey, auto-confirming a menu's default. Flush them before reading input.
|
||||
function Clear-InputBuffer {
|
||||
try {
|
||||
if ([Console]::IsInputRedirected) { return }
|
||||
while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# ── arrow-key selectable menu with number fallback ───────────────
|
||||
# Usage: $idx = Select-Menu -Title "..." -Options @("a","b") [-Default 1]
|
||||
# Returns the selected 1-based index.
|
||||
@@ -125,6 +174,10 @@ function Select-Menu {
|
||||
Write-Info $Title
|
||||
Write-Host (T "↑/↓ 选择,Enter 确认" "Use ↑/↓ to move, Enter to select") -ForegroundColor Cyan
|
||||
|
||||
# Discard keystrokes buffered during the previous (possibly long-running)
|
||||
# step so a leftover Enter doesn't instantly confirm the default option.
|
||||
Clear-InputBuffer
|
||||
|
||||
[Console]::CursorVisible = $false
|
||||
$firstDraw = $true
|
||||
try {
|
||||
@@ -370,7 +423,7 @@ function Install-Dependencies {
|
||||
# Each entry: Provider / default model name / config key field / optional base.
|
||||
$ModelChoices = @{
|
||||
1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" }
|
||||
2 = @{ Provider = "Claude"; Default = "claude-fable-5"; Field = "claude_api_key"; BaseField = "claude_api_base" }
|
||||
2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" }
|
||||
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
|
||||
4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
|
||||
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" }
|
||||
@@ -387,7 +440,7 @@ function Select-Model {
|
||||
$title = T "选择 AI 模型" "Select AI Model"
|
||||
$options = @(
|
||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
|
||||
"Claude (claude-fable-5, claude-opus-4-8, etc.)",
|
||||
"Claude (claude-opus-4-8, claude-fable-5, etc.)",
|
||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
|
||||
"OpenAI (gpt-5.5, etc.)",
|
||||
"MiniMax (MiniMax-M3, etc.)",
|
||||
@@ -573,8 +626,11 @@ function New-ConfigFile {
|
||||
mimo_api_key = ""
|
||||
deepseek_api_key = ""
|
||||
deepseek_api_base = "https://api.deepseek.com/v1"
|
||||
voice_to_text = "openai"
|
||||
text_to_voice = "openai"
|
||||
# Leave ASR/TTS provider empty so the web console auto-suggests the
|
||||
# vendor whose API key is actually configured (e.g. LinkAI), instead
|
||||
# of always defaulting to OpenAI.
|
||||
voice_to_text = ""
|
||||
text_to_voice = ""
|
||||
voice_reply_voice = $false
|
||||
speech_recognition = $true
|
||||
group_speech_recognition = $false
|
||||
@@ -582,9 +638,11 @@ function New-ConfigFile {
|
||||
linkai_api_key = ""
|
||||
linkai_app_code = ""
|
||||
agent = $true
|
||||
agent_max_context_tokens = 40000
|
||||
agent_max_context_turns = 30
|
||||
agent_max_steps = 15
|
||||
agent_max_context_tokens = 50000
|
||||
agent_max_context_turns = 20
|
||||
agent_max_steps = 20
|
||||
# New installs opt into self-evolution (matches run.sh).
|
||||
self_evolution_enabled = $true
|
||||
}
|
||||
|
||||
# Set the API key into the right field (skipped models leave it empty).
|
||||
@@ -690,6 +748,7 @@ function Install-Mode {
|
||||
# Auto-start after configuration for a true out-of-the-box experience.
|
||||
Write-Host ""
|
||||
if ($script:AccessInfo) { Write-Cow $script:AccessInfo }
|
||||
Write-Warn (T "提示:需要让 Agent 浏览网页时,运行 cow install-browser 安装浏览器工具" "Tip: to let the Agent browse the web, run 'cow install-browser' to install the browser tool")
|
||||
Start-CowAgent
|
||||
}
|
||||
|
||||
|
||||
240
tests/test_custom_provider.py
Normal file
240
tests/test_custom_provider.py
Normal file
@@ -0,0 +1,240 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
Unit tests for multiple custom (OpenAI-compatible) provider support (issue #2838).
|
||||
|
||||
Covers models/custom_provider.py:
|
||||
- Backward compatibility: legacy custom_api_key / custom_api_base fallback
|
||||
- Multi-provider selection via bot_type "custom:<id>" routing
|
||||
- parse_custom_bot_type helper
|
||||
- Robustness against malformed config (missing id, non-dict, non-list)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import config as config_module
|
||||
from config import Config
|
||||
|
||||
|
||||
def set_conf(d):
|
||||
"""Install a fresh Config as the global config used by conf()."""
|
||||
config_module.config = Config(d)
|
||||
|
||||
|
||||
class TestParseCustomBotType(unittest.TestCase):
|
||||
"""parse_custom_bot_type() parsing logic."""
|
||||
|
||||
def setUp(self):
|
||||
from models.custom_provider import parse_custom_bot_type
|
||||
self.parse = parse_custom_bot_type
|
||||
|
||||
def test_legacy_custom(self):
|
||||
is_custom, pid = self.parse("custom")
|
||||
self.assertTrue(is_custom)
|
||||
self.assertEqual(pid, "")
|
||||
|
||||
def test_custom_with_id(self):
|
||||
is_custom, pid = self.parse("custom:3f2a9c1b")
|
||||
self.assertTrue(is_custom)
|
||||
self.assertEqual(pid, "3f2a9c1b")
|
||||
|
||||
def test_non_custom(self):
|
||||
is_custom, pid = self.parse("openai")
|
||||
self.assertFalse(is_custom)
|
||||
self.assertEqual(pid, "")
|
||||
|
||||
def test_empty(self):
|
||||
is_custom, pid = self.parse("")
|
||||
self.assertFalse(is_custom)
|
||||
self.assertEqual(pid, "")
|
||||
|
||||
def test_none(self):
|
||||
is_custom, pid = self.parse(None)
|
||||
self.assertFalse(is_custom)
|
||||
self.assertEqual(pid, "")
|
||||
|
||||
|
||||
class TestResolveCustomCredentials(unittest.TestCase):
|
||||
"""resolve_custom_credentials() resolution order and fallbacks."""
|
||||
|
||||
def setUp(self):
|
||||
from models.custom_provider import resolve_custom_credentials, get_custom_providers
|
||||
self.resolve = resolve_custom_credentials
|
||||
self.get_providers = get_custom_providers
|
||||
|
||||
# --- Backward compatibility ---
|
||||
|
||||
def test_legacy_fallback_when_no_providers(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_api_key": "legacy-key",
|
||||
"custom_api_base": "https://legacy.example.com/v1",
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
def test_empty_config(self):
|
||||
set_conf({"bot_type": "custom"})
|
||||
self.assertEqual(self.resolve(), ("", None, None))
|
||||
|
||||
# --- Multi-provider selection via bot_type ---
|
||||
|
||||
def test_provider_selected_by_id(self):
|
||||
set_conf({
|
||||
"bot_type": "custom:abc12345",
|
||||
"custom_providers": [
|
||||
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
|
||||
"api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"},
|
||||
{"id": "abc12345", "name": "qiniu", "api_key": "qn-key",
|
||||
"api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"),
|
||||
)
|
||||
|
||||
def test_id_not_found_falls_back_to_legacy(self):
|
||||
set_conf({
|
||||
"bot_type": "custom:ghost",
|
||||
"custom_api_key": "legacy-key",
|
||||
"custom_api_base": "https://legacy.example.com/v1",
|
||||
"custom_providers": [
|
||||
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
|
||||
"api_base": "https://api.siliconflow.cn/v1"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
def test_provider_without_model_returns_none_model(self):
|
||||
set_conf({
|
||||
"bot_type": "custom:local01",
|
||||
"custom_providers": [
|
||||
{"id": "local01", "name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("", "http://localhost:11434/v1", None),
|
||||
)
|
||||
|
||||
# --- Robustness against malformed config ---
|
||||
|
||||
def test_malformed_entries_filtered_and_fallback(self):
|
||||
set_conf({
|
||||
"bot_type": "custom:nope",
|
||||
"custom_api_key": "legacy-key",
|
||||
"custom_api_base": "https://legacy.example.com/v1",
|
||||
"custom_providers": [
|
||||
{"name": "no-id", "api_key": "no-id-key"}, # invalid: no id
|
||||
"not-a-dict", # invalid: wrong type
|
||||
],
|
||||
})
|
||||
# All entries invalid -> treated as empty -> legacy fallback
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
def test_get_custom_providers_filters_invalid(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_providers": [
|
||||
{"id": "ok1", "name": "ok", "api_key": "k", "api_base": "https://x/v1"},
|
||||
{"name": "no-id", "api_key": "no-id"}, # dropped: no id
|
||||
123, # dropped
|
||||
],
|
||||
})
|
||||
providers = self.get_providers()
|
||||
self.assertEqual(len(providers), 1)
|
||||
self.assertEqual(providers[0]["id"], "ok1")
|
||||
|
||||
def test_custom_providers_not_a_list_falls_back(self):
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_api_key": "legacy-key",
|
||||
"custom_api_base": "https://legacy.example.com/v1",
|
||||
"custom_providers": "oops-a-string",
|
||||
})
|
||||
self.assertEqual(
|
||||
self.resolve(),
|
||||
("legacy-key", "https://legacy.example.com/v1", None),
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateProviderId(unittest.TestCase):
|
||||
"""generate_provider_id() produces valid short ids."""
|
||||
|
||||
def test_length_and_hex(self):
|
||||
from models.custom_provider import generate_provider_id
|
||||
pid = generate_provider_id()
|
||||
self.assertEqual(len(pid), 8)
|
||||
# Must be valid hex characters
|
||||
int(pid, 16)
|
||||
|
||||
def test_uniqueness(self):
|
||||
from models.custom_provider import generate_provider_id
|
||||
ids = {generate_provider_id() for _ in range(100)}
|
||||
self.assertEqual(len(ids), 100)
|
||||
|
||||
|
||||
class TestConfigDefaults(unittest.TestCase):
|
||||
"""The new config fields must exist with safe defaults."""
|
||||
|
||||
def test_default_config_has_custom_providers(self):
|
||||
from config import available_setting
|
||||
self.assertIn("custom_providers", available_setting)
|
||||
self.assertEqual(available_setting["custom_providers"], [])
|
||||
|
||||
def test_default_config_no_custom_active_provider(self):
|
||||
"""custom_active_provider was removed — replaced by bot_type routing."""
|
||||
from config import available_setting
|
||||
self.assertNotIn("custom_active_provider", available_setting)
|
||||
|
||||
|
||||
class TestDragSensitiveNested(unittest.TestCase):
|
||||
"""drag_sensitive() must mask api_key in nested structures."""
|
||||
|
||||
def test_nested_api_key_masked(self):
|
||||
from config import drag_sensitive
|
||||
import json
|
||||
test_config = {
|
||||
"open_ai_api_key": "sk-1234567890abcdef",
|
||||
"custom_providers": [
|
||||
{"id": "x1", "name": "test", "api_key": "sk-nested-secret-key-long", "api_base": "https://x/v1"}
|
||||
],
|
||||
}
|
||||
result = drag_sensitive(test_config)
|
||||
# Top-level key should be masked
|
||||
self.assertNotIn("1234567890abcdef", str(result))
|
||||
# Nested key should also be masked
|
||||
self.assertNotIn("nested-secret-key-long", str(result))
|
||||
# But the id/name/api_base should not be masked
|
||||
self.assertIn("x1", str(result))
|
||||
self.assertIn("test", str(result))
|
||||
self.assertIn("https://x/v1", str(result))
|
||||
|
||||
def test_string_config_masked(self):
|
||||
from config import drag_sensitive
|
||||
import json
|
||||
test_str = json.dumps({
|
||||
"open_ai_api_key": "sk-1234567890abcdef",
|
||||
"custom_providers": [
|
||||
{"id": "x1", "api_key": "sk-nested-very-long-secret"}
|
||||
],
|
||||
})
|
||||
result = drag_sensitive(test_str)
|
||||
self.assertNotIn("1234567890abcdef", result)
|
||||
self.assertNotIn("nested-very-long-secret", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
289
tests/test_custom_provider_handlers.py
Normal file
289
tests/test_custom_provider_handlers.py
Normal file
@@ -0,0 +1,289 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
Unit tests for the multi custom-provider management API (issue #2838, web UI).
|
||||
|
||||
Covers channel/web/web_channel.py::ModelsHandler:
|
||||
- _custom_provider_cards / _provider_overview expansion
|
||||
- _handle_set_custom_provider (create / edit / activate)
|
||||
- _handle_delete_custom_provider
|
||||
- _handle_set_active_custom_provider
|
||||
|
||||
Uses id-based routing (bot_type: "custom:<id>") — no custom_active_provider.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
# Add project root to path.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# Stub the web.py framework so web_channel imports without the dependency.
|
||||
if "web" not in sys.modules:
|
||||
_web_stub = types.ModuleType("web")
|
||||
_web_stub.header = lambda *a, **k: None
|
||||
_web_stub.data = lambda: b"{}"
|
||||
_web_stub.ctx = types.SimpleNamespace()
|
||||
sys.modules["web"] = _web_stub
|
||||
|
||||
import config as config_module
|
||||
from config import Config
|
||||
from channel.web.web_channel import ModelsHandler
|
||||
|
||||
|
||||
def set_conf(d):
|
||||
"""Install a fresh Config as the global config used by conf()."""
|
||||
config_module.config = Config(d)
|
||||
|
||||
|
||||
class _HandlerHarness:
|
||||
"""Test double around ModelsHandler that captures persisted config in
|
||||
memory instead of touching config.json, and no-ops the Bridge reset."""
|
||||
|
||||
def __init__(self):
|
||||
self.handler = ModelsHandler.__new__(ModelsHandler)
|
||||
self._file_cfg = {}
|
||||
self.bridge_resets = 0
|
||||
# Patch the disk + bridge boundary on this instance.
|
||||
self.handler._read_file_config = lambda: dict(self._file_cfg)
|
||||
self.handler._write_file_config = self._capture_write
|
||||
self.handler._reset_bridge = self._capture_reset
|
||||
|
||||
def _capture_write(self, data):
|
||||
self._file_cfg = dict(data)
|
||||
|
||||
def _capture_reset(self):
|
||||
self.bridge_resets += 1
|
||||
|
||||
def call(self, **payload):
|
||||
# Resolve the bound method by action for convenience.
|
||||
action = payload.get("action")
|
||||
method = {
|
||||
"set_custom_provider": self.handler._handle_set_custom_provider,
|
||||
"delete_custom_provider": self.handler._handle_delete_custom_provider,
|
||||
"set_active_custom_provider": self.handler._handle_set_active_custom_provider,
|
||||
}[action]
|
||||
return json.loads(method(payload))
|
||||
|
||||
|
||||
class TestSetCustomProvider(unittest.TestCase):
|
||||
def setUp(self):
|
||||
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||
self.h = _HandlerHarness()
|
||||
|
||||
def test_create_provider_does_not_hijack_bot_type(self):
|
||||
"""Creating a provider without make_active must not change bot_type."""
|
||||
res = self.h.call(action="set_custom_provider", name="siliconflow",
|
||||
api_base="https://api.siliconflow.cn/v1", api_key="sf-key")
|
||||
self.assertEqual(res["status"], "success")
|
||||
self.assertTrue(res["created"])
|
||||
self.assertIn("id", res)
|
||||
# bot_type must remain unchanged — no auto-activation.
|
||||
bot_type = config_module.conf().get("bot_type")
|
||||
self.assertEqual(bot_type, "custom") # unchanged from setUp
|
||||
providers = config_module.conf().get("custom_providers")
|
||||
self.assertEqual(len(providers), 1)
|
||||
self.assertEqual(providers[0]["id"], res["id"])
|
||||
self.assertEqual(providers[0]["name"], "siliconflow")
|
||||
self.assertEqual(self.h.bridge_resets, 1)
|
||||
|
||||
def test_create_with_make_active_switches_bot_type(self):
|
||||
"""Creating a provider with make_active=true must switch bot_type."""
|
||||
res = self.h.call(action="set_custom_provider", name="siliconflow",
|
||||
api_base="https://api.siliconflow.cn/v1", api_key="sf-key",
|
||||
make_active=True)
|
||||
self.assertEqual(res["status"], "success")
|
||||
bot_type = config_module.conf().get("bot_type")
|
||||
self.assertEqual(bot_type, f"custom:{res['id']}")
|
||||
|
||||
def test_create_requires_api_base(self):
|
||||
res = self.h.call(action="set_custom_provider", name="x", api_key="k")
|
||||
self.assertEqual(res["status"], "error")
|
||||
self.assertIn("api_base", res["message"])
|
||||
|
||||
def test_create_requires_name(self):
|
||||
res = self.h.call(action="set_custom_provider", name="", api_base="https://x/v1")
|
||||
self.assertEqual(res["status"], "error")
|
||||
|
||||
def test_second_provider_does_not_steal_active(self):
|
||||
# Explicitly activate the first provider.
|
||||
res1 = self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="ak", make_active=True)
|
||||
res2 = self.h.call(action="set_custom_provider", name="b",
|
||||
api_base="https://b/v1", api_key="bk")
|
||||
self.assertTrue(res2["created"])
|
||||
# First provider stays active — second creation doesn't steal it.
|
||||
bot_type = config_module.conf().get("bot_type")
|
||||
self.assertEqual(bot_type, f"custom:{res1['id']}")
|
||||
|
||||
def test_make_active_flag(self):
|
||||
self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="ak")
|
||||
res2 = self.h.call(action="set_custom_provider", name="b",
|
||||
api_base="https://b/v1", api_key="bk", make_active=True)
|
||||
bot_type = config_module.conf().get("bot_type")
|
||||
self.assertEqual(bot_type, f"custom:{res2['id']}")
|
||||
|
||||
def test_edit_keeps_key_when_omitted(self):
|
||||
res = self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="secret")
|
||||
pid = res["id"]
|
||||
# Edit only the base; omit api_key.
|
||||
res2 = self.h.call(action="set_custom_provider", name="a",
|
||||
id=pid, api_base="https://a2/v1")
|
||||
self.assertEqual(res2["status"], "success")
|
||||
self.assertFalse(res2["created"])
|
||||
providers = config_module.conf().get("custom_providers")
|
||||
self.assertEqual(providers[0]["api_base"], "https://a2/v1")
|
||||
self.assertEqual(providers[0]["api_key"], "secret") # preserved
|
||||
|
||||
def test_edit_can_rename(self):
|
||||
res = self.h.call(action="set_custom_provider", name="old",
|
||||
api_base="https://a/v1", api_key="ak")
|
||||
pid = res["id"]
|
||||
res2 = self.h.call(action="set_custom_provider", name="new",
|
||||
id=pid, api_base="https://a/v1")
|
||||
self.assertEqual(res2["status"], "success")
|
||||
providers = config_module.conf().get("custom_providers")
|
||||
self.assertEqual(providers[0]["name"], "new")
|
||||
# ID stays the same
|
||||
self.assertEqual(providers[0]["id"], pid)
|
||||
|
||||
def test_edit_clears_model_when_empty(self):
|
||||
res = self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="ak", model="m1")
|
||||
pid = res["id"]
|
||||
self.assertEqual(config_module.conf().get("custom_providers")[0]["model"], "m1")
|
||||
self.h.call(action="set_custom_provider", name="a", id=pid,
|
||||
api_base="https://a/v1", model="")
|
||||
self.assertNotIn("model", config_module.conf().get("custom_providers")[0])
|
||||
|
||||
|
||||
class TestDeleteCustomProvider(unittest.TestCase):
|
||||
def setUp(self):
|
||||
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||
self.h = _HandlerHarness()
|
||||
self.res_a = self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="ak",
|
||||
make_active=True)
|
||||
self.res_b = self.h.call(action="set_custom_provider", name="b",
|
||||
api_base="https://b/v1", api_key="bk")
|
||||
|
||||
def test_delete_unknown(self):
|
||||
res = self.h.call(action="delete_custom_provider", id="ghost")
|
||||
self.assertEqual(res["status"], "error")
|
||||
|
||||
def test_delete_non_active(self):
|
||||
res = self.h.call(action="delete_custom_provider", id=self.res_b["id"])
|
||||
self.assertEqual(res["status"], "success")
|
||||
ids = [p["id"] for p in config_module.conf().get("custom_providers")]
|
||||
self.assertEqual(ids, [self.res_a["id"]])
|
||||
# bot_type unchanged (still pointing to a)
|
||||
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
|
||||
|
||||
def test_delete_active_falls_back_to_first_remaining(self):
|
||||
# 'a' is active (created first); deleting it should re-point to 'b'.
|
||||
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
|
||||
res = self.h.call(action="delete_custom_provider", id=self.res_a["id"])
|
||||
self.assertEqual(res["status"], "success")
|
||||
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}")
|
||||
|
||||
def test_delete_last_reverts_to_legacy(self):
|
||||
self.h.call(action="delete_custom_provider", id=self.res_a["id"])
|
||||
self.h.call(action="delete_custom_provider", id=self.res_b["id"])
|
||||
self.assertEqual(config_module.conf().get("custom_providers"), [])
|
||||
# When all providers deleted, reverts to legacy "custom"
|
||||
self.assertEqual(config_module.conf().get("bot_type"), "custom")
|
||||
|
||||
|
||||
class TestSetActiveCustomProvider(unittest.TestCase):
|
||||
def setUp(self):
|
||||
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||
self.h = _HandlerHarness()
|
||||
self.res_a = self.h.call(action="set_custom_provider", name="a",
|
||||
api_base="https://a/v1", api_key="ak",
|
||||
make_active=True)
|
||||
self.res_b = self.h.call(action="set_custom_provider", name="b",
|
||||
api_base="https://b/v1", api_key="bk")
|
||||
|
||||
def test_set_active_valid(self):
|
||||
res = self.h.call(action="set_active_custom_provider", id=self.res_b["id"])
|
||||
self.assertEqual(res["status"], "success")
|
||||
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}")
|
||||
|
||||
def test_set_active_unknown(self):
|
||||
res = self.h.call(action="set_active_custom_provider", id="ghost")
|
||||
self.assertEqual(res["status"], "error")
|
||||
# bot_type unchanged
|
||||
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}")
|
||||
|
||||
def test_activation_syncs_model_to_global(self):
|
||||
"""Activating a provider must write its model into global model field."""
|
||||
set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"})
|
||||
h = _HandlerHarness()
|
||||
res = h.call(action="set_custom_provider", name="sf",
|
||||
api_base="https://sf/v1", api_key="k", model="deepseek-v3",
|
||||
make_active=True)
|
||||
# Global model field should now be the provider's model.
|
||||
self.assertEqual(config_module.conf().get("model"), "deepseek-v3")
|
||||
self.assertEqual(config_module.conf().get("bot_type"), f"custom:{res['id']}")
|
||||
|
||||
def test_activation_without_model_keeps_global_model(self):
|
||||
"""Activating a provider with no model must not overwrite global model."""
|
||||
set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"})
|
||||
h = _HandlerHarness()
|
||||
h.call(action="set_custom_provider", name="local",
|
||||
api_base="http://localhost:11434/v1", api_key="",
|
||||
make_active=True)
|
||||
# Global model field should remain unchanged.
|
||||
self.assertEqual(config_module.conf().get("model"), "gpt-4o")
|
||||
|
||||
|
||||
class TestProviderOverviewExpansion(unittest.TestCase):
|
||||
"""_provider_overview / _custom_provider_cards should expand the list."""
|
||||
|
||||
def test_no_custom_providers_keeps_single_card(self):
|
||||
set_conf({"bot_type": "custom", "custom_providers": []})
|
||||
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
||||
self.assertEqual(cards, [])
|
||||
overview = ModelsHandler._provider_overview()
|
||||
custom_cards = [c for c in overview if c.get("id") == "custom"]
|
||||
# Legacy single custom card remains present.
|
||||
self.assertEqual(len(custom_cards), 1)
|
||||
|
||||
def test_multi_providers_expand_into_cards(self):
|
||||
set_conf({
|
||||
"bot_type": "custom:id_b",
|
||||
"custom_providers": [
|
||||
{"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
||||
{"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"},
|
||||
],
|
||||
})
|
||||
overview = ModelsHandler._provider_overview()
|
||||
custom_cards = [c for c in overview if c.get("is_custom")]
|
||||
self.assertEqual(len(custom_cards), 2)
|
||||
by_id = {c["custom_id"]: c for c in custom_cards}
|
||||
self.assertEqual(by_id["id_a"]["id"], "custom:id_a")
|
||||
self.assertFalse(by_id["id_a"]["active"])
|
||||
self.assertTrue(by_id["id_b"]["active"])
|
||||
self.assertEqual(by_id["id_b"]["model"], "m")
|
||||
# No single legacy "custom" card when expanded.
|
||||
self.assertFalse(any(c.get("id") == "custom" for c in overview))
|
||||
|
||||
def test_no_active_shows_none_active(self):
|
||||
"""When bot_type is plain 'custom', no card is marked active."""
|
||||
set_conf({
|
||||
"bot_type": "custom",
|
||||
"custom_providers": [
|
||||
{"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"},
|
||||
{"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1"},
|
||||
],
|
||||
})
|
||||
cards = ModelsHandler._custom_provider_cards(config_module.conf())
|
||||
active_cards = [c for c in cards if c.get("active")]
|
||||
self.assertEqual(len(active_cards), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
121
tests/test_robustness_fixes.py
Normal file
121
tests/test_robustness_fixes.py
Normal file
@@ -0,0 +1,121 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
Unit tests for robustness fixes:
|
||||
1. ChatChannel.cancel_session / cancel_all_session must not raise KeyError
|
||||
when a session has been produced but no task has been dispatched yet
|
||||
(so self.futures[session_id] does not exist).
|
||||
2. common.utils.compress_imgfile must terminate (no infinite loop / invalid
|
||||
PIL quality) when an image cannot be compressed below max_size.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. cancel_session / cancel_all_session KeyError regression
|
||||
# =============================================================================
|
||||
|
||||
class TestCancelSessionMissingFutures(unittest.TestCase):
|
||||
"""A session may exist in self.sessions before any future is recorded."""
|
||||
|
||||
def _make_channel(self):
|
||||
# Import lazily and build a bare object without running __init__,
|
||||
# to avoid pulling the full channel setup / config.
|
||||
from channel.chat_channel import ChatChannel
|
||||
|
||||
ch = ChatChannel.__new__(ChatChannel)
|
||||
import threading
|
||||
|
||||
ch.lock = threading.RLock()
|
||||
# A produced session whose future has NOT been dispatched yet.
|
||||
queue = MagicMock()
|
||||
queue.qsize.return_value = 0
|
||||
semaphore = MagicMock()
|
||||
ch.sessions = {"sid": [queue, semaphore]}
|
||||
ch.futures = {} # intentionally empty: consume() never ran
|
||||
return ch
|
||||
|
||||
def test_cancel_session_no_futures_entry(self):
|
||||
ch = self._make_channel()
|
||||
# Should not raise KeyError.
|
||||
try:
|
||||
ch.cancel_session("sid")
|
||||
except KeyError:
|
||||
self.fail("cancel_session raised KeyError when futures entry missing")
|
||||
|
||||
def test_cancel_all_session_no_futures_entry(self):
|
||||
ch = self._make_channel()
|
||||
try:
|
||||
ch.cancel_all_session()
|
||||
except KeyError:
|
||||
self.fail("cancel_all_session raised KeyError when futures entry missing")
|
||||
|
||||
def test_cancel_session_cancels_existing_futures(self):
|
||||
ch = self._make_channel()
|
||||
fut = MagicMock()
|
||||
ch.futures["sid"] = [fut]
|
||||
ch.cancel_session("sid")
|
||||
fut.cancel.assert_called_once()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. compress_imgfile termination
|
||||
# =============================================================================
|
||||
|
||||
class TestCompressImgfileTermination(unittest.TestCase):
|
||||
"""compress_imgfile must always return, even for incompressible input."""
|
||||
|
||||
def setUp(self):
|
||||
# Skip if Pillow is not available in the test environment.
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest("Pillow not installed")
|
||||
|
||||
def _make_image_buf(self, size=(64, 64)):
|
||||
from PIL import Image
|
||||
import random
|
||||
|
||||
img = Image.new("RGB", size)
|
||||
# Fill with random noise so JPEG cannot compress it well.
|
||||
pixels = [
|
||||
(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
||||
for _ in range(size[0] * size[1])
|
||||
]
|
||||
img.putdata(pixels)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "JPEG", quality=95)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
def test_returns_when_target_unreachable(self):
|
||||
from common.utils import compress_imgfile
|
||||
|
||||
buf = self._make_image_buf()
|
||||
# An impossibly small target that even quality=10 won't reach.
|
||||
out = compress_imgfile(buf, max_size=10)
|
||||
self.assertIsInstance(out, io.BytesIO)
|
||||
# Verify the result is still a valid JPEG (PIL never got invalid quality).
|
||||
from PIL import Image
|
||||
|
||||
out.seek(0)
|
||||
img = Image.open(out)
|
||||
img.verify()
|
||||
|
||||
def test_no_compression_needed_returns_same_object(self):
|
||||
from common.utils import compress_imgfile
|
||||
|
||||
buf = self._make_image_buf()
|
||||
size = buf.getbuffer().nbytes
|
||||
out = compress_imgfile(buf, max_size=size + 1)
|
||||
self.assertIs(out, buf)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
194
tests/test_security_ssrf_path_traversal.py
Normal file
194
tests/test_security_ssrf_path_traversal.py
Normal file
@@ -0,0 +1,194 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
Unit tests for security fixes:
|
||||
1. Vision tool SSRF protection (issue #2878, #2872)
|
||||
2. Skill service path traversal protection (issue #2873)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# Stub 'requests' if not installed so vision.py can be imported for testing.
|
||||
if "requests" not in sys.modules:
|
||||
_requests_stub = types.ModuleType("requests")
|
||||
_requests_stub.get = lambda *a, **k: None
|
||||
sys.modules["requests"] = _requests_stub
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Vision SSRF tests
|
||||
# =============================================================================
|
||||
|
||||
class TestVisionSSRFValidation(unittest.TestCase):
|
||||
"""Test that _validate_url_safe blocks internal/private URLs."""
|
||||
|
||||
def setUp(self):
|
||||
from agent.tools.vision.vision import Vision
|
||||
self.validate = Vision._validate_url_safe
|
||||
|
||||
def test_loopback_ipv4_blocked(self):
|
||||
"""127.0.0.1 must be rejected."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("http://127.0.0.1/canary.png")
|
||||
self.assertIn("non-public", str(ctx.exception))
|
||||
|
||||
def test_loopback_localhost_blocked(self):
|
||||
"""localhost must be rejected."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("http://localhost/canary.png")
|
||||
self.assertIn("non-public", str(ctx.exception))
|
||||
|
||||
def test_private_10_network_blocked(self):
|
||||
"""10.x.x.x RFC1918 must be rejected."""
|
||||
with patch("socket.getaddrinfo") as mock_gai:
|
||||
mock_gai.return_value = [
|
||||
(2, 1, 6, "", ("10.0.0.1", 0)),
|
||||
]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("http://internal.corp/image.png")
|
||||
self.assertIn("non-public", str(ctx.exception))
|
||||
|
||||
def test_private_172_network_blocked(self):
|
||||
"""172.16.x.x RFC1918 must be rejected."""
|
||||
with patch("socket.getaddrinfo") as mock_gai:
|
||||
mock_gai.return_value = [
|
||||
(2, 1, 6, "", ("172.16.0.1", 0)),
|
||||
]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("http://internal.corp/image.png")
|
||||
self.assertIn("non-public", str(ctx.exception))
|
||||
|
||||
def test_private_192_168_blocked(self):
|
||||
"""192.168.x.x RFC1918 must be rejected."""
|
||||
with patch("socket.getaddrinfo") as mock_gai:
|
||||
mock_gai.return_value = [
|
||||
(2, 1, 6, "", ("192.168.1.1", 0)),
|
||||
]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("http://router.local/image.png")
|
||||
self.assertIn("non-public", str(ctx.exception))
|
||||
|
||||
def test_link_local_blocked(self):
|
||||
"""169.254.x.x (link-local / cloud metadata) must be rejected."""
|
||||
with patch("socket.getaddrinfo") as mock_gai:
|
||||
mock_gai.return_value = [
|
||||
(2, 1, 6, "", ("169.254.169.254", 0)),
|
||||
]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("http://metadata.google.internal/image.png")
|
||||
self.assertIn("non-public", str(ctx.exception))
|
||||
|
||||
def test_ipv6_loopback_blocked(self):
|
||||
"""::1 (IPv6 loopback) must be rejected."""
|
||||
with patch("socket.getaddrinfo") as mock_gai:
|
||||
mock_gai.return_value = [
|
||||
(10, 1, 6, "", ("::1", 0, 0, 0)),
|
||||
]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("http://[::1]/image.png")
|
||||
self.assertIn("non-public", str(ctx.exception))
|
||||
|
||||
def test_public_url_allowed(self):
|
||||
"""A URL resolving to a public IP should pass validation."""
|
||||
with patch("socket.getaddrinfo") as mock_gai:
|
||||
mock_gai.return_value = [
|
||||
(2, 1, 6, "", ("151.101.1.140", 0)),
|
||||
]
|
||||
# Should not raise
|
||||
self.validate("https://cdn.example.com/image.png")
|
||||
|
||||
def test_no_hostname_rejected(self):
|
||||
"""A URL with no host must be rejected."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("http:///path/to/image.png")
|
||||
self.assertIn("no hostname", str(ctx.exception))
|
||||
|
||||
def test_non_http_scheme_rejected(self):
|
||||
"""file:// and ftp:// schemes must be rejected."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("file:///etc/passwd")
|
||||
self.assertIn("scheme", str(ctx.exception))
|
||||
|
||||
def test_dns_failure_rejected(self):
|
||||
"""Unresolvable hostname must be rejected."""
|
||||
import socket as sock_mod
|
||||
with patch("socket.getaddrinfo", side_effect=sock_mod.gaierror("Name does not resolve")):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.validate("http://nonexistent.invalid/img.png")
|
||||
self.assertIn("Cannot resolve", str(ctx.exception))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Skill service path traversal tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSkillServicePathTraversal(unittest.TestCase):
|
||||
"""Test that _safe_skill_dir blocks path traversal attempts."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp_root = tempfile.mkdtemp()
|
||||
# Create a minimal SkillManager mock with custom_dir set.
|
||||
from agent.skills.service import SkillService
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.custom_dir = self.tmp_root
|
||||
self.svc = SkillService(mock_manager)
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.tmp_root, ignore_errors=True)
|
||||
|
||||
def test_normal_name_allowed(self):
|
||||
"""A simple name like 'my-skill' should produce a valid path."""
|
||||
result = self.svc._safe_skill_dir("my-skill")
|
||||
expected = os.path.realpath(os.path.join(self.tmp_root, "my-skill"))
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_dotdot_traversal_blocked(self):
|
||||
"""'../escaped' must be rejected."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.svc._safe_skill_dir("../escaped")
|
||||
self.assertIn("path traversal", str(ctx.exception))
|
||||
|
||||
def test_nested_dotdot_blocked(self):
|
||||
"""'foo/../../escaped' must be rejected."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.svc._safe_skill_dir("foo/../../escaped")
|
||||
self.assertIn("path traversal", str(ctx.exception))
|
||||
|
||||
def test_absolute_path_blocked(self):
|
||||
"""'/tmp/evil' must be rejected."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.svc._safe_skill_dir("/tmp/evil")
|
||||
self.assertIn("path traversal", str(ctx.exception))
|
||||
|
||||
def test_backslash_path_blocked(self):
|
||||
r"""'\\server\share' must be rejected."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.svc._safe_skill_dir("\\server\\share")
|
||||
self.assertIn("path traversal", str(ctx.exception))
|
||||
|
||||
def test_empty_name_blocked(self):
|
||||
"""Empty name must be rejected."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.svc._safe_skill_dir("")
|
||||
|
||||
def test_whitespace_only_blocked(self):
|
||||
"""Whitespace-only name must be rejected."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.svc._safe_skill_dir(" ")
|
||||
|
||||
def test_subdir_name_allowed(self):
|
||||
"""A name with a forward slash but no traversal is allowed if it stays in root."""
|
||||
# e.g. "category/skill-name" is a valid nested skill directory
|
||||
result = self.svc._safe_skill_dir("category/skill-name")
|
||||
expected = os.path.realpath(os.path.join(self.tmp_root, "category/skill-name"))
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user