diff --git a/.env.example b/.env.example index dd68c79c..f95c57d0 100644 --- a/.env.example +++ b/.env.example @@ -258,6 +258,10 @@ DB_PASSWORD=pedscribe_secret_change_me # CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS=90000 # CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS=600000 # CLINICAL_ASSISTANT_MCP_CONCURRENCY=3 # library searches in flight at once; they used to run one at a time +# CLINICAL_ASSISTANT_MCP_QUEUE_MAX= # callers allowed to wait for a slot (default 4 x concurrency); past it: "busy, try again" +# CLINICAL_ASSISTANT_MCP_QUEUE_WAIT_MS=8000 # how long a caller waits in that line before being told the library is busy +# CLINICAL_ASSISTANT_ASK_LIMIT_PER_MINUTE=30 # paid assistant questions per user per minute, counted in Redis (no Redis: no limit) +# CLINICAL_ASSISTANT_RETRIEVAL_CACHE_TTL_S=60 # same library search within this window is answered from Redis; 0 disables # CLINICAL_ASSISTANT_MCP_WARMUP= # open a session at boot # CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS= diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js new file mode 100644 index 00000000..12c4ff29 --- /dev/null +++ b/src/middleware/rateLimit.js @@ -0,0 +1,45 @@ +// A per-user ceiling on the expensive routes, counted in Redis so every +// replica sees the same count. Fixed windows: the key carries the window's +// start, INCR counts, EXPIRE lets it fall away on its own. +// +// Without Redis the limiter lets everything through. A missing cache must +// never turn into a refused question; it only means one process's view. +var { getRedis } = require('../utils/redis'); + +function positiveInt(value, fallback) { + var n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +// rateLimit('clinical-chat', { limit: 30, windowSeconds: 60 }) +function rateLimit(scope, opts) { + opts = opts || {}; + var limit = positiveInt(opts.limit, 30); + var windowSeconds = positiveInt(opts.windowSeconds, 60); + return async function(req, res, next) { + var who = req.user && req.user.id; + if (!who) return next(); + var redis; + try { redis = await getRedis(); } catch (e) { redis = null; } + if (!redis) return next(); + var windowStart = Math.floor(Date.now() / 1000 / windowSeconds) * windowSeconds; + var key = 'rl:' + scope + ':' + who + ':' + windowStart; + var count; + try { + count = await redis.incr(key); + if (count === 1) await redis.expire(key, windowSeconds + 1); + } catch (e) { + return next(); + } + if (count <= limit) return next(); + var retryAfter = windowStart + windowSeconds - Math.floor(Date.now() / 1000); + res.set('Retry-After', String(Math.max(1, retryAfter))); + return res.status(429).json({ + error: 'You have sent a lot of questions in the last minute. Please wait a moment and try again.', + code: 'RATE_LIMITED', + retryAfter: Math.max(1, retryAfter) + }); + }; +} + +module.exports = { rateLimit }; diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 1e2101a5..6da98f55 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -10,6 +10,7 @@ var axios = require('axios'); var router = express.Router(); var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); +var { rateLimit } = require('../middleware/rateLimit'); var { callAI, callAIStream } = require('../utils/ai'); var generatedImages = require('../utils/generatedImages'); var imageTool = require('../utils/imageTool'); @@ -51,6 +52,17 @@ var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts'); // router.use(authMiddleware) gates every /api path, including routes owned by // routers mounted after it in server.js. router.use('/clinical-assistant', authMiddleware); +// The paid routes: a question costs a library search and a model call, so +// one account gets a bounded number per minute. Counted in Redis, shared by +// every replica; without Redis nothing is refused. +function positiveIntEnv(name, fallback) { + var n = Number(process.env[name]); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} +var askLimit = rateLimit('clinical-ask', { + limit: positiveIntEnv('CLINICAL_ASSISTANT_ASK_LIMIT_PER_MINUTE', 30), windowSeconds: 60 +}); +router.post(['/clinical-assistant/chat', '/clinical-assistant/chat/stream', '/clinical-assistant/image', '/clinical-assistant/image/jobs'], askLimit); var MAX_SAVED_CHATS_PER_USER = 100; var MAX_SAVED_CHAT_TITLE = 160; @@ -559,6 +571,26 @@ router.get('/clinical-assistant/image/jobs/:id/download', async function(req, re } catch (e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); } }); +// The same question asked twice in a minute (a retry, a refresh, two people on +// the same case) is answered from Redis instead of the library. The index only +// changes when the admin re-runs it, so a short window loses nothing; 0 turns +// it off. Without Redis every search goes to the library, as before. +var RETRIEVAL_CACHE_TTL_S = positiveIntEnv('CLINICAL_ASSISTANT_RETRIEVAL_CACHE_TTL_S', 60); +function retrievalCacheKey(query, opts) { + var hash = require('crypto').createHash('sha256').update(String(query)).digest('hex'); + return 'retrieval:' + hash + ':' + opts.limit + ':' + (opts.includeContext ? 1 : 0) + ':' + opts.contextChars; +} +async function cachedSemanticSearch(query, opts, timing) { + var cache = redisCache && typeof redisCache.getJson === 'function' ? redisCache : null; + if (!RETRIEVAL_CACHE_TTL_S || !cache) return semanticSearch(query, opts); + var key = retrievalCacheKey(query, opts); + var hit = await cache.getJson(key).catch(function() { return null; }); + if (hit) { if (timing) timing.searchCached = true; return hit; } + var response = await semanticSearch(query, opts); + cache.setJson(key, response, RETRIEVAL_CACHE_TTL_S).catch(function() {}); + return response; +} + async function prepareAssistantChat(body) { body = body || {}; var checked = checkConversation(body.history, body.message, await getConversationLimit()); @@ -592,11 +624,11 @@ async function prepareAssistantChat(body) { return message; }); timing.rewriteMs = Date.now() - phase; phase = Date.now(); - var searchResponse = await semanticSearch(searchQuery, { + var searchResponse = await cachedSemanticSearch(searchQuery, { limit: searchLimit, includeContext: includeContext, contextChars: contextChars - }); + }, timing); timing.searchMs = Date.now() - phase; // Retrieval is text-only. The multimodal path called nc_multimodal_search // against a second hardcoded collection with an embedding service that was diff --git a/src/utils/clinicalMcpClient.js b/src/utils/clinicalMcpClient.js index 565610af..087b7566 100644 --- a/src/utils/clinicalMcpClient.js +++ b/src/utils/clinicalMcpClient.js @@ -22,6 +22,12 @@ var _mcpCallQueue = Promise.resolve(); // session, so a small bound is enough to stop the queueing without letting a // burst pile onto it. var MCP_CONCURRENCY = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_CONCURRENCY, 3); +// The queue behind those slots is bounded too. Past MCP_QUEUE_MAX waiters, or +// after MCP_QUEUE_WAIT_MS in line, a caller is told the library is busy +// instead of holding a request open indefinitely: a burst then degrades into +// a few quick "try again" answers rather than a minute of silence for everyone. +var MCP_QUEUE_MAX = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_QUEUE_MAX, MCP_CONCURRENCY * 4); +var MCP_QUEUE_WAIT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_QUEUE_WAIT_MS, 8000); var _inFlight = 0; var _waiting = []; // The session is renewed before it expires, in the background, so no one @@ -117,14 +123,36 @@ function warmMcpSession() { return getMcpSession(); } +function busyError() { + var e = new Error('The library is busy right now. Please try again in a moment.'); + e.statusCode = 503; + e.code = 'RETRIEVAL_BUSY'; + e.retryAfterSeconds = 5; + return e; +} + function acquireSlot() { if (_inFlight < MCP_CONCURRENCY) { _inFlight++; return Promise.resolve(); } - return new Promise(function(resolve) { _waiting.push(resolve); }); + if (_waiting.length >= MCP_QUEUE_MAX) return Promise.reject(busyError()); + return new Promise(function(resolve, reject) { + var waiter = { resolve: resolve, timer: null }; + waiter.timer = setTimeout(function() { + var at = _waiting.indexOf(waiter); + if (at !== -1) _waiting.splice(at, 1); + reject(busyError()); + }, MCP_QUEUE_WAIT_MS); + if (waiter.timer.unref) waiter.timer.unref(); + _waiting.push(waiter); + }); } function releaseSlot() { var next = _waiting.shift(); - if (next) next(); else _inFlight--; + if (next) { clearTimeout(next.timer); next.resolve(); } else _inFlight--; +} + +function queueDepth() { + return { inFlight: _inFlight, waiting: _waiting.length, concurrency: MCP_CONCURRENCY, queueMax: MCP_QUEUE_MAX }; } async function callMcpTool(name, args) { @@ -374,6 +402,7 @@ function parseMcpResponse(body) { module.exports = { semanticSearch: semanticSearch, + queueDepth: queueDepth, indexedTopicSuggestions: indexedTopicSuggestions, getMcpHealth: getMcpHealth, warmMcpSession: warmMcpSession, diff --git a/test/assistant-attachment-roundtrip.test.js b/test/assistant-attachment-roundtrip.test.js index 7f1bb9db..4766420b 100644 --- a/test/assistant-attachment-roundtrip.test.js +++ b/test/assistant-attachment-roundtrip.test.js @@ -55,6 +55,7 @@ function server(options = {}) { const mocks = { express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db, '../middleware/auth': { authMiddleware() {} }, + '../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } }, './auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai }, '../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} }, '../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') }, diff --git a/test/assistant-image-attachments.test.js b/test/assistant-image-attachments.test.js index 896aefcf..db308366 100644 --- a/test/assistant-image-attachments.test.js +++ b/test/assistant-image-attachments.test.js @@ -53,6 +53,7 @@ function server(options = {}) { const mocks = { express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db, '../middleware/auth': { authMiddleware() {} }, + '../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } }, './auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai }, '../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} }, '../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') }, diff --git a/test/assistant-rate-limit-and-cache.test.js b/test/assistant-rate-limit-and-cache.test.js new file mode 100644 index 00000000..05f0954b --- /dev/null +++ b/test/assistant-rate-limit-and-cache.test.js @@ -0,0 +1,68 @@ +// A person gets a bounded number of paid questions a minute, counted in +// Redis; and the same library search twice in a minute is answered from +// Redis. Neither refuses anything when Redis is absent. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const Module = require('node:module'); + +function fakeRedis() { + const store = new Map(); + return { + store, + incr: async k => { const v = (store.get(k) || 0) + 1; store.set(k, v); return v; }, + expire: async () => 1 + }; +} + +function loadLimiter(redis) { + const target = require.resolve('../src/middleware/rateLimit'); + delete require.cache[target]; + const original = Module._load; + Module._load = function(request, parent) { + if (/utils\/redis$/.test(request)) return { getRedis: async () => redis }; + return original.apply(this, arguments); + }; + try { return require(target); } finally { Module._load = original; } +} + +function run(mw, userId) { + return new Promise(resolve => { + const res = { headers: {}, set(k, v) { this.headers[k] = v; }, status(c) { this.code = c; return this; }, json(b) { this.body = b; resolve({ res, next: false }); } }; + mw({ user: { id: userId } }, res, () => resolve({ res, next: true })); + }); +} + +test('the limit counts per user per window and answers 429 with a retry hint past it', async () => { + const { rateLimit } = loadLimiter(fakeRedis()); + const mw = rateLimit('t', { limit: 2, windowSeconds: 60 }); + assert.equal((await run(mw, 1)).next, true); + assert.equal((await run(mw, 1)).next, true); + const third = await run(mw, 1); + assert.equal(third.next, false); + assert.equal(third.res.code, 429); + assert.equal(third.res.body.code, 'RATE_LIMITED'); + assert.ok(Number(third.res.headers['Retry-After']) >= 1); + assert.equal((await run(mw, 2)).next, true, 'another user has their own count'); +}); + +test('without Redis nothing is refused', async () => { + const { rateLimit } = loadLimiter(null); + const mw = rateLimit('t', { limit: 1, windowSeconds: 60 }); + assert.equal((await run(mw, 1)).next, true); + assert.equal((await run(mw, 1)).next, true); +}); + +test('the paid assistant routes sit behind the limiter, and the library search is cached briefly', () => { + const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/clinicalAssistant.js'), 'utf8'); + assert.match(route, /router\.post\(\['\/clinical-assistant\/chat', '\/clinical-assistant\/chat\/stream', '\/clinical-assistant\/image', '\/clinical-assistant\/image\/jobs'\], askLimit\)/); + assert.match(route, /rateLimit\('clinical-ask', \{\s*limit: positiveIntEnv\('CLINICAL_ASSISTANT_ASK_LIMIT_PER_MINUTE', 30\), windowSeconds: 60/); + assert.match(route, /await cachedSemanticSearch\(searchQuery, \{/); + assert.match(route, /RETRIEVAL_CACHE_TTL_S = positiveIntEnv\('CLINICAL_ASSISTANT_RETRIEVAL_CACHE_TTL_S', 60\)/); + assert.match(route, /if \(!RETRIEVAL_CACHE_TTL_S \|\| !cache\) return semanticSearch\(query, opts\)/, '0 turns the cache off, and so does a missing cache'); + assert.match(route, /cache\.setJson\(key, response, RETRIEVAL_CACHE_TTL_S\)\.catch/, 'a cache write failure never fails the answer'); + const client = fs.readFileSync(path.join(__dirname, '..', 'src/utils/clinicalMcpClient.js'), 'utf8'); + assert.match(client, /e\.message\s*=|new Error\('The library is busy right now/, 'the busy answer reads as a sentence'); + assert.doesNotMatch(client.slice(client.indexOf('function busyError'), client.indexOf('function acquireSlot')), /MCP/, 'so assistantErrorMessage passes it through unchanged'); +}); diff --git a/test/assistant-translate.test.js b/test/assistant-translate.test.js index b53ee2fa..41fd26a2 100644 --- a/test/assistant-translate.test.js +++ b/test/assistant-translate.test.js @@ -81,7 +81,7 @@ test('translate route is owner-bound, validated and cached; admin default provid const db = { async getSetting(key) { return null; }, async get() { return null; }, async run() { return { lastInsertRowid: 1 }; }, async query() { return { rows: [] }; } }; const mocks = { express, axios: { async post(url, payload) { calls.push(String(url)); return { data: { translatedText: 'Traducción sintética.' } }; } }, crypto: require('node:crypto'), - '../db/database': db, '../middleware/auth': { authMiddleware() {} }, '../utils/ai': { callAI: async () => ({}), callAIStream: async () => ({}) }, + '../db/database': db, '../middleware/auth': { authMiddleware() {} }, '../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } }, '../utils/ai': { callAI: async () => ({}), callAIStream: async () => ({}) }, '../utils/errors': { gatewayUrl: p => 'http://synthetic.invalid' + p }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} }, '../utils/crypto': { encryptString: v => 'encrypted:' + v, decryptString: v => v.replace(/^encrypted:/, '') }, '../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, diff --git a/test/clinical-conversation.test.js b/test/clinical-conversation.test.js index b3773705..58b777ab 100644 --- a/test/clinical-conversation.test.js +++ b/test/clinical-conversation.test.js @@ -37,6 +37,7 @@ function server(options = {}) { const mocks = { express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db, '../middleware/auth': { authMiddleware() {} }, + '../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } }, './auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai }, '../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} }, '../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') }, diff --git a/test/generated-image-tools.test.js b/test/generated-image-tools.test.js index 5404e9c2..abf22338 100644 --- a/test/generated-image-tools.test.js +++ b/test/generated-image-tools.test.js @@ -76,6 +76,7 @@ function route(file, ai, jobs) { express, axios:{}, crypto:require('crypto'), multer:require('multer'), path:require('path'), '../utils/ai':ai, '../db/database':{getSetting:async key=>key.includes('model')?'test':null,all:async()=>[]}, '../middleware/auth':{authMiddleware(){},moderatorMiddleware(){}}, + '../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } }, '../utils/crypto':{},'../utils/urlSafety':{},'../utils/policy':{requireFeature:()=>()=>{}}, // Retrieval is opt-in and these cases do not ask for it; the stub proves // the route never reaches the corpus unless useCorpus was set. diff --git a/test/mcp-client-concurrency.test.js b/test/mcp-client-concurrency.test.js index 47dff1e3..e4e5dfd2 100644 --- a/test/mcp-client-concurrency.test.js +++ b/test/mcp-client-concurrency.test.js @@ -6,10 +6,17 @@ const fs = require('node:fs'); const path = require('node:path'); const src = fs.readFileSync(path.join(__dirname, '..', 'src/utils/clinicalMcpClient.js'), 'utf8'); +// The slot and queue logic on its own, with the bounds given explicitly. +function slots(concurrency, queueMax, waitMs) { + const slice = src.slice(src.indexOf('function busyError'), src.indexOf('async function callMcpTool')); + return new Function('MCP_CONCURRENCY', 'MCP_QUEUE_MAX', 'MCP_QUEUE_WAIT_MS', + 'var _inFlight = 0, _waiting = [];' + slice + + '; return { acquireSlot, releaseSlot, count: () => _inFlight, waiting: () => _waiting.length };')(concurrency, queueMax, waitMs); +} + test('a bounded number of calls run at once; the rest wait their turn', async () => { // The slot logic on its own, with the environment's default bound. - const slice = src.slice(src.indexOf('function acquireSlot'), src.indexOf('async function callMcpTool')); - const fn = new Function('MCP_CONCURRENCY', 'var _inFlight = 0, _waiting = [];' + slice + '; return { acquireSlot, releaseSlot, count: () => _inFlight, waiting: () => _waiting.length };')(3); + const fn = slots(3, 12, 8000); await fn.acquireSlot(); await fn.acquireSlot(); await fn.acquireSlot(); assert.equal(fn.count(), 3); let fourthStarted = false; @@ -25,6 +32,36 @@ test('a bounded number of calls run at once; the rest wait their turn', async () assert.equal(fn.count(), 0); }); +test('past the queue bound a caller is told the library is busy, not held', async () => { + const fn = slots(1, 2, 8000); + await fn.acquireSlot(); + const second = fn.acquireSlot(); const third = fn.acquireSlot(); + assert.equal(fn.waiting(), 2); + await assert.rejects(fn.acquireSlot(), e => e.statusCode === 503 && e.code === 'RETRIEVAL_BUSY' && e.retryAfterSeconds === 5); + fn.releaseSlot(); await second; fn.releaseSlot(); await third; fn.releaseSlot(); + assert.equal(fn.count(), 0); +}); + +test('a caller that waits too long is released from the line, and the line forgets it', async () => { + const fn = slots(1, 4, 20); + // The wait timer is unref'd (a live search keeps the process up in real + // use); hold the loop open here so it can fire. + const hold = setTimeout(() => {}, 200); + await fn.acquireSlot(); + const late = fn.acquireSlot(); + await assert.rejects(late, e => e.code === 'RETRIEVAL_BUSY'); + assert.equal(fn.waiting(), 0, 'the timed-out waiter is gone'); + fn.releaseSlot(); + assert.equal(fn.count(), 0, 'releasing does not hand the slot to a caller that already gave up'); + clearTimeout(hold); +}); + +test('the queue bounds and the busy answer are wired where the route can see them', () => { + assert.match(src, /MCP_QUEUE_MAX = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_QUEUE_MAX, MCP_CONCURRENCY \* 4\)/); + assert.match(src, /MCP_QUEUE_WAIT_MS = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_QUEUE_WAIT_MS, 8000\)/); + assert.match(src, /queueDepth: queueDepth/); +}); + test('the serial promise chain is gone, the session is kept warm, and the split is logged', () => { assert.doesNotMatch(src, /_mcpCallQueue = queued\.catch/, 'no more one-at-a-time chain'); assert.match(src, /var MCP_CONCURRENCY = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_CONCURRENCY, 3\)/); diff --git a/test/my-resources-refine.test.js b/test/my-resources-refine.test.js index 1b94a3b1..7d507f4a 100644 --- a/test/my-resources-refine.test.js +++ b/test/my-resources-refine.test.js @@ -57,6 +57,7 @@ function router(t, overrides = {}) { } }, '../middleware/auth': { authMiddleware: (req, res, next) => next() }, + '../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } }, '../utils/ai': { callAI: async (messages, options) => { aiCalls.push({ messages, options }); diff --git a/test/patient-takehome.test.js b/test/patient-takehome.test.js index 3cf7c576..fab853dc 100644 --- a/test/patient-takehome.test.js +++ b/test/patient-takehome.test.js @@ -33,6 +33,7 @@ function server(t, overrides = {}) { query: async () => ({ rows: [] }) }, '../middleware/auth': { authMiddleware: (req, res, next) => next() }, + '../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } }, '../utils/ai': { callAI: async (messages, options) => { aiCalls.push({ messages, options }); return { content: 'Take home [1] text.', model: 'synthetic-chat' }; }, callAIStream: async () => { throw new Error('unexpected'); }, activeProvider: 'synthetic', discoverModels: async () => [], vertexClient: null, litellmClient: null, applyImageAttachments: x => x }, '../utils/generatedImages': { workflows: ['clinical_assistant'], snapshot: async () => ({}), enqueue: async () => ({}), tick: async () => {}, ready: async () => {} }, '../utils/visionTool': require('../src/utils/visionTool'), @@ -132,6 +133,7 @@ test('a text-only answer no longer conjures an image; the model must call the to axios: {}, '../db/database': { get: async () => null, getSetting: async () => '', all: async () => [], run: async () => ({ changes: 1, lastInsertRowid: 1 }), query: async () => ({ rows: [] }) }, '../middleware/auth': { authMiddleware: (req, res, next) => next() }, + '../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } }, '../utils/ai': { callAI: async () => ({ content: 'Create a poster showing oxygen delivery for neonates.' }), callAIStream: async () => ({ content: 'Create a poster showing oxygen delivery for neonates.' }) }, '../utils/generatedImages': { service: () => ({ enqueue: async (owner, workflow, input, key, replay, context, model) => { jobs.push({ owner, workflow, input, model }); return { jobId: 'job-x', status: 'pending', imageUrl: null }; } }), imageContext: (r, h) => ({ request: r, history: h }), requestKey: b => 'k' + String(b).length }, '../utils/imageTool': { tools: [], dispatch: async ai => ai },