Files
clawd/lib/openclaw-provider.js
stswangzhiping 7737e08464 fix: inactive 路径不重启 gateway,修复 PIN 不闪问题
removeProviderByName 中移除 restartGateway() 调用。

原因:inactive 时 kill openclaw-gateway 会破坏 vfdservice
(gateway 的子进程或共享 VFD 管道的进程),导致后续 showPin
写管道失败或被 gateway 重启后的时钟命令覆盖,数码管无法显示
PIN 闪烁。

inactive 期间设备 frp 隧道已断开,无人访问 gateway,无需立即
重启。下次 active 时 applyFullProviderFromVps 会写完整 provider
config 并重启 gateway。

Made-with: Cursor
2026-04-03 18:20:09 +08:00

320 lines
11 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}`);
// 注意:此处不重启 gateway。inactive 期间 frp 隧道已断开,无人访问 gateway
// 下次 active 时 applyFullProviderFromVps 会写完整 config 并重启。
// 若在此处 kill gateway会破坏 vfdservice 管道,导致 PIN 码无法在数码管上闪烁。
}
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,
};