From 73380d76dff45dc7b5342f5d4b42d005e94f7ff6 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 19 Sep 2025 15:02:49 +0800 Subject: [PATCH] feat(models): enhance models endpoint with filtering and pagination support --- src/main/apiServer/routes/models.ts | 178 ++++++++++++------ .../apiServer/services/chat-completion.ts | 67 +------ src/main/apiServer/services/models.ts | 112 +++++++++++ src/main/apiServer/utils/index.ts | 2 + 4 files changed, 232 insertions(+), 127 deletions(-) create mode 100644 src/main/apiServer/services/models.ts diff --git a/src/main/apiServer/routes/models.ts b/src/main/apiServer/routes/models.ts index 9f4d2f13c9..197f5ccb5a 100644 --- a/src/main/apiServer/routes/models.ts +++ b/src/main/apiServer/routes/models.ts @@ -1,73 +1,129 @@ import express, { Request, Response } from 'express' import { loggerService } from '../../services/LoggerService' -import { chatCompletionService } from '../services/chat-completion' +import { ModelsFilterSchema, modelsService } from '../services/models' const logger = loggerService.withContext('ApiServerModelsRoutes') -const router = express.Router() +const router = express + .Router() -/** - * @swagger - * /v1/models: - * get: - * summary: List available models - * description: Returns a list of available AI models from all configured providers - * tags: [Models] - * responses: - * 200: - * description: List of available models - * content: - * application/json: - * schema: - * type: object - * properties: - * object: - * type: string - * example: list - * data: - * type: array - * items: - * $ref: '#/components/schemas/Model' - * 503: - * description: Service unavailable - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -router.get('/', async (_req: Request, res: Response) => { - try { - logger.info('Models list request received') + /** + * @swagger + * /v1/models: + * get: + * summary: List available models + * description: Returns a list of available AI models from all configured providers with optional filtering + * tags: [Models] + * parameters: + * - in: query + * name: provider + * schema: + * type: string + * enum: [openai, anthropic] + * description: Filter models by provider type + * - in: query + * name: offset + * schema: + * type: integer + * minimum: 0 + * default: 0 + * description: Pagination offset + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * description: Maximum number of models to return + * responses: + * 200: + * description: List of available models + * content: + * application/json: + * schema: + * type: object + * properties: + * object: + * type: string + * example: list + * data: + * type: array + * items: + * $ref: '#/components/schemas/Model' + * total: + * type: integer + * description: Total number of models (when using pagination) + * offset: + * type: integer + * description: Current offset (when using pagination) + * limit: + * type: integer + * description: Current limit (when using pagination) + * 400: + * description: Invalid query parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 503: + * description: Service unavailable + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + .get('/', async (req: Request, res: Response) => { + try { + logger.info('Models list request received', { query: req.query }) - const models = await chatCompletionService.getModels() + // Validate query parameters using Zod schema + const filterResult = ModelsFilterSchema.safeParse(req.query) - if (models.length === 0) { - logger.warn( - 'No models available from providers. This may be because no OpenAI providers are configured or enabled.' - ) - } - - logger.info(`Returning ${models.length} models (OpenAI providers only)`) - logger.debug( - 'Model IDs:', - models.map((m) => m.id) - ) - - return res.json({ - object: 'list', - data: models - }) - } catch (error: any) { - logger.error('Error fetching models:', error) - return res.status(503).json({ - error: { - message: 'Failed to retrieve models from available providers', - type: 'service_unavailable', - code: 'models_unavailable' + if (!filterResult.success) { + logger.warn('Invalid query parameters:', filterResult.error.issues) + return res.status(400).json({ + error: { + message: 'Invalid query parameters', + type: 'invalid_request_error', + code: 'invalid_parameters', + details: filterResult.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message + })) + } + }) } - }) - } -}) + + const filter = filterResult.data + const response = await modelsService.getModels(filter) + + if (response.data.length === 0) { + logger.warn( + 'No models available from providers. This may be because no OpenAI/Anthropic providers are configured or enabled.', + { filter } + ) + } + + logger.info(`Returning ${response.data.length} models`, { + filter, + total: response.total + }) + logger.debug( + 'Model IDs:', + response.data.map((m) => m.id) + ) + + return res.json(response) + } catch (error: any) { + logger.error('Error fetching models:', error) + return res.status(503).json({ + error: { + message: 'Failed to retrieve models from available providers', + type: 'service_unavailable', + code: 'models_unavailable' + } + }) + } + }) export { router as modelsRoutes } diff --git a/src/main/apiServer/services/chat-completion.ts b/src/main/apiServer/services/chat-completion.ts index 7df6226706..ff03b54455 100644 --- a/src/main/apiServer/services/chat-completion.ts +++ b/src/main/apiServer/services/chat-completion.ts @@ -2,70 +2,16 @@ import OpenAI from 'openai' import { ChatCompletionCreateParams } from 'openai/resources' import { loggerService } from '../../services/LoggerService' -import { - getProviderByModel, - getRealProviderModel, - listAllAvailableModels, - OpenAICompatibleModel, - transformModelToOpenAI, - validateProvider -} from '../utils' +import { getProviderByModel, getRealProviderModel, validateProvider } from '../utils' const logger = loggerService.withContext('ChatCompletionService') -export interface ModelData extends OpenAICompatibleModel { - provider_id: string - model_id: string - name: string -} - export interface ValidationResult { isValid: boolean errors: string[] } export class ChatCompletionService { - async getModels(): Promise { - try { - logger.info('Getting available models from providers') - - const models = await listAllAvailableModels() - - // Use Map to deduplicate models by their full ID (provider:model_id) - const uniqueModels = new Map() - - for (const model of models) { - const openAIModel = transformModelToOpenAI(model) - const fullModelId = openAIModel.id // This is already in format "provider:model_id" - - // Only add if not already present (first occurrence wins) - if (!uniqueModels.has(fullModelId)) { - uniqueModels.set(fullModelId, { - ...openAIModel, - provider_id: model.provider, - model_id: model.id, - name: model.name - }) - } else { - logger.debug(`Skipping duplicate model: ${fullModelId}`) - } - } - - const modelData = Array.from(uniqueModels.values()) - - logger.info(`Successfully retrieved ${modelData.length} unique models from ${models.length} total models`) - - if (models.length > modelData.length) { - logger.debug(`Filtered out ${models.length - modelData.length} duplicate models`) - } - - return modelData - } catch (error: any) { - logger.error('Error getting models:', error) - return [] - } - } - validateRequest(request: ChatCompletionCreateParams): ValidationResult { const errors: string[] = [] @@ -98,17 +44,6 @@ export class ChatCompletionService { } // Validate optional parameters - if (request.temperature !== undefined) { - if (typeof request.temperature !== 'number' || request.temperature < 0 || request.temperature > 2) { - errors.push('Temperature must be a number between 0 and 2') - } - } - - if (request.max_tokens !== undefined) { - if (typeof request.max_tokens !== 'number' || request.max_tokens < 1) { - errors.push('max_tokens must be a positive number') - } - } return { isValid: errors.length === 0, diff --git a/src/main/apiServer/services/models.ts b/src/main/apiServer/services/models.ts new file mode 100644 index 0000000000..f52e222853 --- /dev/null +++ b/src/main/apiServer/services/models.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' + +import { loggerService } from '../../services/LoggerService' +import { getAvailableProviders, listAllAvailableModels, OpenAICompatibleModel, transformModelToOpenAI } from '../utils' + +const logger = loggerService.withContext('ModelsService') + +// Zod schema for models filtering +export const ModelsFilterSchema = z.object({ + provider: z.enum(['openai', 'anthropic']).optional(), + offset: z.coerce.number().min(0).default(0).optional(), + limit: z.coerce.number().min(1).optional() +}) + +export type ModelsFilter = z.infer + +export interface ModelsResponse { + object: 'list' + data: OpenAICompatibleModel[] + total?: number + offset?: number + limit?: number +} + +export class ModelsService { + async getModels(filter?: ModelsFilter): Promise { + try { + logger.info('Getting available models from providers', { filter }) + + const models = await listAllAvailableModels() + const providers = await getAvailableProviders() + + // Use Map to deduplicate models by their full ID (provider:model_id) + const uniqueModels = new Map() + + for (const model of models) { + const openAIModel = transformModelToOpenAI(model) + const fullModelId = openAIModel.id // This is already in format "provider:model_id" + + // Only add if not already present (first occurrence wins) + if (!uniqueModels.has(fullModelId)) { + uniqueModels.set(fullModelId, { + ...openAIModel, + name: model.name + }) + } else { + logger.debug(`Skipping duplicate model: ${fullModelId}`) + } + } + + let modelData = Array.from(uniqueModels.values()) + + // Apply filters + if (filter?.provider) { + const providerType = filter.provider + modelData = modelData.filter((model) => { + // Find the provider for this model and check its type + const provider = providers.find((p) => p.id === model.provider) + return provider && provider.type === providerType + }) + logger.debug(`Filtered by provider type '${providerType}': ${modelData.length} models`) + } + + const total = modelData.length + + // Apply pagination + const offset = filter?.offset || 0 + const limit = filter?.limit + + if (limit !== undefined) { + modelData = modelData.slice(offset, offset + limit) + logger.debug( + `Applied pagination: offset=${offset}, limit=${limit}, showing ${modelData.length} of ${total} models` + ) + } else if (offset > 0) { + modelData = modelData.slice(offset) + logger.debug(`Applied offset: offset=${offset}, showing ${modelData.length} of ${total} models`) + } + + logger.info(`Successfully retrieved ${modelData.length} models from ${models.length} total models`) + + if (models.length > total) { + logger.debug(`Filtered out ${models.length - total} models after deduplication and filtering`) + } + + const response: ModelsResponse = { + object: 'list', + data: modelData + } + + // Add pagination metadata if applicable + if (filter?.limit !== undefined || filter?.offset !== undefined) { + response.total = total + response.offset = offset + if (filter?.limit !== undefined) { + response.limit = filter.limit + } + } + + return response + } catch (error: any) { + logger.error('Error getting models:', error) + return { + object: 'list', + data: [] + } + } + } +} + +// Export singleton instance +export const modelsService = new ModelsService() diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts index 9b198325c5..d535e404dc 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -9,6 +9,7 @@ export interface OpenAICompatibleModel { id: string object: 'model' created: number + name: string owned_by: string provider?: string provider_model_id?: string @@ -185,6 +186,7 @@ export function transformModelToOpenAI(model: Model): OpenAICompatibleModel { return { id: `${model.provider}:${model.id}`, object: 'model', + name: model.name, created: Math.floor(Date.now() / 1000), owned_by: model.owned_by || model.provider, provider: model.provider,