feat: configure Pi models for CutOS agent

This commit is contained in:
yankun
2026-08-02 15:41:38 +08:00
parent 782a8b6d88
commit 9489b334c5
5 changed files with 363 additions and 6 deletions
+9 -3
View File
@@ -17,6 +17,7 @@ const { ProvisionManager } = require('./provisioning');
const { BtMonitor } = require('./bt-monitor');
const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network');
const { applyFullProviderFromVps, removeProviderByName, refreshModelsIfChanged, isFullProvider } = require('./openclaw-provider');
const piProvider = require('./pi-provider');
const sysCall = require('./sys-call');
const led = require('./led');
const { OPENCLAW, resolveAgentType } = require('./agent-type');
@@ -481,8 +482,9 @@ class ClawClient {
_applyStatus(msg) {
if (msg.status === 'inactive') {
if (this._isOpenClaw() && msg.provider && msg.provider.name) {
removeProviderByName(String(msg.provider.name));
if (msg.provider && msg.provider.name) {
if (this._isOpenClaw()) removeProviderByName(String(msg.provider.name));
else piProvider.removeProviderByName(String(msg.provider.name));
}
this._cfg.activated = false;
config.save(this._cfg);
@@ -507,7 +509,11 @@ class ClawClient {
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
const clawIdStr = String(this._cfg.claw_id);
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)) {
applyFullProviderFromVps(msg.provider, () => {
this._updateOpenClawOrigin(clawIdStr);
+204
View File
@@ -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,
};