From 45c072ff93aeeb58a3680f23c53d3f15651b6fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Dub=C3=A9?= Date: Thu, 27 Mar 2025 17:12:09 -0400 Subject: [PATCH] feat: add public endpoint for models list (#1228) * feat: add public endpoint for models list - Created ChatAPIService for public endpoints\n- Added /chat/models and /chat/models/details endpoints\n- Registered service in CoreModule\n- Added tests for the new service\n\nCloses #1227 ai: true * Update src/backend/src/services/ChatAPIService.js --- src/backend/src/CoreModule.js | 3 + src/backend/src/services/ChatAPIService.js | 115 ++++++++++++ .../src/services/ChatAPIService.test.js | 166 ++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 src/backend/src/services/ChatAPIService.js create mode 100644 src/backend/src/services/ChatAPIService.test.js diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js index d6115e586..10cf8cc34 100644 --- a/src/backend/src/CoreModule.js +++ b/src/backend/src/CoreModule.js @@ -367,6 +367,9 @@ const install = async ({ services, app, useapi, modapi }) => { const { ThreadService } = require('./services/ThreadService'); services.registerService('thread', ThreadService); + + const { ChatAPIService } = require('./services/ChatAPIService'); + services.registerService('__chat-api', ChatAPIService); } const install_legacy = async ({ services }) => { diff --git a/src/backend/src/services/ChatAPIService.js b/src/backend/src/services/ChatAPIService.js new file mode 100644 index 000000000..943ead8b4 --- /dev/null +++ b/src/backend/src/services/ChatAPIService.js @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// METADATA // {"ai-commented":{"service":"claude"}} +const { Endpoint } = require("../util/expressutil"); +const BaseService = require("./BaseService"); +const APIError = require("../api/APIError"); + +/** +* @class ChatAPIService +* @extends BaseService +* @description Service class that handles public (unauthenticated) API endpoints for AI chat functionality. +* This service provides endpoints for retrieving available AI chat models without requiring authentication. +*/ +class ChatAPIService extends BaseService { + static MODULES = { + express: require('express'), + }; + + /** + * Installs routes for chat API endpoints into the Express app + * @param {Object} _ Unused parameter + * @param {Object} options Installation options + * @param {Express} options.app Express application instance to install routes on + * @returns {Promise} + */ + async ['__on_install.routes'] (_, { app }) { + // Create a router for chat API endpoints + const router = (() => { + const require = this.require; + const express = require('express'); + return express.Router(); + })(); + + // Register the router with the Express app + app.use('/puterai/chat', router); + + // Install endpoints + this.install_chat_endpoints_({ router }); + } + + /** + * Installs chat API endpoints on the provided router + * @param {Object} options Options object + * @param {express.Router} options.router Express router to install endpoints on + * @private + */ + install_chat_endpoints_ ({ router }) { + // Endpoint to list available AI chat models + Endpoint({ + route: '/models', + methods: ['GET'], + handler: async (req, res) => { + try { + // Use SUService to access AIChatService as system user + const svc_su = this.services.get('su'); + const models = await svc_su.sudo(async () => { + const svc_aiChat = this.services.get('ai-chat'); + // Return the simple model list which contains basic model information + return svc_aiChat.simple_model_list; + }); + + // Return the list of models + res.json({ models }); + } catch (error) { + this.log.error('Error fetching models:', error); + throw APIError.create('internal_server_error'); + } + } + }).attach(router); + + // Endpoint to get detailed information about available AI chat models + Endpoint({ + route: '/models/details', + methods: ['GET'], + handler: async (req, res) => { + try { + // Use SUService to access AIChatService as system user + const svc_su = this.services.get('su'); + const models = await svc_su.sudo(async () => { + const svc_aiChat = this.services.get('ai-chat'); + // Return the detailed model list which includes cost and capability information + return svc_aiChat.detail_model_list; + }); + + // Return the detailed list of models + res.json({ models }); + } catch (error) { + this.log.error('Error fetching model details:', error); + throw APIError.create('internal_server_error'); + } + } + }).attach(router); + } +} + +module.exports = { + ChatAPIService, +}; \ No newline at end of file diff --git a/src/backend/src/services/ChatAPIService.test.js b/src/backend/src/services/ChatAPIService.test.js new file mode 100644 index 000000000..c6fe92596 --- /dev/null +++ b/src/backend/src/services/ChatAPIService.test.js @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { ChatAPIService } = require('./ChatAPIService'); + +describe('ChatAPIService', () => { + let chatApiService; + let mockServices; + let mockRouter; + let mockApp; + let mockSUService; + let mockAIChatService; + let mockEndpoint; + let mockReq; + let mockRes; + + beforeEach(() => { + // Mock AIChatService + mockAIChatService = { + simple_model_list: ['model1', 'model2'], + detail_model_list: [ + { id: 'model1', name: 'Model 1', cost: { input: 1, output: 2 } }, + { id: 'model2', name: 'Model 2', cost: { input: 3, output: 4 } } + ] + }; + + // Mock SUService + mockSUService = { + sudo: jest.fn().mockImplementation(async (callback) => { + if (typeof callback === 'function') { + return await callback(); + } + return await mockSUService.sudo.mockImplementation(async (cb) => await cb()); + }) + }; + + // Mock services + mockServices = { + get: jest.fn().mockImplementation((serviceName) => { + if (serviceName === 'su') return mockSUService; + if (serviceName === 'ai-chat') return mockAIChatService; + return null; + }) + }; + + // Mock router and app + mockRouter = { + use: jest.fn(), + get: jest.fn(), + post: jest.fn() + }; + mockApp = { + use: jest.fn() + }; + + // Mock Endpoint function + mockEndpoint = jest.fn().mockReturnValue({ + attach: jest.fn() + }); + + // Mock request and response + mockReq = {}; + mockRes = { + json: jest.fn() + }; + + // Setup ChatAPIService + chatApiService = new ChatAPIService(); + chatApiService.services = mockServices; + chatApiService.log = { + error: jest.fn() + }; + + // Mock the require function + chatApiService.require = jest.fn().mockImplementation((module) => { + if (module === 'express') return { Router: () => mockRouter }; + return require(module); + }); + }); + + describe('install_chat_endpoints_', () => { + it('should attach models endpoint to router', () => { + // Setup + global.Endpoint = mockEndpoint; + + // Execute + chatApiService.install_chat_endpoints_({ router: mockRouter }); + + // Verify + expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({ + route: '/models', + methods: ['GET'] + })); + }); + + it('should attach models/details endpoint to router', () => { + // Setup + global.Endpoint = mockEndpoint; + + // Execute + chatApiService.install_chat_endpoints_({ router: mockRouter }); + + // Verify + expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({ + route: '/models/details', + methods: ['GET'] + })); + }); + }); + + describe('/models endpoint', () => { + it('should return list of models', async () => { + // Setup + global.Endpoint = mockEndpoint; + chatApiService.install_chat_endpoints_({ router: mockRouter }); + + // Get the handler function + const handler = mockEndpoint.mock.calls[0][0].handler; + + // Execute + await handler(mockReq, mockRes); + + // Verify + expect(mockSUService.sudo).toHaveBeenCalled(); + expect(mockRes.json).toHaveBeenCalledWith({ + models: mockAIChatService.simple_model_list + }); + }); + }); + + describe('/models/details endpoint', () => { + it('should return detailed list of models', async () => { + // Setup + global.Endpoint = mockEndpoint; + chatApiService.install_chat_endpoints_({ router: mockRouter }); + + // Get the handler function + const handler = mockEndpoint.mock.calls[1][0].handler; + + // Execute + await handler(mockReq, mockRes); + + // Verify + expect(mockSUService.sudo).toHaveBeenCalled(); + expect(mockRes.json).toHaveBeenCalledWith({ + models: mockAIChatService.detail_model_list + }); + }); + }); +}); \ No newline at end of file