Skip to content

Commit 05344e8

Browse files
authored
Merge pull request #129 from SharpAI/feat/streaming-benchmark-env-vars
feat: streaming SSE for benchmark LLM calls with direct endpoint support
2 parents 75beb9a + a651039 commit 05344e8

1 file changed

Lines changed: 154 additions & 39 deletions

File tree

skills/analysis/home-security-benchmark/scripts/run-benchmark.cjs

Lines changed: 154 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,23 @@ try { skillParams = JSON.parse(process.env.AEGIS_SKILL_PARAMS || '{}'); } catch
8080

8181
// Aegis provides config via env vars; CLI args are fallback for standalone
8282
const GATEWAY_URL = process.env.AEGIS_GATEWAY_URL || getArg('gateway', 'http://localhost:5407');
83+
const LLM_URL = process.env.AEGIS_LLM_URL || getArg('llm', ''); // Direct llama-server LLM port
8384
const VLM_URL = process.env.AEGIS_VLM_URL || getArg('vlm', '');
8485
const RESULTS_DIR = getArg('out', path.join(os.homedir(), '.aegis-ai', 'benchmarks'));
8586
const IS_SKILL_MODE = !!process.env.AEGIS_SKILL_ID;
8687
const NO_OPEN = args.includes('--no-open') || skillParams.noOpen || false;
8788
const TEST_MODE = skillParams.mode || 'full';
88-
const TIMEOUT_MS = 30000;
89+
const IDLE_TIMEOUT_MS = 30000; // Streaming idle timeout — resets on each received token
8990
const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures');
9091

92+
// API type and model info from Aegis (or defaults for standalone)
93+
const LLM_API_TYPE = process.env.AEGIS_LLM_API_TYPE || 'openai';
94+
const LLM_MODEL = process.env.AEGIS_LLM_MODEL || '';
95+
const LLM_API_KEY = process.env.AEGIS_LLM_API_KEY || '';
96+
const LLM_BASE_URL = process.env.AEGIS_LLM_BASE_URL || '';
97+
const VLM_API_TYPE = process.env.AEGIS_VLM_API_TYPE || 'openai-compatible';
98+
const VLM_MODEL = process.env.AEGIS_VLM_MODEL || '';
99+
91100
// ─── Skill Protocol: JSON lines on stdout, human text on stderr ──────────────
92101

93102
/**
@@ -127,44 +136,134 @@ const results = {
127136
};
128137

129138
async function llmCall(messages, opts = {}) {
130-
const body = { messages, stream: false };
139+
const body = { messages, stream: true };
140+
if (opts.model || LLM_MODEL) body.model = opts.model || LLM_MODEL;
131141
if (opts.maxTokens) body.max_tokens = opts.maxTokens;
132142
if (opts.temperature !== undefined) body.temperature = opts.temperature;
133143
if (opts.tools) body.tools = opts.tools;
134144

135-
// Strip trailing /v1 from VLM_URL to avoid double-path (e.g. host:5405/v1/v1/...)
136-
const vlmBase = VLM_URL ? VLM_URL.replace(/\/v1\/?$/, '') : '';
137-
const url = opts.vlm ? `${vlmBase}/v1/chat/completions` : `${GATEWAY_URL}/v1/chat/completions`;
138-
const response = await fetch(url, {
139-
method: 'POST',
140-
headers: { 'Content-Type': 'application/json' },
141-
body: JSON.stringify(body),
142-
signal: AbortSignal.timeout(opts.timeout || TIMEOUT_MS),
143-
});
144-
145-
if (!response.ok) {
146-
const errBody = await response.text().catch(() => '');
147-
throw new Error(`HTTP ${response.status}: ${errBody.slice(0, 200)}`);
145+
// Resolve LLM endpoint — priority:
146+
// 1. Cloud provider base URL (e.g. https://api.openai.com/v1) when set via UI
147+
// 2. Direct llama-server URL (port 5411) for builtin local models
148+
// 3. Gateway (port 5407) as final fallback
149+
const strip = (u) => u.replace(/\/v1\/?$/, '');
150+
let url;
151+
if (opts.vlm) {
152+
const vlmBase = VLM_URL ? strip(VLM_URL) : '';
153+
url = `${vlmBase}/v1/chat/completions`;
154+
} else if (LLM_BASE_URL) {
155+
url = `${strip(LLM_BASE_URL)}/chat/completions`;
156+
} else if (LLM_URL) {
157+
url = `${strip(LLM_URL)}/v1/chat/completions`;
158+
} else {
159+
url = `${GATEWAY_URL}/v1/chat/completions`;
148160
}
149161

150-
const data = await response.json();
151-
const content = data.choices?.[0]?.message?.content || '';
152-
const toolCalls = data.choices?.[0]?.message?.tool_calls || null;
153-
const usage = data.usage || {};
162+
// Build headers — include API key if available (for direct cloud provider access)
163+
const headers = { 'Content-Type': 'application/json' };
164+
if (LLM_API_KEY && !opts.vlm) headers['Authorization'] = `Bearer ${LLM_API_KEY}`;
154165

155-
// Track token totals
156-
results.tokenTotals.prompt += usage.prompt_tokens || 0;
157-
results.tokenTotals.completion += usage.completion_tokens || 0;
158-
results.tokenTotals.total += usage.total_tokens || 0;
166+
// Use an AbortController with idle timeout that resets on each SSE chunk.
167+
// This way long inferences that stream tokens succeed, but requests
168+
// stuck with no output for IDLE_TIMEOUT_MS still abort.
169+
const controller = new AbortController();
170+
const idleMs = opts.timeout || IDLE_TIMEOUT_MS;
171+
let idleTimer = setTimeout(() => controller.abort(), idleMs);
172+
const resetIdle = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => controller.abort(), idleMs); };
159173

160-
// Capture model name from first response
161-
if (opts.vlm) {
162-
if (!results.model.vlm && data.model) results.model.vlm = data.model;
163-
} else {
164-
if (!results.model.name && data.model) results.model.name = data.model;
165-
}
174+
try {
175+
const response = await fetch(url, {
176+
method: 'POST',
177+
headers,
178+
body: JSON.stringify(body),
179+
signal: controller.signal,
180+
});
166181

167-
return { content, toolCalls, usage, model: data.model };
182+
if (!response.ok) {
183+
const errBody = await response.text().catch(() => '');
184+
throw new Error(`HTTP ${response.status}: ${errBody.slice(0, 200)}`);
185+
}
186+
187+
// Parse SSE stream
188+
let content = '';
189+
let reasoningContent = '';
190+
let toolCalls = null;
191+
let model = '';
192+
let usage = {};
193+
let finishReason = '';
194+
let tokenCount = 0;
195+
196+
const reader = response.body;
197+
const decoder = new TextDecoder();
198+
let buffer = '';
199+
200+
for await (const chunk of reader) {
201+
resetIdle();
202+
buffer += decoder.decode(chunk, { stream: true });
203+
204+
// Process complete SSE lines
205+
const lines = buffer.split('\n');
206+
buffer = lines.pop(); // Keep incomplete line in buffer
207+
208+
for (const line of lines) {
209+
if (!line.startsWith('data: ')) continue;
210+
const payload = line.slice(6).trim();
211+
if (payload === '[DONE]') continue;
212+
213+
try {
214+
const evt = JSON.parse(payload);
215+
if (evt.model) model = evt.model;
216+
217+
const delta = evt.choices?.[0]?.delta;
218+
if (delta?.content) content += delta.content;
219+
if (delta?.reasoning_content) reasoningContent += delta.reasoning_content;
220+
if (delta?.content || delta?.reasoning_content) {
221+
tokenCount++;
222+
// Log progress every 100 tokens so the console isn't silent
223+
if (tokenCount % 100 === 0) {
224+
log(` … ${tokenCount} tokens received`);
225+
}
226+
}
227+
if (delta?.tool_calls) {
228+
// Accumulate streamed tool calls
229+
if (!toolCalls) toolCalls = [];
230+
for (const tc of delta.tool_calls) {
231+
const idx = tc.index ?? 0;
232+
if (!toolCalls[idx]) {
233+
toolCalls[idx] = { id: tc.id, type: tc.type || 'function', function: { name: '', arguments: '' } };
234+
}
235+
if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;
236+
if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
237+
}
238+
}
239+
if (evt.choices?.[0]?.finish_reason) finishReason = evt.choices[0].finish_reason;
240+
if (evt.usage) usage = evt.usage;
241+
} catch { /* skip malformed SSE */ }
242+
}
243+
}
244+
245+
// If the model only produced reasoning_content (thinking) with no content,
246+
// use the reasoning output as the response content for evaluation purposes.
247+
if (!content && reasoningContent) {
248+
content = reasoningContent;
249+
}
250+
251+
// Track token totals
252+
results.tokenTotals.prompt += usage.prompt_tokens || 0;
253+
results.tokenTotals.completion += usage.completion_tokens || 0;
254+
results.tokenTotals.total += usage.total_tokens || 0;
255+
256+
// Capture model name from first response
257+
if (opts.vlm) {
258+
if (!results.model.vlm && model) results.model.vlm = model;
259+
} else {
260+
if (!results.model.name && model) results.model.name = model;
261+
}
262+
263+
return { content, toolCalls, usage, model };
264+
} finally {
265+
clearTimeout(idleTimer);
266+
}
168267
}
169268

170269
function stripThink(text) {
@@ -1675,17 +1774,32 @@ async function main() {
16751774
log('╔══════════════════════════════════════════════════════════════════╗');
16761775
log('║ Home Security AI Benchmark Suite • DeepCamera / SharpAI ║');
16771776
log('╚══════════════════════════════════════════════════════════════════╝');
1678-
log(` Gateway: ${GATEWAY_URL}`);
1679-
log(` VLM: ${VLM_URL || '(disabled — use --vlm URL to enable)'}`);
1777+
// Resolve the LLM endpoint that will actually be used
1778+
const effectiveLlmUrl = LLM_BASE_URL
1779+
? LLM_BASE_URL.replace(/\/v1\/?$/, '')
1780+
: LLM_URL
1781+
? LLM_URL.replace(/\/v1\/?$/, '')
1782+
: GATEWAY_URL;
1783+
1784+
log(` LLM: ${LLM_API_TYPE} @ ${effectiveLlmUrl}${LLM_MODEL ? ' → ' + LLM_MODEL : ''}`);
1785+
log(` VLM: ${VLM_URL || '(disabled — use --vlm URL to enable)'}${VLM_MODEL ? ' → ' + VLM_MODEL : ''}`);
16801786
log(` Results: ${RESULTS_DIR}`);
1681-
log(` Mode: ${IS_SKILL_MODE ? 'Aegis Skill' : 'Standalone'}`);
1787+
log(` Mode: ${IS_SKILL_MODE ? 'Aegis Skill' : 'Standalone'} (streaming, ${IDLE_TIMEOUT_MS / 1000}s idle timeout)`);
16821788
log(` Time: ${new Date().toLocaleString()}`);
16831789

1684-
// Healthcheck
1790+
// Healthcheck — ping the actual LLM endpoint directly
1791+
const healthUrl = LLM_BASE_URL
1792+
? `${LLM_BASE_URL.replace(/\/v1\/?$/, '')}/v1/chat/completions`
1793+
: LLM_URL
1794+
? `${LLM_URL.replace(/\/v1\/?$/, '')}/v1/chat/completions`
1795+
: `${GATEWAY_URL}/v1/chat/completions`;
1796+
const healthHeaders = { 'Content-Type': 'application/json' };
1797+
if (LLM_API_KEY) healthHeaders['Authorization'] = `Bearer ${LLM_API_KEY}`;
1798+
16851799
try {
1686-
const ping = await fetch(`${GATEWAY_URL}/v1/chat/completions`, {
1800+
const ping = await fetch(healthUrl, {
16871801
method: 'POST',
1688-
headers: { 'Content-Type': 'application/json' },
1802+
headers: healthHeaders,
16891803
body: JSON.stringify({ messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
16901804
signal: AbortSignal.timeout(15000),
16911805
});
@@ -1694,9 +1808,10 @@ async function main() {
16941808
results.model.name = data.model || 'unknown';
16951809
log(` Model: ${results.model.name}`);
16961810
} catch (err) {
1697-
log(`\n ❌ Cannot reach LLM gateway: ${err.message}`);
1698-
log(' Start the llama-cpp server and gateway, then re-run.\n');
1699-
emit({ event: 'error', message: `Cannot reach LLM gateway: ${err.message}` });
1811+
log(`\n ❌ Cannot reach LLM endpoint: ${err.message}`);
1812+
log(` Endpoint: ${healthUrl}`);
1813+
log(' Check that the LLM server is running.\n');
1814+
emit({ event: 'error', message: `Cannot reach LLM endpoint: ${err.message}` });
17001815
process.exit(1);
17011816
}
17021817

0 commit comments

Comments
 (0)