Skip to content

Commit 253edd7

Browse files
feat: add image token (#675)
* feat: add user statistics by model endpoint and export * fix: lint * feat: add image params * feat: add image usage * fix: type check * feat: add image token * feat: delete log * feat: add image config
1 parent 4d47472 commit 253edd7

16 files changed

Lines changed: 722 additions & 27 deletions

File tree

service/src/chatgpt/index.ts

Lines changed: 119 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ClientOptions } from 'openai'
22
import type { RequestInit } from 'undici'
3-
import type { APIMODEL, AuditConfig, Config, KeyConfig, SearchResult, UserInfo } from '../storage/model'
3+
import type { APIMODEL, AuditConfig, Config, ImageUsageItem, KeyConfig, SearchResult, UserInfo } from '../storage/model'
44
import type { TextAuditService } from '../utils/textAudit'
55
import type { ChatMessage, RequestOptions } from './types'
66
import { tavily } from '@tavily/core'
@@ -19,6 +19,59 @@ function renderSystemMessage(template: string, currentTime: string): string {
1919
return template.replace(/\{current_time\}/g, currentTime)
2020
}
2121

22+
/**
23+
* 根据图片的size和quality计算token
24+
* @param size 图片尺寸,如 '1024x1024', '1024x1536', '1536x1024'
25+
* @param quality 图片质量,如 'low', 'medium', 'high'
26+
* @returns token数量,如果无法匹配则返回0
27+
*/
28+
function calculateImageTokens(size: string | undefined, quality: string | undefined): number {
29+
if (!size || !quality) {
30+
return 0
31+
}
32+
33+
// 标准化quality
34+
const normalizedQuality = quality.toLowerCase()
35+
// 标准化size,判断是square、portrait还是landscape
36+
const sizeLower = size.toLowerCase()
37+
let sizeType: 'square' | 'portrait' | 'landscape' | null = null
38+
39+
if (sizeLower === '1024x1024') {
40+
sizeType = 'square'
41+
}
42+
else if (sizeLower === '1024x1536') {
43+
sizeType = 'portrait'
44+
}
45+
else if (sizeLower === '1536x1024') {
46+
sizeType = 'landscape'
47+
}
48+
49+
if (!sizeType) {
50+
return 0
51+
}
52+
53+
// Token计算表
54+
const tokenMap: Record<string, Record<string, number>> = {
55+
low: {
56+
square: 272,
57+
portrait: 408,
58+
landscape: 400,
59+
},
60+
medium: {
61+
square: 1056,
62+
portrait: 1584,
63+
landscape: 1568,
64+
},
65+
high: {
66+
square: 4160,
67+
portrait: 6240,
68+
landscape: 6208,
69+
},
70+
}
71+
72+
return tokenMap[normalizedQuality]?.[sizeType] || 0
73+
}
74+
2275
const ErrorCodeMessage: Record<string, string> = {
2376
401: '[OpenAI] 提供错误的API密钥 | Incorrect API key provided',
2477
403: '[OpenAI] 服务器拒绝访问,请稍后再试 | Server refused to access, please try again later',
@@ -275,6 +328,27 @@ search result: <search_result>${searchResultContent}</search_result>`,
275328
}
276329
}
277330

331+
// 如果 tools 中有 image_generation,并且 keyConfig 中有配置,则使用 keyConfig 中的配置
332+
let finalTools = tools
333+
if (tools && tools.length > 0) {
334+
finalTools = tools.map((tool: any) => {
335+
if (tool.type === 'image_generation') {
336+
// 从 keyConfig 读取配置,如果不存在则使用默认值
337+
const inputFidelity = key.inputFidelity || tool.input_fidelity || 'high'
338+
const quality = key.quality || tool.quality || 'high'
339+
const model = key.imageModel || tool.model || 'gpt-image-1.5'
340+
341+
return {
342+
type: 'image_generation',
343+
input_fidelity: inputFidelity,
344+
quality,
345+
model,
346+
}
347+
}
348+
return tool
349+
})
350+
}
351+
278352
const stream = await openai.responses.create(
279353
{
280354
model,
@@ -283,7 +357,7 @@ search result: <search_result>${searchResultContent}</search_result>`,
283357
reasoning,
284358
store: options.room.toolsEnabled,
285359
stream: true,
286-
...(tools && tools.length > 0 && { tools: tools as any }),
360+
...(finalTools && finalTools.length > 0 && { tools: finalTools as any }),
287361
// 如果有图片代表是编辑当前图片,不传递该参数,否则传递该参数
288362
...(previousResponseId && uploadFileKeys.length === 0 && { previous_response_id: previousResponseId }),
289363
},
@@ -299,6 +373,7 @@ search result: <search_result>${searchResultContent}</search_result>`,
299373
const usage = new UsageResponse()
300374
const toolCalls: Array<{ type: string, result?: any }> = []
301375
let editImageId: string | undefined
376+
let imageUsageList: ImageUsageItem[] = []
302377

303378
// 心跳机制:防止生图等长时间操作时连接超时
304379
let heartbeatInterval: NodeJS.Timeout | null = null
@@ -341,26 +416,52 @@ search result: <search_result>${searchResultContent}</search_result>`,
341416
usage.prompt_tokens = resp.usage.input_tokens
342417
usage.completion_tokens = resp.usage.output_tokens
343418
usage.total_tokens = resp.usage.total_tokens
419+
// 重置图片使用列表
420+
imageUsageList = []
421+
422+
// 获取主模型和图片生成模型
423+
const mainModel = resp.model
424+
const imageModel = resp.tools && Array.isArray(resp.tools) && resp.tools.length > 0
425+
? (resp.tools.find((tool: any) => tool.type === 'image_generation') as any)?.model
426+
: undefined
344427

345428
// Extract tool calls from response
346429
if (resp.output && Array.isArray(resp.output)) {
347430
editImageId = responseId
348431
for (const output of resp.output) {
349-
if (output.type === 'image_generation_call' && output.result) {
350-
const base64Data = output.result
351-
const fileIdentifier = await saveBase64ToFile(base64Data)
352-
353-
if (fileIdentifier) {
354-
toolCalls.push({
355-
type: 'image_generation',
356-
result: fileIdentifier, // 文件名或S3 URL,前端会自动处理
357-
})
358-
}
359-
else {
360-
toolCalls.push({
361-
type: 'image_generation',
362-
result: base64Data,
363-
})
432+
if (output.type === 'image_generation_call') {
433+
// 记录图片使用信息:size、quality、模型
434+
const imageOutput = output as any
435+
const imageSize = imageOutput.size
436+
const imageQuality = imageOutput.quality
437+
const imageTokens = calculateImageTokens(imageSize, imageQuality)
438+
439+
imageUsageList.push({
440+
size: imageSize,
441+
quality: imageQuality,
442+
model: imageModel,
443+
mainModel,
444+
tokens: imageTokens,
445+
data_type: 'output',
446+
})
447+
448+
// 处理图片结果
449+
if (output.result) {
450+
const base64Data = output.result
451+
const fileIdentifier = await saveBase64ToFile(base64Data)
452+
453+
if (fileIdentifier) {
454+
toolCalls.push({
455+
type: 'image_generation',
456+
result: fileIdentifier, // 文件名或S3 URL,前端会自动处理
457+
})
458+
}
459+
else {
460+
toolCalls.push({
461+
type: 'image_generation',
462+
result: base64Data,
463+
})
464+
}
364465
}
365466
}
366467
}
@@ -384,6 +485,7 @@ search result: <search_result>${searchResultContent}</search_result>`,
384485
detail: { usage },
385486
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
386487
...(editImageId && { editImageId }),
488+
...(imageUsageList.length > 0 && { image_usage: imageUsageList }),
387489
}
388490
return sendResponse({ type: 'Success', data: response })
389491
}

service/src/chatgpt/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type OpenAI from 'openai'
2-
import type { ChatRoom, SearchResult, UserInfo } from 'src/storage/model'
2+
import type { ChatRoom, ImageUsageItem, SearchResult, UserInfo } from 'src/storage/model'
3+
import type { ImageGenerationTool } from '../types'
34

45
export interface ChatMessage {
56
id: string
@@ -35,14 +36,15 @@ export interface ResponseChunk {
3536
}>
3637
// 编辑图片时使用的文件 ID(用于后续作为 previousResponseId)
3738
editImageId?: string
39+
image_usage?: ImageUsageItem[]
3840
}
3941

4042
export interface RequestOptions {
4143
message: string
4244
uploadFileKeys?: string[]
4345
parentMessageId?: string
4446
previousResponseId?: string
45-
tools?: Array<{ type: 'image_generation' }>
47+
tools?: Array<ImageGenerationTool>
4648
process?: (chunk: ResponseChunk) => void
4749
systemMessage?: string
4850
temperature?: number

service/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getUserById,
2626
getUsers,
2727
getUserStatisticsByDay,
28+
getUserStatisticsByModel,
2829
initializeMongoDB,
2930
updateApiKeyStatus,
3031
updateConfig,
@@ -1084,6 +1085,22 @@ router.post('/statistics/by-day', auth, async (req, res) => {
10841085
}
10851086
})
10861087

1088+
router.post('/statistics/by-model', auth, async (req, res) => {
1089+
try {
1090+
const { start, end } = req.body as { start?: number, end?: number }
1091+
1092+
// 只有管理员可以查看所有用户的统计
1093+
if (!isAdmin(req.headers.userId as string))
1094+
throw new Error('无权限 | No permission')
1095+
1096+
const data = await getUserStatisticsByModel(start, end)
1097+
res.send({ status: 'Success', message: '', data })
1098+
}
1099+
catch (error) {
1100+
res.send(error)
1101+
}
1102+
})
1103+
10871104
app.use('', chatRouter)
10881105
app.use('/api', chatRouter)
10891106

service/src/routes/chat.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ResponseChunk } from '../chatgpt/types'
2-
import type { ChatInfo, ChatOptions, UsageResponse, UserInfo } from '../storage/model'
2+
import type { ChatInfo, ChatOptions, ImageUsageItem, UsageResponse, UserInfo } from '../storage/model'
33
import type { RequestProps } from '../types'
44
import * as console from 'node:console'
55
import * as process from 'node:process'
@@ -450,7 +450,8 @@ router.post('/chat-process', [auth, limiter], async (req, res) => {
450450
}
451451

452452
if (result.data.detail?.usage) {
453-
await insertChatUsage(new ObjectId(req.headers.userId), roomId, message._id, result.data.id, model, result.data.detail?.usage as UsageResponse)
453+
const imageUsage = result.data.image_usage as ImageUsageItem[] | undefined
454+
await insertChatUsage(new ObjectId(req.headers.userId), roomId, message._id, result.data.id, model, result.data.detail?.usage as UsageResponse, imageUsage)
454455
}
455456
// update personal useAmount moved here
456457
// if not fakeuserid, and has valid user info and valid useAmount set by admin nut null and limit is enabled

service/src/storage/model.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,15 @@ export class UsageResponse {
167167
estimated: boolean
168168
}
169169

170+
export interface ImageUsageItem {
171+
size?: string
172+
quality?: string
173+
model?: string
174+
mainModel?: string
175+
tokens?: number
176+
data_type?: string
177+
}
178+
170179
export class ChatUsage {
171180
_id: ObjectId
172181
userId: ObjectId
@@ -179,7 +188,8 @@ export class ChatUsage {
179188
totalTokens: number
180189
estimated: boolean
181190
dateTime: number
182-
constructor(userId: ObjectId, roomId: number, chatId: ObjectId, messageId: string, model: string, usage?: UsageResponse) {
191+
imageUsage?: ImageUsageItem[]
192+
constructor(userId: ObjectId, roomId: number, chatId: ObjectId, messageId: string, model: string, usage?: UsageResponse, imageUsage?: ImageUsageItem[]) {
183193
this.userId = userId
184194
this.roomId = roomId
185195
this.chatId = chatId
@@ -191,6 +201,9 @@ export class ChatUsage {
191201
this.totalTokens = usage.total_tokens
192202
this.estimated = usage.estimated
193203
}
204+
if (imageUsage && imageUsage.length > 0) {
205+
this.imageUsage = imageUsage
206+
}
194207
this.dateTime = new Date().getTime()
195208
}
196209
}
@@ -316,6 +329,9 @@ export class KeyConfig {
316329
baseUrl?: string
317330
toolsEnabled?: boolean
318331
imageUploadEnabled?: boolean
332+
inputFidelity?: 'low' | 'medium' | 'high'
333+
quality?: 'low' | 'medium' | 'high'
334+
imageModel?: 'gpt-image-1' | 'gpt-image-1.5'
319335
constructor(key: string, keyModel: APIMODEL, chatModel: string, userRoles: UserRole[], remark: string) {
320336
this.key = key
321337
this.keyModel = keyModel

0 commit comments

Comments
 (0)