diff --git a/README.md b/README.md index c0489a6..3f6cde4 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,13 @@ node bin/clawd.js | `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 | | `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 | | `AGENT_TYPE` | `openclaw` | Agent 类型:`openclaw` 或 `cutos-agent`;空值兼容为 `openclaw` | +| `PI_MODELS_CONFIG` | `~/.pi/models.json` | CutOS Agent 的 Pi 模型配置路径 | systemd 安装后环境变量文件位于 `/etc/clawd/env`。 切换 Agent 类型前,必须先在云端解绑设备,再修改 `/etc/clawd/env` 中的 -`AGENT_TYPE`,重启 `clawd` 后重新绑定。`cutos-agent` 模式当前仅接入通用设备管理, -不会读写 OpenClaw 配置或调用 OpenClaw 微信能力。 +`AGENT_TYPE`,重启 `clawd` 后重新绑定。`cutos-agent` 模式会维护 Pi 的 `providers.cutos` +模型配置,但不会读写 OpenClaw 配置、重启 OpenClaw Gateway 或调用 OpenClaw 微信能力。 ## 服务管理 diff --git a/lib/client.js b/lib/client.js index fd97c2f..fa310b0 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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); diff --git a/lib/pi-provider.js b/lib/pi-provider.js new file mode 100644 index 0000000..e23cffa --- /dev/null +++ b/lib/pi-provider.js @@ -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, +}; diff --git a/package.json b/package.json index d0493f0..8c3d634 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawd", - "version": "1.5.8", + "version": "1.5.9", "description": "Claw Box daemon - connects local Linux box to claw.cutos.ai via WebSocket", "main": "lib/client.js", "bin": { diff --git a/test/pi-provider.test.js b/test/pi-provider.test.js new file mode 100644 index 0000000..fe9b240 --- /dev/null +++ b/test/pi-provider.test.js @@ -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)); + } +});