feat: apply VPS provider to OpenClaw on status_update (fetch models, merge config)

- Add openclaw-provider: fetch /v1/models, merge provider/auth, remove by name
- Wire client _applyStatus: inactive removes provider; active applies full provider then origin
- Single-flight _busy to ignore concurrent apply/remove; isFullProvider uses base-url key

Made-with: Cursor
This commit is contained in:
stswangzhiping
2026-03-31 14:32:50 +08:00
parent 5a56e0015c
commit 4cfdaa46e5
2 changed files with 309 additions and 1 deletions

297
lib/openclaw-provider.js Normal file
View File

@@ -0,0 +1,297 @@
'use strict';
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
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 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}`);
}
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}`);
}
}
} 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,
};