| 1 | import { AIMessageChunk, BaseMessage } from '@langchain/core/messages' |
| 2 | import { ChatOpenAI, OpenAIChatInput } from '@langchain/openai' |
| 3 | import { Injectable, Logger, Optional } from '@nestjs/common' |
| 4 | import OpenAI from 'openai' |
| 5 | import { AiAvailabilityService } from '../../../ai-availability' |
| 6 | import { RelayMediaResolverService } from '../../relay-media' |
| 7 | import { OpenaiConfig } from './openai.config' |
| 8 | import { SoraCharacterResponse, SoraCreateCharacterRequest } from './openai.interface' |
| 9 | |
| 10 | @Injectable() |
| 11 | export class OpenaiService { |
| 12 | private readonly logger = new Logger(OpenaiService.name) |
| 13 | private readonly openAI: OpenAI |
| 14 | private readonly chatOpenAI: ChatOpenAI |
| 15 | |
| 16 | constructor( |
| 17 | private readonly config: OpenaiConfig, |
| 18 | private readonly aiAvailability: AiAvailabilityService, |
| 19 | @Optional() private readonly relayMediaResolver?: RelayMediaResolverService, |
| 20 | ) { |
| 21 | this.openAI = this._createOpenAIClient() |
| 22 | this.chatOpenAI = this._createChatModel({}) |
| 23 | } |
| 24 | |
| 25 | private async withAvailability<T>(operation: string, fn: () => Promise<T>, model?: string): Promise<T> { |
| 26 | return this.aiAvailability.execute( |
| 27 | { provider: 'openai', operation, model }, |
| 28 | fn, |
| 29 | ) |
| 30 | } |
| 31 | |
| 32 | private _createOpenAIClient(): OpenAI { |
| 33 | return new OpenAI({ |
| 34 | apiKey: this.config.apiKey, |
| 35 | baseURL: this.config.baseUrl, |
| 36 | timeout: this.config.timeout, |
| 37 | }) |
| 38 | } |
| 39 | |
| 40 | private _createChatModel(options: Partial<OpenAIChatInput>): ChatOpenAI { |
| 41 | return new ChatOpenAI({ |
| 42 | ...options, |
| 43 | maxRetries: 1, |
| 44 | timeout: options.timeout ?? this.config.timeout, |
| 45 | apiKey: options.apiKey ?? this.config.apiKey, |
| 46 | configuration: { |
| 47 | baseURL: this.config.baseUrl, |
| 48 | }, |
| 49 | streaming: true, |
| 50 | }) |
| 51 | } |
| 52 | |
| 53 | async createChatCompletionStream(options: Partial<OpenAIChatInput> & { |
| 54 | model: string |
| 55 | messages: BaseMessage[] |
| 56 | }) { |
| 57 | const { |
| 58 | messages, |
| 59 | } = options |
| 60 | |
| 61 | const chatModel = this._createChatModel(options) |
| 62 | return await chatModel.stream(messages, options) |
| 63 | } |
| 64 | |
| 65 | async createRawStream(options: OpenAI.Chat.ChatCompletionCreateParamsStreaming) { |
| 66 | return this.withAvailability('createRawStream', async () => { |
| 67 | const resolvedOptions = await this.resolveRelayJson(options) |
| 68 | return this.openAI.chat.completions.create(resolvedOptions) |
| 69 | }, options.model) |
| 70 | } |
| 71 | |
| 72 | async createRawCompletion(options: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.ChatCompletion> { |
| 73 | return this.withAvailability('createRawCompletion', async () => { |
| 74 | const resolvedOptions = await this.resolveRelayJson(options) |
| 75 | return this.openAI.chat.completions.create(resolvedOptions) |
| 76 | }, options.model) |
| 77 | } |
| 78 | |
| 79 | async createChatCompletion(options: Partial<OpenAIChatInput> & { |
| 80 | model: string |
| 81 | messages: BaseMessage[] |
| 82 | }): Promise<AIMessageChunk> { |
| 83 | return this.withAvailability('createChatCompletion', async () => { |
| 84 | const stream = await this.createChatCompletionStream(options) |
| 85 | let result: AIMessageChunk | undefined |
| 86 | |
| 87 | for await (const chunk of stream) { |
| 88 | if (result) { |
| 89 | result = result.concat(chunk) |
| 90 | } |
| 91 | else { |
| 92 | result = chunk |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | return result! |
| 97 | }, options.model) |
| 98 | } |
| 99 | |
| 100 | async createImageGeneration(options: Omit<OpenAI.Images.ImageGenerateParams, 'user' | 'stream'>): Promise<OpenAI.Images.ImagesResponse> { |
| 101 | return this.withAvailability('createImageGeneration', async () => { |
| 102 | const resolvedOptions = await this.resolveRelayJson(options) |
| 103 | return this.openAI.images.generate(resolvedOptions) |
| 104 | }, options.model ?? undefined) |
| 105 | } |
| 106 | |
| 107 | async createImageEdit(options: Omit<OpenAI.Images.ImageEditParams, 'user' | 'stream'>): Promise<OpenAI.Images.ImagesResponse> { |
| 108 | return this.withAvailability('createImageEdit', async () => { |
| 109 | return this.openAI.images.edit(options) |
| 110 | }, options.model ?? undefined) |
| 111 | } |
| 112 | |
| 113 | async createImageVariation(options: Omit<OpenAI.Images.ImageCreateVariationParams, 'user'>): Promise<OpenAI.Images.ImagesResponse> { |
| 114 | return this.withAvailability('createImageVariation', async () => { |
| 115 | return this.openAI.images.createVariation(options) |
| 116 | }) |
| 117 | } |
| 118 | |
| 119 | private normalizeVideoTimestamp(video: OpenAI.Videos.Video): OpenAI.Videos.Video { |
| 120 | // 判断阈值:10000000000 对应 2001-09-09 |
| 121 | // 大于此值则认为是毫秒值,需转换为秒值 |
| 122 | if (video.created_at > 10000000000) { |
| 123 | return { |
| 124 | ...video, |
| 125 | created_at: Math.floor(video.created_at / 1000), |
| 126 | } |
| 127 | } |
| 128 | return video |
| 129 | } |
| 130 | |
| 131 | async createVideo(params: OpenAI.VideoCreateParams): Promise<OpenAI.Videos.Video> { |
| 132 | return this.withAvailability('createVideo', async () => { |
| 133 | const video = await this.openAI.videos.create(params) |
| 134 | return this.normalizeVideoTimestamp(video) |
| 135 | }, params.model) |
| 136 | } |
| 137 | |
| 138 | async retrieveVideo(videoId: string): Promise<OpenAI.Videos.Video> { |
| 139 | return this.withAvailability('retrieveVideo', async () => { |
| 140 | const video = await this.openAI.videos.retrieve(videoId) |
| 141 | return this.normalizeVideoTimestamp(video) |
| 142 | }) |
| 143 | } |
| 144 | |
| 145 | async listVideos(params?: OpenAI.VideoListParams): Promise<OpenAI.Videos.VideosPage> { |
| 146 | return this.withAvailability('listVideos', async () => { |
| 147 | const result = await this.openAI.videos.list(params) |
| 148 | result.data = result.data.map(video => this.normalizeVideoTimestamp(video)) |
| 149 | return result |
| 150 | }) |
| 151 | } |
| 152 | |
| 153 | async deleteVideo(videoId: string): Promise<OpenAI.Videos.VideoDeleteResponse> { |
| 154 | return this.withAvailability('deleteVideo', async () => { |
| 155 | return this.openAI.videos.delete(videoId) |
| 156 | }) |
| 157 | } |
| 158 | |
| 159 | async downloadVideoContent(videoId: string, variant?: 'video' | 'thumbnail' | 'spritesheet'): Promise<Response> { |
| 160 | return this.withAvailability('downloadVideoContent', async () => { |
| 161 | return this.openAI.videos.downloadContent(videoId, { variant }) |
| 162 | }) |
| 163 | } |
| 164 | |
| 165 | async remixVideo(videoId: string, prompt: string): Promise<OpenAI.Videos.Video> { |
| 166 | return this.withAvailability('remixVideo', async () => { |
| 167 | const video = await this.openAI.videos.remix(videoId, { prompt }) |
| 168 | return this.normalizeVideoTimestamp(video) |
| 169 | }) |
| 170 | } |
| 171 | |
| 172 | async createCharacter(params: SoraCreateCharacterRequest): Promise<SoraCharacterResponse> { |
| 173 | return this.withAvailability('createCharacter', async () => { |
| 174 | const response = await this.openAI.videos.create(params as unknown as OpenAI.VideoCreateParams) |
| 175 | return response as unknown as SoraCharacterResponse |
| 176 | }) |
| 177 | } |
| 178 | |
| 179 | async getCharacter(characterId: string): Promise<SoraCharacterResponse> { |
| 180 | return this.withAvailability('getCharacter', async () => { |
| 181 | const response = await this.openAI.videos.retrieve(characterId) |
| 182 | return response as unknown as SoraCharacterResponse |
| 183 | }) |
| 184 | } |
| 185 | |
| 186 | private async resolveRelayJson<T>(value: T): Promise<T> { |
| 187 | if (!this.relayMediaResolver) { |
| 188 | return value |
| 189 | } |
| 190 | return await this.relayMediaResolver.resolveJson(value) |
| 191 | } |
| 192 | } |
| 193 |