Files
clawd/lib/openclaw-provider.js
2026-05-05 18:46:17 +08:00

398 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const log = require('./logger');
const { resolveOpenclawConfigFile } = require('./frpc');
const DEFAULT_BASE_URL = 'https://api.cutos.ai/v1';
const FETCH_TIMEOUT_MS = 10_000;
/** 拉模型 + 写盘单次飞行:进行中则忽略新的 apply/remove */
let _busy = false;
function buildModelsUrl(baseUrl) {
let u = String(baseUrl || '').trim().replace(/\/+$/, '');
if (!/\/v1$/.test(u)) u = `${u}/v1`;
return `${u}/models`;
}
/**
* 异步 GET /v1/models不阻塞完成回调 (err, models)models 为 { id, name }[]
*/
function fetchModels(baseUrl, apiKey, callback) {
const urlStr = buildModelsUrl(baseUrl);
let u;
try {
u = new URL(urlStr);
} catch (e) {
callback(new Error(`invalid base-url: ${urlStr}`));
return;
}
const lib = u.protocol === 'https:' ? https : http;
const opts = {
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: `${u.pathname}${u.search || ''}`,
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey || ''}`,
'Content-Type': 'application/json',
},
};
log.info('openclaw-provider', `GET models: ${urlStr}`);
const req = lib.request(opts, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const json = JSON.parse(data);
if (json.data && Array.isArray(json.data)) {
callback(null, json.data.map((m) => ({ id: m.id, name: m.id, input: ['text', 'image'] })));
} else if (json.error) {
callback(new Error(json.error.message || JSON.stringify(json.error)));
} else {
callback(new Error(`bad models response: ${data.slice(0, 200)}`));
}
} catch (e) {
callback(new Error(`parse models: ${e.message}`));
}
});
});
req.on('error', callback);
req.setTimeout(FETCH_TIMEOUT_MS, () => {
req.destroy();
callback(new Error('models request timeout'));
});
req.end();
}
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJsonFile(filePath, obj) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
}
/**
* 同步:从 openclaw.json 删除指定 provider解绑
* 若 primary 指向该 provider先置为空串。
* gateway 检测到文件变更后会自动重启,无需 clawd 主动 kill。
*/
function removeProviderByName(providerId) {
if (_busy) {
log.warn('openclaw-provider', `跳过 removeprovider 应用进行中): ${providerId}`);
return;
}
const configFile = resolveOpenclawConfigFile();
if (!configFile) {
log.warn('openclaw-provider', 'remove: 未找到 openclaw.json');
return;
}
const config = readJsonFile(configFile);
const primary = config.agents?.defaults?.model?.primary || '';
if (primary.startsWith(`${providerId}/`)) {
if (!config.agents) config.agents = { defaults: {} };
if (!config.agents.defaults) config.agents.defaults = {};
if (!config.agents.defaults.model) config.agents.defaults.model = {};
config.agents.defaults.model.primary = '';
log.info('openclaw-provider', `已清空默认模型 primary${primary}`);
}
if (config.models?.providers?.[providerId]) {
delete config.models.providers[providerId];
log.info('openclaw-provider', `已删除 models.providers.${providerId}`);
}
if (config.agents?.defaults?.models) {
const prefix = `${providerId}/`;
Object.keys(config.agents.defaults.models).forEach((key) => {
if (key.startsWith(prefix)) delete config.agents.defaults.models[key];
});
}
if (config.auth?.profiles) {
delete config.auth.profiles[`${providerId}:default`];
}
// web search: 解绑时精确删除 searxng 相关配置
if (config.plugins?.entries?.searxng) {
delete config.plugins.entries.searxng;
}
if (config.tools?.web?.search?.provider === 'searxng') {
delete config.tools.web.search;
}
writeJsonFile(configFile, config);
log.info('openclaw-provider', `provider 已移除: ${providerId}`);
}
function removeProviderFromConfig(config, providerId) {
if (config.models?.providers?.[providerId]) {
delete config.models.providers[providerId];
}
if (config.agents?.defaults?.models) {
const prefix = `${providerId}/`;
Object.keys(config.agents.defaults.models).forEach((key) => {
if (key.startsWith(prefix)) delete config.agents.defaults.models[key];
});
}
if (config.auth?.profiles) {
delete config.auth.profiles[`${providerId}:default`];
}
}
const WEB_SEARCH_BASE_URL = 'https://web-search.cutos.ai/';
/**
* 检查并补全 searxng web search 配置,返回 true 表示有修改。
* 激活和重连时均调用,确保配置存在且正确。
*/
function ensureWebSearchConfig(config) {
let dirty = false;
const expectedSearch = { provider: 'searxng' };
const expectedSearxng = { config: { webSearch: { baseUrl: WEB_SEARCH_BASE_URL } } };
const curSearch = config.tools?.web?.search;
const curSearxng = config.plugins?.entries?.searxng;
if (!curSearch || curSearch.provider !== 'searxng') {
if (!config.tools) config.tools = {};
if (!config.tools.web) config.tools.web = {};
config.tools.web.search = expectedSearch;
dirty = true;
}
if (!curSearxng || curSearxng.config?.webSearch?.baseUrl !== WEB_SEARCH_BASE_URL) {
if (!config.plugins) config.plugins = {};
if (!config.plugins.entries) config.plugins.entries = {};
config.plugins.entries.searxng = expectedSearxng;
dirty = true;
}
return dirty;
}
function addProviderSync(configFile, providerId, baseUrl, apiKey, models, defaultModelRaw) {
const config = readJsonFile(configFile);
removeProviderFromConfig(config, providerId);
if (!config.models) config.models = { mode: 'merge', providers: {} };
if (!config.models.providers) config.models.providers = {};
config.models.mode = 'merge';
let cleanBase = String(baseUrl || '').replace(/\/+$/, '');
if (!/\/v1$/.test(cleanBase)) cleanBase = `${cleanBase}/v1`;
config.models.providers[providerId] = {
baseUrl: cleanBase,
apiKey,
api: 'openai-completions',
models,
};
if (!config.agents) config.agents = { defaults: {} };
if (!config.agents.defaults) config.agents.defaults = {};
if (!config.agents.defaults.models) config.agents.defaults.models = {};
models.forEach((m) => {
const fullId = `${providerId}/${m.id}`;
if (!config.agents.defaults.models[fullId]) config.agents.defaults.models[fullId] = {};
});
if (defaultModelRaw) {
const dm = String(defaultModelRaw).trim();
const defaultFull = dm.includes('/') ? dm : `${providerId}/${dm}`;
if (!config.agents.defaults.model) config.agents.defaults.model = {};
config.agents.defaults.model.primary = defaultFull;
log.info('openclaw-provider', `默认模型: ${defaultFull}`);
}
if (!config.auth) config.auth = { profiles: {} };
if (!config.auth.profiles) config.auth.profiles = {};
config.auth.profiles[`${providerId}:default`] = {
provider: providerId,
mode: 'api_key',
};
ensureWebSearchConfig(config);
writeJsonFile(configFile, config);
log.info('openclaw-provider', `provider 已写入: ${providerId}${models.length} 个模型)`);
}
/**
* VPS 绑定:异步拉模型后一次性写入 openclaw.json。
* gateway 检测到文件变更后会自动重启,无需 clawd 主动 kill。
*/
function applyFullProviderFromVps(provider, onDone) {
if (_busy) {
log.warn('openclaw-provider', '跳过 apply上一次 provider 操作尚未结束)');
return;
}
const name = provider && provider.name;
if (!name || typeof name !== 'string') {
log.warn('openclaw-provider', 'apply: provider.name 无效');
return;
}
const baseUrl = provider['base-url'] || provider.baseUrl || DEFAULT_BASE_URL;
const apiKey = provider['api-key'] != null ? String(provider['api-key']) : '';
const defaultModel = provider['default-model'] != null ? String(provider['default-model']) : '';
const configFile = resolveOpenclawConfigFile();
if (!configFile) {
log.warn('openclaw-provider', 'apply: 未找到 openclaw.json');
return;
}
_busy = true;
fetchModels(baseUrl, apiKey, (err, models) => {
try {
const list = err ? [] : models;
if (err) {
log.warn('openclaw-provider', `拉模型失败,使用空列表: ${err.message}`);
}
// 校验 apiKey + 模型列表是否有实际变化,无变化则跳过写盘,避免触发不必要的 gateway 重启
try {
const existing = readJsonFile(configFile);
const cur = existing.models?.providers?.[name] || {};
const curApiKey = cur.apiKey || '';
const curMd5 = computeModelsMd5(cur.models || []);
const newMd5 = computeModelsMd5(list);
if (curApiKey === apiKey && curMd5 === newMd5) {
log.info('openclaw-provider', `provider 无变化apiKey + 模型列表相同),跳过写盘`);
if (typeof onDone === 'function') { try { onDone(); } catch (e) { log.warn('openclaw-provider', `onDone: ${e.message}`); } }
return;
}
} catch (_) { /* 读取失败则继续写盘 */ }
addProviderSync(configFile, name, baseUrl, apiKey, list, defaultModel);
if (typeof onDone === 'function') {
try {
onDone();
} catch (e) {
log.warn('openclaw-provider', `onDone: ${e.message}`);
}
}
} catch (e) {
log.error('openclaw-provider', `apply 写配置失败: ${e.message}`);
} finally {
_busy = false;
}
});
}
/** 与解绑区分:解绑仅含 name绑定含 base-url或 baseUrl */
function isFullProvider(p) {
if (!p || typeof p.name !== 'string' || !p.name) return false;
return Object.prototype.hasOwnProperty.call(p, 'base-url')
|| Object.prototype.hasOwnProperty.call(p, 'baseUrl');
}
/**
* 对模型列表计算 MD5按 id 排序后 JSON 序列化),用于变更检测。
*/
function computeModelsMd5(models) {
const ids = (models || []).map((m) => m.id).sort();
return crypto.createHash('md5').update(JSON.stringify(ids)).digest('hex');
}
/**
* 重连时刷新模型列表:读取现有 openclaw.json 中第一个 provider 的 baseUrl/apiKey
* 拉取最新模型MD5 与现有模型对比,不一致才写盘(触发 gateway 自动重启)。
* 若模型未变则跳过,不写盘,不触发 gateway 重启。
* 完成后调用 onDone()(无论是否更新)。
*/
function refreshModelsIfChanged(onDone) {
if (_busy) {
log.info('openclaw-provider', 'refreshModels: 有操作进行中,跳过');
if (typeof onDone === 'function') onDone();
return;
}
const configFile = resolveOpenclawConfigFile();
if (!configFile) {
if (typeof onDone === 'function') onDone();
return;
}
let config;
try {
config = readJsonFile(configFile);
} catch (e) {
log.warn('openclaw-provider', `refreshModels: 读取配置失败: ${e.message}`);
if (typeof onDone === 'function') onDone();
return;
}
const providers = config.models?.providers || {};
const providerId = Object.keys(providers)[0];
if (!providerId) {
log.info('openclaw-provider', 'refreshModels: 未找到已配置的 provider跳过');
if (typeof onDone === 'function') onDone();
return;
}
const providerCfg = providers[providerId];
const baseUrl = providerCfg.baseUrl || '';
const apiKey = providerCfg.apiKey || '';
const currentModels = providerCfg.models || [];
_busy = true;
fetchModels(baseUrl, apiKey, (err, newModels) => {
try {
if (err) {
log.warn('openclaw-provider', `refreshModels: 拉模型失败: ${err.message}`);
return;
}
const currentMd5 = computeModelsMd5(currentModels);
const newMd5 = computeModelsMd5(newModels);
if (currentMd5 === newMd5) {
// 模型未变,但仍检查 searxng 配置是否缺失或过期
try {
const cfg = readJsonFile(configFile);
if (ensureWebSearchConfig(cfg)) {
writeJsonFile(configFile, cfg);
log.info('openclaw-provider', 'searxng web search config repaired');
} else {
log.info('openclaw-provider', `模型列表未变化(${newModels.length} 个),跳过更新`);
}
} catch (_) {}
return;
}
log.info('openclaw-provider', `模型列表已变化(${currentModels.length}${newModels.length} 个),更新 openclaw.json`);
addProviderSync(configFile, providerId, baseUrl, apiKey, newModels, null);
} catch (e) {
log.error('openclaw-provider', `refreshModels: ${e.message}`);
} finally {
_busy = false;
if (typeof onDone === 'function') onDone();
}
});
}
module.exports = {
applyFullProviderFromVps,
removeProviderByName,
refreshModelsIfChanged,
isFullProvider,
DEFAULT_BASE_URL,
};