✨ feat: implement comprehensive Claude Code OAuth integration and API enhancements
- Add shared Anthropic utilities package with OAuth and API key client creation - Implement provider-specific message routing alongside existing v1 API - Enhance authentication middleware with priority handling (API key > Bearer token) - Add comprehensive auth middleware test suite with timing attack protection - Update session handling and message transformation for Claude Code integration - Improve error handling and validation across message processing pipeline - Standardize import formatting and code structure across affected modules This establishes the foundation for Claude Code OAuth authentication while maintaining backward compatibility with existing API key authentication methods.
This commit is contained in:
+20
-11
@@ -1,16 +1,16 @@
|
||||
import { loggerService } from '@main/services/LoggerService'
|
||||
import {loggerService} from '@main/services/LoggerService'
|
||||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import {v4 as uuidv4} from 'uuid'
|
||||
|
||||
import { authMiddleware } from './middleware/auth'
|
||||
import { errorHandler } from './middleware/error'
|
||||
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'
|
||||
import {authMiddleware} from './middleware/auth'
|
||||
import {errorHandler} from './middleware/error'
|
||||
import {setupOpenAPIDocumentation} from './middleware/openapi'
|
||||
import {agentsRoutes} from './routes/agents'
|
||||
import {chatRoutes} from './routes/chat'
|
||||
import {mcpRoutes} from './routes/mcp'
|
||||
import {messagesProviderRoutes, messagesRoutes} from './routes/messages'
|
||||
import {modelsRoutes} from './routes/models'
|
||||
|
||||
const logger = loggerService.withContext('ApiServer')
|
||||
|
||||
@@ -108,6 +108,14 @@ app.get('/', (_req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
// Provider-specific API routes with auth (must be before /v1 to avoid conflicts)
|
||||
const providerRouter = express.Router({mergeParams: true})
|
||||
providerRouter.use(authMiddleware)
|
||||
providerRouter.use(express.json())
|
||||
// Mount provider-specific messages route
|
||||
providerRouter.use('/v1/messages', messagesProviderRoutes)
|
||||
app.use('/:provider', providerRouter)
|
||||
|
||||
// API v1 routes with auth
|
||||
const apiRouter = express.Router()
|
||||
apiRouter.use(authMiddleware)
|
||||
@@ -120,10 +128,11 @@ apiRouter.use('/models', modelsRoutes)
|
||||
apiRouter.use('/agents', agentsRoutes)
|
||||
app.use('/v1', apiRouter)
|
||||
|
||||
|
||||
// Setup OpenAPI documentation
|
||||
setupOpenAPIDocumentation(app)
|
||||
|
||||
// Error handling (must be last)
|
||||
app.use(errorHandler)
|
||||
|
||||
export { app }
|
||||
export {app}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import type {NextFunction, Request, Response} from 'express'
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
|
||||
import {config} from '../../config'
|
||||
import {authMiddleware} from '../auth'
|
||||
|
||||
// Mock the config module
|
||||
vi.mock('../../config', () => ({
|
||||
config: {
|
||||
get: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: vi.fn(() => ({
|
||||
debug: vi.fn()
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
const mockConfig = config as any
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
let req: Partial<Request>
|
||||
let res: Partial<Response>
|
||||
let next: NextFunction
|
||||
let jsonMock: ReturnType<typeof vi.fn>
|
||||
let statusMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
jsonMock = vi.fn()
|
||||
statusMock = vi.fn(() => ({json: jsonMock}))
|
||||
|
||||
req = {
|
||||
header: vi.fn()
|
||||
}
|
||||
res = {
|
||||
status: statusMock
|
||||
}
|
||||
next = vi.fn()
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Missing credentials', () => {
|
||||
it('should return 401 when both auth headers are missing', async () => {
|
||||
;(req.header as any).mockReturnValue('')
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Unauthorized: missing credentials'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 when both auth headers are empty strings', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return ''
|
||||
if (header === 'x-api-key') return ''
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Unauthorized: missing credentials'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Server configuration', () => {
|
||||
it('should return 403 when API key is not configured', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'some-key'
|
||||
return ''
|
||||
})
|
||||
|
||||
mockConfig.get.mockResolvedValue({apiKey: ''})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Forbidden'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 when API key is null', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'some-key'
|
||||
return ''
|
||||
})
|
||||
|
||||
mockConfig.get.mockResolvedValue({apiKey: null})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Forbidden'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Key authentication (priority)', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({apiKey: validApiKey})
|
||||
})
|
||||
|
||||
it('should authenticate successfully with valid API key', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return validApiKey
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 with invalid API key', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'invalid-key'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Forbidden'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 with empty API key', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return ' '
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Unauthorized: empty x-api-key'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle API key with whitespace', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return ` ${validApiKey} `
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prioritize API key over Bearer token when both are present', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return validApiKey
|
||||
if (header === 'authorization') return 'Bearer invalid-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 when API key is invalid even if Bearer token is valid', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'invalid-key'
|
||||
if (header === 'authorization') return `Bearer ${validApiKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Forbidden'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bearer token authentication (fallback)', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({apiKey: validApiKey})
|
||||
})
|
||||
|
||||
it('should authenticate successfully with valid Bearer token when no API key', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return `Bearer ${validApiKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 with invalid Bearer token', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Bearer invalid-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Forbidden'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 with malformed authorization header', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Basic sometoken'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Unauthorized: invalid authorization format'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 with Bearer without space', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Bearer'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Unauthorized: invalid authorization format'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Bearer token with only trailing spaces (edge case)', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Bearer ' // This will be trimmed to "Bearer" and fail format check
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Unauthorized: invalid authorization format'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Bearer token with case insensitive prefix', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return `bearer ${validApiKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Bearer token with whitespace', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return ` Bearer ${validApiKey} `
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({apiKey: validApiKey})
|
||||
})
|
||||
|
||||
it('should handle config.get() rejection', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return validApiKey
|
||||
return ''
|
||||
})
|
||||
|
||||
mockConfig.get.mockRejectedValue(new Error('Config error'))
|
||||
|
||||
await expect(authMiddleware(req as Request, res as Response, next)).rejects.toThrow('Config error')
|
||||
})
|
||||
|
||||
it('should use timing-safe comparison for different length tokens', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'short'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Forbidden'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 when neither credential format is valid', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Invalid format'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Unauthorized: invalid authorization format'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Timing attack protection', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({apiKey: validApiKey})
|
||||
})
|
||||
|
||||
it('should handle similar length but different API keys securely', async () => {
|
||||
const similarKey = 'valid-api-key-124' // Same length, different last char
|
||||
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return similarKey
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Forbidden'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle similar length but different Bearer tokens securely', async () => {
|
||||
const similarKey = 'valid-api-key-124' // Same length, different last char
|
||||
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return `Bearer ${similarKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({error: 'Forbidden'})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,62 +1,67 @@
|
||||
import crypto from 'crypto'
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import {NextFunction, Request, Response} from 'express'
|
||||
|
||||
import { config } from '../config'
|
||||
import {config} from '../config'
|
||||
|
||||
|
||||
const isValidToken = (token: string, apiKey: string): boolean => {
|
||||
if (token.length !== apiKey.length) {
|
||||
return false
|
||||
}
|
||||
const tokenBuf = Buffer.from(token)
|
||||
const keyBuf = Buffer.from(apiKey)
|
||||
return crypto.timingSafeEqual(tokenBuf, keyBuf)
|
||||
}
|
||||
|
||||
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const auth = req.header('Authorization') || ''
|
||||
const auth = req.header('authorization') || ''
|
||||
const xApiKey = req.header('x-api-key') || ''
|
||||
|
||||
// Fast rejection if neither credential header provided
|
||||
if (!auth && !xApiKey) {
|
||||
return res.status(401).json({ error: 'Unauthorized: missing credentials' })
|
||||
return res.status(401).json({error: 'Unauthorized: missing credentials'})
|
||||
}
|
||||
|
||||
let token: string | undefined
|
||||
const {apiKey} = await config.get()
|
||||
|
||||
// Prefer Bearer if well‑formed
|
||||
if (!apiKey) {
|
||||
return res.status(403).json({error: 'Forbidden'})
|
||||
}
|
||||
|
||||
// Check API key first (priority)
|
||||
if (xApiKey) {
|
||||
const trimmedApiKey = xApiKey.trim()
|
||||
if (!trimmedApiKey) {
|
||||
return res.status(401).json({error: 'Unauthorized: empty x-api-key'})
|
||||
}
|
||||
|
||||
if (isValidToken(trimmedApiKey, apiKey)) {
|
||||
return next()
|
||||
} else {
|
||||
return res.status(403).json({error: 'Forbidden'})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Bearer token
|
||||
if (auth) {
|
||||
const trimmed = auth.trim()
|
||||
const bearerPrefix = /^Bearer\s+/i
|
||||
if (bearerPrefix.test(trimmed)) {
|
||||
const candidate = trimmed.replace(bearerPrefix, '').trim()
|
||||
if (!candidate) {
|
||||
return res.status(401).json({ error: 'Unauthorized: empty bearer token' })
|
||||
}
|
||||
token = candidate
|
||||
|
||||
if (!bearerPrefix.test(trimmed)) {
|
||||
return res.status(401).json({error: 'Unauthorized: invalid authorization format'})
|
||||
}
|
||||
|
||||
const token = trimmed.replace(bearerPrefix, '').trim()
|
||||
if (!token) {
|
||||
return res.status(401).json({error: 'Unauthorized: empty bearer token'})
|
||||
}
|
||||
|
||||
if (isValidToken(token, apiKey)) {
|
||||
return next()
|
||||
} else {
|
||||
return res.status(403).json({error: 'Forbidden'})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to x-api-key if token still not resolved
|
||||
if (!token && xApiKey) {
|
||||
if (!xApiKey.trim()) {
|
||||
return res.status(401).json({ error: 'Unauthorized: empty x-api-key' })
|
||||
}
|
||||
token = xApiKey.trim()
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
// At this point we had at least one header, but none yielded a usable token
|
||||
return res.status(401).json({ error: 'Unauthorized: invalid credentials format' })
|
||||
}
|
||||
|
||||
const { apiKey } = await config.get()
|
||||
|
||||
if (!apiKey) {
|
||||
// If server not configured, treat as forbidden (or could be 500). Choose 403 to avoid leaking config state.
|
||||
return res.status(403).json({ error: 'Forbidden' })
|
||||
}
|
||||
|
||||
// Timing-safe compare when lengths match, else immediate forbidden
|
||||
if (token.length !== apiKey.length) {
|
||||
return res.status(403).json({ error: 'Forbidden' })
|
||||
}
|
||||
|
||||
const tokenBuf = Buffer.from(token)
|
||||
const keyBuf = Buffer.from(apiKey)
|
||||
if (!crypto.timingSafeEqual(tokenBuf, keyBuf)) {
|
||||
return res.status(403).json({ error: 'Forbidden' })
|
||||
}
|
||||
|
||||
return next()
|
||||
return res.status(401).json({error: 'Unauthorized: invalid credentials format'})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,185 @@
|
||||
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
|
||||
import express, { Request, Response } from 'express'
|
||||
import {MessageCreateParams} from '@anthropic-ai/sdk/resources'
|
||||
import {loggerService} from '@logger'
|
||||
import express, {Request, Response} from 'express'
|
||||
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
import { messagesService } from '../services/messages'
|
||||
import { validateModelId } from '../utils'
|
||||
import {messagesService} from '../services/messages'
|
||||
import {getProviderById, validateModelId} from '../utils'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerMessagesRoutes')
|
||||
|
||||
const router = express.Router()
|
||||
const providerRouter = express.Router({mergeParams: true})
|
||||
|
||||
// Helper functions for shared logic
|
||||
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
|
||||
logger.info('Validating request body', { body: req.body })
|
||||
const request: MessageCreateParams = req.body
|
||||
|
||||
if (!request) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: 'Request body is required'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {valid: true}
|
||||
}
|
||||
|
||||
async function handleStreamingResponse(
|
||||
res: Response,
|
||||
request: MessageCreateParams,
|
||||
provider: any,
|
||||
messagesService: any,
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
function handleErrorResponse(res: Response, error: any, logger: any): Response {
|
||||
logger.error('Message processing 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function processMessageRequest(
|
||||
req: Request,
|
||||
res: Response,
|
||||
provider: any,
|
||||
modelId?: string
|
||||
): Promise<Response | void> {
|
||||
try {
|
||||
const request: MessageCreateParams = req.body
|
||||
|
||||
// Use provided modelId or keep original model
|
||||
if (modelId) {
|
||||
request.model = modelId
|
||||
}
|
||||
|
||||
logger.info('Processing message request:', {
|
||||
provider: provider.id,
|
||||
model: request.model,
|
||||
messageCount: request.messages?.length || 0,
|
||||
stream: request.stream,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature
|
||||
})
|
||||
|
||||
// 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.`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Provider validation successful:', {
|
||||
provider: provider.id,
|
||||
providerType: provider.type,
|
||||
modelId: 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) {
|
||||
await handleStreamingResponse(res, request, provider, messagesService, logger)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle non-streaming
|
||||
const response = await messagesService.processMessage(request, provider)
|
||||
return res.json(response)
|
||||
} catch (error: any) {
|
||||
return handleErrorResponse(res, error, logger)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -133,25 +305,20 @@ const router = express.Router()
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
// Validate request body
|
||||
const bodyValidation = await validateRequestBody(req)
|
||||
if (!bodyValidation.valid) {
|
||||
return res.status(400).json(bodyValidation.error)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -169,20 +336,7 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -191,100 +345,181 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
fullModelId: request.model
|
||||
})
|
||||
|
||||
// Validate request
|
||||
const validation = messagesService.validateRequest(request)
|
||||
if (!validation.isValid) {
|
||||
// Use shared processing function
|
||||
return await processMessageRequest(req, res, provider, modelId)
|
||||
} catch (error: any) {
|
||||
return handleErrorResponse(res, error, logger)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /{provider_id}/v1/messages:
|
||||
* post:
|
||||
* summary: Create message with provider in path
|
||||
* description: Create a message response using provider ID from URL path
|
||||
* tags: [Messages]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: provider_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Provider ID (e.g., "my-anthropic")
|
||||
* example: "my-anthropic"
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - model
|
||||
* - max_tokens
|
||||
* - messages
|
||||
* properties:
|
||||
* model:
|
||||
* type: string
|
||||
* description: Model ID without provider prefix
|
||||
* example: "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
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 429:
|
||||
* description: Rate limit exceeded
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
providerRouter.post('/', async (req: Request, res: Response) => {
|
||||
// Validate request body
|
||||
const bodyValidation = await validateRequestBody(req)
|
||||
if (!bodyValidation.valid) {
|
||||
return res.status(400).json(bodyValidation.error)
|
||||
}
|
||||
|
||||
try {
|
||||
const providerId = req.params.provider
|
||||
const request: MessageCreateParams = req.body
|
||||
|
||||
if (!providerId) {
|
||||
return res.status(400).json({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: validation.errors.join('; ')
|
||||
message: 'Provider ID is required in URL path'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
logger.info('Provider-specific message request:', {
|
||||
providerId,
|
||||
model: request.model,
|
||||
messageCount: request.messages?.length || 0,
|
||||
stream: request.stream,
|
||||
max_tokens: request.max_tokens
|
||||
})
|
||||
|
||||
// Get provider directly by ID from URL path
|
||||
const provider = await getProviderById(providerId)
|
||||
if (!provider) {
|
||||
return res.status(400).json({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: `Provider '${providerId}' not found or not enabled`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Provider validation successful:', {
|
||||
provider: provider.id,
|
||||
providerType: provider.type,
|
||||
modelId: request.model
|
||||
})
|
||||
|
||||
// Use shared processing function (no modelId override needed)
|
||||
return await processMessageRequest(req, res, provider)
|
||||
} catch (error: any) {
|
||||
return handleErrorResponse(res, error, logger)
|
||||
}
|
||||
})
|
||||
|
||||
export { router as messagesRoutes }
|
||||
export {providerRouter as messagesProviderRoutes, router as messagesRoutes}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { Message, MessageCreateParams, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources'
|
||||
import { Provider } from '@types'
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import {Message, MessageCreateParams, RawMessageStreamEvent} from '@anthropic-ai/sdk/resources'
|
||||
import {loggerService} from '@logger'
|
||||
import anthropicService from "@main/services/AnthropicService";
|
||||
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
|
||||
import {Provider} from '@types'
|
||||
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
|
||||
const logger = loggerService.withContext('MessagesService')
|
||||
|
||||
@@ -35,6 +37,16 @@ export class MessagesService {
|
||||
}
|
||||
}
|
||||
|
||||
async getClient(provider: Provider): Promise<Anthropic> {
|
||||
// Create Anthropic client for the provider
|
||||
if (provider.authType === 'oauth') {
|
||||
const oauthToken = await anthropicService.getValidAccessToken()
|
||||
return getSdkClient(provider, oauthToken)
|
||||
}
|
||||
return getSdkClient(provider)
|
||||
}
|
||||
|
||||
|
||||
async processMessage(request: MessageCreateParams, provider: Provider): Promise<Message> {
|
||||
logger.info('Processing Anthropic message request:', {
|
||||
model: request.model,
|
||||
@@ -44,10 +56,7 @@ export class MessagesService {
|
||||
})
|
||||
|
||||
// Create Anthropic client for the provider
|
||||
const client = new Anthropic({
|
||||
baseURL: provider.apiHost,
|
||||
apiKey: provider.apiKey
|
||||
})
|
||||
const client = await this.getClient(provider)
|
||||
|
||||
// Prepare request with the actual model ID
|
||||
const anthropicRequest: MessageCreateParams = {
|
||||
@@ -55,6 +64,10 @@ export class MessagesService {
|
||||
stream: false
|
||||
}
|
||||
|
||||
if (provider.authType === 'oauth') {
|
||||
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system || '')
|
||||
}
|
||||
|
||||
logger.debug('Sending request to Anthropic provider:', {
|
||||
provider: provider.id,
|
||||
apiHost: provider.apiHost
|
||||
@@ -66,7 +79,7 @@ export class MessagesService {
|
||||
return response
|
||||
}
|
||||
|
||||
async *processStreamingMessage(
|
||||
async* processStreamingMessage(
|
||||
request: MessageCreateParams,
|
||||
provider: Provider
|
||||
): AsyncIterable<RawMessageStreamEvent> {
|
||||
@@ -76,10 +89,7 @@ export class MessagesService {
|
||||
})
|
||||
|
||||
// Create Anthropic client for the provider
|
||||
const client = new Anthropic({
|
||||
baseURL: provider.apiHost,
|
||||
apiKey: provider.apiKey
|
||||
})
|
||||
const client = await this.getClient(provider)
|
||||
|
||||
// Prepare streaming request
|
||||
const streamingRequest: MessageCreateParams = {
|
||||
@@ -87,6 +97,10 @@ export class MessagesService {
|
||||
stream: true
|
||||
}
|
||||
|
||||
if (provider.authType === 'oauth') {
|
||||
streamingRequest.system = buildClaudeCodeSystemMessage(request.system || '')
|
||||
}
|
||||
|
||||
logger.debug('Sending streaming request to Anthropic provider:', {
|
||||
provider: provider.id,
|
||||
apiHost: provider.apiHost
|
||||
|
||||
@@ -204,6 +204,31 @@ export function transformModelToOpenAI(model: Model, providers: Provider[]): Api
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProviderById(providerId: string): Promise<Provider | undefined> {
|
||||
try {
|
||||
if (!providerId || typeof providerId !== 'string') {
|
||||
logger.warn(`Invalid provider ID parameter: ${providerId}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providers = await getAvailableProviders()
|
||||
const provider = providers.find((p: Provider) => p.id === providerId)
|
||||
|
||||
if (!provider) {
|
||||
logger.warn(
|
||||
`Provider '${providerId}' not found or not enabled. Available providers: ${providers.map((p) => p.id).join(', ')}`
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.debug(`Found provider '${providerId}'`)
|
||||
return provider
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get provider by ID:', error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function validateProvider(provider: Provider): boolean {
|
||||
try {
|
||||
if (!provider) {
|
||||
|
||||
Reference in New Issue
Block a user