diff --git a/src/main/apiServer/app.ts b/src/main/apiServer/app.ts index c52f4f85f1..786ca8b599 100644 --- a/src/main/apiServer/app.ts +++ b/src/main/apiServer/app.ts @@ -9,6 +9,7 @@ import { setupOpenAPIDocumentation } from './middleware/openapi' import { agentsRoutes } from './routes/agents' import { chatRoutes } from './routes/chat' import { mcpRoutes } from './routes/mcp' +import { messagesRoutes } from './routes/messages' import { modelsRoutes } from './routes/models' const logger = loggerService.withContext('ApiServer') @@ -114,6 +115,7 @@ apiRouter.use(express.json()) // Mount routes apiRouter.use('/chat', chatRoutes) apiRouter.use('/mcps', mcpRoutes) +apiRouter.use('/messages', messagesRoutes) apiRouter.use('/models', modelsRoutes) apiRouter.use('/agents', agentsRoutes) app.use('/v1', apiRouter) diff --git a/src/main/apiServer/routes/chat.ts b/src/main/apiServer/routes/chat.ts index be43d866a4..678d92d115 100644 --- a/src/main/apiServer/routes/chat.ts +++ b/src/main/apiServer/routes/chat.ts @@ -1,15 +1,105 @@ import express, { Request, Response } from 'express' -import OpenAI from 'openai' import { ChatCompletionCreateParams } from 'openai/resources' import { loggerService } from '../../services/LoggerService' -import { chatCompletionService } from '../services/chat-completion' -import { validateModelId } from '../utils' +import { + ChatCompletionModelError, + chatCompletionService, + ChatCompletionValidationError +} from '../services/chat-completion' const logger = loggerService.withContext('ApiServerChatRoutes') const router = express.Router() +interface ErrorResponseBody { + error: { + message: string + type: string + code: string + } +} + +const mapChatCompletionError = (error: unknown): { status: number; body: ErrorResponseBody } => { + if (error instanceof ChatCompletionValidationError) { + logger.warn('Chat completion validation error:', { + errors: error.errors + }) + + return { + status: 400, + body: { + error: { + message: error.errors.join('; '), + type: 'invalid_request_error', + code: 'validation_failed' + } + } + } + } + + if (error instanceof ChatCompletionModelError) { + logger.warn('Chat completion model error:', error.error) + + return { + status: 400, + body: { + error: { + message: error.error.message, + type: 'invalid_request_error', + code: error.error.code + } + } + } + } + + if (error instanceof Error) { + let statusCode = 500 + let errorType = 'server_error' + let errorCode = 'internal_error' + + if (error.message.includes('API key') || error.message.includes('authentication')) { + statusCode = 401 + errorType = 'authentication_error' + errorCode = 'invalid_api_key' + } else if (error.message.includes('rate limit') || error.message.includes('quota')) { + statusCode = 429 + errorType = 'rate_limit_error' + errorCode = 'rate_limit_exceeded' + } else if (error.message.includes('timeout') || error.message.includes('connection')) { + statusCode = 502 + errorType = 'server_error' + errorCode = 'upstream_error' + } + + logger.error('Chat completion error:', { error }) + + return { + status: statusCode, + body: { + error: { + message: error.message || 'Internal server error', + type: errorType, + code: errorCode + } + } + } + } + + logger.error('Chat completion unknown error:', { error }) + + return { + status: 500, + body: { + error: { + message: 'Internal server error', + type: 'server_error', + code: 'internal_error' + } + } + } +} + /** * @swagger * /v1/chat/completions: @@ -60,7 +150,7 @@ const router = express.Router() * type: integer * total_tokens: * type: integer - * text/plain: + * text/event-stream: * schema: * type: string * description: Server-sent events stream (when stream=true) @@ -110,63 +200,22 @@ router.post('/completions', async (req: Request, res: Response) => { temperature: request.temperature }) - // Validate request - const validation = chatCompletionService.validateRequest(request) - if (!validation.isValid) { - return res.status(400).json({ - error: { - message: validation.errors.join('; '), - type: 'invalid_request_error', - code: 'validation_failed' - } - }) - } + const isStreaming = !!request.stream - // Validate model ID and get provider - const modelValidation = await validateModelId(request.model) - if (!modelValidation.valid) { - const error = modelValidation.error! - logger.warn(`Model validation failed for '${request.model}':`, error) - return res.status(400).json({ - error: { - message: error.message, - type: 'invalid_request_error', - code: error.code - } - }) - } + if (isStreaming) { + const { stream } = await chatCompletionService.processStreamingCompletion(request) - const provider = modelValidation.provider! - const modelId = modelValidation.modelId! - - logger.info('Model validation successful:', { - provider: provider.id, - providerType: provider.type, - modelId: modelId, - fullModelId: request.model - }) - - // Create OpenAI client - const client = new OpenAI({ - baseURL: provider.apiHost, - apiKey: provider.apiKey - }) - request.model = modelId - - // Handle streaming - if (request.stream) { - const streamResponse = await client.chat.completions.create(request) - - res.setHeader('Content-Type', 'text/plain; charset=utf-8') - res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8') + res.setHeader('Cache-Control', 'no-cache, no-transform') res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() try { - for await (const chunk of streamResponse as any) { + for await (const chunk of stream) { res.write(`data: ${JSON.stringify(chunk)}\n\n`) } res.write('data: [DONE]\n\n') - res.end() } catch (streamError: any) { logger.error('Stream error:', streamError) res.write( @@ -178,47 +227,17 @@ router.post('/completions', async (req: Request, res: Response) => { } })}\n\n` ) + } finally { res.end() } return } - // Handle non-streaming - const response = await client.chat.completions.create(request) + const { response } = await chatCompletionService.processCompletion(request) return res.json(response) - } catch (error: any) { - logger.error('Chat completion error:', error) - - let statusCode = 500 - let errorType = 'server_error' - let errorCode = 'internal_error' - let errorMessage = 'Internal server error' - - if (error instanceof Error) { - errorMessage = error.message - - if (error.message.includes('API key') || error.message.includes('authentication')) { - statusCode = 401 - errorType = 'authentication_error' - errorCode = 'invalid_api_key' - } else if (error.message.includes('rate limit') || error.message.includes('quota')) { - statusCode = 429 - errorType = 'rate_limit_error' - errorCode = 'rate_limit_exceeded' - } else if (error.message.includes('timeout') || error.message.includes('connection')) { - statusCode = 502 - errorType = 'server_error' - errorCode = 'upstream_error' - } - } - - return res.status(statusCode).json({ - error: { - message: errorMessage, - type: errorType, - code: errorCode - } - }) + } catch (error: unknown) { + const { status, body } = mapChatCompletionError(error) + return res.status(status).json(body) } }) diff --git a/src/main/apiServer/routes/messages.ts b/src/main/apiServer/routes/messages.ts new file mode 100644 index 0000000000..faee304e4b --- /dev/null +++ b/src/main/apiServer/routes/messages.ts @@ -0,0 +1,290 @@ +import { MessageCreateParams } from '@anthropic-ai/sdk/resources' +import express, { Request, Response } from 'express' + +import { loggerService } from '../../services/LoggerService' +import { messagesService } from '../services/messages' +import { validateModelId } from '../utils' + +const logger = loggerService.withContext('ApiServerMessagesRoutes') + +const router = express.Router() + +/** + * @swagger + * /v1/messages: + * post: + * summary: Create message + * description: Create a message response using Anthropic's API format + * tags: [Messages] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - model + * - max_tokens + * - messages + * properties: + * model: + * type: string + * description: Model ID in format "provider:model_id" + * example: "my-anthropic:claude-3-5-sonnet-20241022" + * max_tokens: + * type: integer + * minimum: 1 + * description: Maximum number of tokens to generate + * example: 1024 + * messages: + * type: array + * items: + * type: object + * properties: + * role: + * type: string + * enum: [user, assistant] + * content: + * oneOf: + * - type: string + * - type: array + * system: + * type: string + * description: System message + * temperature: + * type: number + * minimum: 0 + * maximum: 1 + * description: Sampling temperature + * top_p: + * type: number + * minimum: 0 + * maximum: 1 + * description: Nucleus sampling + * top_k: + * type: integer + * minimum: 0 + * description: Top-k sampling + * stream: + * type: boolean + * description: Whether to stream the response + * tools: + * type: array + * description: Available tools for the model + * responses: + * 200: + * description: Message response + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * type: + * type: string + * example: message + * role: + * type: string + * example: assistant + * content: + * type: array + * items: + * type: object + * model: + * type: string + * stop_reason: + * type: string + * stop_sequence: + * type: string + * usage: + * type: object + * properties: + * input_tokens: + * type: integer + * output_tokens: + * type: integer + * text/event-stream: + * schema: + * type: string + * description: Server-sent events stream (when stream=true) + * 400: + * description: Bad request + * content: + * application/json: + * schema: + * type: object + * properties: + * type: + * type: string + * example: error + * error: + * type: object + * properties: + * type: + * type: string + * message: + * type: string + * 401: + * description: Unauthorized + * 429: + * description: Rate limit exceeded + * 500: + * description: Internal server error + */ +router.post('/', async (req: Request, res: Response) => { + try { + const request: MessageCreateParams = req.body + + if (!request) { + return res.status(400).json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: 'Request body is required' + } + }) + } + + logger.info('Anthropic message request:', { + model: request.model, + messageCount: request.messages?.length || 0, + stream: request.stream, + max_tokens: request.max_tokens, + temperature: request.temperature + }) + + // Validate model ID and get provider + const modelValidation = await validateModelId(request.model) + if (!modelValidation.valid) { + const error = modelValidation.error! + logger.warn(`Model validation failed for '${request.model}':`, error) + return res.status(400).json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: error.message + } + }) + } + + const provider = modelValidation.provider! + + // Ensure provider is Anthropic type + if (provider.type !== 'anthropic') { + return res.status(400).json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: `Invalid provider type '${provider.type}' for messages endpoint. Expected 'anthropic' provider.` + } + }) + } + + const modelId = modelValidation.modelId! + request.model = modelId + + logger.info('Model validation successful:', { + provider: provider.id, + providerType: provider.type, + modelId: modelId, + fullModelId: request.model + }) + + // Validate request + const validation = messagesService.validateRequest(request) + if (!validation.isValid) { + return res.status(400).json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: validation.errors.join('; ') + } + }) + } + + // Handle streaming + if (request.stream) { + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8') + res.setHeader('Cache-Control', 'no-cache, no-transform') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() + + try { + for await (const chunk of messagesService.processStreamingMessage(request, provider)) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`) + } + res.write('data: [DONE]\n\n') + } catch (streamError: any) { + logger.error('Stream error:', streamError) + res.write( + `data: ${JSON.stringify({ + type: 'error', + error: { + type: 'api_error', + message: 'Stream processing error' + } + })}\n\n` + ) + } finally { + res.end() + } + return + } + + // Handle non-streaming + const response = await messagesService.processMessage(request, provider) + return res.json(response) + } catch (error: any) { + logger.error('Anthropic message error:', error) + + let statusCode = 500 + let errorType = 'api_error' + let errorMessage = 'Internal server error' + + const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined + const anthropicError = error?.error + + if (anthropicStatus) { + statusCode = anthropicStatus + } + + if (anthropicError?.type) { + errorType = anthropicError.type + } + + if (anthropicError?.message) { + errorMessage = anthropicError.message + } else if (error instanceof Error && error.message) { + errorMessage = error.message + } + + if (!anthropicStatus && error instanceof Error) { + if (error.message.includes('API key') || error.message.includes('authentication')) { + statusCode = 401 + errorType = 'authentication_error' + } else if (error.message.includes('rate limit') || error.message.includes('quota')) { + statusCode = 429 + errorType = 'rate_limit_error' + } else if (error.message.includes('timeout') || error.message.includes('connection')) { + statusCode = 502 + errorType = 'api_error' + } else if (error.message.includes('validation') || error.message.includes('invalid')) { + statusCode = 400 + errorType = 'invalid_request_error' + } + } + + return res.status(statusCode).json({ + type: 'error', + error: { + type: errorType, + message: errorMessage, + requestId: error?.request_id + } + }) + } +}) + +export { router as messagesRoutes } 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..ba88c32343 100644 --- a/src/main/apiServer/services/chat-completion.ts +++ b/src/main/apiServer/services/chat-completion.ts @@ -1,83 +1,131 @@ +import { Provider } from '@types' import OpenAI from 'openai' -import { ChatCompletionCreateParams } from 'openai/resources' +import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources' import { loggerService } from '../../services/LoggerService' -import { - getProviderByModel, - getRealProviderModel, - listAllAvailableModels, - OpenAICompatibleModel, - transformModelToOpenAI, - validateProvider -} from '../utils' +import { ModelValidationError, validateModelId } 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 ChatCompletionValidationError extends Error { + constructor(public readonly errors: string[]) { + super(`Request validation failed: ${errors.join('; ')}`) + this.name = 'ChatCompletionValidationError' + } +} + +export class ChatCompletionModelError extends Error { + constructor(public readonly error: ModelValidationError) { + super(`Model validation failed: ${error.message}`) + this.name = 'ChatCompletionModelError' + } +} + +export type PrepareRequestResult = + | { status: 'validation_error'; errors: string[] } + | { status: 'model_error'; error: ModelValidationError } + | { + status: 'ok' + provider: Provider + modelId: string + client: OpenAI + providerRequest: ChatCompletionCreateParams + } + export class ChatCompletionService { - async getModels(): Promise { - try { - logger.info('Getting available models from providers') + async resolveProviderContext(model: string): Promise< + | { ok: false; error: ModelValidationError } + | { ok: true; provider: Provider; modelId: string; client: OpenAI } + > { + const modelValidation = await validateModelId(model) + if (!modelValidation.valid) { + return { + ok: false, + error: modelValidation.error! + } + } - const models = await listAllAvailableModels() + const provider = modelValidation.provider! - // 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}`) + if (provider.type !== 'openai') { + return { + ok: false, + error: { + type: 'unsupported_provider_type', + message: `Provider '${provider.id}' of type '${provider.type}' is not supported for OpenAI chat completions`, + code: 'unsupported_provider_type' } } + } - const modelData = Array.from(uniqueModels.values()) + const modelId = modelValidation.modelId! - logger.info(`Successfully retrieved ${modelData.length} unique models from ${models.length} total models`) + const client = new OpenAI({ + baseURL: provider.apiHost, + apiKey: provider.apiKey + }) - if (models.length > modelData.length) { - logger.debug(`Filtered out ${models.length - modelData.length} duplicate models`) + return { + ok: true, + provider, + modelId, + client + } + } + + async prepareRequest(request: ChatCompletionCreateParams, stream: boolean): Promise { + const requestValidation = this.validateRequest(request) + if (!requestValidation.isValid) { + return { + status: 'validation_error', + errors: requestValidation.errors } + } - return modelData - } catch (error: any) { - logger.error('Error getting models:', error) - return [] + const providerContext = await this.resolveProviderContext(request.model!) + if (!providerContext.ok) { + return { + status: 'model_error', + error: providerContext.error + } + } + + const { provider, modelId, client } = providerContext + + logger.info('Model validation successful:', { + provider: provider.id, + providerType: provider.type, + modelId, + fullModelId: request.model + }) + + return { + status: 'ok', + provider, + modelId, + client, + providerRequest: stream + ? { + ...request, + model: modelId, + stream: true as const + } + : { + ...request, + model: modelId, + stream: false as const + } } } validateRequest(request: ChatCompletionCreateParams): ValidationResult { const errors: string[] = [] - // Validate model - if (!request.model) { - errors.push('Model is required') - } else if (typeof request.model !== 'string') { - errors.push('Model must be a string') - } else if (!request.model.includes(':')) { - errors.push('Model must be in format "provider:model_id"') - } - // Validate messages if (!request.messages) { errors.push('Messages array is required') @@ -98,17 +146,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, @@ -116,7 +153,11 @@ export class ChatCompletionService { } } - async processCompletion(request: ChatCompletionCreateParams): Promise { + async processCompletion(request: ChatCompletionCreateParams): Promise<{ + provider: Provider + modelId: string + response: OpenAI.Chat.Completions.ChatCompletion + }> { try { logger.info('Processing chat completion request:', { model: request.model, @@ -124,38 +165,16 @@ export class ChatCompletionService { stream: request.stream }) - // Validate request - const validation = this.validateRequest(request) - if (!validation.isValid) { - throw new Error(`Request validation failed: ${validation.errors.join(', ')}`) + const preparation = await this.prepareRequest(request, false) + if (preparation.status === 'validation_error') { + throw new ChatCompletionValidationError(preparation.errors) } - // Get provider for the model - const provider = await getProviderByModel(request.model!) - if (!provider) { - throw new Error(`Provider not found for model: ${request.model}`) + if (preparation.status === 'model_error') { + throw new ChatCompletionModelError(preparation.error) } - // Validate provider - if (!validateProvider(provider)) { - throw new Error(`Provider validation failed for: ${provider.id}`) - } - - // Extract model ID from the full model string - const modelId = getRealProviderModel(request.model) - - // Create OpenAI client for the provider - const client = new OpenAI({ - baseURL: provider.apiHost, - apiKey: provider.apiKey - }) - - // Prepare request with the actual model ID - const providerRequest = { - ...request, - model: modelId, - stream: false - } + const { provider, modelId, client, providerRequest } = preparation logger.debug('Sending request to provider:', { provider: provider.id, @@ -166,54 +185,40 @@ export class ChatCompletionService { const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion logger.info('Successfully processed chat completion') - return response + return { + provider, + modelId, + response + } } catch (error: any) { logger.error('Error processing chat completion:', error) throw error } } - async *processStreamingCompletion( + async processStreamingCompletion( request: ChatCompletionCreateParams - ): AsyncIterable { + ): Promise<{ + provider: Provider + modelId: string + stream: AsyncIterable + }> { try { logger.info('Processing streaming chat completion request:', { model: request.model, messageCount: request.messages.length }) - // Validate request - const validation = this.validateRequest(request) - if (!validation.isValid) { - throw new Error(`Request validation failed: ${validation.errors.join(', ')}`) + const preparation = await this.prepareRequest(request, true) + if (preparation.status === 'validation_error') { + throw new ChatCompletionValidationError(preparation.errors) } - // Get provider for the model - const provider = await getProviderByModel(request.model!) - if (!provider) { - throw new Error(`Provider not found for model: ${request.model}`) + if (preparation.status === 'model_error') { + throw new ChatCompletionModelError(preparation.error) } - // Validate provider - if (!validateProvider(provider)) { - throw new Error(`Provider validation failed for: ${provider.id}`) - } - - // Extract model ID from the full model string - const modelId = getRealProviderModel(request.model) - - // Create OpenAI client for the provider - const client = new OpenAI({ - baseURL: provider.apiHost, - apiKey: provider.apiKey - }) - - // Prepare streaming request - const streamingRequest = { - ...request, - model: modelId, - stream: true as const - } + const { provider, modelId, client, providerRequest } = preparation logger.debug('Sending streaming request to provider:', { provider: provider.id, @@ -221,13 +226,17 @@ export class ChatCompletionService { apiHost: provider.apiHost }) - const stream = await client.chat.completions.create(streamingRequest) + const streamRequest = providerRequest as ChatCompletionCreateParamsStreaming + const stream = (await client.chat.completions.create(streamRequest)) as AsyncIterable< + OpenAI.Chat.Completions.ChatCompletionChunk + > - for await (const chunk of stream) { - yield chunk + logger.info('Successfully started streaming chat completion') + return { + provider, + modelId, + stream } - - logger.info('Successfully completed streaming chat completion') } catch (error: any) { logger.error('Error processing streaming chat completion:', error) throw error diff --git a/src/main/apiServer/services/messages.ts b/src/main/apiServer/services/messages.ts new file mode 100644 index 0000000000..f3ee894ebb --- /dev/null +++ b/src/main/apiServer/services/messages.ts @@ -0,0 +1,106 @@ +import Anthropic from '@anthropic-ai/sdk' +import { Message, MessageCreateParams, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources' +import { Provider } from '@types' + +import { loggerService } from '../../services/LoggerService' + +const logger = loggerService.withContext('MessagesService') + +export interface ValidationResult { + isValid: boolean + errors: string[] +} + +export class MessagesService { + // oxlint-disable-next-line no-unused-vars + validateRequest(request: MessageCreateParams): ValidationResult { + // TODO: Implement comprehensive request validation + const errors: string[] = [] + + if (!request.model) { + errors.push('Model is required') + } + + if (!request.max_tokens || request.max_tokens < 1) { + errors.push('max_tokens is required and must be at least 1') + } + + if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) { + errors.push('messages is required and must be a non-empty array') + } + + return { + isValid: errors.length === 0, + errors + } + } + + async processMessage(request: MessageCreateParams, provider: Provider): Promise { + logger.info('Processing Anthropic message request:', { + model: request.model, + messageCount: request.messages.length, + stream: request.stream, + max_tokens: request.max_tokens + }) + + // Create Anthropic client for the provider + const client = new Anthropic({ + baseURL: provider.apiHost, + apiKey: provider.apiKey + }) + + // Prepare request with the actual model ID + const anthropicRequest: MessageCreateParams = { + ...request, + stream: false + } + + logger.debug('Sending request to Anthropic provider:', { + provider: provider.id, + apiHost: provider.apiHost + }) + + const response = await client.messages.create(anthropicRequest) + + logger.info('Successfully processed Anthropic message') + return response + } + + async *processStreamingMessage( + request: MessageCreateParams, + provider: Provider + ): AsyncIterable { + logger.info('Processing streaming Anthropic message request:', { + model: request.model, + messageCount: request.messages.length + }) + + // Create Anthropic client for the provider + const client = new Anthropic({ + baseURL: provider.apiHost, + apiKey: provider.apiKey + }) + + // Prepare streaming request + const streamingRequest: MessageCreateParams = { + ...request, + stream: true + } + + logger.debug('Sending streaming request to Anthropic provider:', { + provider: provider.id, + apiHost: provider.apiHost + }) + + const stream = client.messages.stream(streamingRequest) + + for await (const chunk of stream) { + yield chunk + } + + logger.info('Successfully completed streaming Anthropic message') + } +} + +// Export singleton instance +export const messagesService = new MessagesService() diff --git a/src/main/apiServer/services/models.ts b/src/main/apiServer/services/models.ts new file mode 100644 index 0000000000..98445081c6 --- /dev/null +++ b/src/main/apiServer/services/models.ts @@ -0,0 +1,103 @@ +import { + ApiModelsRequest, + ApiModelsRequestSchema, + ApiModelsResponse, + OpenAICompatibleModel +} from '../../../renderer/src/types/apiModels' +import { loggerService } from '../../services/LoggerService' +import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils' + +const logger = loggerService.withContext('ModelsService') + +// Re-export for backward compatibility +export const ModelsFilterSchema = ApiModelsRequestSchema +export type ModelsFilter = ApiModelsRequest + +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: ApiModelsResponse = { + 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 9d3b81c328..d3137d3a1f 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -1,19 +1,9 @@ import { loggerService } from '@main/services/LoggerService' import { reduxService } from '@main/services/ReduxService' -import { Model, Provider } from '@types' +import { Model, OpenAICompatibleModel, Provider } from '@types' const logger = loggerService.withContext('ApiServerUtils') -// OpenAI compatible model format -export interface OpenAICompatibleModel { - id: string - object: 'model' - created: number - owned_by: string - provider?: string - provider_model_id?: string -} - export async function getAvailableProviders(): Promise { try { // Wait for store to be ready before accessing providers @@ -23,12 +13,14 @@ export async function getAvailableProviders(): Promise { return [] } - // Only support OpenAI type providers for API server - const openAIProviders = providers.filter((p: Provider) => p.enabled && p.type === 'openai') + // Support OpenAI and Anthropic type providers for API server + const supportedProviders = providers.filter( + (p: Provider) => p.enabled && (p.type === 'openai' || p.type === 'anthropic') + ) - logger.info(`Filtered to ${openAIProviders.length} OpenAI providers from ${providers.length} total providers`) + logger.info(`Filtered to ${supportedProviders.length} supported providers from ${providers.length} total providers`) - return openAIProviders + return supportedProviders } catch (error: any) { logger.error('Failed to get providers from Redux store:', error) return [] @@ -185,6 +177,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, @@ -215,10 +208,10 @@ export function validateProvider(provider: Provider): boolean { return false } - // Only support OpenAI type providers - if (provider.type !== 'openai') { + // Support OpenAI and Anthropic type providers + if (provider.type !== 'openai' && provider.type !== 'anthropic') { logger.debug( - `Provider type '${provider.type}' not supported, only 'openai' type is currently supported: ${provider.id}` + `Provider type '${provider.type}' not supported, only 'openai' and 'anthropic' types are currently supported: ${provider.id}` ) return false } diff --git a/src/renderer/src/types/apiModels.ts b/src/renderer/src/types/apiModels.ts new file mode 100644 index 0000000000..f023085bd8 --- /dev/null +++ b/src/renderer/src/types/apiModels.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' + +// Request schema for /v1/models +export const ApiModelsRequestSchema = z.object({ + provider: z.enum(['openai', 'anthropic']).optional(), + offset: z.coerce.number().min(0).default(0).optional(), + limit: z.coerce.number().min(1).optional() +}) + +// OpenAI compatible model schema +export const OpenAICompatibleModelSchema = z.object({ + id: z.string(), + object: z.literal('model'), + created: z.number(), + name: z.string(), + owned_by: z.string(), + provider: z.string().optional(), + provider_model_id: z.string().optional() +}) + +// Response schema for /v1/models +export const ApiModelsResponseSchema = z.object({ + object: z.literal('list'), + data: z.array(OpenAICompatibleModelSchema), + total: z.number().optional(), + offset: z.number().optional(), + limit: z.number().optional() +}) + +// Inferred TypeScript types +export type ApiModelsRequest = z.infer +export type OpenAICompatibleModel = z.infer +export type ApiModelsResponse = z.infer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 50f507fb04..b54e701074 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -16,6 +16,7 @@ import type { Message } from './newMessage' import type { BaseTool, MCPTool } from './tool' export * from './agent' +export * from './apiModels' export * from './knowledge' export * from './mcp' export * from './notification' diff --git a/tests/apis/chat.http b/tests/apis/chat.http new file mode 100644 index 0000000000..3025bb3b9d --- /dev/null +++ b/tests/apis/chat.http @@ -0,0 +1,79 @@ +@host=http://localhost:23333 +@token=cs-sk-af798ed4-7cf5-4fd7-ae4b-df203b164194 +@agent_id=agent_1758092281575_tn9dxio9k + + +### List All Models +GET {{host}}/v1/models +Authorization: Bearer {{token}} + + +### List Models With Filters +GET {{host}}/v1/models?provider=anthropic&limit=5 +Authorization: Bearer {{token}} + + +### OpenAI Chat Completion +POST {{host}}/v1/chat/completions +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "model": "tokenflux:openai/gpt-5-nano", + "messages": [ + { + "role": "user", + "content": "Explain the theory of relativity in simple terms." + } + ] +} + +### OpenAI Chat Completion with streaming +POST {{host}}/v1/chat/completions +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "model": "tokenflux:openai/gpt-5-nano", + "stream": true, + "messages": [ + { + "role": "user", + "content": "Explain the theory of relativity in simple terms." + } + ] +} + +### Anthropic Chat Message +POST {{host}}/v1/messages +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "model": "anthropic:claude-sonnet-4-20250514", + "stream": false, + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "Explain the theory of relativity in simple terms." + } + ] +} + +### Anthropic Chat Message with streaming +POST {{host}}/v1/messages +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "model": "anthropic:claude-sonnet-4-20250514", + "stream": true, + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "Explain the theory of relativity in simple terms." + } + ] +}