Files
clawd/lib/openclaw-provider.js
stswangzhiping f6afcd5cc2 refactor: 移除 restartGateway,openclaw.json 单次写入
gateway 自动检测文件变更并重启,clawd 无需主动 kill。
同时去掉 applyFullProviderFromVps 的预清理写盘,
改为拉完模型后一次性写入,避免 gateway 读到中间状态。

Made-with: Cursor
2026-04-05 09:12:57 +08:00

255 lines
8.2 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 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 })));
} 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`];
}
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`];
}
}
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);
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}`);
}
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,
};