import {measureAsync, sleep} from './utils.js'; /** * RequestManager routes inference requests to on-device or cloud services based on a routing strategy and configurations. * The manager does orchestrate the inference requests, collects statistics, evaluates the results and returns the final statistic. * * We provide different routing strategies: * - always_cloud: all requests go to cloud * - always_device: all requests go to device * - probabilistic: each request goes to cloud with a defined probability * - roundrobin: requests alternate between cloud and device * - hero: routes to the server with the shortest expected queue time */ export class RequestManager { constructor({ deviceService, cloudService, evaluator, logger = null, routeStrategy = 'roundrobin', cloudProb = 0.5, devicePerfModel = {slope: 0, intercept: 0}, cloudPerfModel = {slope: 0, intercept: 0} } = {}) { /** * On-device inference service */ this.device = deviceService; /** * Cloud inference service */ this.cloud = cloudService; /** * Evaluator instance for evaluating inference results */ this.evaluator = evaluator; /** * Optional logger callback function * @type {null} */ this.logger = logger; /** * Routing strategy (always_cloud, always_device, probabilistic, roundrobin) * @type {string} */ this.routeStrategy = routeStrategy; /** * Probability of routing to cloud when using 'probabilistic' strategy * @type {number} */ this.cloudProb = cloudProb; /** * Performance model for the device {slope, intercept} * @type {{slope: number, intercept: number}} */ this.devicePerfModel = devicePerfModel; /** * Performance model for the cloud {slope, intercept} * @type {{slope: number, intercept: number}} */ this.cloudPerfModel = cloudPerfModel; // Initialize Thompson samplers using offline fits as priors const deviceMu0 = [this.devicePerfModel.intercept || 0, this.devicePerfModel.slope || 0]; const cloudMu0 = [this.cloudPerfModel.intercept || 0, this.cloudPerfModel.slope || 0]; // Hyperparameters: priorLambda and sigma2 (tune as needed) const priorLambda = 1e-3; // prior regularization (fast adaptation: 1e-6 … 1e-3, balanced: 1e-2 … 1.0, slow adaptation (anchor to offline fit): 10 … 1e3) const observationSigma2 = 1e4; // noise variance (ms^2) => sd ~100ms (low-noise (sd ≈ 20–50 ms): 400 … 2500, medium-noise (sd ≈ 50–100 ms): 2500 … 10000, high-noise (sd ≈ 100–200 ms): 10000 … 40000) this.tsDevice = new LinearThompsonSampler(deviceMu0, priorLambda, observationSigma2); this.tsCloud = new LinearThompsonSampler(cloudMu0, priorLambda, observationSigma2); /** * Internal round robin counter (even = cloud, odd = device) * @type {number} * @private */ this._rrCounter = 0; /** * Statistics about routing and evaluations of this job run * @type {{cloud: number, evaluations: *[], count: number, device: number, totalLatencyMs: number}} */ this.stats = {count: 0, cloud: 0, device: 0, totalLatencyMs: 0, results: []}; /** * Cloud job queue * @type {*[]} */ this.cloud_queue = []; /** * Device job queue * @type {*[]} */ this.device_queue = []; // start processing jobs from the queues this.runOnDeviceJobsFromQueue(); this.runCloudJobsFromQueue(); } /** * Push a job to the appropriate queue based on routing strategy. * * @param job - The job to be processed */ pushJob(job) { // get routing strategy and inference service const route = this._choose(job); console.log(`Device Queue Length: ${this.device_queue.length}, \nCloud Queue Length: ${this.cloud_queue.length}`); if (route === 'cloud') { this.cloud_queue.push(job); } else { this.device_queue.push(job); } } /** * Update routing configuration * * @param routeStrategy - New routing strategy * @param cloudProb - New cloud probability for 'probabilistic' strategy * @param devicePerfModel * @param cloudPerfModel */ updateRouting({routeStrategy, cloudProb, devicePerfModel, cloudPerfModel}) { if (routeStrategy) this.routeStrategy = routeStrategy; if (cloudProb !== undefined) this.cloudProb = cloudProb; if (devicePerfModel) this.devicePerfModel = devicePerfModel; if (cloudPerfModel) this.cloudPerfModel = cloudPerfModel; } /** * Handle device jobs by routing it to the appropriate service, as long as there are jobs in the queue. * * @returns {Promise} */ async runOnDeviceJobsFromQueue() { while (true) { if (this.device_queue.length > 0) { const job = this._getNextJobFromQueue(this.device_queue, 'fifo'); const service = this.device; const route = 'device'; // run the job and await until compteted await this._runJob(job, route, service); } // sleep for 10ms to not run into memory leak await sleep(10); } } /** * Handle cloud jobs by routing it to the appropriate service, as long as there are jobs in the queue. * * @returns {Promise} */ async runCloudJobsFromQueue() { while (true) { if (this.cloud_queue.length > 0) { const job = this._getNextJobFromQueue(this.cloud_queue, 'fifo'); const service = this.cloud; const route = 'cloud'; // run the job and await until it completes await this._runJob(job, route, service); } // sleep for 10ms to not run into memory leak await sleep(10); } } /** * Run the given job on the specified service and record statistics. * * @param job - The job object containing prompt and ground truth * @param route - The selected route ('cloud' or 'device') * @param service - The inference service to use * @returns {Promise} * @private */ async _runJob(job, route, service) { let full_prompt = job.prompt; // ensure string input // this is a little workaround to disable the thinking mode in qwen models if (service.getModelName().toLowerCase().includes("qwen3".toLowerCase())) { full_prompt = full_prompt; // + "/no_think"; console.log("ℹ️ \"/no_think\" was added to the prompt to avoid thinking") } let response, latencyMs, cleanedResponse; // response is object with .answer and .stats try { // Mark inference start job.timestamps.inferenceStart = Date.now(); const {res, ms} = await measureAsync(() => service.infer(full_prompt)); response = res; latencyMs = ms; // Mark inference end job.timestamps.inferenceEnd = Date.now(); } catch (err) { response = `__error__:${err.message}`; latencyMs = -1; job.timestamps.inferenceEnd = Date.now(); } // Calculate timing metrics const queueingTime = job.timestamps.inferenceStart - job.timestamps.jobStart; const inferenceTime = job.timestamps.inferenceEnd - job.timestamps.inferenceStart; const totalLatency = job.timestamps.inferenceEnd - job.timestamps.jobStart; // clean response cleanedResponse = this._cleanResponse(response); // evaluate result and store results const evalRes = this.evaluator.evaluate(cleanedResponse, job.groundTruth, latencyMs); this._record(route, latencyMs, evalRes, job, cleanedResponse, {queueingTime, inferenceTime, totalLatency}); if (this.logger) { try { this.logger({job, route, latency: latencyMs, evalRes, response: cleanedResponse.answer, queueingTime, inferenceTime, totalLatency}); } catch (error) { console.error("Logger encountered an error:", error); } } // update Thompson sampler with observed inference time (ms) try { if (latencyMs > 0) { const x = [1, job.prompt.length]; const y = inferenceTime; // ms if (route === 'device') { this.tsDevice.update(x, y); } else { this.tsCloud.update(x, y); } // update public linear models (used by _decideHERO UI) this._updateLinearModelsForHERO(); } } catch (err) { console.warn("TS update failed:", err); } // logging on console console.log(cleanedResponse) console.log("🎯 Models Answer: " + response.answer + "; \nCleaned Answer: " + cleanedResponse.answer + '; \nGround Truth: ' + job.groundTruth + "; \nInference Time: " + inferenceTime.toFixed(2) + "ms" + "; \nQueueing Time: " + queueingTime.toFixed(2) + "ms" + "; \nTotal Latency: " + totalLatency.toFixed(2) + "ms"); } _getNextJobFromQueue(queue, policy) { // currently only FIFO is implemented return queue.shift(); } /** * Choose a route based on the configured strategy. * * @returns {string} 'cloud' or 'device' * @private */ _choose(job) { switch (this.routeStrategy) { case 'always_cloud': return 'cloud'; case 'always_device': return 'device'; case 'probabilistic': return Math.random() < this.cloudProb ? 'cloud' : 'device'; case 'roundrobin': this._rrCounter++; return this._rrCounter % 2 === 0 ? 'cloud' : 'device'; case 'hero': return this._decideHERO(job); default: return 'device'; } } /** * Decide route based on our HERO policy. * * @param job * @returns {string} * @private */ _decideHERO(job) { const now = Date.now(); const input_size = job.prompt.length; // Thompson sample thetas (intercept, slope) in ms const thetaDevice = this.tsDevice.sampleTheta(); const thetaCloud = this.tsCloud.sampleTheta(); // predict inference time for the new job on both servers const device_predicted_inference_time = thetaDevice[0] + thetaDevice[1] * input_size; const cloud_predicted_inference_time = thetaCloud[0] + thetaCloud[1] * input_size; // Get the last job in the queue for both servers, to estimate when they will be free const lastDeviceJob = this.device_queue.length > 0 ? this.device_queue[this.device_queue.length - 1] : null; const lastCloudJob = this.cloud_queue.length > 0 ? this.cloud_queue[this.cloud_queue.length - 1] : null; // Calculate when each server will be free based on the last job in the queue const device_free_at = Math.max(now, lastDeviceJob?.hero_predictions?.device?.expectedFinishTime || 0); const cloud_free_at = Math.max(now, lastCloudJob?.hero_predictions?.cloud?.expectedFinishTime || 0); // Calculate expected finish time for the new job on both servers const device_expected_finish_time = device_free_at + device_predicted_inference_time; const cloud_expected_finish_time = cloud_free_at + cloud_predicted_inference_time; // calculate expected total time for both servers (predicted inference time + time until server is free) const device_expected_total_time = device_expected_finish_time - now; const cloud_expected_total_time = cloud_expected_finish_time - now; // store the predicted values in the job for logging and analysis job.hero_predictions = { device: { predictedInferenceTime: device_predicted_inference_time, predictedTotalTime: device_expected_total_time, expectedFinishTime: device_expected_finish_time, numberOfJobsInQueue: this.device_queue.length || 0 }, cloud: { predictedInferenceTime: cloud_predicted_inference_time, predictedTotalTime: cloud_expected_total_time, expectedFinishTime: cloud_expected_finish_time, numberOfJobsInQueue: this.cloud_queue.length || 0 } }; // Choose the server with the earlier expected finish time if (device_expected_finish_time <= cloud_expected_finish_time) { return 'device'; } else { return 'cloud'; } } /** * Update public linear model values from sampler posteriors (used by UI & logging) */ _updateLinearModelsForHERO() { const devMean = this.tsDevice.posteriorMean(); const cloudMean = this.tsCloud.posteriorMean(); // posteriorMean returns [intercept, slope] this.devicePerfModel = { intercept: devMean[0], slope: devMean[1] }; this.cloudPerfModel = { intercept: cloudMean[0], slope: cloudMean[1] }; // dispatch update event for the frontend if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('perfModelsUpdated', {detail: { device: this.devicePerfModel, cloud: this.cloudPerfModel }})); } } /** * Record statistics for the given job evaluation. * Increases counters for total requests and cloud/device usage. * Updates the total latency. * * @param route - The route taken ('cloud' or 'device') * @param latency - Latency in milliseconds * @param evalRes - Evaluation result object * @param job - The job object * @param text - The inference result text * @param timingMetrics - Object containing queueingTime, inferenceTime, and totalLatency * @private */ _record(route, latency, evalRes, job, text, timingMetrics) { this.stats.count++; if (route === 'cloud') this.stats.cloud++; else this.stats.device++; if (latency > 0) this.stats.totalLatencyMs += latency; this.stats.results.push({ job: job, route, latency, evalRes, text, queueingTime: timingMetrics.queueingTime, inferenceTime: timingMetrics.inferenceTime, totalLatency: timingMetrics.totalLatency, timestamps: job.timestamps }); } /** * Remove reasoning/thinking tokens and sections from a model's response. * Supports various formats (XML tags, special tokens, markdown, etc.) used by reasoning models. * Returns a cleaned response object with only the final answer for evaluation. * * @param response - The uncleaned response object (may include reasoning/thinking sections) * @return {object|string} - Cleaned response object or original error string * @private */ _cleanResponse(response) { // If response is an error string, return as-is if (typeof response === 'string' && response.startsWith('__error__')) { return response; } // Clone the response object to avoid mutating the original const cleanedResponse = { ...response }; if (!cleanedResponse.answer || typeof cleanedResponse.answer !== 'string') { return cleanedResponse; } let cleanedAnswer = cleanedResponse.answer; // Define patterns for thinking tokens (common formats) const thinkingPatterns = [ // XML-style tags /[\s\S]*?<\/think>/gi, /[\s\S]*?<\/thinking>/gi, /[\s\S]*?<\/reasoning>/gi, /[\s\S]*?<\/thought>/gi, // Special tokens /<\|startofthinking\|>[\s\S]*?<\|endofthinking\|>/gi, /<\|reasoning_start\|>[\s\S]*?<\|reasoning_end\|>/gi, // Markdown-style /\[THINKING\][\s\S]*?\[\/THINKING\]/gi, /\[REASONING\][\s\S]*?\[\/REASONING\]/gi, /\[THOUGHT\][\s\S]*?\[\/THOUGHT\]/gi, // Other common patterns /\*\*Thinking:\*\*[\s\S]*?(?=\*\*Answer:\*\*|$)/gi, /\*\*Reasoning:\*\*[\s\S]*?(?=\*\*Answer:\*\*|$)/gi, ]; // Apply all patterns to remove thinking sections for (const pattern of thinkingPatterns) { cleanedAnswer = cleanedAnswer.replace(pattern, ''); } // Clean up extra whitespace cleanedAnswer = cleanedAnswer.trim(); // If we removed everything, keep original (safety check) if (cleanedAnswer.length === 0 && cleanedResponse.answer.length > 0) { console.warn('⚠️ Thinking token removal resulted in empty answer. Keeping original.'); return cleanedResponse; } cleanedResponse.answer = cleanedAnswer; return cleanedResponse; } } /** * 2-D Bayesian linear regressor for Thompson Sampling. * Models y = x^T theta + noise, x = [1, n] * Maintains A = Lambda + (1/sigma2) X^T X (2x2), b = Lambda*mu0 + (1/sigma2) X^T y (2x1) * Posterior: theta | data ~ N(mu = A^{-1} b, cov = A^{-1}) */ class LinearThompsonSampler { constructor(mu0 = [0, 0], priorLambda = 1e-3, sigma2 = 1e4) { // mu0: prior mean [intercept, slope] // priorLambda: scalar multiplied with identity (regularization) // sigma2: observation noise variance (ms^2) this.mu0 = [...mu0]; this.priorLambda = priorLambda; this.sigma2 = sigma2; // A (2x2) initialized to priorLambda * I this.A = [ [priorLambda, 0], [0, priorLambda] ]; // b (2x1) initialized to priorLambda * mu0 this.b = [ priorLambda * this.mu0[0], priorLambda * this.mu0[1] ]; } /** * Sample theta ~ N(mu, cov) * Uses Cholesky decomposition for sampling from multivariate normal. * * @returns {*[]} */ sampleTheta() { const cov = invert2x2(this.A); // cov = A^{-1} const mu = matVecMul(cov, this.b); // Cholesky of cov (2x2) for sampling const L = cholesky2x2(cov); const z0 = randn(), z1 = randn(); // theta = mu + L * z const theta0 = mu[0] + L[0][0] * z0; const theta1 = mu[1] + L[1][0] * z0 + L[1][1] * z1; return [theta0, theta1]; } /** * Update with one observation (x: [1, n], y: observed_time_ms) * * @param x * @param y */ update(x, y) { // A += (1/sigma2) * x x^T const factor = 1.0 / this.sigma2; this.A[0][0] += factor * x[0] * x[0]; this.A[0][1] += factor * x[0] * x[1]; this.A[1][0] += factor * x[1] * x[0]; this.A[1][1] += factor * x[1] * x[1]; // b += (1/sigma2) * x * y this.b[0] += factor * x[0] * y; this.b[1] += factor * x[1] * y; } /** * Return posterior mean [intercept, slope] * * @returns {*} */ posteriorMean() { const cov = invert2x2(this.A); return matVecMul(cov, this.b); } } /* ===== Helper linear algebra (small, 2x2 implementations) ===== */ function matVecMul(mat, vec) { return [ mat[0][0] * vec[0] + mat[0][1] * vec[1], mat[1][0] * vec[0] + mat[1][1] * vec[1] ]; } function invert2x2(m) { // returns inverse of 2x2 matrix m const a = m[0][0], b = m[0][1], c = m[1][0], d = m[1][1]; const det = a * d - b * c; const eps = 1e-12; const detSafe = Math.abs(det) < eps ? (det >= 0 ? eps : -eps) : det; const invDet = 1.0 / detSafe; return [ [ d * invDet, -b * invDet ], [ -c * invDet, a * invDet ] ]; } function cholesky2x2(m) { // m must be symmetric positive definite const a = m[0][0]; const b = m[0][1]; // equals m[1][0] const c = m[1][1]; const l00 = Math.sqrt(Math.max(a, 1e-12)); const l10 = b / l00; const l11 = Math.sqrt(Math.max(c - l10 * l10, 1e-12)); return [ [l00, 0], [l10, l11] ]; } function randn() { // Box-Muller let u = 0, v = 0; while (u === 0) u = Math.random(); while (v === 0) v = Math.random(); return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); }