'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(); if (configured) return path.resolve(configured); const candidates = []; if (process.getuid && process.getuid() === 0) { candidates.push('/home/sts/.pi/agent/models.json'); } candidates.push(path.join(os.homedir(), '.pi', 'agent', 'models.json')); candidates.push('/root/.pi/agent/models.json'); return candidates.find((candidate) => { try { return fs.existsSync(path.dirname(candidate)) || fs.existsSync(candidate); } catch (_) { return false; } }) || candidates[0]; } 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); const owner = resolveOwnerForPath(dir); fs.mkdirSync(dir, { recursive: true }); chownIfPossible(dir, owner); 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); chownIfPossible(configFile, owner); } finally { try { fs.unlinkSync(tempFile); } catch (_) {} } } function resolveOwnerForPath(targetPath) { let current = targetPath; while (current && current !== path.dirname(current)) { try { const stat = fs.statSync(current); return { uid: stat.uid, gid: stat.gid }; } catch (_) { current = path.dirname(current); } } return null; } function chownIfPossible(targetPath, owner) { if (!owner || !(process.getuid && process.getuid() === 0)) return; try { fs.chownSync(targetPath, owner.uid, owner.gid); } catch (error) { log.warn('pi-provider', `chown failed for ${targetPath}: ${error.message}`); } } 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, };