@@ -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
8282const 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
8384const VLM_URL = process . env . AEGIS_VLM_URL || getArg ( 'vlm' , '' ) ;
8485const RESULTS_DIR = getArg ( 'out' , path . join ( os . homedir ( ) , '.aegis-ai' , 'benchmarks' ) ) ;
8586const IS_SKILL_MODE = ! ! process . env . AEGIS_SKILL_ID ;
8687const NO_OPEN = args . includes ( '--no-open' ) || skillParams . noOpen || false ;
8788const TEST_MODE = skillParams . mode || 'full' ;
88- const TIMEOUT_MS = 30000 ;
89+ const IDLE_TIMEOUT_MS = 30000 ; // Streaming idle timeout — resets on each received token
8990const 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
129138async 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 ( / \/ v 1 \/ ? $ / , '' ) : '' ;
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 ( / \/ v 1 \/ ? $ / , '' ) ;
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
170269function 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 ( / \/ v 1 \/ ? $ / , '' )
1780+ : LLM_URL
1781+ ? LLM_URL . replace ( / \/ v 1 \/ ? $ / , '' )
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 ( / \/ v 1 \/ ? $ / , '' ) } /v1/chat/completions`
1793+ : LLM_URL
1794+ ? `${ LLM_URL . replace ( / \/ v 1 \/ ? $ / , '' ) } /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