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:
@@ -12,6 +12,7 @@ const { getDashboardInfo, resolveOpenclawConfigFile, startTtyd, FrpcManager } =
|
|||||||
const { ProvisionManager } = require('./provisioning');
|
const { ProvisionManager } = require('./provisioning');
|
||||||
const { BtMonitor } = require('./bt-monitor');
|
const { BtMonitor } = require('./bt-monitor');
|
||||||
const { hasInternet, hasWiredInternetProbe, getLocalIps } = require('./network');
|
const { hasInternet, hasWiredInternetProbe, getLocalIps } = require('./network');
|
||||||
|
const { applyFullProviderFromVps, removeProviderByName, isFullProvider } = require('./openclaw-provider');
|
||||||
const led = require('./led');
|
const led = require('./led');
|
||||||
|
|
||||||
const MAX_BACKOFF_MS = 60_000;
|
const MAX_BACKOFF_MS = 60_000;
|
||||||
@@ -408,6 +409,9 @@ class ClawClient {
|
|||||||
|
|
||||||
_applyStatus(msg) {
|
_applyStatus(msg) {
|
||||||
if (msg.status === 'inactive') {
|
if (msg.status === 'inactive') {
|
||||||
|
if (msg.provider && msg.provider.name) {
|
||||||
|
removeProviderByName(String(msg.provider.name));
|
||||||
|
}
|
||||||
this._cfg.activated = false;
|
this._cfg.activated = false;
|
||||||
config.save(this._cfg);
|
config.save(this._cfg);
|
||||||
led.status.setSetup();
|
led.status.setSetup();
|
||||||
@@ -429,7 +433,14 @@ class ClawClient {
|
|||||||
led.status.setApps();
|
led.status.setApps();
|
||||||
led.display.showTime();
|
led.display.showTime();
|
||||||
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
|
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
|
||||||
this._updateOpenClawOrigin(String(this._cfg.claw_id));
|
const clawIdStr = String(this._cfg.claw_id);
|
||||||
|
if (isFullProvider(msg.provider)) {
|
||||||
|
applyFullProviderFromVps(msg.provider, () => {
|
||||||
|
this._updateOpenClawOrigin(clawIdStr);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._updateOpenClawOrigin(clawIdStr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
297
lib/openclaw-provider.js
Normal file
297
lib/openclaw-provider.js
Normal 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', `跳过 remove(provider 应用进行中): ${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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user