From 5f8931bfcd64bf78081f79021848de3cf13e819b Mon Sep 17 00:00:00 2001 From: Akash Gundapuneni Date: Mon, 30 Mar 2026 14:43:34 +0200 Subject: [PATCH 001/206] feat: add LiteLLM RPC service and integration --- .env | 3 + Makefile | 2 +- backend/webserver/rpcs/liteLLMRPC.js | 51 +++++++++++++++ backend/webserver/services/nlp.js | 93 ++++++++++++++++++++++++++-- docker-compose.yml | 8 +++ docker-dev.yml | 5 +- utils/rpcs/litellm/Dockerfile | 13 ++++ utils/rpcs/litellm/main.py | 84 +++++++++++++++++++++++++ utils/rpcs/litellm/requirements.txt | 4 ++ 9 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 backend/webserver/rpcs/liteLLMRPC.js create mode 100644 utils/rpcs/litellm/Dockerfile create mode 100644 utils/rpcs/litellm/main.py create mode 100644 utils/rpcs/litellm/requirements.txt diff --git a/.env b/.env index 994466470..49773354c 100644 --- a/.env +++ b/.env @@ -46,6 +46,9 @@ RPC_MOODLE_PORT=3011 RPC_PDF_HOST=127.0.0.1 RPC_PDF_PORT=3012 +RPC_LITELLM_HOST=127.0.0.1 +RPC_LITELLM_PORT=3013 + # System Statistics # PG_STATS_INTERVAL_MS: # Interval in milliseconds between each PostgreSQL statistics snapshot diff --git a/Makefile b/Makefile index 875cf83db..13261be4d 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ lint: frontend/node_modules/.uptodate .PHONY: docker docker: - @docker compose -f docker-compose.yml -f docker-dev.yml up postgres rpc_test rpc_moodle rpc_pdf + @docker compose -f docker-compose.yml -f docker-dev.yml up postgres rpc_test rpc_moodle rpc_pdf rpc_litellm .PHONY: db db: backend/node_modules/.uptodate diff --git a/backend/webserver/rpcs/liteLLMRPC.js b/backend/webserver/rpcs/liteLLMRPC.js new file mode 100644 index 000000000..43fb9bafe --- /dev/null +++ b/backend/webserver/rpcs/liteLLMRPC.js @@ -0,0 +1,51 @@ +const RPC = require("../RPC.js"); + +/** + * LiteLLMRPC - Routes LLM requests through LiteLLM for external and local model access + * + * Pure passthrough: the caller supplies the model, messages, API key, and any + * provider-specific parameters. Nothing is hardcoded here; the bridge forwards + * everything to LiteLLM as-is. + * + * @class + * @extends RPC + */ +module.exports = class LiteLLMRPC extends RPC { + + constructor(server) { + const url = "ws://" + process.env.RPC_LITELLM_HOST + ":" + process.env.RPC_LITELLM_PORT; + super(server, url); + + this.timeout = 120000; + } + + /** + * Send a chat completion request to LiteLLM. + * All fields in `data` are forwarded to the Python bridge verbatim. + * At minimum the caller must provide `model` and `messages`. + * + * @param {Object} data - Arbitrary params forwarded to litellm.completion() + * @param {string} data.model - Model identifier (provider-specific, e.g. "gpt-4o", "ollama/llama3") + * @param {Array} data.messages - OpenAI-format messages array + * @returns {Promise} LiteLLM response with choices and usage + * @throws {Error} If the RPC service call fails + */ + async chatCompletion(data) { + this.logger.info("Sending chatCompletion request: model=" + data.model); + + const response = await this.emit("chatCompletion", data); + if (!response['success']) { + this.logger.error("chatCompletion error: " + response['message']); + throw new Error(response['message']); + } + return response; + } + + /** + * @returns {Promise} Status including connectivity info + */ + async getStatus() { + const online = await this.isOnline(); + return {online}; + } +} diff --git a/backend/webserver/services/nlp.js b/backend/webserver/services/nlp.js index aa4f01d04..461f6d107 100644 --- a/backend/webserver/services/nlp.js +++ b/backend/webserver/services/nlp.js @@ -18,12 +18,15 @@ module.exports = class NLPService extends Service { super(server, { cmdTypes: [ "skillGetAll", - "skillGetConfig" + "skillGetConfig", + "llmGetStatus" ], resTypes: [ "skillUpdate", "skillConfig", - "skillResults" + "skillResults", + "llmResponse", + "llmStatus" ] }); @@ -33,6 +36,7 @@ module.exports = class NLPService extends Service { this.timer = null; this.nlpSocket = null; + this.litellmEnabled = false; } /** @@ -259,6 +263,8 @@ module.exports = class NLPService extends Service { // check for skill with config to send const skill = this.skills.find(n => this.#hasConfig(n) && n.name === data.name); await this.send(client, "skillConfig", skill ? skill.config : null); + } else if (command === "llmGetStatus") { + await this.#handleLLMGetStatus(client); } else { await super.command(client, command, data); } @@ -338,13 +344,18 @@ module.exports = class NLPService extends Service { } /** - * Overwrite method to handle incoming requests + * Overwrite method to handle incoming requests. + * Routes to LiteLLM for LLM requests (data.type === "llm") or to BrokerIO for skill requests. * @param client * @param data * @return {Promise} */ async request(client, data) { - await this._handleSkillRequest(client, data, false); + if (data.type === "llm") { + await this.#handleLLMRequest(client, data); + } else { + await this._handleSkillRequest(client, data, false); + } } @@ -358,6 +369,80 @@ module.exports = class NLPService extends Service { await this._handleSkillRequest(client, data, true); } + /** + * Returns the LiteLLMRPC instance if available and connected, or null. + * @returns {Object|null} + */ + #getLiteLLMRPC() { + const rpc = this.server.rpcs['LiteLLMRPC']; + return rpc || null; + } + + /** + * Handle an LLM chat completion request by routing it through LiteLLMRPC. + * Forwards the entire payload as-is — the caller controls model, api_key, + * and all provider-specific parameters. + * @param client + * @param data - must include model and messages at minimum + */ + async #handleLLMRequest(client, data) { + const rpc = this.#getLiteLLMRPC(); + if (!rpc) { + this.logger.error("LiteLLM RPC is not registered"); + await this.send(client, "llmResponse", { + id: data.id, + error: "LiteLLM service is not available" + }); + return; + } + + const online = await rpc.isOnline(); + if (!online) { + this.logger.error("LiteLLM RPC is not connected"); + await this.send(client, "llmResponse", { + id: data.id, + error: "LiteLLM service is not connected" + }); + return; + } + + try { + const {id, type, ...llmParams} = data; + const response = await rpc.chatCompletion(llmParams); + + await this.send(client, "llmResponse", { + id: data.id, + ...response.data + }); + } catch (err) { + this.logger.error("LLM request failed: " + err.message); + await this.send(client, "llmResponse", { + id: data.id, + error: err.message + }); + } + } + + /** + * Return LiteLLM RPC status to the client. + * @param client + */ + async #handleLLMGetStatus(client) { + const rpc = this.#getLiteLLMRPC(); + if (!rpc) { + await this.send(client, "llmStatus", {online: false, error: "LiteLLM RPC not registered"}); + return; + } + + try { + const status = await rpc.getStatus(); + await this.send(client, "llmStatus", status); + } catch (err) { + this.logger.error("Failed to get LLM status: " + err.message); + await this.send(client, "llmStatus", {online: false, error: err.message}); + } + } + /** * Load fallbacks for skills if service is not available * @return {Promise<*>} diff --git a/docker-compose.yml b/docker-compose.yml index 8addba609..24fdc9879 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,4 +54,12 @@ services: context: ./utils/rpcs/pdf dockerfile: Dockerfile command: gunicorn --workers 1 --threads 100 --bind 0.0.0.0:8082 'main:create_app()' --access-logfile '-' --error-logfile '-' + restart: unless-stopped + rpc_litellm: + build: + context: ./utils/rpcs/litellm + dockerfile: Dockerfile + command: gunicorn --workers 1 --threads 100 --timeout 120 --bind 0.0.0.0:8083 'main:create_app()' --access-logfile '-' --error-logfile '-' + extra_hosts: + - "host.docker.internal:host-gateway" restart: unless-stopped \ No newline at end of file diff --git a/docker-dev.yml b/docker-dev.yml index 8768a332b..faa3a946a 100644 --- a/docker-dev.yml +++ b/docker-dev.yml @@ -11,4 +11,7 @@ services: - ${RPC_MOODLE_PORT}:8081 rpc_pdf: ports: - - ${RPC_PDF_PORT}:8082 \ No newline at end of file + - ${RPC_PDF_PORT}:8082 + rpc_litellm: + ports: + - ${RPC_LITELLM_PORT}:8083 \ No newline at end of file diff --git a/utils/rpcs/litellm/Dockerfile b/utils/rpcs/litellm/Dockerfile new file mode 100644 index 000000000..69f775f9a --- /dev/null +++ b/utils/rpcs/litellm/Dockerfile @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1 +FROM python:3.10-slim-bullseye + +WORKDIR /usr/src/app + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . + +EXPOSE 8083 + +CMD [ "python", "./main.py"] diff --git a/utils/rpcs/litellm/main.py b/utils/rpcs/litellm/main.py new file mode 100644 index 000000000..17a30ab0b --- /dev/null +++ b/utils/rpcs/litellm/main.py @@ -0,0 +1,84 @@ +import logging +import socketio +import litellm + + +def create_app(): + logging.basicConfig(level=logging.INFO) + logger = logging.getLogger('gunicorn.error') + logger.setLevel(logging.INFO) + + sio = socketio.Server(async_mode='threading', ping_timeout=120, ping_interval=25) + + @sio.event + def connect(sid, environ, auth): + logger.info(f"Connection established with {sid}") + + @sio.on("call") + def call(sid, data): + logger.info(f"Health check call from {sid}") + return {"success": True, "data": "LiteLLM RPC is running"} + + @sio.on("chatCompletion") + def chat_completion(sid, data): + """ + Pure passthrough to litellm.completion(). + Caller must provide 'model' and 'messages'. Everything else is forwarded + as-is to litellm so the caller controls the provider, key, and parameters. + """ + model = data.get("model") + messages = data.get("messages") + + if not model: + return {"success": False, "message": "Missing required field: model"} + if not messages: + return {"success": False, "message": "Missing required field: messages"} + + logger.info(f"chatCompletion from {sid}: model={model}") + + try: + params = {k: v for k, v in data.items() + if k not in ("model", "messages") and v is not None} + + response = litellm.completion( + model=model, + messages=messages, + **params + ) + + result = { + "success": True, + "data": { + "id": response.id, + "model": response.model, + "choices": [ + { + "index": c.index, + "message": { + "role": c.message.role, + "content": c.message.content, + }, + "finish_reason": c.finish_reason, + } + for c in response.choices + ], + "usage": { + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, + "total_tokens": response.usage.total_tokens, + } if response.usage else None, + } + } + logger.info( + f"chatCompletion success: model={response.model}, " + f"tokens={response.usage.total_tokens if response.usage else 'N/A'}" + ) + return result + + except Exception as e: + logger.error(f"chatCompletion error: {e}") + return {"success": False, "message": str(e)} + + logger.info("Creating LiteLLM RPC App...") + app = socketio.WSGIApp(sio) + return app diff --git a/utils/rpcs/litellm/requirements.txt b/utils/rpcs/litellm/requirements.txt new file mode 100644 index 000000000..dad354448 --- /dev/null +++ b/utils/rpcs/litellm/requirements.txt @@ -0,0 +1,4 @@ +python-socketio>=5.13.0 +python-socketio[asyncio]>=5.13.0 +gunicorn>=20.1.0 +litellm From 721b9314db97633c9c46db14f94f88010ebb1fb0 Mon Sep 17 00:00:00 2001 From: Akash Gundapuneni Date: Mon, 30 Mar 2026 16:42:06 +0200 Subject: [PATCH 002/206] chore: update LiteLLM dependency to version 1.82.6 --- utils/rpcs/litellm/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/rpcs/litellm/requirements.txt b/utils/rpcs/litellm/requirements.txt index dad354448..ca73072a0 100644 --- a/utils/rpcs/litellm/requirements.txt +++ b/utils/rpcs/litellm/requirements.txt @@ -1,4 +1,4 @@ python-socketio>=5.13.0 python-socketio[asyncio]>=5.13.0 gunicorn>=20.1.0 -litellm +litellm==1.82.6 From 8f8a27f5e5be280a320ce7eb846f18520b2e1dc7 Mon Sep 17 00:00:00 2001 From: Akash Gundapuneni Date: Mon, 20 Apr 2026 13:53:51 +0200 Subject: [PATCH 003/206] docs: add author annotation to LiteLLMRPC.js file --- backend/webserver/rpcs/liteLLMRPC.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/webserver/rpcs/liteLLMRPC.js b/backend/webserver/rpcs/liteLLMRPC.js index 43fb9bafe..d33fbc6d2 100644 --- a/backend/webserver/rpcs/liteLLMRPC.js +++ b/backend/webserver/rpcs/liteLLMRPC.js @@ -6,7 +6,8 @@ const RPC = require("../RPC.js"); * Pure passthrough: the caller supplies the model, messages, API key, and any * provider-specific parameters. Nothing is hardcoded here; the bridge forwards * everything to LiteLLM as-is. - * + * + * @author Akash Gundapuneni * @class * @extends RPC */ From da9c5404bd3f52015ffa90db4593427668892b29 Mon Sep 17 00:00:00 2001 From: Akash Gundapuneni Date: Mon, 20 Apr 2026 13:58:16 +0200 Subject: [PATCH 004/206] feat: implement AIService for handling AI/LLM requests and integrate with frontend --- backend/webserver/services/ai.js | 99 ++++++++++++++++++++++++++++ backend/webserver/services/nlp.js | 92 ++------------------------ backend/webserver/sockets/service.js | 22 +++++-- frontend/src/main.js | 5 ++ frontend/src/plugins/ai.js | 93 ++++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 94 deletions(-) create mode 100644 backend/webserver/services/ai.js create mode 100644 frontend/src/plugins/ai.js diff --git a/backend/webserver/services/ai.js b/backend/webserver/services/ai.js new file mode 100644 index 000000000..f74235f94 --- /dev/null +++ b/backend/webserver/services/ai.js @@ -0,0 +1,99 @@ +const Service = require("../Service.js"); + +/** + * AIService - handles AI / LLM requests from the frontend. + * + * The client emits a `serviceCommand` with an ack callback and gets the + * response back on that same callback (no `serviceRefresh` push events). + * + * Supported commands: + * - chatCompletion(data): forward the payload to LiteLLM as-is + * - getStatus(): report whether LiteLLM is reachable + * + * @class + * @author Akash Gundapuneni + * @extends Service + */ +module.exports = class AIService extends Service { + constructor(server) { + super(server, { + cmdTypes: [ + "chatCompletion", + "getStatus" + ], + resTypes: [] + }); + } + + /** + * Route a command to the matching handler. + * Return values / thrown errors are forwarded to the client's ack callback + * by Socket.createSocket as {success, data} or {success:false, message}. + * + * @param {object} client + * @param {string} command + * @param {object} data + * @returns {Promise<*>} + */ + async command(client, command, data) { + switch (command) { + case "chatCompletion": + return await this.chatCompletion(data); + case "getStatus": + return await this.getStatus(); + default: + return await super.command(client, command, data); + } + } + + /** + * @returns {Object|null} The LiteLLMRPC instance, or null if not registered. + */ + #getRPC() { + return this.server.rpcs['LiteLLMRPC'] || null; + } + + /** + * Send a chat completion request to LiteLLM. + * The payload (model, messages, api_key, ...) is forwarded as-is. + * + * @param {object} data + * @param {string} data.model + * @param {Array} data.messages + * @returns {Promise} LiteLLM response (choices, usage, ...) + * @throws {Error} if LiteLLM is unavailable or the call fails + */ + async chatCompletion(data) { + const rpc = this.#getRPC(); + if (!rpc) { + this.logger.error("LiteLLM RPC is not registered"); + throw new Error("LiteLLM service is not available"); + } + if (!(await rpc.isOnline())) { + this.logger.error("LiteLLM RPC is not connected"); + throw new Error("LiteLLM service is not connected"); + } + + const response = await rpc.chatCompletion(data); + return response.data !== undefined ? response.data : response; + } + + /** + * Report LiteLLM connection status. + * Never throws - returns an object so the UI can render state directly. + * + * @returns {Promise<{online: boolean, error?: string}>} + */ + async getStatus() { + const rpc = this.#getRPC(); + if (!rpc) { + return {online: false, error: "LiteLLM RPC not registered"}; + } + try { + return await rpc.getStatus(); + } catch (err) { + this.logger.error("Failed to get LLM status: " + err.message); + return {online: false, error: err.message}; + } + } +}; diff --git a/backend/webserver/services/nlp.js b/backend/webserver/services/nlp.js index 461f6d107..570f4d75f 100644 --- a/backend/webserver/services/nlp.js +++ b/backend/webserver/services/nlp.js @@ -18,15 +18,12 @@ module.exports = class NLPService extends Service { super(server, { cmdTypes: [ "skillGetAll", - "skillGetConfig", - "llmGetStatus" + "skillGetConfig" ], resTypes: [ "skillUpdate", "skillConfig", - "skillResults", - "llmResponse", - "llmStatus" + "skillResults" ] }); @@ -36,7 +33,6 @@ module.exports = class NLPService extends Service { this.timer = null; this.nlpSocket = null; - this.litellmEnabled = false; } /** @@ -263,8 +259,6 @@ module.exports = class NLPService extends Service { // check for skill with config to send const skill = this.skills.find(n => this.#hasConfig(n) && n.name === data.name); await this.send(client, "skillConfig", skill ? skill.config : null); - } else if (command === "llmGetStatus") { - await this.#handleLLMGetStatus(client); } else { await super.command(client, command, data); } @@ -345,17 +339,13 @@ module.exports = class NLPService extends Service { /** * Overwrite method to handle incoming requests. - * Routes to LiteLLM for LLM requests (data.type === "llm") or to BrokerIO for skill requests. + * Forwards skill requests to the NLP broker. * @param client * @param data * @return {Promise} */ async request(client, data) { - if (data.type === "llm") { - await this.#handleLLMRequest(client, data); - } else { - await this._handleSkillRequest(client, data, false); - } + await this._handleSkillRequest(client, data, false); } @@ -369,80 +359,6 @@ module.exports = class NLPService extends Service { await this._handleSkillRequest(client, data, true); } - /** - * Returns the LiteLLMRPC instance if available and connected, or null. - * @returns {Object|null} - */ - #getLiteLLMRPC() { - const rpc = this.server.rpcs['LiteLLMRPC']; - return rpc || null; - } - - /** - * Handle an LLM chat completion request by routing it through LiteLLMRPC. - * Forwards the entire payload as-is — the caller controls model, api_key, - * and all provider-specific parameters. - * @param client - * @param data - must include model and messages at minimum - */ - async #handleLLMRequest(client, data) { - const rpc = this.#getLiteLLMRPC(); - if (!rpc) { - this.logger.error("LiteLLM RPC is not registered"); - await this.send(client, "llmResponse", { - id: data.id, - error: "LiteLLM service is not available" - }); - return; - } - - const online = await rpc.isOnline(); - if (!online) { - this.logger.error("LiteLLM RPC is not connected"); - await this.send(client, "llmResponse", { - id: data.id, - error: "LiteLLM service is not connected" - }); - return; - } - - try { - const {id, type, ...llmParams} = data; - const response = await rpc.chatCompletion(llmParams); - - await this.send(client, "llmResponse", { - id: data.id, - ...response.data - }); - } catch (err) { - this.logger.error("LLM request failed: " + err.message); - await this.send(client, "llmResponse", { - id: data.id, - error: err.message - }); - } - } - - /** - * Return LiteLLM RPC status to the client. - * @param client - */ - async #handleLLMGetStatus(client) { - const rpc = this.#getLiteLLMRPC(); - if (!rpc) { - await this.send(client, "llmStatus", {online: false, error: "LiteLLM RPC not registered"}); - return; - } - - try { - const status = await rpc.getStatus(); - await this.send(client, "llmStatus", status); - } catch (err) { - this.logger.error("Failed to get LLM status: " + err.message); - await this.send(client, "llmStatus", {online: false, error: err.message}); - } - } - /** * Load fallbacks for skills if service is not available * @return {Promise<*>} diff --git a/backend/webserver/sockets/service.js b/backend/webserver/sockets/service.js index e1a4068aa..006bb24cf 100644 --- a/backend/webserver/sockets/service.js +++ b/backend/webserver/sockets/service.js @@ -43,33 +43,43 @@ class ServiceSocket extends Socket { /** * Request a service (with default command) - * + * + * The return value of the service's `request` method is forwarded to the + * socket.io ack callback (via Socket.createSocket) so services that + * follow the new ack-based pattern can respond directly instead of + * pushing `serviceRefresh` events. + * * @socketEvent serviceRequest * @param {Object} data The input data * @param {string} data.service The name of the service to disconnect * @param {Object} data.data Additional data to pass to the service * @param {Object} options Additional configuration parameters (currently unused). - * @returns {Promise} A promise that resolves (with no value) once the service request attempt is complete. + * @returns {Promise<*>} The result returned by the service, if any. */ async requestService(data, options) { if (this.server.services[data.service]) { - await this.server.services[data.service].request(this, data.data); + return await this.server.services[data.service].request(this, data.data); } } /** * Request a service but with a specific command - * + * + * The return value of the service's `command` method is forwarded to the + * socket.io ack callback (via Socket.createSocket). Services using the + * legacy push flow can still return undefined without affecting clients + * that do not pass a callback. + * * @socketEvent serviceCommand * @param {Object} data The input data * @param {string} data.service The name of the service to disconnect * @param {Object} data.data Additional data to pass to the service * @param {Object} options Additional configuration parameters (currently unused). - * @returns {Promise} A promise that resolves (with no value) once the service command attempt is complete. + * @returns {Promise<*>} The result returned by the service, if any. */ async serviceCommand(data, options) { if (this.server.services[data.service]) { - await this.server.services[data.service].command(this, data.command, data.data); + return await this.server.services[data.service].command(this, data.command, data.data); } } diff --git a/frontend/src/main.js b/frontend/src/main.js index 8ad1b481d..7a7fedd34 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -75,4 +75,9 @@ import subscribeTable from "@/plugins/subscribeTable"; app.use(subscribeTable); +// Expose `this.$ai` to every component for AI / LLM requests via ack callbacks +import ai from "@/plugins/ai"; + +app.use(ai); + router.isReady().then(() => app.mount('#app')); diff --git a/frontend/src/plugins/ai.js b/frontend/src/plugins/ai.js new file mode 100644 index 000000000..1b517c190 --- /dev/null +++ b/frontend/src/plugins/ai.js @@ -0,0 +1,93 @@ +/** + * AI plugin - exposes `this.$ai` to every Vue component. + * + * Lets any component send AI requests to the backend AIService without + * mounting a dedicated component. Each call emits `serviceCommand` with + * an ack callback and returns a Promise that resolves with the response + * or rejects with an Error. + * + * Usage: + * const reply = await this.$ai.chatCompletion({ model, messages }); + * const status = await this.$ai.getStatus(); + * + * @author Akash Gundapuneni + */ + +// LiteLLM server-side timeout is 120s. Keep a small buffer so the real +// server error reaches the caller before the client gives up. +const DEFAULT_TIMEOUT_MS = 130000; + +/** + * Emit a `serviceCommand` and wrap the ack callback in a Promise. + * + * @param {object} socket vue-3-socket.io $socket + * @param {string} command AIService command name + * @param {object} data payload + * @param {number} timeoutMs client-side timeout + * @returns {Promise<*>} resolves with response.data; rejects with Error + */ +const emitAiCommand = (socket, command, data = {}, timeoutMs = DEFAULT_TIMEOUT_MS) => { + return new Promise((resolve, reject) => { + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error(`AI request timed out after ${timeoutMs}ms (command: ${command})`)); + }, timeoutMs); + + socket.emit("serviceCommand", { + service: "AIService", + command, + data, + }, (response) => { + if (settled) return; + settled = true; + clearTimeout(timer); + + if (!response) { + reject(new Error("No response received from AIService")); + return; + } + if (response.success) { + resolve(response.data); + } else { + reject(new Error(response.message || "AIService request failed")); + } + }); + }); +}; + +export default { + install: (app) => { + app.mixin({ + computed: { + // `this.$ai` binds the component's $socket to the AIService helpers. + // Defined as a computed so $socket is resolved per component. + $ai() { + const socket = this.$socket; + return { + /** + * Send a chat completion request. + * @param {object} params - at minimum `model` and `messages` + * @param {object} [opts] + * @param {number} [opts.timeout] - override client-side timeout (ms) + * @returns {Promise} + */ + chatCompletion(params, opts = {}) { + return emitAiCommand(socket, "chatCompletion", params, opts.timeout); + }, + + /** + * Get current LiteLLM / AIService connection status. + * @returns {Promise<{online: boolean, error?: string}>} + */ + getStatus() { + return emitAiCommand(socket, "getStatus", {}, 10000); + }, + }; + }, + }, + }); + }, +}; From ed479b5f6b56f678adbebfe78c10818dfb49837c Mon Sep 17 00:00:00 2001 From: Akash Gundapuneni Date: Mon, 20 Apr 2026 14:06:40 +0200 Subject: [PATCH 005/206] refactor: rename RPC call tests and update health check implementation across services --- backend/tests/rpcs/RPCtest.test.js | 10 +-- backend/webserver/RPC.js | 102 ++++++++++++++++++--------- backend/webserver/rpcs/liteLLMRPC.js | 8 --- backend/webserver/services/ai.js | 21 ++++-- utils/rpcs/litellm/main.py | 72 ++++++++++--------- utils/rpcs/moodleAPI/main.py | 17 ++--- utils/rpcs/pdf/main.py | 21 ++---- utils/rpcs/test/main.py | 8 +-- 8 files changed, 148 insertions(+), 111 deletions(-) diff --git a/backend/tests/rpcs/RPCtest.test.js b/backend/tests/rpcs/RPCtest.test.js index 4b4d0ff16..56968a8fc 100644 --- a/backend/tests/rpcs/RPCtest.test.js +++ b/backend/tests/rpcs/RPCtest.test.js @@ -3,9 +3,9 @@ const Server = require("../../webserver/Server.js"); describe('Test RPC call', () => { /** - * Test the RPC call + * Test the RPC healthy probe */ - test('Test call', async () => { + test('Test healthy', async () => { let server = new Server(); // wait until RPCtest service is connected @@ -14,9 +14,9 @@ describe('Test RPC call', () => { // check status expect(await server.rpcs["RPCtest"].isOnline()).toEqual(true); - // call rpc and check response - const response = await server.rpcs["RPCtest"].call("Hello") - expect(response).toEqual("World!") + // run healthy probe and check response + const response = await server.rpcs["RPCtest"].healthy(); + expect(response).toEqual("World!"); server.stop(); diff --git a/backend/webserver/RPC.js b/backend/webserver/RPC.js index a9abad3b8..16bab5da5 100644 --- a/backend/webserver/RPC.js +++ b/backend/webserver/RPC.js @@ -139,64 +139,98 @@ module.exports = class RPC { } /** - * This method returns the status of the RPC. + * Returns a combined status for this RPC: + * - `online`: whether the local socket is connected + * - `health`: the payload returned by the Python RPC's "healthy" handler + * (omitted if the RPC does not implement it) + * - `error`: the error message if the health probe failed for any other reason * - * @returns {Promise<{}>} an object describing the status + * @returns {Promise<{online: boolean, health?: object, error?: string}>} */ async getStatus() { - return {}; + const online = await this.isOnline(); + if (!online) { + return {online: false}; + } + try { + const health = await this.healthy(); + return {online: true, health}; + } catch (err) { + if (err.message === "Not Implemented") { + return {online: true}; + } + return {online: true, error: err.message}; + } } /** - * This emits an event to the RPC service with handling the acknowledgement response accordingly + * Emits an event to the RPC service and returns the ack response. * - * @param event - * @param data + * @param {string} event + * @param {*} data + * @param {number} [timeoutMs] override of the default `this.timeout` for this call * @returns {Promise} */ - async emit(event, data) { + async emit(event, data, timeoutMs = this.timeout) { this.logger.info("Emitting event to RPC service..."); if (!this.socket) { throw new Error("RPC service not connected"); } - if (!this.socket) { - throw new Error("RPC service not connected"); - } - - try { - return new Promise((resolve, reject) => { - this.socket.timeout(this.timeout).emit(event, data, (err, response) => { - if (err) { - this.logger.error(err); - reject(err); - } else { - this.logger.info(response); - resolve(response); - } - }); + return new Promise((resolve, reject) => { + this.socket.timeout(timeoutMs).emit(event, data, (err, response) => { + if (err) { + this.logger.error(err); + reject(err); + } else { + this.logger.info(response); + resolve(response); + } }); - - } catch (err) { - throw err; - } + }); } /** - * This method should be overwritten to handle the call to the RPC service - * @param data - * @returns {Promise<*>} + * Standard health check for an RPC. Emits a "healthy" event to the + * Python RPC service and returns its response. + * + * By convention, Python RPCs that implement a health check respond with + * either: + * {success: true, data: {...}} - service is healthy + * {success: false, message: "Not Implemented"} - opt-out + * + * Throws Error("Not Implemented") when: + * - the Python RPC explicitly replied with that message + * - the ack times out (python-socketio silently drops events without a + * registered handler, so timeout is our best signal for a missing + * implementation on the other side) + * + * Other errors (e.g. transport failures) propagate as-is. + * + * @param {number} [timeoutMs=5000] ack timeout in ms + * @returns {Promise<*>} the `data` returned by the Python RPC + * @throws {Error} */ - async call(data) { - this.logger.info("Calling RPC service..."); - + async healthy(timeoutMs = 5000) { + let response; try { - return this.emit("call", data); + response = await this.emit("healthy", {}, timeoutMs); } catch (err) { - throw err + if (err && err.message && /timed out|timeout/i.test(err.message)) { + throw new Error("Not Implemented"); + } + throw err; + } + + if (response && response.success === false) { + if (response.message === "Not Implemented") { + throw new Error("Not Implemented"); + } + throw new Error(response.message || "Health check failed"); } + return response && response.data !== undefined ? response.data : response; } } \ No newline at end of file diff --git a/backend/webserver/rpcs/liteLLMRPC.js b/backend/webserver/rpcs/liteLLMRPC.js index d33fbc6d2..7ff1215b1 100644 --- a/backend/webserver/rpcs/liteLLMRPC.js +++ b/backend/webserver/rpcs/liteLLMRPC.js @@ -41,12 +41,4 @@ module.exports = class LiteLLMRPC extends RPC { } return response; } - - /** - * @returns {Promise} Status including connectivity info - */ - async getStatus() { - const online = await this.isOnline(); - return {online}; - } } diff --git a/backend/webserver/services/ai.js b/backend/webserver/services/ai.js index f74235f94..f8fb4e090 100644 --- a/backend/webserver/services/ai.js +++ b/backend/webserver/services/ai.js @@ -54,13 +54,16 @@ module.exports = class AIService extends Service { } /** - * Send a chat completion request to LiteLLM. - * The payload (model, messages, api_key, ...) is forwarded as-is. + * Forward a chat completion request to LiteLLM. + * Payload (model, messages, api_key, ...) is passed through untouched. + * + * The full response is logged server-side; only `choices` is returned + * to the frontend. Add more fields here if a client needs them. * * @param {object} data * @param {string} data.model * @param {Array} data.messages - * @returns {Promise} LiteLLM response (choices, usage, ...) + * @returns {Promise<{choices: Array}>} * @throws {Error} if LiteLLM is unavailable or the call fails */ async chatCompletion(data) { @@ -75,7 +78,17 @@ module.exports = class AIService extends Service { } const response = await rpc.chatCompletion(data); - return response.data !== undefined ? response.data : response; + const payload = response.data !== undefined ? response.data : response; + + const {choices = [], usage, model, id} = payload || {}; + const finishReasons = choices.map(c => c.finish_reason).filter(Boolean); + this.logger.info( + `chatCompletion: id=${id} model=${model} ` + + `tokens=${usage ? usage.total_tokens : "N/A"} ` + + `finish=${finishReasons.join(",") || "N/A"}` + ); + + return {choices}; } /** diff --git a/utils/rpcs/litellm/main.py b/utils/rpcs/litellm/main.py index 17a30ab0b..6530ed868 100644 --- a/utils/rpcs/litellm/main.py +++ b/utils/rpcs/litellm/main.py @@ -14,17 +14,32 @@ def create_app(): def connect(sid, environ, auth): logger.info(f"Connection established with {sid}") - @sio.on("call") - def call(sid, data): - logger.info(f"Health check call from {sid}") - return {"success": True, "data": "LiteLLM RPC is running"} + @sio.on("healthy") + def healthy(sid, data): + """ + Liveness probe: process is up and litellm is importable. + No model check - credentials arrive per-request, so there's nothing + to verify upfront. See litellm /health/liveliness for the same idea. + """ + logger.info(f"Health check from {sid}") + try: + return { + "success": True, + "data": { + "status": "ok", + "litellm_version": getattr(litellm, "__version__", "unknown"), + }, + } + except Exception as e: + logger.error(f"Health check error: {e}") + return {"success": False, "message": str(e)} @sio.on("chatCompletion") def chat_completion(sid, data): """ - Pure passthrough to litellm.completion(). - Caller must provide 'model' and 'messages'. Everything else is forwarded - as-is to litellm so the caller controls the provider, key, and parameters. + Passthrough to litellm.completion(). Caller supplies `model` and + `messages` (required); all other keys are forwarded verbatim, so the + caller controls provider, API key, and any extra parameters. """ model = data.get("model") messages = data.get("messages") @@ -43,37 +58,26 @@ def chat_completion(sid, data): response = litellm.completion( model=model, messages=messages, - **params + **params, ) - result = { - "success": True, - "data": { - "id": response.id, - "model": response.model, - "choices": [ - { - "index": c.index, - "message": { - "role": c.message.role, - "content": c.message.content, - }, - "finish_reason": c.finish_reason, - } - for c in response.choices - ], - "usage": { - "prompt_tokens": response.usage.prompt_tokens, - "completion_tokens": response.usage.completion_tokens, - "total_tokens": response.usage.total_tokens, - } if response.usage else None, - } - } + # Return the full response as-is. ModelResponse is a pydantic + # model, so model_dump() gives the complete OpenAI-compatible + # dict without hand-mapping (which would silently drop any new + # fields litellm / the provider adds). + if hasattr(response, "model_dump"): + response_data = response.model_dump() + elif hasattr(response, "dict"): + response_data = response.dict() + else: + response_data = dict(response) + + usage = response_data.get("usage") or {} logger.info( - f"chatCompletion success: model={response.model}, " - f"tokens={response.usage.total_tokens if response.usage else 'N/A'}" + f"chatCompletion success: model={response_data.get('model')}, " + f"tokens={usage.get('total_tokens', 'N/A')}" ) - return result + return {"success": True, "data": response_data} except Exception as e: logger.error(f"chatCompletion error: {e}") diff --git a/utils/rpcs/moodleAPI/main.py b/utils/rpcs/moodleAPI/main.py index c66ae6412..63237a6cd 100644 --- a/utils/rpcs/moodleAPI/main.py +++ b/utils/rpcs/moodleAPI/main.py @@ -26,16 +26,17 @@ def create_app(): def connect(sid, environ, auth): logger.info(f"Connection established with {sid}") - @sio.on("call") - def call(sid, data): - logger.info(f"Received call: {data} from {sid}") + @sio.on("healthy") + def healthy(sid, data): + """ + Liveness probe. Reports that the Moodle RPC process is up and responsive. + """ + logger.info(f"Health check from {sid}") try: - response = {"success": True, "data": "Hello World!"} - return response + return {"success": True, "data": {"status": "ok"}} except Exception as e: - logger.error(f"Error: {e}") - response = {"success": False, "message": "error: " + str(e)} - return response + logger.error(f"Health check error: {e}") + return {"success": False, "message": str(e)} @sio.on("test") def test(sid, data): diff --git a/utils/rpcs/pdf/main.py b/utils/rpcs/pdf/main.py index b5cbffb3e..aebdcd123 100644 --- a/utils/rpcs/pdf/main.py +++ b/utils/rpcs/pdf/main.py @@ -30,24 +30,17 @@ def connect(sid, environ, auth): """ logger.info(f"Connection established with {sid}") - @sio.on("call") - def call(sid, data): + @sio.on("healthy") + def healthy(sid, data): """ - Handles a generic 'call' event for testing connectivity. - Args: - sid: Session ID. - data: Incoming data. - Returns: - A simple hello world response. + Liveness probe. Reports that the PDF RPC process is up and responsive. """ - logger.info(f"Received call: {data} from {sid}") + logger.info(f"Health check from {sid}") try: - response = {"success": True, "data": "Hello World!"} - return response + return {"success": True, "data": {"status": "ok"}} except Exception as e: - logger.error(f"Error: {e}") - response = {"success": False, "message": "error: " + str(e)} - return response + logger.error(f"Health check error: {e}") + return {"success": False, "message": str(e)} @sio.on("test") def test(sid, data): diff --git a/utils/rpcs/test/main.py b/utils/rpcs/test/main.py index b29bd61c4..d764c4ad5 100644 --- a/utils/rpcs/test/main.py +++ b/utils/rpcs/test/main.py @@ -15,10 +15,10 @@ def create_app(): def connect(sid, environ, auth): logger.info(f"Connection established with {sid}") - @sio.on("call") - def call(sid, data): - logger.info(f"Received call: {data} from {sid}") - return "World!" + @sio.on("healthy") + def healthy(sid, data): + logger.info(f"Health check from {sid}") + return {"success": True, "data": "World!"} logger.info("Creating App...") From a654e9eff18cce2a21d8ed8bf51e03af37c29b42 Mon Sep 17 00:00:00 2001 From: Akash Gundapuneni Date: Wed, 22 Apr 2026 16:11:21 +0200 Subject: [PATCH 006/206] feat: add abortChatCompletion functionality to AIService and LiteLLMRPC, enabling request cancellation --- backend/webserver/rpcs/liteLLMRPC.js | 17 ++++++++++++ backend/webserver/services/ai.js | 31 +++++++++++++++++++++ docker-compose.yml | 2 +- frontend/src/plugins/ai.js | 40 +++++++++++++++++++++++++++- utils/rpcs/litellm/main.py | 25 +++++++++++++++-- 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/backend/webserver/rpcs/liteLLMRPC.js b/backend/webserver/rpcs/liteLLMRPC.js index 7ff1215b1..094eef828 100644 --- a/backend/webserver/rpcs/liteLLMRPC.js +++ b/backend/webserver/rpcs/liteLLMRPC.js @@ -41,4 +41,21 @@ module.exports = class LiteLLMRPC extends RPC { } return response; } + + /** + * Best-effort cancellation of an in-flight chat completion. + * @param {Object} data + * @param {string} data.requestId + * @returns {Promise} + */ + async abortChatCompletion(data) { + this.logger.info("Sending abortChatCompletion request: requestId=" + data.requestId); + + const response = await this.emit("abortChatCompletion", data); + if (!response['success']) { + this.logger.error("abortChatCompletion error: " + response['message']); + throw new Error(response['message']); + } + return response; + } } diff --git a/backend/webserver/services/ai.js b/backend/webserver/services/ai.js index f8fb4e090..225406af7 100644 --- a/backend/webserver/services/ai.js +++ b/backend/webserver/services/ai.js @@ -8,6 +8,7 @@ const Service = require("../Service.js"); * * Supported commands: * - chatCompletion(data): forward the payload to LiteLLM as-is + * - abortChatCompletion({requestId}): cancel a pending request * - getStatus(): report whether LiteLLM is reachable * * @class @@ -19,6 +20,7 @@ module.exports = class AIService extends Service { super(server, { cmdTypes: [ "chatCompletion", + "abortChatCompletion", "getStatus" ], resTypes: [] @@ -39,6 +41,8 @@ module.exports = class AIService extends Service { switch (command) { case "chatCompletion": return await this.chatCompletion(data); + case "abortChatCompletion": + return await this.abortChatCompletion(data); case "getStatus": return await this.getStatus(); default: @@ -91,6 +95,33 @@ module.exports = class AIService extends Service { return {choices}; } + /** + * Best-effort cancellation of a pending chat completion. + * @param {object} data + * @param {string} data.requestId + * @returns {Promise<{aborted: boolean, requestId: string}>} + */ + async abortChatCompletion(data = {}) { + const {requestId} = data; + if (!requestId) { + throw new Error("Missing required field: requestId"); + } + + const rpc = this.#getRPC(); + if (!rpc) { + this.logger.error("LiteLLM RPC is not registered"); + throw new Error("LiteLLM service is not available"); + } + if (!(await rpc.isOnline())) { + this.logger.error("LiteLLM RPC is not connected"); + throw new Error("LiteLLM service is not connected"); + } + + await rpc.abortChatCompletion({requestId}); + this.logger.info(`abortChatCompletion: requestId=${requestId}`); + return {aborted: true, requestId}; + } + /** * Report LiteLLM connection status. * Never throws - returns an object so the UI can render state directly. diff --git a/docker-compose.yml b/docker-compose.yml index 24fdc9879..06a58364a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: build: context: ./utils/rpcs/litellm dockerfile: Dockerfile - command: gunicorn --workers 1 --threads 100 --timeout 120 --bind 0.0.0.0:8083 'main:create_app()' --access-logfile '-' --error-logfile '-' + command: gunicorn --workers 1 --threads 100 --bind 0.0.0.0:8083 'main:create_app()' --access-logfile '-' --error-logfile '-' extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped \ No newline at end of file diff --git a/frontend/src/plugins/ai.js b/frontend/src/plugins/ai.js index 1b517c190..c1f1ebf71 100644 --- a/frontend/src/plugins/ai.js +++ b/frontend/src/plugins/ai.js @@ -72,10 +72,39 @@ export default { * @param {object} params - at minimum `model` and `messages` * @param {object} [opts] * @param {number} [opts.timeout] - override client-side timeout (ms) + * @param {AbortSignal} [opts.signal] - optional request cancellation signal * @returns {Promise} */ chatCompletion(params, opts = {}) { - return emitAiCommand(socket, "chatCompletion", params, opts.timeout); + const requestId = params.requestId || (globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`); + const payload = {...params, requestId}; + const request = emitAiCommand(socket, "chatCompletion", payload, opts.timeout); + + // Keep abort handling local to chatCompletion for minimal plugin changes. + if (!opts.signal) return request; + + const emitAbort = () => { + socket.emit("serviceCommand", { + service: "AIService", + command: "abortChatCompletion", + data: {requestId}, + }); + }; + + if (opts.signal.aborted) { + emitAbort(); + return Promise.reject(new Error("AI request aborted (command: chatCompletion)")); + } + + return Promise.race([ + request, + new Promise((_, reject) => { + opts.signal.addEventListener("abort", () => { + emitAbort(); + reject(new Error("AI request aborted (command: chatCompletion)")); + }, {once: true}); + }), + ]); }, /** @@ -85,6 +114,15 @@ export default { getStatus() { return emitAiCommand(socket, "getStatus", {}, 10000); }, + + /** + * Explicitly abort a pending chat completion by requestId. + * @param {string} requestId + * @returns {Promise} + */ + abortChatCompletion(requestId) { + return emitAiCommand(socket, "abortChatCompletion", {requestId}, 10000); + }, }; }, }, diff --git a/utils/rpcs/litellm/main.py b/utils/rpcs/litellm/main.py index 6530ed868..171ffd11e 100644 --- a/utils/rpcs/litellm/main.py +++ b/utils/rpcs/litellm/main.py @@ -9,6 +9,7 @@ def create_app(): logger.setLevel(logging.INFO) sio = socketio.Server(async_mode='threading', ping_timeout=120, ping_interval=25) + aborted_request_ids = set() @sio.event def connect(sid, environ, auth): @@ -43,17 +44,22 @@ def chat_completion(sid, data): """ model = data.get("model") messages = data.get("messages") + request_id = data.get("requestId") if not model: return {"success": False, "message": "Missing required field: model"} if not messages: return {"success": False, "message": "Missing required field: messages"} + if request_id and request_id in aborted_request_ids: + aborted_request_ids.discard(request_id) + logger.info(f"chatCompletion skipped (already aborted): requestId={request_id}") + return {"success": False, "message": "Request aborted"} - logger.info(f"chatCompletion from {sid}: model={model}") + logger.info(f"chatCompletion from {sid}: model={model}, requestId={request_id or 'N/A'}") try: params = {k: v for k, v in data.items() - if k not in ("model", "messages") and v is not None} + if k not in ("model", "messages", "requestId") and v is not None} response = litellm.completion( model=model, @@ -61,6 +67,11 @@ def chat_completion(sid, data): **params, ) + if request_id and request_id in aborted_request_ids: + aborted_request_ids.discard(request_id) + logger.info(f"chatCompletion aborted after completion: requestId={request_id}") + return {"success": False, "message": "Request aborted"} + # Return the full response as-is. ModelResponse is a pydantic # model, so model_dump() gives the complete OpenAI-compatible # dict without hand-mapping (which would silently drop any new @@ -83,6 +94,16 @@ def chat_completion(sid, data): logger.error(f"chatCompletion error: {e}") return {"success": False, "message": str(e)} + @sio.on("abortChatCompletion") + def abort_chat_completion(sid, data): + request_id = (data or {}).get("requestId") + if not request_id: + return {"success": False, "message": "Missing required field: requestId"} + + aborted_request_ids.add(request_id) + logger.info(f"abortChatCompletion from {sid}: requestId={request_id}") + return {"success": True, "data": {"aborted": True, "requestId": request_id}} + logger.info("Creating LiteLLM RPC App...") app = socketio.WSGIApp(sio) return app From b707e58b79543bdc0acc5d01ff8fe73a75238769 Mon Sep 17 00:00:00 2001 From: Akash Gundapuneni Date: Wed, 22 Apr 2026 16:13:12 +0200 Subject: [PATCH 007/206] Revert "feat: add abortChatCompletion functionality to AIService and LiteLLMRPC, enabling request cancellation" This reverts commit a654e9eff18cce2a21d8ed8bf51e03af37c29b42. --- backend/webserver/rpcs/liteLLMRPC.js | 17 ------------ backend/webserver/services/ai.js | 31 --------------------- docker-compose.yml | 2 +- frontend/src/plugins/ai.js | 40 +--------------------------- utils/rpcs/litellm/main.py | 25 ++--------------- 5 files changed, 4 insertions(+), 111 deletions(-) diff --git a/backend/webserver/rpcs/liteLLMRPC.js b/backend/webserver/rpcs/liteLLMRPC.js index 094eef828..7ff1215b1 100644 --- a/backend/webserver/rpcs/liteLLMRPC.js +++ b/backend/webserver/rpcs/liteLLMRPC.js @@ -41,21 +41,4 @@ module.exports = class LiteLLMRPC extends RPC { } return response; } - - /** - * Best-effort cancellation of an in-flight chat completion. - * @param {Object} data - * @param {string} data.requestId - * @returns {Promise} - */ - async abortChatCompletion(data) { - this.logger.info("Sending abortChatCompletion request: requestId=" + data.requestId); - - const response = await this.emit("abortChatCompletion", data); - if (!response['success']) { - this.logger.error("abortChatCompletion error: " + response['message']); - throw new Error(response['message']); - } - return response; - } } diff --git a/backend/webserver/services/ai.js b/backend/webserver/services/ai.js index 225406af7..f8fb4e090 100644 --- a/backend/webserver/services/ai.js +++ b/backend/webserver/services/ai.js @@ -8,7 +8,6 @@ const Service = require("../Service.js"); * * Supported commands: * - chatCompletion(data): forward the payload to LiteLLM as-is - * - abortChatCompletion({requestId}): cancel a pending request * - getStatus(): report whether LiteLLM is reachable * * @class @@ -20,7 +19,6 @@ module.exports = class AIService extends Service { super(server, { cmdTypes: [ "chatCompletion", - "abortChatCompletion", "getStatus" ], resTypes: [] @@ -41,8 +39,6 @@ module.exports = class AIService extends Service { switch (command) { case "chatCompletion": return await this.chatCompletion(data); - case "abortChatCompletion": - return await this.abortChatCompletion(data); case "getStatus": return await this.getStatus(); default: @@ -95,33 +91,6 @@ module.exports = class AIService extends Service { return {choices}; } - /** - * Best-effort cancellation of a pending chat completion. - * @param {object} data - * @param {string} data.requestId - * @returns {Promise<{aborted: boolean, requestId: string}>} - */ - async abortChatCompletion(data = {}) { - const {requestId} = data; - if (!requestId) { - throw new Error("Missing required field: requestId"); - } - - const rpc = this.#getRPC(); - if (!rpc) { - this.logger.error("LiteLLM RPC is not registered"); - throw new Error("LiteLLM service is not available"); - } - if (!(await rpc.isOnline())) { - this.logger.error("LiteLLM RPC is not connected"); - throw new Error("LiteLLM service is not connected"); - } - - await rpc.abortChatCompletion({requestId}); - this.logger.info(`abortChatCompletion: requestId=${requestId}`); - return {aborted: true, requestId}; - } - /** * Report LiteLLM connection status. * Never throws - returns an object so the UI can render state directly. diff --git a/docker-compose.yml b/docker-compose.yml index 06a58364a..24fdc9879 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: build: context: ./utils/rpcs/litellm dockerfile: Dockerfile - command: gunicorn --workers 1 --threads 100 --bind 0.0.0.0:8083 'main:create_app()' --access-logfile '-' --error-logfile '-' + command: gunicorn --workers 1 --threads 100 --timeout 120 --bind 0.0.0.0:8083 'main:create_app()' --access-logfile '-' --error-logfile '-' extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped \ No newline at end of file diff --git a/frontend/src/plugins/ai.js b/frontend/src/plugins/ai.js index c1f1ebf71..1b517c190 100644 --- a/frontend/src/plugins/ai.js +++ b/frontend/src/plugins/ai.js @@ -72,39 +72,10 @@ export default { * @param {object} params - at minimum `model` and `messages` * @param {object} [opts] * @param {number} [opts.timeout] - override client-side timeout (ms) - * @param {AbortSignal} [opts.signal] - optional request cancellation signal * @returns {Promise} */ chatCompletion(params, opts = {}) { - const requestId = params.requestId || (globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`); - const payload = {...params, requestId}; - const request = emitAiCommand(socket, "chatCompletion", payload, opts.timeout); - - // Keep abort handling local to chatCompletion for minimal plugin changes. - if (!opts.signal) return request; - - const emitAbort = () => { - socket.emit("serviceCommand", { - service: "AIService", - command: "abortChatCompletion", - data: {requestId}, - }); - }; - - if (opts.signal.aborted) { - emitAbort(); - return Promise.reject(new Error("AI request aborted (command: chatCompletion)")); - } - - return Promise.race([ - request, - new Promise((_, reject) => { - opts.signal.addEventListener("abort", () => { - emitAbort(); - reject(new Error("AI request aborted (command: chatCompletion)")); - }, {once: true}); - }), - ]); + return emitAiCommand(socket, "chatCompletion", params, opts.timeout); }, /** @@ -114,15 +85,6 @@ export default { getStatus() { return emitAiCommand(socket, "getStatus", {}, 10000); }, - - /** - * Explicitly abort a pending chat completion by requestId. - * @param {string} requestId - * @returns {Promise} - */ - abortChatCompletion(requestId) { - return emitAiCommand(socket, "abortChatCompletion", {requestId}, 10000); - }, }; }, }, diff --git a/utils/rpcs/litellm/main.py b/utils/rpcs/litellm/main.py index 171ffd11e..6530ed868 100644 --- a/utils/rpcs/litellm/main.py +++ b/utils/rpcs/litellm/main.py @@ -9,7 +9,6 @@ def create_app(): logger.setLevel(logging.INFO) sio = socketio.Server(async_mode='threading', ping_timeout=120, ping_interval=25) - aborted_request_ids = set() @sio.event def connect(sid, environ, auth): @@ -44,22 +43,17 @@ def chat_completion(sid, data): """ model = data.get("model") messages = data.get("messages") - request_id = data.get("requestId") if not model: return {"success": False, "message": "Missing required field: model"} if not messages: return {"success": False, "message": "Missing required field: messages"} - if request_id and request_id in aborted_request_ids: - aborted_request_ids.discard(request_id) - logger.info(f"chatCompletion skipped (already aborted): requestId={request_id}") - return {"success": False, "message": "Request aborted"} - logger.info(f"chatCompletion from {sid}: model={model}, requestId={request_id or 'N/A'}") + logger.info(f"chatCompletion from {sid}: model={model}") try: params = {k: v for k, v in data.items() - if k not in ("model", "messages", "requestId") and v is not None} + if k not in ("model", "messages") and v is not None} response = litellm.completion( model=model, @@ -67,11 +61,6 @@ def chat_completion(sid, data): **params, ) - if request_id and request_id in aborted_request_ids: - aborted_request_ids.discard(request_id) - logger.info(f"chatCompletion aborted after completion: requestId={request_id}") - return {"success": False, "message": "Request aborted"} - # Return the full response as-is. ModelResponse is a pydantic # model, so model_dump() gives the complete OpenAI-compatible # dict without hand-mapping (which would silently drop any new @@ -94,16 +83,6 @@ def chat_completion(sid, data): logger.error(f"chatCompletion error: {e}") return {"success": False, "message": str(e)} - @sio.on("abortChatCompletion") - def abort_chat_completion(sid, data): - request_id = (data or {}).get("requestId") - if not request_id: - return {"success": False, "message": "Missing required field: requestId"} - - aborted_request_ids.add(request_id) - logger.info(f"abortChatCompletion from {sid}: requestId={request_id}") - return {"success": True, "data": {"aborted": True, "requestId": request_id}} - logger.info("Creating LiteLLM RPC App...") app = socketio.WSGIApp(sio) return app From d832ce1b8bafa73db3e2fb9f19a0d77dd6d5dc69 Mon Sep 17 00:00:00 2001 From: "akashgundapuneni@gmail.com" Date: Mon, 27 Apr 2026 18:25:34 +0200 Subject: [PATCH 008/206] feat: implement abortChatCompletion method in LiteLLMRPC and AIService for request cancellation --- backend/webserver/rpcs/liteLLMRPC.js | 53 ++++++++++++++++++++- backend/webserver/services/ai.js | 21 +++++++++ frontend/src/plugins/ai.js | 49 ++++++++++++++++--- utils/rpcs/litellm/main.py | 70 +++++++++++++++++++++++++--- 4 files changed, 177 insertions(+), 16 deletions(-) diff --git a/backend/webserver/rpcs/liteLLMRPC.js b/backend/webserver/rpcs/liteLLMRPC.js index 7ff1215b1..078dcb999 100644 --- a/backend/webserver/rpcs/liteLLMRPC.js +++ b/backend/webserver/rpcs/liteLLMRPC.js @@ -1,4 +1,7 @@ const RPC = require("../RPC.js"); +const {randomUUID} = require("crypto"); + +const ACK_TIMEOUT_BUFFER_MS = 5000; /** * LiteLLMRPC - Routes LLM requests through LiteLLM for external and local model access @@ -32,13 +35,59 @@ module.exports = class LiteLLMRPC extends RPC { * @throws {Error} If the RPC service call fails */ async chatCompletion(data) { - this.logger.info("Sending chatCompletion request: model=" + data.model); + const { + __requestId: requestId = randomUUID(), + __timeoutMs: requestedTimeoutMs, + ...params + } = data || {}; + const timeoutOverride = Number(requestedTimeoutMs); + const timeoutMs = Number.isFinite(timeoutOverride) && timeoutOverride > 0 + ? Math.min(timeoutOverride, this.timeout) + : this.timeout; + const ackTimeoutMs = timeoutMs + ACK_TIMEOUT_BUFFER_MS; + + this.logger.info("Sending chatCompletion request: model=" + params.model + " requestId=" + requestId); - const response = await this.emit("chatCompletion", data); + let response; + try { + response = await this.emit("chatCompletion", { + requestId, + timeoutMs, + params, + }, ackTimeoutMs); + } catch (err) { + await this.abortChatCompletion(requestId, "RPC acknowledgement timed out"); + throw err; + } if (!response['success']) { this.logger.error("chatCompletion error: " + response['message']); throw new Error(response['message']); } return response; } + + /** + * Ask the Python bridge to abort an in-flight chat completion. + * + * @param {string} requestId + * @param {string} [reason] + * @returns {Promise} + */ + async abortChatCompletion(requestId, reason = "request aborted") { + if (!requestId) { + return {aborted: false, message: "Missing requestId"}; + } + + try { + const response = await this.emit("abortChatCompletion", {requestId, reason}, 5000); + if (!response['success']) { + this.logger.error("abortChatCompletion error: " + response['message']); + return {aborted: false, message: response['message']}; + } + return response.data || {aborted: true}; + } catch (err) { + this.logger.error("abortChatCompletion failed: " + err.message); + return {aborted: false, message: err.message}; + } + } } diff --git a/backend/webserver/services/ai.js b/backend/webserver/services/ai.js index f8fb4e090..51c6b70b1 100644 --- a/backend/webserver/services/ai.js +++ b/backend/webserver/services/ai.js @@ -8,6 +8,7 @@ const Service = require("../Service.js"); * * Supported commands: * - chatCompletion(data): forward the payload to LiteLLM as-is + * - abortChatCompletion(data): abort an in-flight LiteLLM request by id * - getStatus(): report whether LiteLLM is reachable * * @class @@ -19,6 +20,7 @@ module.exports = class AIService extends Service { super(server, { cmdTypes: [ "chatCompletion", + "abortChatCompletion", "getStatus" ], resTypes: [] @@ -39,6 +41,8 @@ module.exports = class AIService extends Service { switch (command) { case "chatCompletion": return await this.chatCompletion(data); + case "abortChatCompletion": + return await this.abortChatCompletion(data); case "getStatus": return await this.getStatus(); default: @@ -91,6 +95,23 @@ module.exports = class AIService extends Service { return {choices}; } + /** + * Abort an in-flight chat completion request. + * + * @param {object} data + * @param {string} data.requestId frontend-generated request id + * @param {string} [data.reason] diagnostic reason for logs + * @returns {Promise} + */ + async abortChatCompletion(data) { + const rpc = this.#getRPC(); + if (!rpc || !(await rpc.isOnline())) { + return {aborted: false, message: "LiteLLM service is not connected"}; + } + + return await rpc.abortChatCompletion(data && data.requestId, data && data.reason); + } + /** * Report LiteLLM connection status. * Never throws - returns an object so the UI can render state directly. diff --git a/frontend/src/plugins/ai.js b/frontend/src/plugins/ai.js index 1b517c190..161ee6780 100644 --- a/frontend/src/plugins/ai.js +++ b/frontend/src/plugins/ai.js @@ -17,33 +17,68 @@ // server error reaches the caller before the client gives up. const DEFAULT_TIMEOUT_MS = 130000; +const createRequestId = () => { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + return `ai-${Date.now()}-${Math.random().toString(36).slice(2)}`; +}; + /** * Emit a `serviceCommand` and wrap the ack callback in a Promise. * * @param {object} socket vue-3-socket.io $socket * @param {string} command AIService command name * @param {object} data payload - * @param {number} timeoutMs client-side timeout + * @param {object} opts client-side options * @returns {Promise<*>} resolves with response.data; rejects with Error */ -const emitAiCommand = (socket, command, data = {}, timeoutMs = DEFAULT_TIMEOUT_MS) => { +const emitAiCommand = (socket, command, data = {}, opts = {}) => { + const timeoutMs = opts.timeout || DEFAULT_TIMEOUT_MS; + const isAbortable = command === "chatCompletion"; + const requestId = isAbortable ? createRequestId() : null; + const payload = isAbortable ? { + ...data, + __requestId: requestId, + __timeoutMs: timeoutMs, + } : data; + return new Promise((resolve, reject) => { let settled = false; + let timer = null; + + const sendAbort = (reason) => { + if (!isAbortable) return; + socket.emit("serviceCommand", { + service: "AIService", + command: "abortChatCompletion", + data: {requestId, reason}, + }, () => {}); + }; + + const clearTimer = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + }; - const timer = setTimeout(() => { + timer = setTimeout(() => { if (settled) return; settled = true; + clearTimer(); + sendAbort(`client timeout after ${timeoutMs}ms`); reject(new Error(`AI request timed out after ${timeoutMs}ms (command: ${command})`)); }, timeoutMs); socket.emit("serviceCommand", { service: "AIService", command, - data, + data: payload, }, (response) => { if (settled) return; settled = true; - clearTimeout(timer); + clearTimer(); if (!response) { reject(new Error("No response received from AIService")); @@ -75,7 +110,7 @@ export default { * @returns {Promise} */ chatCompletion(params, opts = {}) { - return emitAiCommand(socket, "chatCompletion", params, opts.timeout); + return emitAiCommand(socket, "chatCompletion", params, opts); }, /** @@ -83,7 +118,7 @@ export default { * @returns {Promise<{online: boolean, error?: string}>} */ getStatus() { - return emitAiCommand(socket, "getStatus", {}, 10000); + return emitAiCommand(socket, "getStatus", {}, {timeout: 10000}); }, }; }, diff --git a/utils/rpcs/litellm/main.py b/utils/rpcs/litellm/main.py index 6530ed868..ecdd97e61 100644 --- a/utils/rpcs/litellm/main.py +++ b/utils/rpcs/litellm/main.py @@ -1,4 +1,5 @@ import logging +import threading import socketio import litellm @@ -9,6 +10,8 @@ def create_app(): logger.setLevel(logging.INFO) sio = socketio.Server(async_mode='threading', ping_timeout=120, ping_interval=25) + active_requests = {} + active_requests_lock = threading.Lock() @sio.event def connect(sid, environ, auth): @@ -41,26 +44,48 @@ def chat_completion(sid, data): `messages` (required); all other keys are forwarded verbatim, so the caller controls provider, API key, and any extra parameters. """ - model = data.get("model") - messages = data.get("messages") + data = data or {} + request_id = data.get("requestId") + timeout_ms = data.get("timeoutMs") + params = data.get("params") or {} + + if not request_id: + return {"success": False, "message": "Missing required field: requestId"} + + model = params.get("model") + messages = params.get("messages") if not model: return {"success": False, "message": "Missing required field: model"} if not messages: return {"success": False, "message": "Missing required field: messages"} - logger.info(f"chatCompletion from {sid}: model={model}") + logger.info(f"chatCompletion from {sid}: model={model} requestId={request_id}") + + request_state = {"cancelled": False} + with active_requests_lock: + active_requests[request_id] = request_state try: - params = {k: v for k, v in data.items() - if k not in ("model", "messages") and v is not None} + completion_params = {k: v for k, v in params.items() + if k not in ("model", "messages") and v is not None} + + if "timeout" not in completion_params and timeout_ms: + completion_params["timeout"] = int(timeout_ms) / 1000 response = litellm.completion( model=model, messages=messages, - **params, + **completion_params, ) + if request_state["cancelled"]: + logger.info(f"chatCompletion aborted after provider returned: requestId={request_id}") + return { + "success": False, + "message": "Not implemented: provider-level abort is unavailable; request was marked aborted", + } + # Return the full response as-is. ModelResponse is a pydantic # model, so model_dump() gives the complete OpenAI-compatible # dict without hand-mapping (which would silently drop any new @@ -80,8 +105,39 @@ def chat_completion(sid, data): return {"success": True, "data": response_data} except Exception as e: - logger.error(f"chatCompletion error: {e}") + logger.error(f"chatCompletion error: requestId={request_id} {e}") return {"success": False, "message": str(e)} + finally: + with active_requests_lock: + active_requests.pop(request_id, None) + + @sio.on("abortChatCompletion") + def abort_chat_completion(sid, data): + """ + Mark an in-flight completion as cancelled. + + LiteLLM's synchronous completion API does not expose a provider-level + abort handle, so this records cancellation for result suppression while + the per-request LiteLLM timeout caps the provider call. + """ + data = data or {} + request_id = data.get("requestId") + reason = data.get("reason") or "request aborted" + + if not request_id: + return {"success": False, "message": "Missing required field: requestId"} + + with active_requests_lock: + request_state = active_requests.get(request_id) + if request_state: + request_state["cancelled"] = True + + if not request_state: + logger.info(f"abortChatCompletion ignored for inactive requestId={request_id}") + return {"success": True, "data": {"aborted": False, "message": "Request is not active"}} + + logger.info(f"abortChatCompletion marked requestId={request_id}: {reason}") + return {"success": True, "data": {"aborted": True}} logger.info("Creating LiteLLM RPC App...") app = socketio.WSGIApp(sio) From f344b9af80f04d090281d109c44983c461c103da Mon Sep 17 00:00:00 2001 From: junaidferoz <60928280+junaidferoz@users.noreply.github.com> Date: Mon, 4 May 2026 12:38:04 +0200 Subject: [PATCH 009/206] feat(db): add AI credential, model, share, and log tables with Sequelize models --- .../20260331100037-create-ai_credential.js | 76 +++++++++++ .../20260331101522-create-ai_model.js | 86 ++++++++++++ .../20260331102200-create-ai_model_share.js | 74 +++++++++++ .../20260331103048-create-ai_log.js | 125 ++++++++++++++++++ backend/db/models/ai_credential.js | 121 +++++++++++++++++ backend/db/models/ai_log.js | 40 ++++++ backend/db/models/ai_model.js | 40 ++++++ backend/db/models/ai_model_share.js | 32 +++++ 8 files changed, 594 insertions(+) create mode 100644 backend/db/migrations/20260331100037-create-ai_credential.js create mode 100644 backend/db/migrations/20260331101522-create-ai_model.js create mode 100644 backend/db/migrations/20260331102200-create-ai_model_share.js create mode 100644 backend/db/migrations/20260331103048-create-ai_log.js create mode 100644 backend/db/models/ai_credential.js create mode 100644 backend/db/models/ai_log.js create mode 100644 backend/db/models/ai_model.js create mode 100644 backend/db/models/ai_model_share.js diff --git a/backend/db/migrations/20260331100037-create-ai_credential.js b/backend/db/migrations/20260331100037-create-ai_credential.js new file mode 100644 index 000000000..f528039ee --- /dev/null +++ b/backend/db/migrations/20260331100037-create-ai_credential.js @@ -0,0 +1,76 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_credential', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + apiKey: { + type: Sequelize.TEXT, + allowNull: false, + }, + apiBaseUrl: { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }, + apiVersion: { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }, + additionalParameters: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: {}, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_credential'); + }, +}; diff --git a/backend/db/migrations/20260331101522-create-ai_model.js b/backend/db/migrations/20260331101522-create-ai_model.js new file mode 100644 index 000000000..3fe147aed --- /dev/null +++ b/backend/db/migrations/20260331101522-create-ai_model.js @@ -0,0 +1,86 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_model', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + aiCredentialId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'ai_credential', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + model: { + type: Sequelize.STRING, + allowNull: false, + }, + provider: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }, + additionalParameters: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: {}, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_model'); + }, +}; diff --git a/backend/db/migrations/20260331102200-create-ai_model_share.js b/backend/db/migrations/20260331102200-create-ai_model_share.js new file mode 100644 index 000000000..ff4804390 --- /dev/null +++ b/backend/db/migrations/20260331102200-create-ai_model_share.js @@ -0,0 +1,74 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_model_share', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + aiModelId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'ai_model', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + roleId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'study', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + expiryDate: { + type: Sequelize.DATE, + allowNull: false, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_model_share'); + }, +}; diff --git a/backend/db/migrations/20260331103048-create-ai_log.js b/backend/db/migrations/20260331103048-create-ai_log.js new file mode 100644 index 000000000..655d16261 --- /dev/null +++ b/backend/db/migrations/20260331103048-create-ai_log.js @@ -0,0 +1,125 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_log', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + aiModelId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'ai_model', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + documentId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + studySessionId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + studyStepId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + requestId: { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }, + input: { + type: Sequelize.TEXT, + allowNull: true, + }, + output: { + type: Sequelize.TEXT, + allowNull: true, + }, + reasoning: { + type: Sequelize.TEXT, + allowNull: true, + }, + inputTokens: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + outputTokens: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + reasoningTokens: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + total_tokens: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + costs: { + type: Sequelize.FLOAT, + allowNull: true, + defaultValue: null, + }, + status: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'success', + }, + requestStart: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_log'); + }, +}; diff --git a/backend/db/models/ai_credential.js b/backend/db/models/ai_credential.js new file mode 100644 index 000000000..7fa1006b2 --- /dev/null +++ b/backend/db/models/ai_credential.js @@ -0,0 +1,121 @@ +'use strict'; +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiCredential extends MetaModel { + static autoTable = true; + + static associate(models) { + AiCredential.belongsTo(models['user'], { + foreignKey: 'userId', + as: 'owner', + }); + AiCredential.hasMany(models['ai_model'], { + foreignKey: 'aiCredentialId', + as: 'models', + }); + } + + /** + * Find all credentials accessible to a user (owner + direct user share). + * @param {number} userId + * @param {Object} options + * @returns {Promise} + */ + static async getAccessibleCredentials(userId, options = {}) { + const {Op} = require('sequelize'); + const now = new Date(); + return await this.findAll({ + where: { + deleted: false, + enabled: true, + [Op.or]: [ + {userId: userId}, + { + id: { + [Op.in]: sequelize.literal(`( + SELECT "aiCredentialId" + FROM "ai_model" + WHERE "deleted" = false + AND "id" IN ( + SELECT "aiModelId" + FROM "ai_model_share" + WHERE "deleted" = false + AND "userId" = ${parseInt(userId, 10)} + AND "expiryDate" > '${now.toISOString()}' + ) + )`), + }, + }, + ], + }, + raw: true, + ...options, + }); + } + + /** + * Resolve a specific credential for a requesting user. + * Priority: user's own credential > valid user share. + * @param {number} userId + * @param {number} aiCredentialId + * @returns {Promise} + */ + static async resolveCredential(userId, aiCredentialId) { + const {Op} = require('sequelize'); + const now = new Date(); + const keys = await this.findAll({ + where: { + deleted: false, + enabled: true, + id: aiCredentialId, + [Op.or]: [ + {userId: userId}, + { + id: { + [Op.in]: sequelize.literal(`( + SELECT "aiCredentialId" + FROM "ai_model" + WHERE "deleted" = false + AND "id" IN ( + SELECT "aiModelId" + FROM "ai_model_share" + WHERE "deleted" = false + AND "userId" = ${parseInt(userId, 10)} + AND "expiryDate" > '${now.toISOString()}' + ) + )`), + }, + }, + ], + }, + order: [ + [sequelize.literal(`CASE WHEN "userId" = ${parseInt(userId)} THEN 0 ELSE 1 END`), 'ASC'], + ['createdAt', 'ASC'], + ], + raw: true, + }); + return keys.length > 0 ? keys[0] : null; + } + } + + AiCredential.init({ + userId: DataTypes.INTEGER, + name: DataTypes.STRING, + apiKey: DataTypes.TEXT, + apiBaseUrl: DataTypes.STRING, + apiVersion: DataTypes.STRING, + enabled: DataTypes.BOOLEAN, + additionalParameters: DataTypes.JSONB, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_credential', + tableName: 'ai_credential', + }); + + return AiCredential; +}; diff --git a/backend/db/models/ai_log.js b/backend/db/models/ai_log.js new file mode 100644 index 000000000..32cc9c654 --- /dev/null +++ b/backend/db/models/ai_log.js @@ -0,0 +1,40 @@ +'use strict'; +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiLog extends MetaModel { + static autoTable = true; + + static associate(models) { + } + } + + AiLog.init({ + userId: DataTypes.INTEGER, + aiModelId: DataTypes.INTEGER, + documentId: DataTypes.INTEGER, + studySessionId: DataTypes.INTEGER, + studyStepId: DataTypes.INTEGER, + requestId: DataTypes.STRING, + input: DataTypes.TEXT, + output: DataTypes.TEXT, + reasoning: DataTypes.TEXT, + inputTokens: DataTypes.INTEGER, + outputTokens: DataTypes.INTEGER, + reasoningTokens: DataTypes.INTEGER, + total_tokens: DataTypes.INTEGER, + costs: DataTypes.FLOAT, + status: DataTypes.STRING, + requestStart: DataTypes.DATE, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_log', + tableName: 'ai_log', + }); + + return AiLog; +}; diff --git a/backend/db/models/ai_model.js b/backend/db/models/ai_model.js new file mode 100644 index 000000000..01f55b2d0 --- /dev/null +++ b/backend/db/models/ai_model.js @@ -0,0 +1,40 @@ +'use strict'; +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiModel extends MetaModel { + static autoTable = true; + + static associate(models) { + AiModel.belongsTo(models['user'], { + foreignKey: 'userId', + as: 'creator', + }); + AiModel.belongsTo(models['ai_credential'], { + foreignKey: 'aiCredentialId', + as: 'credential', + }); + } + } + + AiModel.init({ + aiCredentialId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + name: DataTypes.STRING, + model: DataTypes.STRING, + provider: DataTypes.STRING, + description: DataTypes.TEXT, + additionalParameters: DataTypes.JSONB, + enabled: DataTypes.BOOLEAN, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_model', + tableName: 'ai_model', + }); + + return AiModel; +}; diff --git a/backend/db/models/ai_model_share.js b/backend/db/models/ai_model_share.js new file mode 100644 index 000000000..f34864307 --- /dev/null +++ b/backend/db/models/ai_model_share.js @@ -0,0 +1,32 @@ +'use strict'; +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiModelShare extends MetaModel { + static autoTable = true; + + static associate(models) { + AiModelShare.belongsTo(models['ai_model'], { + foreignKey: 'aiModelId', + as: 'model', + }); + } + } + + AiModelShare.init({ + aiModelId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + roleId: DataTypes.INTEGER, + expiryDate: DataTypes.DATE, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_model_share', + tableName: 'ai_model_share', + }); + + return AiModelShare; +}; From a586c096ecf55d7406425746bc36fdcd91200934 Mon Sep 17 00:00:00 2001 From: junaidferoz <60928280+junaidferoz@users.noreply.github.com> Date: Mon, 4 May 2026 13:13:50 +0200 Subject: [PATCH 010/206] fix: removed redundant code and resolved defaultValue/allowNull issue --- .../20260331101522-create-ai_model.js | 1 - .../20260331103048-create-ai_log.js | 3 +- backend/db/models/ai_credential.js | 92 ------------------- backend/db/models/ai_log.js | 3 - backend/db/models/ai_model.js | 11 --- backend/db/models/ai_model_share.js | 7 -- 6 files changed, 1 insertion(+), 116 deletions(-) diff --git a/backend/db/migrations/20260331101522-create-ai_model.js b/backend/db/migrations/20260331101522-create-ai_model.js index 3fe147aed..da5ef6d14 100644 --- a/backend/db/migrations/20260331101522-create-ai_model.js +++ b/backend/db/migrations/20260331101522-create-ai_model.js @@ -49,7 +49,6 @@ module.exports = { }, additionalParameters: { type: Sequelize.JSONB, - allowNull: true, defaultValue: {}, }, enabled: { diff --git a/backend/db/migrations/20260331103048-create-ai_log.js b/backend/db/migrations/20260331103048-create-ai_log.js index 655d16261..b63f3afa6 100644 --- a/backend/db/migrations/20260331103048-create-ai_log.js +++ b/backend/db/migrations/20260331103048-create-ai_log.js @@ -88,8 +88,7 @@ module.exports = { }, status: { type: Sequelize.STRING, - allowNull: false, - defaultValue: 'success', + allowNull: true, }, requestStart: { type: Sequelize.DATE, diff --git a/backend/db/models/ai_credential.js b/backend/db/models/ai_credential.js index 7fa1006b2..910e80802 100644 --- a/backend/db/models/ai_credential.js +++ b/backend/db/models/ai_credential.js @@ -5,98 +5,6 @@ module.exports = (sequelize, DataTypes) => { class AiCredential extends MetaModel { static autoTable = true; - static associate(models) { - AiCredential.belongsTo(models['user'], { - foreignKey: 'userId', - as: 'owner', - }); - AiCredential.hasMany(models['ai_model'], { - foreignKey: 'aiCredentialId', - as: 'models', - }); - } - - /** - * Find all credentials accessible to a user (owner + direct user share). - * @param {number} userId - * @param {Object} options - * @returns {Promise} - */ - static async getAccessibleCredentials(userId, options = {}) { - const {Op} = require('sequelize'); - const now = new Date(); - return await this.findAll({ - where: { - deleted: false, - enabled: true, - [Op.or]: [ - {userId: userId}, - { - id: { - [Op.in]: sequelize.literal(`( - SELECT "aiCredentialId" - FROM "ai_model" - WHERE "deleted" = false - AND "id" IN ( - SELECT "aiModelId" - FROM "ai_model_share" - WHERE "deleted" = false - AND "userId" = ${parseInt(userId, 10)} - AND "expiryDate" > '${now.toISOString()}' - ) - )`), - }, - }, - ], - }, - raw: true, - ...options, - }); - } - - /** - * Resolve a specific credential for a requesting user. - * Priority: user's own credential > valid user share. - * @param {number} userId - * @param {number} aiCredentialId - * @returns {Promise} - */ - static async resolveCredential(userId, aiCredentialId) { - const {Op} = require('sequelize'); - const now = new Date(); - const keys = await this.findAll({ - where: { - deleted: false, - enabled: true, - id: aiCredentialId, - [Op.or]: [ - {userId: userId}, - { - id: { - [Op.in]: sequelize.literal(`( - SELECT "aiCredentialId" - FROM "ai_model" - WHERE "deleted" = false - AND "id" IN ( - SELECT "aiModelId" - FROM "ai_model_share" - WHERE "deleted" = false - AND "userId" = ${parseInt(userId, 10)} - AND "expiryDate" > '${now.toISOString()}' - ) - )`), - }, - }, - ], - }, - order: [ - [sequelize.literal(`CASE WHEN "userId" = ${parseInt(userId)} THEN 0 ELSE 1 END`), 'ASC'], - ['createdAt', 'ASC'], - ], - raw: true, - }); - return keys.length > 0 ? keys[0] : null; - } } AiCredential.init({ diff --git a/backend/db/models/ai_log.js b/backend/db/models/ai_log.js index 32cc9c654..5b68eacea 100644 --- a/backend/db/models/ai_log.js +++ b/backend/db/models/ai_log.js @@ -4,9 +4,6 @@ const MetaModel = require('../MetaModel.js'); module.exports = (sequelize, DataTypes) => { class AiLog extends MetaModel { static autoTable = true; - - static associate(models) { - } } AiLog.init({ diff --git a/backend/db/models/ai_model.js b/backend/db/models/ai_model.js index 01f55b2d0..f8a15923b 100644 --- a/backend/db/models/ai_model.js +++ b/backend/db/models/ai_model.js @@ -4,17 +4,6 @@ const MetaModel = require('../MetaModel.js'); module.exports = (sequelize, DataTypes) => { class AiModel extends MetaModel { static autoTable = true; - - static associate(models) { - AiModel.belongsTo(models['user'], { - foreignKey: 'userId', - as: 'creator', - }); - AiModel.belongsTo(models['ai_credential'], { - foreignKey: 'aiCredentialId', - as: 'credential', - }); - } } AiModel.init({ diff --git a/backend/db/models/ai_model_share.js b/backend/db/models/ai_model_share.js index f34864307..f03bfdd85 100644 --- a/backend/db/models/ai_model_share.js +++ b/backend/db/models/ai_model_share.js @@ -4,13 +4,6 @@ const MetaModel = require('../MetaModel.js'); module.exports = (sequelize, DataTypes) => { class AiModelShare extends MetaModel { static autoTable = true; - - static associate(models) { - AiModelShare.belongsTo(models['ai_model'], { - foreignKey: 'aiModelId', - as: 'model', - }); - } } AiModelShare.init({ From 529a0ba8ce84770b1e7ce43cfdc6c7dde5b93aba Mon Sep 17 00:00:00 2001 From: junaidferoz <60928280+junaidferoz@users.noreply.github.com> Date: Mon, 4 May 2026 13:19:01 +0200 Subject: [PATCH 011/206] changed autoTable value to false --- backend/db/models/ai_log.js | 2 +- backend/db/models/ai_model_share.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/db/models/ai_log.js b/backend/db/models/ai_log.js index 5b68eacea..670a53caf 100644 --- a/backend/db/models/ai_log.js +++ b/backend/db/models/ai_log.js @@ -3,7 +3,7 @@ const MetaModel = require('../MetaModel.js'); module.exports = (sequelize, DataTypes) => { class AiLog extends MetaModel { - static autoTable = true; + static autoTable = false; } AiLog.init({ diff --git a/backend/db/models/ai_model_share.js b/backend/db/models/ai_model_share.js index f03bfdd85..be9252c74 100644 --- a/backend/db/models/ai_model_share.js +++ b/backend/db/models/ai_model_share.js @@ -3,7 +3,7 @@ const MetaModel = require('../MetaModel.js'); module.exports = (sequelize, DataTypes) => { class AiModelShare extends MetaModel { - static autoTable = true; + static autoTable = false; } AiModelShare.init({ From da1c969b44a03461c393b90a63bfab2665149324 Mon Sep 17 00:00:00 2001 From: junaidferoz <60928280+junaidferoz@users.noreply.github.com> Date: Mon, 4 May 2026 13:23:54 +0200 Subject: [PATCH 012/206] fix: converted variable to camelCase --- backend/db/models/ai_log.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db/models/ai_log.js b/backend/db/models/ai_log.js index 670a53caf..9b69210f8 100644 --- a/backend/db/models/ai_log.js +++ b/backend/db/models/ai_log.js @@ -19,7 +19,7 @@ module.exports = (sequelize, DataTypes) => { inputTokens: DataTypes.INTEGER, outputTokens: DataTypes.INTEGER, reasoningTokens: DataTypes.INTEGER, - total_tokens: DataTypes.INTEGER, + totalTokens: DataTypes.INTEGER, costs: DataTypes.FLOAT, status: DataTypes.STRING, requestStart: DataTypes.DATE, From e0ef306c09e86be2c0a3cb36261c04265bc0b5d6 Mon Sep 17 00:00:00 2001 From: "akashgundapuneni@gmail.com" Date: Mon, 4 May 2026 13:51:38 +0200 Subject: [PATCH 013/206] fix(migration): update foreign key reference from 'study' to 'user_role' --- backend/db/migrations/20260331102200-create-ai_model_share.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db/migrations/20260331102200-create-ai_model_share.js b/backend/db/migrations/20260331102200-create-ai_model_share.js index ff4804390..bb128f2df 100644 --- a/backend/db/migrations/20260331102200-create-ai_model_share.js +++ b/backend/db/migrations/20260331102200-create-ai_model_share.js @@ -35,7 +35,7 @@ module.exports = { allowNull: true, defaultValue: null, references: { - model: 'study', + model: 'user_role', key: 'id', }, onDelete: 'CASCADE', From 086a47e541e2d93fce347d1c57bdf7e0de0537f1 Mon Sep 17 00:00:00 2001 From: "akashgundapuneni@gmail.com" Date: Mon, 4 May 2026 13:52:41 +0200 Subject: [PATCH 014/206] feat(migration): add foreign key references for ai_log table --- .../migrations/20260331103048-create-ai_log.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/db/migrations/20260331103048-create-ai_log.js b/backend/db/migrations/20260331103048-create-ai_log.js index b63f3afa6..1d95b9b5d 100644 --- a/backend/db/migrations/20260331103048-create-ai_log.js +++ b/backend/db/migrations/20260331103048-create-ai_log.js @@ -33,16 +33,34 @@ module.exports = { type: Sequelize.INTEGER, allowNull: true, defaultValue: null, + references: { + model: 'document', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', }, studySessionId: { type: Sequelize.INTEGER, allowNull: true, defaultValue: null, + references: { + model: 'study_session', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', }, studyStepId: { type: Sequelize.INTEGER, allowNull: true, defaultValue: null, + references: { + model: 'study_step', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', }, requestId: { type: Sequelize.STRING, From bb133dfd337bcd4daa1390339191efc0e68eb3b1 Mon Sep 17 00:00:00 2001 From: "akashgundapuneni@gmail.com" Date: Mon, 4 May 2026 13:54:09 +0200 Subject: [PATCH 015/206] feat(Socket): add 'apiKey' to default excludes in attribute filtering --- backend/webserver/Socket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/webserver/Socket.js b/backend/webserver/Socket.js index 2395d5725..8c8328196 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -632,7 +632,7 @@ module.exports = class Socket { if (filter.length > 0) { allFilter[Op.or] = filter; } - const defaultExcludes = ["deleted", "deletedAt", "rolesUpdatedAt", "initialPassword", "passwordHash", "salt"]; + const defaultExcludes = ["deleted", "deletedAt", "rolesUpdatedAt", "initialPassword", "passwordHash", "salt","apiKey"]; let allAttributes = { exclude: defaultExcludes, }; From 027b045fd21d481a228628569347bc4d3cc01cce Mon Sep 17 00:00:00 2001 From: "akashgundapuneni@gmail.com" Date: Mon, 4 May 2026 14:07:09 +0200 Subject: [PATCH 016/206] feat(migration): add AI navigation elements and user role permissions --- .../migrations/20260504120157-add-ai-nav.js | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 backend/db/migrations/20260504120157-add-ai-nav.js diff --git a/backend/db/migrations/20260504120157-add-ai-nav.js b/backend/db/migrations/20260504120157-add-ai-nav.js new file mode 100644 index 000000000..e878954d6 --- /dev/null +++ b/backend/db/migrations/20260504120157-add-ai-nav.js @@ -0,0 +1,124 @@ +'use strict'; + +const navElements = [ + { + name: 'AILog', + icon: 'journal-text', + order: 2, + admin: false, + path: 'ai_log', + component: 'AILog', + }, + { + name: 'AIModels', + icon: 'cpu', + order: 3, + admin: false, + path: 'ai_models', + component: 'AIModels', + }, +]; + +const userRights = [ + { + name: 'frontend.dashboard.ai_log.view', + description: 'access to view AI logs in the dashboard', + }, + { + name: 'frontend.dashboard.ai_models.view', + description: 'access to view AI models in the dashboard', + }, +]; + +const roleRights = [ + { + role: 'user', + userRightName: 'frontend.dashboard.ai_log.view', + }, + { + role: 'user', + userRightName: 'frontend.dashboard.ai_models.view', + }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const aiGroupId = await queryInterface.rawSelect( + 'nav_group', + { + where: { name: 'AI' }, + }, + ['id'] + ); + + await queryInterface.bulkInsert( + 'nav_element', + navElements.map((element) => ({ + name: element.name, + icon: element.icon, + order: element.order, + admin: element.admin, + path: element.path, + component: element.component, + groupId: aiGroupId, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + + await queryInterface.bulkInsert( + 'user_right', + userRights.map((right) => ({ + ...right, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + + const userRoles = await queryInterface.sequelize.query( + 'SELECT id, name FROM "user_role"', + { + type: queryInterface.sequelize.QueryTypes.SELECT, + } + ); + + const roleNameIdMapping = userRoles.reduce((acc, role) => { + acc[role.name] = role.id; + return acc; + }, {}); + + await queryInterface.bulkInsert( + 'role_right_matching', + roleRights.map((right) => ({ + userRoleId: roleNameIdMapping[right.role], + userRightName: right.userRightName, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + 'role_right_matching', + { userRightName: roleRights.map((right) => right.userRightName) }, + {} + ); + + await queryInterface.bulkDelete( + 'user_right', + { name: userRights.map((right) => right.name) }, + {} + ); + + await queryInterface.bulkDelete( + 'nav_element', + { name: navElements.map((element) => element.name) }, + {} + ); + }, +}; From 87debc04ce438a622b581fbd857e9402fe2760b4 Mon Sep 17 00:00:00 2001 From: "akashgundapuneni@gmail.com" Date: Mon, 4 May 2026 14:27:51 +0200 Subject: [PATCH 017/206] feat(ai_model): implement credential ownership validation and add hooks for model creation and update --- backend/db/models/ai_model.js | 84 ++- .../src/components/dashboard/AIModels.vue | 585 ++++++++++++++++++ 2 files changed, 640 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/dashboard/AIModels.vue diff --git a/backend/db/models/ai_model.js b/backend/db/models/ai_model.js index f8a15923b..532eb144d 100644 --- a/backend/db/models/ai_model.js +++ b/backend/db/models/ai_model.js @@ -1,29 +1,55 @@ -'use strict'; -const MetaModel = require('../MetaModel.js'); - -module.exports = (sequelize, DataTypes) => { - class AiModel extends MetaModel { - static autoTable = true; - } - - AiModel.init({ - aiCredentialId: DataTypes.INTEGER, - userId: DataTypes.INTEGER, - name: DataTypes.STRING, - model: DataTypes.STRING, - provider: DataTypes.STRING, - description: DataTypes.TEXT, - additionalParameters: DataTypes.JSONB, - enabled: DataTypes.BOOLEAN, - deleted: DataTypes.BOOLEAN, - deletedAt: DataTypes.DATE, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE, - }, { - sequelize, - modelName: 'ai_model', - tableName: 'ai_model', - }); - - return AiModel; -}; +'use strict'; +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiModel extends MetaModel { + static autoTable = true; + + static async validateCredentialOwnership(aiModel, options = {}) { + if (!aiModel.aiCredentialId) { + return; + } + + const credential = await sequelize.models.ai_credential.findByPk(aiModel.aiCredentialId, { + transaction: options.transaction, + }); + + if (!credential || credential.deleted) { + throw new Error("Selected AI credential does not exist"); + } + + if (credential.userId !== aiModel.userId) { + throw new Error("Selected AI credential does not belong to this user"); + } + } + } + + AiModel.init({ + aiCredentialId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + name: DataTypes.STRING, + model: DataTypes.STRING, + provider: DataTypes.STRING, + description: DataTypes.TEXT, + additionalParameters: DataTypes.JSONB, + enabled: DataTypes.BOOLEAN, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_model', + tableName: 'ai_model', + hooks: { + beforeCreate: async (aiModel, options) => { + await AiModel.validateCredentialOwnership(aiModel, options); + }, + beforeUpdate: async (aiModel, options) => { + await AiModel.validateCredentialOwnership(aiModel, options); + }, + }, + }); + + return AiModel; +}; diff --git a/frontend/src/components/dashboard/AIModels.vue b/frontend/src/components/dashboard/AIModels.vue new file mode 100644 index 000000000..c0c1359d5 --- /dev/null +++ b/frontend/src/components/dashboard/AIModels.vue @@ -0,0 +1,585 @@ + + + + + - + - - diff --git a/frontend/src/components/dashboard/settings/SettingsSection.vue b/frontend/src/components/dashboard/settings/SettingsSection.vue new file mode 100644 index 000000000..ac51800e1 --- /dev/null +++ b/frontend/src/components/dashboard/settings/SettingsSection.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/components/dashboard/study/BulkAssignmentModal.vue b/frontend/src/components/dashboard/study/BulkAssignmentModal.vue index 723b5fbb6..be3267575 100644 --- a/frontend/src/components/dashboard/study/BulkAssignmentModal.vue +++ b/frontend/src/components/dashboard/study/BulkAssignmentModal.vue @@ -283,8 +283,8 @@ The following reviewers do not have matching study sessions:
    -
  • - {{ reviewer.firstName }} {{ reviewer.lastName }} (ID: {{ reviewer.id }}) +
  • + {{ unmatchedReviewer.firstName }} {{ unmatchedReviewer.lastName }} (ID: {{ unmatchedReviewer.id }})
@@ -368,8 +368,8 @@ The following reviewers do not have matching study sessions:
    -
  • - {{ reviewer.firstName }} {{ reviewer.lastName }} (ID: {{ reviewer.id }}) +
  • + {{ unmatchedReviewer.firstName }} {{ unmatchedReviewer.lastName }} (ID: {{ unmatchedReviewer.id }})
diff --git a/frontend/src/components/dashboard/study/SingleAssignmentModal.vue b/frontend/src/components/dashboard/study/SingleAssignmentModal.vue index cfc8c90f5..218c62076 100644 --- a/frontend/src/components/dashboard/study/SingleAssignmentModal.vue +++ b/frontend/src/components/dashboard/study/SingleAssignmentModal.vue @@ -397,6 +397,7 @@ export default { } }); } + return []; }, documents() { return this.$store.getters["table/document/getFiltered"]((d) => d.readyForReview); diff --git a/frontend/src/components/dashboard/submission/ImportModal.vue b/frontend/src/components/dashboard/submission/ImportModal.vue index 83c3a4aff..d48260a9a 100644 --- a/frontend/src/components/dashboard/submission/ImportModal.vue +++ b/frontend/src/components/dashboard/submission/ImportModal.vue @@ -28,29 +28,8 @@ :max-table-height="400" /> - - -