Ferrell Synthetic Intelligence commited on
Commit ·
4407241
1
Parent(s): 05d169b
Add managed local model runtime
Browse files- app.js +17 -1
- daemon/model-manager.mjs +59 -0
- daemon/server.mjs +25 -1
- daemon/test-model-manager.mjs +16 -0
- index.html +1 -1
- package.json +1 -1
app.js
CHANGED
|
@@ -159,6 +159,22 @@ async function testRuntime() {
|
|
| 159 |
}
|
| 160 |
}
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
function sendChat() {
|
| 163 |
const input = $('#input');
|
| 164 |
const value = input.value.trim();
|
|
@@ -237,7 +253,7 @@ async function boot() {
|
|
| 237 |
openFile('agent.ts');
|
| 238 |
document.querySelectorAll('[data-file]').forEach(button => button.onclick = () => openFile(button.dataset.file));
|
| 239 |
$('#review-button').onclick = runReview;
|
| 240 |
-
$('#connection-button').onclick =
|
| 241 |
$('#send-button').onclick = sendChat;
|
| 242 |
$('#node-button').onclick = () => { localNodeId(); renderNode(); appendLog('NODE', 'Local identity created. No network connection or private data was shared.'); };
|
| 243 |
$('#case-button').onclick = () => {
|
|
|
|
| 159 |
}
|
| 160 |
}
|
| 161 |
|
| 162 |
+
async function startRuntime() {
|
| 163 |
+
const model = state.selected;
|
| 164 |
+
if (!model) return appendLog('RUNTIME', 'Select a model pack first.', 'warning');
|
| 165 |
+
try {
|
| 166 |
+
const response = await fetch('http://127.0.0.1:4777/api/models/start', {
|
| 167 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: model.id })
|
| 168 |
+
});
|
| 169 |
+
const result = await response.json();
|
| 170 |
+
if (!response.ok) throw new Error(result.error || 'runtime start failed');
|
| 171 |
+
appendLog('RUNTIME', `${model.name} is starting at ${result.endpoint}.`);
|
| 172 |
+
setTimeout(testRuntime, 1500);
|
| 173 |
+
} catch (error) {
|
| 174 |
+
appendLog('RUNTIME', `Local daemon unavailable: ${error.message}. Start daemon/server.mjs first.`, 'warning');
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
function sendChat() {
|
| 179 |
const input = $('#input');
|
| 180 |
const value = input.value.trim();
|
|
|
|
| 253 |
openFile('agent.ts');
|
| 254 |
document.querySelectorAll('[data-file]').forEach(button => button.onclick = () => openFile(button.dataset.file));
|
| 255 |
$('#review-button').onclick = runReview;
|
| 256 |
+
$('#connection-button').onclick = startRuntime;
|
| 257 |
$('#send-button').onclick = sendChat;
|
| 258 |
$('#node-button').onclick = () => { localNodeId(); renderNode(); appendLog('NODE', 'Local identity created. No network connection or private data was shared.'); };
|
| 259 |
$('#case-button').onclick = () => {
|
daemon/model-manager.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { promises as fs } from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import { spawn } from 'node:child_process';
|
| 4 |
+
|
| 5 |
+
export class ModelManager {
|
| 6 |
+
constructor({ manifestPath, modelDir, binaryPath, spawnProcess = spawn } = {}) {
|
| 7 |
+
this.manifestPath = manifestPath;
|
| 8 |
+
this.modelDir = path.resolve(modelDir);
|
| 9 |
+
this.binaryPath = binaryPath;
|
| 10 |
+
this.spawnProcess = spawnProcess;
|
| 11 |
+
this.models = new Map();
|
| 12 |
+
this.processes = new Map();
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
async load() {
|
| 16 |
+
const manifest = JSON.parse(await fs.readFile(this.manifestPath, 'utf8'));
|
| 17 |
+
this.models = new Map(manifest.models.map(model => [model.id, model]));
|
| 18 |
+
return manifest;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
status() {
|
| 22 |
+
return [...this.models.values()].map(model => ({
|
| 23 |
+
id: model.id,
|
| 24 |
+
name: model.name,
|
| 25 |
+
status: this.processes.has(model.id) ? 'running' : model.status,
|
| 26 |
+
endpoint: model.endpoint
|
| 27 |
+
}));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
async start(id) {
|
| 31 |
+
const model = this.models.get(id);
|
| 32 |
+
if (!model) throw new Error('model is not allowlisted');
|
| 33 |
+
if (this.processes.has(id)) return { id, status: 'running', endpoint: model.endpoint };
|
| 34 |
+
if (!this.binaryPath) throw new Error('llama-server binary is not configured');
|
| 35 |
+
await fs.access(this.binaryPath).catch(() => { throw new Error('llama-server binary is unavailable'); });
|
| 36 |
+
const file = path.resolve(this.modelDir, path.basename(model.artifact_uri.replace('local://', '')));
|
| 37 |
+
if (!file.startsWith(`${this.modelDir}${path.sep}`)) throw new Error('model path escaped model directory');
|
| 38 |
+
await fs.access(file);
|
| 39 |
+
await this.stopAll();
|
| 40 |
+
const endpoint = new URL(model.endpoint);
|
| 41 |
+
const args = ['-m', file, '--host', '127.0.0.1', '--port', String(endpoint.port || 8080), '--ctx-size', String(model.context_tokens || 2048), '--threads', '4', '--parallel', '1', '--log-disable'];
|
| 42 |
+
const child = this.spawnProcess(this.binaryPath, args, { stdio: 'ignore' });
|
| 43 |
+
this.processes.set(id, child);
|
| 44 |
+
child.once('exit', () => this.processes.delete(id));
|
| 45 |
+
return { id, status: 'starting', endpoint: model.endpoint };
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async stop(id) {
|
| 49 |
+
const child = this.processes.get(id);
|
| 50 |
+
if (!child) return { id, status: 'stopped' };
|
| 51 |
+
child.kill('SIGTERM');
|
| 52 |
+
this.processes.delete(id);
|
| 53 |
+
return { id, status: 'stopped' };
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async stopAll() {
|
| 57 |
+
for (const id of [...this.processes.keys()]) await this.stop(id);
|
| 58 |
+
}
|
| 59 |
+
}
|
daemon/server.mjs
CHANGED
|
@@ -2,10 +2,15 @@ import http from 'node:http';
|
|
| 2 |
import { execFile } from 'node:child_process';
|
| 3 |
import { promises as fs } from 'node:fs';
|
| 4 |
import path from 'node:path';
|
|
|
|
| 5 |
|
| 6 |
const HOST = '127.0.0.1';
|
| 7 |
const PORT = Number(process.env.AIDE_DAEMON_PORT || 4777);
|
| 8 |
const WORKSPACE = path.resolve(process.env.AIDE_WORKSPACE || process.cwd());
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
function json(response, status, body) {
|
| 11 |
const payload = JSON.stringify(body);
|
|
@@ -13,12 +18,21 @@ function json(response, status, body) {
|
|
| 13 |
'Content-Type': 'application/json; charset=utf-8',
|
| 14 |
'Content-Length': Buffer.byteLength(payload),
|
| 15 |
'Access-Control-Allow-Origin': 'http://127.0.0.1:4173',
|
| 16 |
-
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
| 17 |
'Access-Control-Allow-Headers': 'Content-Type'
|
| 18 |
});
|
| 19 |
response.end(payload);
|
| 20 |
}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
function runGit(args) {
|
| 23 |
return new Promise((resolve, reject) => {
|
| 24 |
execFile('git', args, { cwd: WORKSPACE, timeout: 5000, maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
|
|
@@ -52,6 +66,16 @@ const server = http.createServer(async (request, response) => {
|
|
| 52 |
return json(response, 200, { workspace: WORKSPACE, status: '', unavailable: error.message });
|
| 53 |
}
|
| 54 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
return json(response, 404, { error: 'not found' });
|
| 56 |
} catch (error) {
|
| 57 |
return json(response, 500, { error: 'local daemon error' });
|
|
|
|
| 2 |
import { execFile } from 'node:child_process';
|
| 3 |
import { promises as fs } from 'node:fs';
|
| 4 |
import path from 'node:path';
|
| 5 |
+
import { ModelManager } from './model-manager.mjs';
|
| 6 |
|
| 7 |
const HOST = '127.0.0.1';
|
| 8 |
const PORT = Number(process.env.AIDE_DAEMON_PORT || 4777);
|
| 9 |
const WORKSPACE = path.resolve(process.env.AIDE_WORKSPACE || process.cwd());
|
| 10 |
+
const MODEL_DIR = path.resolve(process.env.AIDE_MODEL_DIR || path.join(WORKSPACE, 'models'));
|
| 11 |
+
const MANIFEST = path.join(WORKSPACE, 'models', 'manifest.json');
|
| 12 |
+
const modelManager = new ModelManager({ manifestPath: MANIFEST, modelDir: MODEL_DIR, binaryPath: process.env.AIDE_LLAMA_SERVER || '/root/runtime/llama-b10333/llama-server' });
|
| 13 |
+
await modelManager.load().catch(() => {});
|
| 14 |
|
| 15 |
function json(response, status, body) {
|
| 16 |
const payload = JSON.stringify(body);
|
|
|
|
| 18 |
'Content-Type': 'application/json; charset=utf-8',
|
| 19 |
'Content-Length': Buffer.byteLength(payload),
|
| 20 |
'Access-Control-Allow-Origin': 'http://127.0.0.1:4173',
|
| 21 |
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
| 22 |
'Access-Control-Allow-Headers': 'Content-Type'
|
| 23 |
});
|
| 24 |
response.end(payload);
|
| 25 |
}
|
| 26 |
|
| 27 |
+
async function body(request) {
|
| 28 |
+
let data = '';
|
| 29 |
+
for await (const chunk of request) {
|
| 30 |
+
data += chunk;
|
| 31 |
+
if (Buffer.byteLength(data) > 16 * 1024) throw new Error('request too large');
|
| 32 |
+
}
|
| 33 |
+
return data ? JSON.parse(data) : {};
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
function runGit(args) {
|
| 37 |
return new Promise((resolve, reject) => {
|
| 38 |
execFile('git', args, { cwd: WORKSPACE, timeout: 5000, maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
|
|
|
|
| 66 |
return json(response, 200, { workspace: WORKSPACE, status: '', unavailable: error.message });
|
| 67 |
}
|
| 68 |
}
|
| 69 |
+
if (request.method === 'GET' && request.url === '/api/models/status') {
|
| 70 |
+
return json(response, 200, { models: modelManager.status() });
|
| 71 |
+
}
|
| 72 |
+
if (request.method === 'POST' && request.url === '/api/models/start') {
|
| 73 |
+
return json(response, 200, await modelManager.start((await body(request)).id));
|
| 74 |
+
}
|
| 75 |
+
if (request.method === 'POST' && request.url === '/api/models/stop') {
|
| 76 |
+
const id = (await body(request)).id;
|
| 77 |
+
return json(response, 200, id ? await modelManager.stop(id) : (await modelManager.stopAll(), { status: 'stopped' }));
|
| 78 |
+
}
|
| 79 |
return json(response, 404, { error: 'not found' });
|
| 80 |
} catch (error) {
|
| 81 |
return json(response, 500, { error: 'local daemon error' });
|
daemon/test-model-manager.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
|
| 3 |
+
import { tmpdir } from 'node:os';
|
| 4 |
+
import path from 'node:path';
|
| 5 |
+
import { ModelManager } from './model-manager.mjs';
|
| 6 |
+
|
| 7 |
+
const root = await mkdtemp(path.join(tmpdir(), 'aide-manager-'));
|
| 8 |
+
await mkdir(path.join(root, 'models'));
|
| 9 |
+
await writeFile(path.join(root, 'models', 'safe.gguf'), 'test');
|
| 10 |
+
await writeFile(path.join(root, 'models', 'manifest.json'), JSON.stringify({ models: [{ id: 'safe', name: 'Safe', status: 'pending', endpoint: 'http://127.0.0.1:9001/v1', artifact_uri: 'local://safe.gguf', context_tokens: 2048 }] }));
|
| 11 |
+
const manager = new ModelManager({ manifestPath: path.join(root, 'models', 'manifest.json'), modelDir: path.join(root, 'models'), binaryPath: '/missing' });
|
| 12 |
+
await manager.load();
|
| 13 |
+
assert.equal(manager.status()[0].status, 'pending');
|
| 14 |
+
await assert.rejects(manager.start('unknown'), /not allowlisted/);
|
| 15 |
+
await assert.rejects(manager.start('safe'), /llama-server binary/);
|
| 16 |
+
console.log('model manager test passed');
|
index.html
CHANGED
|
@@ -79,7 +79,7 @@
|
|
| 79 |
<div class="lane-grid" id="lane-grid"></div>
|
| 80 |
<div class="runtime-row"><span class="dot green"></span><span>Local adapter</span><b id="runtime-name">OpenAI-compatible HTTP</b></div>
|
| 81 |
<div id="collab-log" class="collab-log"><div class="empty-state">Cross-chat is idle.<br>Start a bounded review to create a research, build, and verify run.</div></div>
|
| 82 |
-
<div class="agent-actions"><button id="review-button" class="primary">START BOUNDED REVIEW</button><button id="connection-button" class="secondary">
|
| 83 |
<div class="chat-row"><input id="input" placeholder="Ask the local lanes..." aria-label="Ask the local lanes"><button id="send-button">SEND</button></div>
|
| 84 |
<div id="chat" class="chat" aria-live="polite"></div>
|
| 85 |
</aside>
|
|
|
|
| 79 |
<div class="lane-grid" id="lane-grid"></div>
|
| 80 |
<div class="runtime-row"><span class="dot green"></span><span>Local adapter</span><b id="runtime-name">OpenAI-compatible HTTP</b></div>
|
| 81 |
<div id="collab-log" class="collab-log"><div class="empty-state">Cross-chat is idle.<br>Start a bounded review to create a research, build, and verify run.</div></div>
|
| 82 |
+
<div class="agent-actions"><button id="review-button" class="primary">START BOUNDED REVIEW</button><button id="connection-button" class="secondary">START / TEST RUNTIME</button></div>
|
| 83 |
<div class="chat-row"><input id="input" placeholder="Ask the local lanes..." aria-label="Ask the local lanes"><button id="send-button">SEND</button></div>
|
| 84 |
<div id="chat" class="chat" aria-live="polite"></div>
|
| 85 |
</aside>
|
package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
"description": "Local-first sovereign development workbench",
|
| 6 |
"type": "module",
|
| 7 |
"scripts": {
|
| 8 |
-
"test": "node tests/smoke.mjs && node harness/test-orchestrator.mjs",
|
| 9 |
"check": "node --check app.js && node --check daemon/server.mjs && node --check harness/orchestrator.mjs && node --check harness/checks.mjs",
|
| 10 |
"veritas": "node harness/run-veritas.mjs"
|
| 11 |
},
|
|
|
|
| 5 |
"description": "Local-first sovereign development workbench",
|
| 6 |
"type": "module",
|
| 7 |
"scripts": {
|
| 8 |
+
"test": "node tests/smoke.mjs && node harness/test-orchestrator.mjs && node daemon/test-model-manager.mjs",
|
| 9 |
"check": "node --check app.js && node --check daemon/server.mjs && node --check harness/orchestrator.mjs && node --check harness/checks.mjs",
|
| 10 |
"veritas": "node harness/run-veritas.mjs"
|
| 11 |
},
|