Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/server/nitro.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
// https://nitro.unjs.io/config
export default defineNitroConfig({
srcDir: 'server',
compatibilityDate: '2025-07-24',
compatibilityDate: '2025-07-25',
storage: {
redis: {
driver: 'memory',
},
},
experimental: {
wasm: true,
},
})
3 changes: 3 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"prepare": "nitro prepare",
"preview": "node .output/server/index.mjs"
},
"dependencies": {
"@llamakit/core": "workspace:*"
},
"devDependencies": {
"nitropack": "latest"
}
Expand Down
141 changes: 141 additions & 0 deletions packages/server/server/api/client/[client]/completions/index.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { ChatCompletionResponse, ChatRequestBody } from '@llamakit/core'
import type { ClientData } from '../../../../types/api'
import { createChatTransformer } from '@llamakit/core'

export default defineEventHandler(async (event): Promise<ChatCompletionResponse> => {
const clientId = getRouterParam(event, 'client')
const body = await readBody<ChatRequestBody>(event)

if (!clientId) {
throw createError({
statusCode: 400,
statusMessage: 'Client ID is required',
})
}

const storage = useStorage('redis')

try {
// 获取client配置
const clientData = await storage.getItem<ClientData>(`client:${clientId}`)
if (!clientData) {
throw createError({
statusCode: 404,
statusMessage: 'Client not found',
})
}

// 创建ChatTransformer
const transformer = createChatTransformer({
apiKey: clientData.apiKey,
baseUrl: clientData.baseUrl,
})

const startTimestamp = new Date()
const [result, resolve] = transformer(body, { timestamp: startTimestamp })

// 生成唯一的日志ID
const logId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`

Copilot AI Jul 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log ID generation using Date.now() and Math.random() could potentially create collisions in high-concurrency scenarios. Consider using a more robust unique ID generator like randomUUID() from Node.js crypto, which is already imported in other files.

Suggested change
const logId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
const logId = randomUUID()

Copilot uses AI. Check for mistakes.

// 将初始结果写入本地KV Storage
const logs = await storage.getItem<any[]>(`client:${clientId}:logs`) || []
const logEntry = {
id: logId,
...result,
requestInfo: {
url: `${clientData.baseUrl}/v1/chat/completions`,
method: 'POST',
headers: {
'Authorization': `Bearer ${clientData.apiKey}`,
Comment thread
Leetfs marked this conversation as resolved.
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
timestamp: startTimestamp.toISOString(),
},
}

logs.unshift(logEntry) // 最新的放在前面
await storage.setItem(`client:${clientId}:logs`, logs)

try {
// 转发用户请求到AI服务器
const response = await $fetch<ChatCompletionResponse>(`${clientData.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${clientData.apiKey}`,
'Content-Type': 'application/json',
},
body,
})

// 处理返回结果(异步写入)
const endTimestamp = new Date()
const finalResult = resolve(response, { timestamp: endTimestamp })

// 更新日志条目
const updatedLogs = await storage.getItem<any[]>(`client:${clientId}:logs`) || []
const logIndex = updatedLogs.findIndex(log => log.id === logId)
if (logIndex !== -1) {
updatedLogs[logIndex] = {
...updatedLogs[logIndex],
...finalResult,
responseInfo: {
status: 200,
headers: {},
body: JSON.stringify(response),
timestamp: endTimestamp.toISOString(),
duration: endTimestamp.getTime() - startTimestamp.getTime(),
},
}

// 异步更新存储,不阻塞响应
storage.setItem(`client:${clientId}:logs`, updatedLogs).catch(console.error)

Copilot AI Jul 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.error for handling storage errors provides minimal debugging information. Consider using a more informative error logging approach that includes context about the failed operation, such as the client ID and operation type.

Suggested change
storage.setItem(`client:${clientId}:logs`, updatedLogs).catch(console.error)
storage.setItem(`client:${clientId}:logs`, updatedLogs).catch((error) => {
console.error(`Failed to update logs for client: ${clientId}. Operation: setItem. Key: client:${clientId}:logs. Error:`, error);
})

Copilot uses AI. Check for mistakes.
}

return response
}
catch (error: any) {
// 处理错误情况
const endTimestamp = new Date()
const errorResult = {
...result,
status: 'error' as const,
error: error.message || 'Unknown error',
lastAt: endTimestamp,
timeUsage: endTimestamp.getTime() - startTimestamp.getTime(),
}

// 更新错误日志
const updatedLogs = await storage.getItem<any[]>(`client:${clientId}:logs`) || []
const logIndex = updatedLogs.findIndex(log => log.id === logId)
if (logIndex !== -1) {
updatedLogs[logIndex] = {
...updatedLogs[logIndex],
...errorResult,
responseInfo: {
status: error.status || 500,
error: error.message,
timestamp: endTimestamp.toISOString(),
duration: endTimestamp.getTime() - startTimestamp.getTime(),
},
}

// 异步更新存储
storage.setItem(`client:${clientId}:logs`, updatedLogs).catch(console.error)
Comment thread
Leetfs marked this conversation as resolved.
}

throw error
}
}
catch (error: any) {
// 处理外层错误(如客户端不存在等)
if (error.statusCode) {
throw error
}

throw createError({
statusCode: 500,
statusMessage: 'Internal server error',
})
}
})
54 changes: 54 additions & 0 deletions packages/server/server/api/client/[id].delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { RemoveClientResponse } from '../../types/api'

export default defineEventHandler(async (event): Promise<RemoveClientResponse> => {
const clientId = getRouterParam(event, 'id')

if (!clientId) {
throw createError({
statusCode: 400,
statusMessage: 'Client ID is required',
})
}

const storage = useStorage('redis')

try {
// 检查client是否存在
const clientData = await storage.getItem(`client:${clientId}`)
if (!clientData) {
throw createError({
statusCode: 404,
statusMessage: 'Client not found',
})
}

// 事务性删除:先删除相关数据,再删除客户端
// 清理相关的日志数据
await storage.removeItem(`client:${clientId}:logs`)

// 从client列表中移除
const clientList = await storage.getItem<string[]>('client:list') || []
const updatedList = clientList.filter(id => id !== clientId)
await storage.setItem('client:list', updatedList)

// 最后删除client数据
await storage.removeItem(`client:${clientId}`)

return {
success: true,
message: 'Client removed successfully',
}
}
catch (error: any) {
// 如果是已知错误,直接抛出
if (error.statusCode) {
throw error
}

// 处理未知错误
throw createError({
statusCode: 500,
statusMessage: 'Failed to remove client',
})
}
})
55 changes: 55 additions & 0 deletions packages/server/server/api/client/[id].put.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { ClientData, UpdateClientBody, UpdateClientResponse } from '../../types/api'

export default defineEventHandler(async (event): Promise<UpdateClientResponse> => {
const clientId = getRouterParam(event, 'id')
const body = await readBody<UpdateClientBody>(event)

if (!clientId) {
throw createError({
statusCode: 400,
statusMessage: 'Client ID is required',
})
}

const storage = useStorage('redis')

try {
// 获取现有client数据
const existingClient = await storage.getItem<ClientData>(`client:${clientId}`)
if (!existingClient) {
throw createError({
statusCode: 404,
statusMessage: 'Client not found',
})
}

// 更新client数据
const updatedClient: ClientData = {
...existingClient,
...(body.name && { name: body.name }),
...(body.baseUrl && { baseUrl: body.baseUrl }),
...(body.apiKey && { apiKey: body.apiKey }),
updatedAt: new Date().toISOString(),
}

await storage.setItem(`client:${clientId}`, updatedClient)

return {
success: true,
message: 'Client updated successfully',
client: updatedClient,
}
}
catch (error: any) {
// 如果是已知错误,直接抛出
if (error.statusCode) {
throw error
}

// 处理未知错误
throw createError({
statusCode: 500,
statusMessage: 'Failed to update client',
})
}
})
62 changes: 62 additions & 0 deletions packages/server/server/api/client/create.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ClientData, CreateClientBody, CreateClientResponse } from '../../types/api'
import { randomUUID } from 'node:crypto'

export default defineEventHandler(async (event): Promise<CreateClientResponse> => {
const body = await readBody<CreateClientBody>(event)

// 验证请求体
if (!body.apiKey || !body.baseUrl || !body.name) {
throw createError({
statusCode: 400,
statusMessage: 'Missing required fields: apiKey, baseUrl, or name',
})
}

// 验证URL格式
try {
// eslint-disable-next-line no-new
new URL(body.baseUrl)
Comment on lines +17 to +18

Copilot AI Jul 25, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using new URL() for validation and ignoring the result with eslint-disable is unclear. Consider using a more explicit validation approach like URL.canParse() if available, or assign the result to a variable to make the intent clearer.

Suggested change
// eslint-disable-next-line no-new
new URL(body.baseUrl)
if (!URL.canParse(body.baseUrl)) {
throw new Error('Invalid URL')
}

Copilot uses AI. Check for mistakes.
}
catch {
throw createError({
statusCode: 400,
statusMessage: 'Invalid baseUrl format',
})
}
Comment thread
Leetfs marked this conversation as resolved.

const clientId = randomUUID()
const storage = useStorage('redis')

try {
// 存储client信息到KV storage
const clientData: ClientData = {
id: clientId,
apiKey: body.apiKey,
baseUrl: body.baseUrl,
name: body.name,
createdAt: new Date().toISOString(),
}

await storage.setItem(`client:${clientId}`, clientData)

// 同时维护一个client列表用于分页查询
const clientList = await storage.getItem<string[]>('client:list') || []
clientList.push(clientId)
await storage.setItem('client:list', clientList)

return {
id: clientId,
}
}
catch {
// 清理可能已创建的数据
await storage.removeItem(`client:${clientId}`).catch((error) => {
console.error(`Failed to clean up client data for ID ${clientId}:`, error)
})

throw createError({
statusCode: 500,
statusMessage: 'Failed to create client',
})
}
})
40 changes: 40 additions & 0 deletions packages/server/server/api/client/index.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ClientData, GetClientsQuery, GetClientsResponse } from '../../types/api'

export default defineEventHandler(async (event): Promise<GetClientsResponse> => {
const query = getQuery<GetClientsQuery>(event)

const limit = Math.min(Number(query.limit) || 10, 100) // 最大100个
const offset = Number(query.offset) || 0

const storage = useStorage('redis')

try {
// 获取所有client ID列表
const clientList = await storage.getItem<string[]>('client:list') || []
const total = clientList.length

// 分页获取client数据
const paginatedIds = clientList.slice(offset, offset + limit)
const clients: ClientData[] = []

for (const clientId of paginatedIds) {
const clientData = await storage.getItem<ClientData>(`client:${clientId}`)
if (clientData) {
clients.push(clientData)
}
}

return {
clients,
total,
limit,
offset,
}
}
catch {
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch clients',
})
}
})
Loading
Loading