feat: configure Pi models for CutOS agent
This commit is contained in:
@@ -106,12 +106,13 @@ node bin/clawd.js
|
|||||||
| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 |
|
| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 |
|
||||||
| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 |
|
| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 |
|
||||||
| `AGENT_TYPE` | `openclaw` | Agent 类型:`openclaw` 或 `cutos-agent`;空值兼容为 `openclaw` |
|
| `AGENT_TYPE` | `openclaw` | Agent 类型:`openclaw` 或 `cutos-agent`;空值兼容为 `openclaw` |
|
||||||
|
| `PI_MODELS_CONFIG` | `~/.pi/models.json` | CutOS Agent 的 Pi 模型配置路径 |
|
||||||
|
|
||||||
systemd 安装后环境变量文件位于 `/etc/clawd/env`。
|
systemd 安装后环境变量文件位于 `/etc/clawd/env`。
|
||||||
|
|
||||||
切换 Agent 类型前,必须先在云端解绑设备,再修改 `/etc/clawd/env` 中的
|
切换 Agent 类型前,必须先在云端解绑设备,再修改 `/etc/clawd/env` 中的
|
||||||
`AGENT_TYPE`,重启 `clawd` 后重新绑定。`cutos-agent` 模式当前仅接入通用设备管理,
|
`AGENT_TYPE`,重启 `clawd` 后重新绑定。`cutos-agent` 模式会维护 Pi 的 `providers.cutos`
|
||||||
不会读写 OpenClaw 配置或调用 OpenClaw 微信能力。
|
模型配置,但不会读写 OpenClaw 配置、重启 OpenClaw Gateway 或调用 OpenClaw 微信能力。
|
||||||
|
|
||||||
## 服务管理
|
## 服务管理
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -17,6 +17,7 @@ const { ProvisionManager } = require('./provisioning');
|
|||||||
const { BtMonitor } = require('./bt-monitor');
|
const { BtMonitor } = require('./bt-monitor');
|
||||||
const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network');
|
const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network');
|
||||||
const { applyFullProviderFromVps, removeProviderByName, refreshModelsIfChanged, isFullProvider } = require('./openclaw-provider');
|
const { applyFullProviderFromVps, removeProviderByName, refreshModelsIfChanged, isFullProvider } = require('./openclaw-provider');
|
||||||
|
const piProvider = require('./pi-provider');
|
||||||
const sysCall = require('./sys-call');
|
const sysCall = require('./sys-call');
|
||||||
const led = require('./led');
|
const led = require('./led');
|
||||||
const { OPENCLAW, resolveAgentType } = require('./agent-type');
|
const { OPENCLAW, resolveAgentType } = require('./agent-type');
|
||||||
@@ -481,8 +482,9 @@ class ClawClient {
|
|||||||
|
|
||||||
_applyStatus(msg) {
|
_applyStatus(msg) {
|
||||||
if (msg.status === 'inactive') {
|
if (msg.status === 'inactive') {
|
||||||
if (this._isOpenClaw() && msg.provider && msg.provider.name) {
|
if (msg.provider && msg.provider.name) {
|
||||||
removeProviderByName(String(msg.provider.name));
|
if (this._isOpenClaw()) removeProviderByName(String(msg.provider.name));
|
||||||
|
else piProvider.removeProviderByName(String(msg.provider.name));
|
||||||
}
|
}
|
||||||
this._cfg.activated = false;
|
this._cfg.activated = false;
|
||||||
config.save(this._cfg);
|
config.save(this._cfg);
|
||||||
@@ -507,7 +509,11 @@ class ClawClient {
|
|||||||
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
|
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
|
||||||
const clawIdStr = String(this._cfg.claw_id);
|
const clawIdStr = String(this._cfg.claw_id);
|
||||||
if (!this._isOpenClaw()) {
|
if (!this._isOpenClaw()) {
|
||||||
log.info('clawd', `agent=${this._agentType},跳过 OpenClaw provider/origin 配置`);
|
if (piProvider.isFullProvider(msg.provider)) {
|
||||||
|
piProvider.applyFullProviderFromVps(msg.provider);
|
||||||
|
} else {
|
||||||
|
piProvider.refreshModelsIfChanged();
|
||||||
|
}
|
||||||
} else if (isFullProvider(msg.provider)) {
|
} else if (isFullProvider(msg.provider)) {
|
||||||
applyFullProviderFromVps(msg.provider, () => {
|
applyFullProviderFromVps(msg.provider, () => {
|
||||||
this._updateOpenClawOrigin(clawIdStr);
|
this._updateOpenClawOrigin(clawIdStr);
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const log = require('./logger');
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = 'https://api.cutos.ai/v1';
|
||||||
|
const FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
let busy = false;
|
||||||
|
let operationId = 0;
|
||||||
|
|
||||||
|
function resolvePiModelsConfigFile(env = process.env) {
|
||||||
|
const configured = String(env.PI_MODELS_CONFIG || '').trim();
|
||||||
|
return configured ? path.resolve(configured) : path.join(os.homedir(), '.pi', 'models.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl) {
|
||||||
|
let value = String(baseUrl || DEFAULT_BASE_URL).trim().replace(/\/+$/, '');
|
||||||
|
if (!/\/v1$/i.test(value)) value = `${value}/v1`;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchModels(baseUrl, apiKey, callback) {
|
||||||
|
const url = `${normalizeBaseUrl(baseUrl)}/models`;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch (_) {
|
||||||
|
callback(new Error(`invalid base-url: ${url}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const transport = parsed.protocol === 'https:' ? https : http;
|
||||||
|
const req = transport.request({
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: `${parsed.pathname}${parsed.search}`,
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${apiKey || ''}`, 'Content-Type': 'application/json' },
|
||||||
|
}, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => { body += chunk; });
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(body);
|
||||||
|
if (!Array.isArray(payload.data)) {
|
||||||
|
callback(new Error(payload.error?.message || `bad models response: ${body.slice(0, 200)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, payload.data
|
||||||
|
.filter((model) => model && typeof model.id === 'string' && model.id)
|
||||||
|
.map((model) => ({ id: model.id, reasoning: true })));
|
||||||
|
} catch (error) {
|
||||||
|
callback(new Error(`parse models: ${error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', callback);
|
||||||
|
req.setTimeout(FETCH_TIMEOUT_MS, () => req.destroy(new Error('models request timeout')));
|
||||||
|
req.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfig(configFile) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||||
|
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('root must be a JSON object');
|
||||||
|
return config;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return { providers: {} };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfigAtomic(configFile, config) {
|
||||||
|
const dir = path.dirname(configFile);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const tempFile = path.join(dir, `.${path.basename(configFile)}.${process.pid}.${Date.now()}.tmp`);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tempFile, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||||
|
fs.renameSync(tempFile, configFile);
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tempFile); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameModels(left, right) {
|
||||||
|
const ids = (models) => (models || []).map((model) => model.id).sort();
|
||||||
|
return JSON.stringify(ids(left)) === JSON.stringify(ids(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullProvider(provider) {
|
||||||
|
return !!provider && typeof provider.name === 'string' && provider.name.length > 0
|
||||||
|
&& (Object.prototype.hasOwnProperty.call(provider, 'base-url')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(provider, 'baseUrl'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFullProviderFromVps(provider, onDone, options = {}) {
|
||||||
|
if (busy) {
|
||||||
|
log.warn('pi-provider', 'provider operation already in progress; skipping apply');
|
||||||
|
if (typeof onDone === 'function') onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isFullProvider(provider)) {
|
||||||
|
log.warn('pi-provider', 'apply: invalid provider payload');
|
||||||
|
if (typeof onDone === 'function') onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const configFile = options.configFile || resolvePiModelsConfigFile();
|
||||||
|
const fetch = options.fetchModels || fetchModels;
|
||||||
|
const name = provider.name;
|
||||||
|
const baseUrl = normalizeBaseUrl(provider['base-url'] || provider.baseUrl);
|
||||||
|
const apiKey = provider['api-key'] != null ? String(provider['api-key']) : '';
|
||||||
|
const currentOperation = ++operationId;
|
||||||
|
busy = true;
|
||||||
|
fetch(baseUrl, apiKey, (fetchError, fetchedModels) => {
|
||||||
|
try {
|
||||||
|
if (currentOperation !== operationId) return;
|
||||||
|
const config = readConfig(configFile);
|
||||||
|
if (!config.providers || typeof config.providers !== 'object' || Array.isArray(config.providers)) config.providers = {};
|
||||||
|
const existing = config.providers[name] || {};
|
||||||
|
const models = fetchError ? (Array.isArray(existing.models) ? existing.models : []) : fetchedModels;
|
||||||
|
if (fetchError) log.warn('pi-provider', `model refresh failed; retaining existing list: ${fetchError.message}`);
|
||||||
|
const next = { baseUrl, api: 'openai-responses', apiKey, models };
|
||||||
|
if (JSON.stringify(existing) !== JSON.stringify(next)) {
|
||||||
|
config.providers[name] = next;
|
||||||
|
writeConfigAtomic(configFile, config);
|
||||||
|
log.info('pi-provider', `provider updated: ${name} (${models.length} models)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('pi-provider', `apply failed: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
if (currentOperation === operationId) busy = false;
|
||||||
|
if (typeof onDone === 'function') onDone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProviderByName(providerName, options = {}) {
|
||||||
|
const name = String(providerName || '');
|
||||||
|
if (!name) return;
|
||||||
|
operationId += 1;
|
||||||
|
busy = false;
|
||||||
|
const configFile = options.configFile || resolvePiModelsConfigFile();
|
||||||
|
let config;
|
||||||
|
try { config = readConfig(configFile); } catch (error) {
|
||||||
|
log.warn('pi-provider', `remove failed: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!config.providers || !Object.prototype.hasOwnProperty.call(config.providers, name)) return;
|
||||||
|
delete config.providers[name];
|
||||||
|
writeConfigAtomic(configFile, config);
|
||||||
|
log.info('pi-provider', `provider removed: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshModelsIfChanged(onDone, options = {}) {
|
||||||
|
if (busy) {
|
||||||
|
if (typeof onDone === 'function') onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const configFile = options.configFile || resolvePiModelsConfigFile();
|
||||||
|
let config;
|
||||||
|
try { config = readConfig(configFile); } catch (error) {
|
||||||
|
log.warn('pi-provider', `refresh failed: ${error.message}`);
|
||||||
|
if (typeof onDone === 'function') onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = Object.keys(config.providers || {})[0];
|
||||||
|
if (!name) {
|
||||||
|
if (typeof onDone === 'function') onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = config.providers[name];
|
||||||
|
const fetch = options.fetchModels || fetchModels;
|
||||||
|
const currentOperation = ++operationId;
|
||||||
|
busy = true;
|
||||||
|
fetch(current.baseUrl, current.apiKey, (error, models) => {
|
||||||
|
try {
|
||||||
|
if (currentOperation !== operationId) return;
|
||||||
|
if (error) log.warn('pi-provider', `model refresh failed: ${error.message}`);
|
||||||
|
else if (!sameModels(current.models, models)) {
|
||||||
|
current.models = models;
|
||||||
|
writeConfigAtomic(configFile, config);
|
||||||
|
log.info('pi-provider', `model list updated: ${name} (${models.length} models)`);
|
||||||
|
}
|
||||||
|
} catch (writeError) {
|
||||||
|
log.error('pi-provider', `refresh failed: ${writeError.message}`);
|
||||||
|
} finally {
|
||||||
|
if (currentOperation === operationId) busy = false;
|
||||||
|
if (typeof onDone === 'function') onDone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
applyFullProviderFromVps,
|
||||||
|
fetchModels,
|
||||||
|
isFullProvider,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
refreshModelsIfChanged,
|
||||||
|
removeProviderByName,
|
||||||
|
resolvePiModelsConfigFile,
|
||||||
|
};
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawd",
|
"name": "clawd",
|
||||||
"version": "1.5.8",
|
"version": "1.5.9",
|
||||||
"description": "Claw Box daemon - connects local Linux box to claw.cutos.ai via WebSocket",
|
"description": "Claw Box daemon - connects local Linux box to claw.cutos.ai via WebSocket",
|
||||||
"main": "lib/client.js",
|
"main": "lib/client.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const piProvider = require('../lib/pi-provider');
|
||||||
|
|
||||||
|
function tempConfig() {
|
||||||
|
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'clawd-pi-')), 'models.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(provider, options) {
|
||||||
|
return new Promise((resolve) => piProvider.applyFullProviderFromVps(provider, resolve, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(options) {
|
||||||
|
return new Promise((resolve) => piProvider.refreshModelsIfChanged(resolve, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(file) {
|
||||||
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: 'cutos',
|
||||||
|
'base-url': 'https://api.example.test',
|
||||||
|
'api-key': 'test-key',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('creates a Pi models file and maps models to reasoning entries', async () => {
|
||||||
|
const configFile = tempConfig();
|
||||||
|
await apply(payload, {
|
||||||
|
configFile,
|
||||||
|
fetchModels: (_url, _key, done) => done(null, [
|
||||||
|
{ id: 'gpt-test', reasoning: true },
|
||||||
|
{ id: 'glm-test', reasoning: true },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
assert.deepEqual(read(configFile), {
|
||||||
|
providers: {
|
||||||
|
cutos: {
|
||||||
|
baseUrl: 'https://api.example.test/v1',
|
||||||
|
api: 'openai-responses',
|
||||||
|
apiKey: 'test-key',
|
||||||
|
models: [
|
||||||
|
{ id: 'gpt-test', reasoning: true },
|
||||||
|
{ id: 'glm-test', reasoning: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves unrelated top-level fields and providers when upserting', async () => {
|
||||||
|
const configFile = tempConfig();
|
||||||
|
fs.writeFileSync(configFile, JSON.stringify({
|
||||||
|
theme: 'dark',
|
||||||
|
providers: { custom: { baseUrl: 'http://localhost:1234' } },
|
||||||
|
}));
|
||||||
|
await apply(payload, {
|
||||||
|
configFile,
|
||||||
|
fetchModels: (_url, _key, done) => done(null, [{ id: 'gpt-test', reasoning: true }]),
|
||||||
|
});
|
||||||
|
const config = read(configFile);
|
||||||
|
assert.equal(config.theme, 'dark');
|
||||||
|
assert.deepEqual(config.providers.custom, { baseUrl: 'http://localhost:1234' });
|
||||||
|
assert.equal(config.providers.cutos.api, 'openai-responses');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retains existing models when model refresh fails while updating credentials', async () => {
|
||||||
|
const configFile = tempConfig();
|
||||||
|
fs.writeFileSync(configFile, JSON.stringify({ providers: { cutos: {
|
||||||
|
baseUrl: 'https://old.example/v1',
|
||||||
|
api: 'openai-responses',
|
||||||
|
apiKey: 'old-key',
|
||||||
|
models: [{ id: 'existing-model', reasoning: true }],
|
||||||
|
} } }));
|
||||||
|
await apply(payload, {
|
||||||
|
configFile,
|
||||||
|
fetchModels: (_url, _key, done) => done(new Error('offline')),
|
||||||
|
});
|
||||||
|
const provider = read(configFile).providers.cutos;
|
||||||
|
assert.equal(provider.apiKey, 'test-key');
|
||||||
|
assert.deepEqual(provider.models, [{ id: 'existing-model', reasoning: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes only the named provider on unbind', () => {
|
||||||
|
const configFile = tempConfig();
|
||||||
|
fs.writeFileSync(configFile, JSON.stringify({ providers: { cutos: {}, custom: { keep: true } } }));
|
||||||
|
piProvider.removeProviderByName('cutos', { configFile });
|
||||||
|
assert.deepEqual(read(configFile), { providers: { custom: { keep: true } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unbind cancels an in-flight provider write', async () => {
|
||||||
|
const configFile = tempConfig();
|
||||||
|
fs.writeFileSync(configFile, JSON.stringify({ providers: { cutos: {}, custom: { keep: true } } }));
|
||||||
|
let finishFetch;
|
||||||
|
const pending = apply(payload, {
|
||||||
|
configFile,
|
||||||
|
fetchModels: (_url, _key, done) => { finishFetch = done; },
|
||||||
|
});
|
||||||
|
piProvider.removeProviderByName('cutos', { configFile });
|
||||||
|
finishFetch(null, [{ id: 'late-model', reasoning: true }]);
|
||||||
|
await pending;
|
||||||
|
assert.deepEqual(read(configFile), { providers: { custom: { keep: true } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh updates models only when their ids change', async () => {
|
||||||
|
const configFile = tempConfig();
|
||||||
|
fs.writeFileSync(configFile, JSON.stringify({ providers: { cutos: {
|
||||||
|
baseUrl: 'https://api.example.test/v1', api: 'openai-responses', apiKey: 'key',
|
||||||
|
models: [{ id: 'old', reasoning: true }],
|
||||||
|
} } }));
|
||||||
|
await refresh({
|
||||||
|
configFile,
|
||||||
|
fetchModels: (_url, _key, done) => done(null, [{ id: 'new', reasoning: true }]),
|
||||||
|
});
|
||||||
|
assert.deepEqual(read(configFile).providers.cutos.models, [{ id: 'new', reasoning: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchModels reads OpenAI model responses without exposing the key', async () => {
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
assert.equal(req.url, '/v1/models');
|
||||||
|
assert.equal(req.headers.authorization, 'Bearer private-test-key');
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ data: [{ id: 'model-a' }, { id: 'model-b' }] }));
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const models = await new Promise((resolve, reject) => {
|
||||||
|
piProvider.fetchModels(`http://127.0.0.1:${address.port}`, 'private-test-key', (error, result) => {
|
||||||
|
if (error) reject(error); else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert.deepEqual(models, [
|
||||||
|
{ id: 'model-a', reasoning: true },
|
||||||
|
{ id: 'model-b', reasoning: true },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user