From 54294be85b7b66371f3c5dabc3540eadfa8f2e2f Mon Sep 17 00:00:00 2001 From: Dennis Landman Date: Thu, 11 Jun 2026 20:54:55 +0200 Subject: [PATCH 1/2] fix(server): stabilize v0.5 routes and build --- README.md | 7 +- package-lock.json | 8 +- package.json | 3 +- packages/dashboard/package.json | 3 +- .../dashboard/src/components/Dashboard.tsx | 3 +- packages/dashboard/src/config/version.ts | 1 + packages/server/package.json | 3 +- packages/server/src/api/routes.ts | 164 ++++++++++++++---- packages/server/src/config/version.ts | 1 + packages/server/src/index.ts | 37 +++- .../server/src/services/benchmark-state.ts | 25 +++ packages/server/src/services/benchmark.ts | 32 ++-- packages/server/src/services/route-advisor.ts | 21 +-- 13 files changed, 240 insertions(+), 68 deletions(-) create mode 100644 packages/dashboard/src/config/version.ts create mode 100644 packages/server/src/config/version.ts create mode 100644 packages/server/src/services/benchmark-state.ts diff --git a/README.md b/README.md index e9d09d9..2ee3757 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ npm install # Start development (backend + frontend) npm run dev +# Verify backend health +curl http://127.0.0.1:3001/api/health + # Open http://localhost:3000 ``` @@ -109,7 +112,9 @@ npm install npm run dev ``` -The backend runs on `http://localhost:3001` and the dashboard on `http://localhost:3000` with hot reload enabled for both. +The backend binds to `127.0.0.1:3001` by default and the dashboard runs on `http://localhost:3000` with hot reload enabled for both. Set `HOST` explicitly only on trusted networks; the local-first server does not provide authentication yet and restricts browser CORS to localhost origins. + +Use `npm run typecheck` to run TypeScript checks for both workspaces without emitting build artifacts. ### Project Structure diff --git a/package-lock.json b/package-lock.json index 8f74cd9..7afcc9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inference-forge", - "version": "0.1.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inference-forge", - "version": "0.1.0", + "version": "0.5.0", "license": "MIT", "workspaces": [ "packages/server", @@ -5065,7 +5065,7 @@ }, "packages/dashboard": { "name": "@inference-forge/dashboard", - "version": "0.1.0", + "version": "0.5.0", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", @@ -5156,7 +5156,7 @@ }, "packages/server": { "name": "@inference-forge/server", - "version": "0.1.0", + "version": "0.5.0", "dependencies": { "cors": "^2.8.5", "dotenv": "^16.4.0", diff --git a/package.json b/package.json index 65818f9..cec505b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inference-forge", - "version": "0.1.0", + "version": "0.5.0", "private": true, "description": "All-in-one local LLM inference management suite — monitoring, benchmarking, and Modelfile generation", "workspaces": [ @@ -12,6 +12,7 @@ "dev:server": "npm run dev -w packages/server", "dev:dashboard": "npm run dev -w packages/dashboard", "build": "npm run build -w packages/server && npm run build -w packages/dashboard", + "typecheck": "npm run typecheck -w packages/server && npm run typecheck -w packages/dashboard", "start": "npm run start -w packages/server" }, "devDependencies": { diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 59c7b4c..c0feb11 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -1,11 +1,12 @@ { "name": "@inference-forge/dashboard", - "version": "0.1.0", + "version": "0.5.0", "private": true, "type": "module", "scripts": { "dev": "vite --port 3000", "build": "tsc && vite build", + "typecheck": "tsc --noEmit", "preview": "vite preview" }, "dependencies": { diff --git a/packages/dashboard/src/components/Dashboard.tsx b/packages/dashboard/src/components/Dashboard.tsx index 08c3389..0a57e9f 100644 --- a/packages/dashboard/src/components/Dashboard.tsx +++ b/packages/dashboard/src/components/Dashboard.tsx @@ -16,6 +16,7 @@ import { TemplateGallery } from './TemplateGallery'; import { AgentPanel } from './AgentPanel'; import { ModelPull } from './ModelPull'; import { BackendPanel } from './BackendPanel'; +import { APP_VERSION } from '../config/version'; type Tab = 'monitor' | 'benchmark' | 'modelfile' | 'analysis' | 'agents'; @@ -56,7 +57,7 @@ export function Dashboard() { Forge - v0.2.0 + v{APP_VERSION}
diff --git a/packages/dashboard/src/config/version.ts b/packages/dashboard/src/config/version.ts new file mode 100644 index 0000000..8eb8578 --- /dev/null +++ b/packages/dashboard/src/config/version.ts @@ -0,0 +1 @@ +export const APP_VERSION = '0.5.0'; diff --git a/packages/server/package.json b/packages/server/package.json index 5845d30..d0433c1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,11 +1,12 @@ { "name": "@inference-forge/server", - "version": "0.1.0", + "version": "0.5.0", "private": true, "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", + "typecheck": "tsc --noEmit", "start": "node dist/index.js" }, "dependencies": { diff --git a/packages/server/src/api/routes.ts b/packages/server/src/api/routes.ts index bf6943e..937a2d6 100644 --- a/packages/server/src/api/routes.ts +++ b/packages/server/src/api/routes.ts @@ -11,6 +11,7 @@ import { hardware } from '../services/hardware.js'; import { throughput } from '../services/throughput.js'; import { alerts } from '../services/alerts.js'; import { perplexity } from '../services/perplexity.js'; +import { benchmarkState } from '../services/benchmark-state.js'; import { promptLibrary } from '../services/prompt-library.js'; import { modelfileLibrary } from '../services/modelfile-library.js'; import { orchestrator, type AgentConfig, type Workflow } from '../services/orchestrator.js'; @@ -18,24 +19,112 @@ import { pressure } from '../services/pressure.js'; import { database } from '../services/database.js'; import { ioProfiler } from '../services/io-profiler.js'; import { costTracker } from '../services/cost-tracker.js'; -import { lmstudio } from '../services/lmstudio.js'; -import { modelRegistry } from '../services/model-registry.js'; +import { lmstudio, type LmsChatMessage } from '../services/lmstudio.js'; +import { modelRegistry, type Backend } from '../services/model-registry.js'; import { storageAdvisor } from '../services/storage-advisor.js'; import { perfProfiler, type ModelProfile } from '../services/perf-profiler.js'; import { routeAdvisor, type RouteRequest } from '../services/route-advisor.js'; import { systemOptimizer } from '../services/system-optimizer.js'; import { openclawBridge } from '../services/openclaw-bridge.js'; import { hivemindBridge } from '../services/hivemind-bridge.js'; +import { APP_VERSION } from '../config/version.js'; export const router = Router(); +type ValidationResult = { ok: true; value: T } | { ok: false; error: string }; + +interface MessageBody { + content: string; +} + +interface OpenClawUsageBody { + agentId: string; + tokens: number; + costUsd: number; +} + +interface LmStudioChatBody { + model: string; + messages: LmsChatMessage[]; + temperature?: number; + max_tokens?: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseMessageBody(body: unknown): ValidationResult { + if (!isRecord(body) || typeof body.content !== 'string' || body.content.trim().length === 0) { + return { ok: false, error: 'content is required' }; + } + return { ok: true, value: { content: body.content } }; +} + +function parseOpenClawUsageBody(body: unknown): ValidationResult { + if (!isRecord(body) || typeof body.agentId !== 'string' || body.agentId.trim().length === 0) { + return { ok: false, error: 'agentId required' }; + } + const tokens = typeof body.tokens === 'number' && Number.isFinite(body.tokens) ? body.tokens : 0; + const costUsd = typeof body.costUsd === 'number' && Number.isFinite(body.costUsd) ? body.costUsd : 0; + if (tokens < 0 || costUsd < 0) { + return { ok: false, error: 'tokens and costUsd must be non-negative numbers' }; + } + return { ok: true, value: { agentId: body.agentId, tokens, costUsd } }; +} + +function parseLmStudioChatBody(body: unknown): ValidationResult { + if (!isRecord(body) || typeof body.model !== 'string' || body.model.trim().length === 0) { + return { ok: false, error: 'model is required' }; + } + if (!Array.isArray(body.messages) || body.messages.length === 0) { + return { ok: false, error: 'messages must be a non-empty array' }; + } + + const messages: LmsChatMessage[] = []; + for (const message of body.messages) { + if (!isRecord(message)) return { ok: false, error: 'messages must contain objects' }; + if (message.role !== 'system' && message.role !== 'user' && message.role !== 'assistant') { + return { ok: false, error: 'message role must be system, user, or assistant' }; + } + if (typeof message.content !== 'string') { + return { ok: false, error: 'message content must be a string' }; + } + messages.push({ role: message.role, content: message.content }); + } + + const temperature = typeof body.temperature === 'number' && Number.isFinite(body.temperature) + ? body.temperature + : undefined; + const maxTokens = typeof body.max_tokens === 'number' && Number.isFinite(body.max_tokens) + ? body.max_tokens + : undefined; + + if (temperature !== undefined && (temperature < 0 || temperature > 2)) { + return { ok: false, error: 'temperature must be between 0 and 2' }; + } + if (maxTokens !== undefined && (maxTokens < 1 || maxTokens > 32768)) { + return { ok: false, error: 'max_tokens must be between 1 and 32768' }; + } + + return { ok: true, value: { model: body.model, messages, temperature, max_tokens: maxTokens } }; +} + +function isBackend(value: string): value is Backend { + return value === 'ollama' || value === 'lmstudio'; +} + +function writeSse(res: { write: (chunk: string) => void }, payload: unknown): void { + res.write(`data: ${JSON.stringify(payload)}\n\n`); +} + // -- Health --------------------------------------------------------- router.get('/health', async (_req, res) => { const ollamaOnline = await ollama.ping(); res.json({ status: 'ok', - version: '0.5.0', + version: APP_VERSION, ollama: ollamaOnline ? 'connected' : 'disconnected', ollamaUrl: ollama.getBaseUrl(), timestamp: Date.now(), @@ -212,14 +301,14 @@ router.post('/benchmark/run', async (req, res) => { try { const result = await benchmark.run(config); - (globalThis as any).__lastBenchmarkResult = result; + benchmarkState.setBenchmarkResult(result); } catch (err) { console.error('[Benchmark] Error:', err); } }); router.get('/benchmark/result', (_req, res) => { - const result = (globalThis as any).__lastBenchmarkResult; + const result = benchmarkState.getBenchmarkResult(); if (!result) { res.status(404).json({ error: 'No benchmark result available' }); return; @@ -243,15 +332,14 @@ router.post('/benchmark/run-expanded', async (req, res) => { try { const result = await benchmark.runExpanded(config); - (globalThis as any).__lastExpandedBenchmarkResult = result; - (globalThis as any).__lastBenchmarkResult = result; + benchmarkState.setExpandedBenchmarkResult(result); } catch (err) { console.error('[Benchmark] Expanded error:', err); } }); router.get('/benchmark/result-expanded', (_req, res) => { - const result = (globalThis as any).__lastExpandedBenchmarkResult; + const result = benchmarkState.getExpandedBenchmarkResult(); if (!result) { res.status(404).json({ error: 'No expanded benchmark result available' }); return; @@ -260,7 +348,7 @@ router.get('/benchmark/result-expanded', (_req, res) => { }); router.get('/benchmark/export/:format', (req, res) => { - const result = (globalThis as any).__lastBenchmarkResult; + const result = benchmarkState.getBenchmarkResult(); if (!result) { res.status(404).json({ error: 'No benchmark result to export' }); return; @@ -645,23 +733,25 @@ router.post('/sessions/:id/message', async (req, res) => { // Streaming chat endpoint — sends tokens as SSE router.post('/sessions/:id/message/stream', async (req, res) => { - const { content } = req.body as { content: string }; + const parsed = parseMessageBody(req.body); + if (!parsed.ok) { res.status(400).json({ error: parsed.error }); return; } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); - orchestrator.sendMessageStream( + await orchestrator.sendMessageStream( req.params.id, - content, - // TODO: Move to env: (token) - const (token) = process.env.(TOKEN) || ''; + parsed.value.content, + (token) => { + writeSse(res, { type: 'token', content: token }); + }, (fullResponse) => { - res.write(`data: ${JSON.stringify({ type: 'done', content: fullResponse })}\n\n`); + writeSse(res, { type: 'done', content: fullResponse }); res.end(); }, (err) => { - res.write(`data: ${JSON.stringify({ type: 'error', error: String(err) })}\n\n`); + writeSse(res, { type: 'error', error: String(err) }); res.end(); } ); @@ -753,8 +843,12 @@ router.get('/profiles', (_req, res) => { }); router.get('/profiles/:backend/:modelId', (req, res) => { + if (!isBackend(req.params.backend)) { + res.status(400).json({ error: 'backend must be ollama or lmstudio' }); + return; + } const profile = perfProfiler.getProfile( - req.params.backend as any, + req.params.backend, decodeURIComponent(req.params.modelId) ); if (!profile) { res.status(404).json({ error: 'Profile not found' }); return; } @@ -767,8 +861,12 @@ router.post('/profiles/run', async (req, res) => { res.status(400).json({ error: 'backend and modelId are required' }); return; } + if (!isBackend(backend)) { + res.status(400).json({ error: 'backend must be ollama or lmstudio' }); + return; + } try { - const profile = await perfProfiler.profileModel(backend as any, modelId); + const profile = await perfProfiler.profileModel(backend, modelId); res.json({ profile }); } catch (err) { res.status(500).json({ error: String(err) }); @@ -837,10 +935,10 @@ router.get('/openclaw/agents', (_req, res) => { }); router.post('/openclaw/usage', (req, res) => { - // TODO: Move to env: const { agentId, tokens, costUsd } - const const { agentId, tokens, costUsd } = process.env.CONST_{_AGENTID,_TOKENS,_COSTUSD_} || ''; - if (!agentId) { res.status(400).json({ error: 'agentId required' }); return; } - openclawBridge.recordAgentUsage(agentId, tokens || 0, costUsd || 0); + const parsed = parseOpenClawUsageBody(req.body); + if (!parsed.ok) { res.status(400).json({ error: parsed.error }); return; } + const { agentId, tokens, costUsd } = parsed.value; + openclawBridge.recordAgentUsage(agentId, tokens, costUsd); res.json({ success: true }); }); @@ -925,11 +1023,11 @@ router.post('/lmstudio/unload', async (req, res) => { }); router.post('/lmstudio/chat', async (req, res) => { - // TODO: Move to env: const { model, messages, temperature, max_tokens } - const const { model, messages, temperature, max_tokens } = process.env.CONST_{_MODEL,_MESSAGES,_TEMPERATURE,_MAX_TOKENS_} || ''; + const parsed = parseLmStudioChatBody(req.body); + if (!parsed.ok) { res.status(400).json({ error: parsed.error }); return; } + const { model, messages, temperature, max_tokens } = parsed.value; try { - // TODO: Move to env: const result - const const result = process.env.CONST_RESULT || ''; + const result = await lmstudio.chat(model, messages, { temperature, max_tokens }); res.json(result); } catch (err) { res.status(502).json({ error: String(err) }); @@ -938,8 +1036,9 @@ router.post('/lmstudio/chat', async (req, res) => { // Streaming chat for LM Studio router.post('/lmstudio/chat/stream', async (req, res) => { - // TODO: Move to env: const { model, messages, temperature, max_tokens } - const const { model, messages, temperature, max_tokens } = process.env.CONST_{_MODEL,_MESSAGES,_TEMPERATURE,_MAX_TOKENS_} || ''; + const parsed = parseLmStudioChatBody(req.body); + if (!parsed.ok) { res.status(400).json({ error: parsed.error }); return; } + const { model, messages, temperature, max_tokens } = parsed.value; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); @@ -948,13 +1047,14 @@ router.post('/lmstudio/chat/stream', async (req, res) => { try { await lmstudio.chatStream( model, messages, - // TODO: Move to env: (token) - const (token) = process.env.(TOKEN) || ''; + (token) => { + writeSse(res, { type: 'token', content: token }); + }, { temperature, max_tokens } ); - res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`); + writeSse(res, { type: 'done' }); } catch (err) { - res.write(`data: ${JSON.stringify({ type: 'error', error: String(err) })}\n\n`); + writeSse(res, { type: 'error', error: String(err) }); } res.end(); }); diff --git a/packages/server/src/config/version.ts b/packages/server/src/config/version.ts new file mode 100644 index 0000000..8eb8578 --- /dev/null +++ b/packages/server/src/config/version.ts @@ -0,0 +1 @@ +export const APP_VERSION = '0.5.0'; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 04d5ada..f872229 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -4,7 +4,7 @@ import 'dotenv/config'; import express from 'express'; -import cors from 'cors'; +import cors, { type CorsOptions } from 'cors'; import { createServer } from 'http'; import { router } from './api/routes.js'; import { setupWebSocket } from './ws/handler.js'; @@ -14,13 +14,33 @@ import { alerts } from './services/alerts.js'; import { pressure } from './services/pressure.js'; import { database } from './services/database.js'; import { benchmark } from './services/benchmark.js'; +import { benchmarkState } from './services/benchmark-state.js'; import { modelRegistry } from './services/model-registry.js'; +import { APP_VERSION } from './config/version.js'; const PORT = parseInt(process.env.PORT || '3001', 10); +const HOST = process.env.HOST || '127.0.0.1'; const app = express(); +const LOCALHOST_ORIGINS = new Set([ + 'http://localhost:3000', + 'http://127.0.0.1:3000', + `http://localhost:${PORT}`, + `http://127.0.0.1:${PORT}`, +]); + +const corsOptions: CorsOptions = { + origin(origin, callback) { + if (!origin || LOCALHOST_ORIGINS.has(origin)) { + callback(null, true); + return; + } + callback(new Error(`CORS origin not allowed: ${origin}`)); + }, +}; + // Middleware -app.use(cors()); +app.use(cors(corsOptions)); app.use(express.json({ limit: '10mb' })); // API routes @@ -68,17 +88,20 @@ alerts.subscribe((alert) => { // Persist benchmark results (via progress subscription to detect completion) benchmark.subscribeProgress((message, progress) => { if (progress >= 1) { - const result = (globalThis as any).__lastBenchmarkResult; + const result = benchmarkState.getBenchmarkResult(); if (result) database.saveBenchmarkRun(result); } }); -server.listen(PORT, () => { +server.listen(PORT, HOST, () => { + if (HOST !== '127.0.0.1' && HOST !== 'localhost') { + console.warn(`[Security] Inference Forge is bound to ${HOST}. This local-first server has no authentication; expose only on trusted networks.`); + } console.log(` +---------------------------------------+ - | Inference Forge v0.3.0 | - | http://localhost:${PORT} | - | WebSocket: ws://localhost:${PORT}/ws | + | Inference Forge v${APP_VERSION} | + | http://${HOST}:${PORT} | + | WebSocket: ws://${HOST}:${PORT}/ws | +---------------------------------------+ `); }); diff --git a/packages/server/src/services/benchmark-state.ts b/packages/server/src/services/benchmark-state.ts new file mode 100644 index 0000000..25ab37f --- /dev/null +++ b/packages/server/src/services/benchmark-state.ts @@ -0,0 +1,25 @@ +import type { BenchmarkSummary, ExpandedBenchmarkSummary } from './benchmark.js'; + +class BenchmarkStateService { + private lastBenchmarkResult: BenchmarkSummary | ExpandedBenchmarkSummary | null = null; + private lastExpandedBenchmarkResult: ExpandedBenchmarkSummary | null = null; + + setBenchmarkResult(result: BenchmarkSummary | ExpandedBenchmarkSummary): void { + this.lastBenchmarkResult = result; + } + + getBenchmarkResult(): BenchmarkSummary | ExpandedBenchmarkSummary | null { + return this.lastBenchmarkResult; + } + + setExpandedBenchmarkResult(result: ExpandedBenchmarkSummary): void { + this.lastExpandedBenchmarkResult = result; + this.setBenchmarkResult(result); + } + + getExpandedBenchmarkResult(): ExpandedBenchmarkSummary | null { + return this.lastExpandedBenchmarkResult; + } +} + +export const benchmarkState = new BenchmarkStateService(); diff --git a/packages/server/src/services/benchmark.ts b/packages/server/src/services/benchmark.ts index 4ab211c..92861b8 100644 --- a/packages/server/src/services/benchmark.ts +++ b/packages/server/src/services/benchmark.ts @@ -92,6 +92,7 @@ export interface ExpandedBenchmarkSummary { } type ProgressCallback = (message: string, progress: number) => void; +type ExpandedParameterKey = 'numGpu' | 'numThread' | 'numCtx' | 'numBatch'; // -- Standard benchmark prompts ------------------------------------- @@ -178,8 +179,7 @@ export class BenchmarkService { const summary = config.kvCacheTypes.map((kvType) => { const kvResults = results.filter((r) => r.kvCacheType === kvType); - // TODO: Move to env: const avgTps - const const avgTps = process.env.CONST_AVGTPS || ''; + const avgTps = average(kvResults.map((r) => r.tokensPerSecond)); const avgTotal = kvResults.reduce((s, r) => s + r.totalDurationMs, 0) / kvResults.length; const avgEval = kvResults.reduce((s, r) => s + r.evalDurationMs, 0) / kvResults.length; @@ -328,7 +328,7 @@ export class BenchmarkService { }; const mode = modeMap[paramName] || 'kv-cache'; - const metaKey: Record = { + const metaKey: Record = { num_gpu: 'numGpu', num_thread: 'numThread', num_ctx: 'numCtx', @@ -350,8 +350,9 @@ export class BenchmarkService { mode, run: run + 1, }; - if (metaKey[paramName]) { - (expanded as any)[metaKey[paramName]] = value; + const key = metaKey[paramName]; + if (key) { + setExpandedParameter(expanded, key, value); } results.push(expanded); } @@ -360,7 +361,7 @@ export class BenchmarkService { const summaryItems = steps.map((value) => { const key = metaKey[paramName]; - const filtered = results.filter((r) => (r as any)[key] === value); + const filtered = results.filter((r) => key ? r[key] === value : false); return this.buildSummaryItem(`${paramName}=${value}`, paramName, value, filtered); }); @@ -374,8 +375,7 @@ export class BenchmarkService { results: ExpandedBenchmarkResult[] ): ExpandedBenchmarkSummary['summary'][0] { const n = results.length || 1; - // TODO: Move to env: const avgTps - const const avgTps = process.env.CONST_AVGTPS || ''; + const avgTps = average(results.map((r) => r.tokensPerSecond)); const avgTotal = results.reduce((s, r) => s + r.totalDurationMs, 0) / n; const avgEval = results.reduce((s, r) => s + r.evalDurationMs, 0) / n; const avgPromptEval = results.reduce((s, r) => { @@ -412,8 +412,7 @@ export class BenchmarkService { const promptEvalDurationMs = (response.prompt_eval_duration || 0) / 1_000_000; const evalDurationMs = (response.eval_duration || 0) / 1_000_000; const evalCount = response.eval_count || 0; - // TODO: Move to env: const tokensPerSecond - const const tokensPerSecond = process.env.CONST_TOKENSPERSECOND || ''; + const tokensPerSecond = evalDurationMs > 0 ? evalCount / (evalDurationMs / 1000) : 0; return { model, @@ -432,4 +431,17 @@ export class BenchmarkService { } } +function average(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function setExpandedParameter( + result: ExpandedBenchmarkResult, + key: ExpandedParameterKey, + value: number +): void { + result[key] = value; +} + export const benchmark = new BenchmarkService(); diff --git a/packages/server/src/services/route-advisor.ts b/packages/server/src/services/route-advisor.ts index a23d823..3a51fb2 100644 --- a/packages/server/src/services/route-advisor.ts +++ b/packages/server/src/services/route-advisor.ts @@ -6,7 +6,7 @@ import { perfProfiler, type ModelProfile } from './perf-profiler.js'; import { modelRegistry, type Backend } from './model-registry.js'; -import { pressure } from './pressure.js'; +import { pressure, type ResourcePressure } from './pressure.js'; // -- Types ---------------------------------------------------------- @@ -138,13 +138,10 @@ export class RouteAdvisor { } } - // Estimate performance const tokS = profile.tokSGpu || profile.tokSCpu || 1; - // TODO: Move to env: const firstToken - const const firstToken = process.env.CONST_FIRSTTOKEN || ''; + const firstToken = estimateFirstTokenMs(profile); - // TODO: Move to env: if (firstToken > latencyLimit && request.latency ! - const if (firstToken > latencyLimit && request.latency ! = process.env.IF_(FIRSTTOKEN_>_LATENCYLIMIT_&&_REQUEST.LATENCY_! || ''; + if (firstToken > latencyLimit && request.latency !== 'batch') { warnings.push(`First token (~${Math.round(firstToken)}ms) may exceed ${request.latency} budget`); } @@ -168,7 +165,7 @@ export class RouteAdvisor { request: RouteRequest, affinityArchs: string[], latencyLimit: number, - pressureState: any + pressureState: ResourcePressure | null ): number { let score = 50; // baseline @@ -207,9 +204,7 @@ export class RouteAdvisor { score += request.latency === 'realtime' ? 20 : request.latency === 'interactive' ? 10 : 2; } - // Latency penalty - // TODO: Move to env: const firstToken - const const firstToken = process.env.CONST_FIRSTTOKEN || ''; + const firstToken = estimateFirstTokenMs(profile); if (firstToken > latencyLimit) { score -= 20; } @@ -277,4 +272,10 @@ export class RouteAdvisor { } } +function estimateFirstTokenMs(profile: ModelProfile): number { + if (profile.firstTokenMs !== null) return profile.firstTokenMs; + const tokS = profile.tokSGpu || profile.tokSCpu || 0; + return tokS > 0 ? Math.round(1000 / tokS) : Infinity; +} + export const routeAdvisor = new RouteAdvisor(); From 0c2c0704ed132c392d570143e6130fd853073b5f Mon Sep 17 00:00:00 2001 From: Dennis Landman Date: Fri, 12 Jun 2026 01:23:57 +0200 Subject: [PATCH 2/2] fix(ci): ensure results directory exists --- .github/workflows/agentic-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/agentic-ci.yml b/.github/workflows/agentic-ci.yml index 04b192b..8e98da7 100644 --- a/.github/workflows/agentic-ci.yml +++ b/.github/workflows/agentic-ci.yml @@ -111,6 +111,7 @@ jobs: - name: Generate summary run: | + mkdir -p ci-results python3 -c " import json, os, glob results = {}