Files
clawd/lib/openclaw-provider.js
stswangzhiping f4efb13612 fix: restartGateway 改异步,修复 inactive 时 PIN 不闪问题
原来 removeProviderByName 内用 execSync 调 pkill,在 showPin 前
阻塞事件循环约 100ms+;若 vfdservice 是 gateway 子进程则管道无
读端,导致 showPin 写入失败或 blink setTimeout 无法触发。

改为 exec(异步):pkill 在后台执行,不阻塞事件循环,LED/VFD
操作(showPin blink)可正常调度。

Made-with: Cursor
2026-04-03 11:41:59 +08:00

318 lines
10 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 { exec } = require('child_process');
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;
/**
* 终止 openclaw-gateway 进程,由 systemd --user 自动重新拉起以读取新配置。
* 每次写盘 openclaw.json 成功后应调用一次。
* 使用异步 exec不阻塞 Node.js 事件循环,避免干扰 LED / VFD 等后续操作。
*/
function restartGateway() {
exec('pkill -9 -x openclaw-gateway', (err) => {
if (err && err.code !== 1) {
log.warn('openclaw-provider', `restartGateway: ${err.message}`);
} else if (!err) {
log.info('openclaw-provider', 'openclaw-gateway 已终止,等待自动重启');
} else {
log.info('openclaw-provider', 'openclaw-gateway 进程不存在,无需终止');
}
});
}
function authProfilesPathFromConfig(configFile) {
return path.join(path.dirname(configFile), 'agents', 'main', 'agent', 'auth-profiles.json');
}
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 })));
} 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 + auth-profiles.json 删除指定 provider解绑
* 若 primary 指向该 provider先置为空串。
*/
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`];
}
writeJsonFile(configFile, config);
const authPath = authProfilesPathFromConfig(configFile);
try {
if (fs.existsSync(authPath)) {
const authProfiles = readJsonFile(authPath);
if (authProfiles.profiles?.[`${providerId}:default`]) {
delete authProfiles.profiles[`${providerId}:default`];
writeJsonFile(authPath, authProfiles);
}
}
} catch (e) {
log.warn('openclaw-provider', `auth-profiles 更新失败: ${e.message}`);
}
log.info('openclaw-provider', `provider 已移除: ${providerId}`);
restartGateway();
}
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`];
}
}
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',
};
writeJsonFile(configFile, config);
const authPath = authProfilesPathFromConfig(configFile);
try {
let authProfiles = { profiles: {} };
if (fs.existsSync(authPath)) {
authProfiles = readJsonFile(authPath);
if (!authProfiles.profiles) authProfiles.profiles = {};
}
authProfiles.profiles[`${providerId}:default`] = {
type: 'api_key',
provider: providerId,
key: apiKey,
};
writeJsonFile(authPath, authProfiles);
} catch (e) {
log.warn('openclaw-provider', `auth-profiles 写入失败: ${e.message}`);
}
log.info('openclaw-provider', `provider 已写入: ${providerId}${models.length} 个模型)`);
}
/**
* VPS 绑定:先同步删掉同名 provider再异步拉模型回调内同步 add。完成后执行 onDone如更新 origin
*/
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;
try {
const cfg = readJsonFile(configFile);
removeProviderFromConfig(cfg, name);
writeJsonFile(configFile, cfg);
} catch (e) {
log.warn('openclaw-provider', `apply 预清理失败: ${e.message}`);
_busy = false;
return;
}
fetchModels(baseUrl, apiKey, (err, models) => {
try {
const list = err ? [] : models;
if (err) {
log.warn('openclaw-provider', `拉模型失败,使用空列表: ${err.message}`);
}
addProviderSync(configFile, name, baseUrl, apiKey, list, defaultModel);
if (typeof onDone === 'function') {
try {
onDone();
} catch (e) {
log.warn('openclaw-provider', `onDone: ${e.message}`);
}
}
restartGateway();
} 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');
}
module.exports = {
applyFullProviderFromVps,
removeProviderByName,
isFullProvider,
DEFAULT_BASE_URL,
};