mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
feat(models): enhance models endpoint with filtering and pagination support
This commit is contained in:
parent
38076babcf
commit
73380d76df
@ -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 }
|
||||
|
||||
@ -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<ModelData[]> {
|
||||
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<string, ModelData>()
|
||||
|
||||
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,
|
||||
|
||||
112
src/main/apiServer/services/models.ts
Normal file
112
src/main/apiServer/services/models.ts
Normal file
@ -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<typeof ModelsFilterSchema>
|
||||
|
||||
export interface ModelsResponse {
|
||||
object: 'list'
|
||||
data: OpenAICompatibleModel[]
|
||||
total?: number
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export class ModelsService {
|
||||
async getModels(filter?: ModelsFilter): Promise<ModelsResponse> {
|
||||
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<string, OpenAICompatibleModel>()
|
||||
|
||||
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()
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user