返回 AiToEarn
gemini.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / libs / gemini / gemini.service.ts
1 import {
2 GenerateContentParameters,
3 GenerateContentResponse,
4 GoogleGenAI,
5 MediaModality,
6 Modality,
7 ModalityTokenCount,
8 } from '@google/genai'
9 import { Injectable, Logger, Optional } from '@nestjs/common'
10 import { AiAvailabilityService } from '../../../ai-availability'
11 import { RelayMediaResolverService } from '../../relay-media'
12 import { GeminiConfig } from './gemini.config'
13 import {
14 GeminiGeneratedImage,
15 GeminiImageGenerateRequest,
16 GeminiImageGenerateResponse,
17 GeminiImageSize,
18 GeminiImageUsage,
19 GeminiModalityTokenDetails,
20 } from './gemini.interface'
21
22 @Injectable()
23 export class GeminiService {
24 private readonly logger = new Logger(GeminiService.name)
25 private readonly genAiClient: GoogleGenAI
26
27 constructor(
28 private readonly config: GeminiConfig,
29 private readonly aiAvailability: AiAvailabilityService,
30 @Optional() private readonly relayMediaResolver?: RelayMediaResolverService,
31 ) {
32 const baseUrl = config.proxyUrl
33 ? `${config.proxyUrl}/${config.baseUrl}`
34 : config.baseUrl
35
36 this.genAiClient = new GoogleGenAI({
37 apiKey: config.apiKey,
38 httpOptions: { baseUrl },
39 })
40 }
41
42 private async withAvailability<T>(operation: string, fn: () => Promise<T>, model?: string): Promise<T> {
43 return this.aiAvailability.execute(
44 { provider: 'gemini', operation, model },
45 fn,
46 )
47 }
48
49 async generateImage(request: GeminiImageGenerateRequest): Promise<GeminiImageGenerateResponse> {
50 const model = request.model || 'gemini-3.1-flash-image-preview'
51 return this.withAvailability('generateImage', async () => {
52 const { prompt, imageUrls = [], imageSize, aspectRatio } = request
53 const resolvedImageUrls = await Promise.all(imageUrls.map(url => this.resolveRelayText(url)))
54
55 this.logger.debug({ prompt, imageUrlsCount: resolvedImageUrls.length, imageSize, aspectRatio }, 'Starting image generation')
56
57 const parts: Array<{ text: string } | { inlineData: { mimeType: string, data: string } }> = []
58
59 for (const url of resolvedImageUrls) {
60 const imageData = await this.fetchImageAsBase64(url)
61 parts.push({
62 inlineData: {
63 mimeType: imageData.mimeType,
64 data: imageData.base64,
65 },
66 })
67 }
68
69 parts.push({ text: prompt })
70
71 const response = await this.genAiClient.models.generateContent({
72 model,
73 contents: [{ role: 'user', parts }],
74 config: {
75 responseModalities: [Modality.IMAGE],
76 imageConfig: {
77 ...(imageSize && { imageSize }),
78 ...(aspectRatio && { aspectRatio }),
79 },
80 },
81 })
82
83 const images: GeminiGeneratedImage[] = []
84
85 if (response.candidates?.[0]?.content?.parts) {
86 for (const part of response.candidates[0].content.parts) {
87 if ('inlineData' in part && part.inlineData) {
88 images.push({
89 imageData: Buffer.from(part.inlineData.data!, 'base64'),
90 mimeType: part.inlineData.mimeType || 'image/png',
91 })
92 }
93 }
94 }
95
96 if (images.length === 0) {
97 this.logger.error('No image generated from Gemini API')
98 throw new Error('No image generated')
99 }
100
101 const usage: GeminiImageUsage | undefined = response.usageMetadata
102 ? {
103 promptTokenCount: response.usageMetadata.promptTokenCount || 0,
104 candidatesTokenCount: response.usageMetadata.candidatesTokenCount || 0,
105 totalTokenCount: response.usageMetadata.totalTokenCount || 0,
106 inputTokenDetails: this.extractGeminiModalityTokenDetails(response.usageMetadata['promptTokensDetails'] || []),
107 outputTokenDetails: this.extractGeminiModalityTokenDetails(response.usageMetadata['candidatesTokensDetails'] || []),
108 }
109 : undefined
110
111 this.logger.debug({
112 imageCount: images.length,
113 totalSize: images.reduce((sum, img) => sum + img.imageData.length, 0),
114 usage,
115 }, 'Image generation completed')
116
117 if (usage && images.length > 0 && (!usage.outputTokenDetails || !usage.outputTokenDetails.image)) {
118 const imageTokens = this.calculateImageTokens(model, imageSize)
119 if (imageTokens > 0) {
120 const totalImageTokens = imageTokens * images.length
121 usage.outputTokenDetails = {
122 ...usage.outputTokenDetails,
123 image: (usage.outputTokenDetails?.image || 0) + totalImageTokens,
124 }
125 usage.candidatesTokenCount += totalImageTokens
126 usage.totalTokenCount += totalImageTokens
127 this.logger.debug({ model, imageSize, imageCount: images.length, totalImageTokens }, 'Manually calculated image tokens')
128 }
129 }
130
131 return { images, usage }
132 }, model)
133 }
134
135 private calculateImageTokens(model: string, size?: GeminiImageSize): number {
136 if (model.includes('gemini-3.1-flash')) {
137 switch (size) {
138 case '0.5K':
139 return 747
140 case '1K':
141 return 1120
142 case '2K':
143 return 1680
144 case '4K':
145 return 2520
146 default:
147 return 1120 // Default to 1K
148 }
149 }
150 else if (model.includes('gemini-3-pro')) {
151 switch (size) {
152 case '4K':
153 return 2000
154 case '1K':
155 case '2K':
156 default:
157 return 1120 // 1K to 2K
158 }
159 }
160 return 0
161 }
162
163 private extractGeminiModalityTokenDetails(details: ModalityTokenCount[]): GeminiModalityTokenDetails | undefined {
164 const result: GeminiModalityTokenDetails = {}
165
166 for (const detail of details) {
167 if (typeof detail !== 'object' || detail == null) {
168 continue
169 }
170
171 const detailRecord = detail
172 const rawModality = detailRecord.modality
173 const rawTokenCount = detailRecord.tokenCount
174
175 if (typeof rawModality !== 'string') {
176 continue
177 }
178
179 const modality = rawModality.toLowerCase()
180 const tokenCount = typeof rawTokenCount === 'number' ? rawTokenCount : 0
181
182 if (tokenCount <= 0) {
183 continue
184 }
185
186 if (modality === MediaModality.TEXT) {
187 result.text = (result.text || 0) + tokenCount
188 }
189 else if (modality === MediaModality.IMAGE) {
190 result.image = (result.image || 0) + tokenCount
191 }
192 else if (modality === MediaModality.AUDIO) {
193 result.audio = (result.audio || 0) + tokenCount
194 }
195 else if (modality === MediaModality.VIDEO) {
196 result.video = (result.video || 0) + tokenCount
197 }
198 }
199
200 return Object.keys(result).length > 0 ? result : undefined
201 }
202
203 private async fetchImageAsBase64(url: string): Promise<{ base64: string, mimeType: string }> {
204 this.logger.debug({ url }, 'Fetching image as base64')
205 const response = await fetch(url)
206 if (!response.ok) {
207 throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`)
208 }
209 const contentType = response.headers.get('content-type') || 'image/jpeg'
210 const buffer = Buffer.from(await response.arrayBuffer())
211 return {
212 base64: buffer.toString('base64'),
213 mimeType: contentType,
214 }
215 }
216
217 async generateContent(params: GenerateContentParameters): Promise<GenerateContentResponse> {
218 return this.withAvailability('generateContent', async () => {
219 const resolvedParams = await this.resolveRelayJson(params)
220 return await this.genAiClient.models.generateContent(resolvedParams)
221 }, params.model)
222 }
223
224 async generateContentStream(params: GenerateContentParameters): Promise<AsyncGenerator<GenerateContentResponse>> {
225 const resolvedParams = await this.resolveRelayJson(params)
226 return await this.genAiClient.models.generateContentStream(resolvedParams)
227 }
228
229 private async resolveRelayJson<T>(value: T): Promise<T> {
230 if (!this.relayMediaResolver) {
231 return value
232 }
233 return await this.relayMediaResolver.resolveJson(value)
234 }
235
236 private async resolveRelayText(text: string): Promise<string> {
237 if (!this.relayMediaResolver) {
238 return text
239 }
240 return await this.relayMediaResolver.resolveText(text)
241 }
242 }
243
243 lines TYPESCRIPT