| 1 | import type { Locale } from '@yikart/common' |
| 2 | import type { AxiosError } from 'axios' |
| 3 | import { HttpException, HttpStatus } from '@nestjs/common' |
| 4 | import { getErrorMessage, getLocale } from '@yikart/common' |
| 5 | import { isAxiosError } from 'axios' |
| 6 | |
| 7 | interface VolcengineErrorPayload { |
| 8 | code?: string | number |
| 9 | message?: string |
| 10 | param?: string |
| 11 | type?: string |
| 12 | } |
| 13 | |
| 14 | interface VolcengineErrorResponseData { |
| 15 | error?: VolcengineErrorPayload |
| 16 | } |
| 17 | |
| 18 | interface VolcengineExceptionInput { |
| 19 | operation: string |
| 20 | httpStatus?: number |
| 21 | providerCode?: string | number |
| 22 | providerMessage?: string |
| 23 | providerType?: string |
| 24 | providerParam?: string |
| 25 | requestId?: string |
| 26 | raw?: unknown |
| 27 | } |
| 28 | |
| 29 | type OfficialMessage = Record<Locale, string> |
| 30 | |
| 31 | const DEFAULT_MESSAGE: OfficialMessage = { |
| 32 | 'en-US': 'Volcengine request failed', |
| 33 | 'zh-CN': '火山引擎请求失败', |
| 34 | } |
| 35 | |
| 36 | const OFFICIAL_MESSAGE_BY_CODE: Record<string, OfficialMessage> = { |
| 37 | 'MissingParameter': { |
| 38 | 'en-US': 'The request failed because it is missing one or multiple required parameters.', |
| 39 | 'zh-CN': '请求缺少必要参数,请查阅 API 文档。', |
| 40 | }, |
| 41 | 'InvalidParameter': { |
| 42 | 'en-US': 'One or more parameters specified in the request are not valid.', |
| 43 | 'zh-CN': '请求包含非法参数,请查阅 API 文档。', |
| 44 | }, |
| 45 | 'InvalidEndpoint.ClosedEndpoint': { |
| 46 | 'en-US': 'The request targeted an endpoint that is currently closed or temporarily unavailable.', |
| 47 | 'zh-CN': '推理接入点处于已被关闭或暂时不可用,请稍后重试,或联系推理接入点管理员。', |
| 48 | }, |
| 49 | 'SensitiveContentDetected': { |
| 50 | 'en-US': 'The request failed because the input text may contain sensitive information.', |
| 51 | 'zh-CN': '输入文本可能包含敏感信息,请您使用其他 prompt。', |
| 52 | }, |
| 53 | 'SensitiveContentDetected.SevereViolation': { |
| 54 | 'en-US': 'The request failed because the input text may contain severe violation information.', |
| 55 | 'zh-CN': '输入文本可能包含严重违规相关信息,请您使用其他 prompt。', |
| 56 | }, |
| 57 | 'SensitiveContentDetected.Violence': { |
| 58 | 'en-US': 'The request failed because the input text may contain violence information.', |
| 59 | 'zh-CN': '输入文本可能包含激进行为相关信息,请您使用其他 prompt。', |
| 60 | }, |
| 61 | 'InputTextSensitiveContentDetected': { |
| 62 | 'en-US': 'The request failed because the input text may contain sensitive information.', |
| 63 | 'zh-CN': '输入文本可能包含敏感信息,请您更换后重试。', |
| 64 | }, |
| 65 | 'InputImageSensitiveContentDetected': { |
| 66 | 'en-US': 'The request failed because the input image may contain sensitive information.', |
| 67 | 'zh-CN': '输入图片可能包含敏感信息,请您更换后重试。', |
| 68 | }, |
| 69 | 'InputVideoSensitiveContentDetected': { |
| 70 | 'en-US': 'The request failed because the input video may contain sensitive information.', |
| 71 | 'zh-CN': '输入视频可能包含敏感信息,请您更换后重试。', |
| 72 | }, |
| 73 | 'InputAudioSensitiveContentDetected': { |
| 74 | 'en-US': 'The request failed because the input audio may contain sensitive information.', |
| 75 | 'zh-CN': '输入音频可能包含敏感信息,请您更换后重试。', |
| 76 | }, |
| 77 | 'OutputTextSensitiveContentDetected': { |
| 78 | 'en-US': 'The request failed because the output may contain sensitive information.', |
| 79 | 'zh-CN': '生成的文字可能包含敏感信息,请您更换输入内容后重试。', |
| 80 | }, |
| 81 | 'OutputImageSensitiveContentDetected': { |
| 82 | 'en-US': 'The request failed because the output image may contain sensitive information.', |
| 83 | 'zh-CN': '生成的图像可能包含敏感信息,请您更换输入内容后重试。', |
| 84 | }, |
| 85 | 'OutputVideoSensitiveContentDetected': { |
| 86 | 'en-US': 'The request failed because the output video may contain sensitive information.', |
| 87 | 'zh-CN': '生成的视频可能包含敏感信息,请您更换输入内容后重试。', |
| 88 | }, |
| 89 | 'OutputAudioSensitiveContentDetected': { |
| 90 | 'en-US': 'The request failed because the output audio may contain sensitive information.', |
| 91 | 'zh-CN': '生成的音频可能包含敏感信息,请您更换输入内容后重试。', |
| 92 | }, |
| 93 | 'InputTextSensitiveContentDetected.PolicyViolation': { |
| 94 | 'en-US': 'The request failed because the input text may violate platform rules.', |
| 95 | 'zh-CN': '输入文本可能违反平台规定,请您更换后重试。', |
| 96 | }, |
| 97 | 'InputImageSensitiveContentDetected.PolicyViolation': { |
| 98 | 'en-US': 'The request failed because the input image may violate platform rules.', |
| 99 | 'zh-CN': '输入图片可能违反平台规定,请您更换后重试。', |
| 100 | }, |
| 101 | 'InputVideoSensitiveContentDetected.PolicyViolation': { |
| 102 | 'en-US': 'The request failed because the input video may violate platform rules.', |
| 103 | 'zh-CN': '输入视频可能违反平台规定,请您更换后重试。', |
| 104 | }, |
| 105 | 'InputAudioSensitiveContentDetected.PolicyViolation': { |
| 106 | 'en-US': 'The request failed because the input audio may violate platform rules.', |
| 107 | 'zh-CN': '输入音频可能违反平台规定,请您更换后重试。', |
| 108 | }, |
| 109 | 'InputImageSensitiveContentDetected.PrivacyInformation': { |
| 110 | 'en-US': 'The request failed because the input image may contain real person.', |
| 111 | 'zh-CN': '输入图片可能包含真人,请您更换后重试。', |
| 112 | }, |
| 113 | 'InputVideoSensitiveContentDetected.PrivacyInformation': { |
| 114 | 'en-US': 'The request failed because the input video may contain real person.', |
| 115 | 'zh-CN': '输入视频可能包含真人,请您更换后重试。', |
| 116 | }, |
| 117 | 'InputTextRiskDetection': { |
| 118 | 'en-US': 'The request could not be processed because the input text includes sensitive content that violates ContentSecurityDetection.', |
| 119 | 'zh-CN': '火山引擎风险识别产品检测到输入文本可能包含敏感信息,请您更换后重试。', |
| 120 | }, |
| 121 | 'InputImageRiskDetection': { |
| 122 | 'en-US': 'The request could not be processed because the input image includes sensitive content that violates ContentSecurityDetection.', |
| 123 | 'zh-CN': '火山引擎风险识别产品检测到输入图片可能包含敏感信息,请您更换后重试。', |
| 124 | }, |
| 125 | 'OutputTextRiskDetection': { |
| 126 | 'en-US': 'The request could not be processed because the output text includes sensitive content that violates ContentSecurityDetection.', |
| 127 | 'zh-CN': '火山引擎风险识别产品检测到输出文本可能包含敏感信息,请您更换后重试。', |
| 128 | }, |
| 129 | 'OutputImageRiskDetection': { |
| 130 | 'en-US': 'The request could not be processed because the output image includes sensitive content that violates ContentSecurityDetection.', |
| 131 | 'zh-CN': '火山引擎风险识别产品检测到输出图片可能包含敏感信息,请您更换后重试。', |
| 132 | }, |
| 133 | 'ContentSecurityDetectionError': { |
| 134 | 'en-US': 'Internal error.', |
| 135 | 'zh-CN': '火山引擎风险识别产品请求失败。', |
| 136 | }, |
| 137 | 'InvalidArgumentError': { |
| 138 | 'en-US': 'The request arguments are invalid.', |
| 139 | 'zh-CN': '请求参数不合法,请检查消息结构后重试。', |
| 140 | }, |
| 141 | 'InvalidArgumentError.UnknownRole': { |
| 142 | 'en-US': 'The role of message is not supported.', |
| 143 | 'zh-CN': '消息体中的 role 不被支持,请检查参数后重试。', |
| 144 | }, |
| 145 | 'InvalidArgumentError.InvalidImageDetail': { |
| 146 | 'en-US': 'Invalid image detail value.', |
| 147 | 'zh-CN': 'image_url.detail 参数值无效,只支持 auto、high、low。', |
| 148 | }, |
| 149 | 'InvalidArgumentError.InvalidPixelLimit': { |
| 150 | 'en-US': 'Customized image pixel limits are invalid.', |
| 151 | 'zh-CN': '图片像素限制参数无效,请检查 min_pixels 与 max_pixels。', |
| 152 | }, |
| 153 | 'InvalidImageURL.EmptyURL': { |
| 154 | 'en-US': 'Empty base64 image url.', |
| 155 | 'zh-CN': '传入的图片 URL 为空。', |
| 156 | }, |
| 157 | 'InvalidImageURL.InvalidFormat': { |
| 158 | 'en-US': 'Invalid base64 image url.', |
| 159 | 'zh-CN': '图片 URL 或 Base64 数据无效,请检查格式后重试。', |
| 160 | }, |
| 161 | 'OutofContextError': { |
| 162 | 'en-US': 'Total tokens of image and text exceed max message tokens.', |
| 163 | 'zh-CN': '图文输入总 token 超出模型上下文长度限制,请缩短输入后重试。', |
| 164 | }, |
| 165 | 'AuthenticationError': { |
| 166 | 'en-US': 'The API key or AK/SK in the request is missing or invalid.', |
| 167 | 'zh-CN': '请求携带的 API Key 或 AK/SK 校验未通过,请检查鉴权凭证。', |
| 168 | }, |
| 169 | 'InvalidAccountStatus': { |
| 170 | 'en-US': 'There is an issue with your account status.', |
| 171 | 'zh-CN': '当前使用的账号状态异常,请联系平台管理员处理。', |
| 172 | }, |
| 173 | 'OperationDenied.ServiceNotOpen': { |
| 174 | 'en-US': 'Operation is denied because the model service is unavailable.', |
| 175 | 'zh-CN': '模型服务不可用,请前往火山方舟控制台开通对应模型服务。', |
| 176 | }, |
| 177 | 'OperationDenied.ServiceOverdue': { |
| 178 | 'en-US': 'Operation is denied because your account balance is overdue.', |
| 179 | 'zh-CN': '您的账单已逾期,请前往火山费用中心充值后重试。', |
| 180 | }, |
| 181 | 'AccountOverdueError': { |
| 182 | 'en-US': 'The request failed because your account has an overdue balance.', |
| 183 | 'zh-CN': '当前账号欠费,请充值后重试。', |
| 184 | }, |
| 185 | 'AccessDenied': { |
| 186 | 'en-US': 'The request failed because you do not have access to the requested resource.', |
| 187 | 'zh-CN': '没有访问该资源的权限,请检查权限设置或联系管理员。', |
| 188 | }, |
| 189 | 'InvalidEndpointOrModel.NotFound': { |
| 190 | 'en-US': 'The model or endpoint does not exist or you do not have access to it.', |
| 191 | 'zh-CN': '模型或推理接入点不存在,或者您无权访问该资源。', |
| 192 | }, |
| 193 | 'ModelNotOpen': { |
| 194 | 'en-US': 'Your account has not activated the model service.', |
| 195 | 'zh-CN': '当前账号暂未开通该模型服务,请前往火山方舟控制台开通。', |
| 196 | }, |
| 197 | 'InvalidEndpointOrModel.ModelIDAccessDisabled': { |
| 198 | 'en-US': 'Accessing the model via Model ID is not allowed for your account.', |
| 199 | 'zh-CN': '当前账号不允许通过模型 ID 调用,请改用有权限的推理接入点 ID。', |
| 200 | }, |
| 201 | 'RateLimitExceeded.EndpointRPMExceeded': { |
| 202 | 'en-US': 'The Requests Per Minute limit of the associated endpoint for your account has been exceeded.', |
| 203 | 'zh-CN': '请求已超过推理接入点 RPM 限制,请稍后重试。', |
| 204 | }, |
| 205 | 'RateLimitExceeded.EndpointTPMExceeded': { |
| 206 | 'en-US': 'The Tokens Per Minute limit of the associated endpoint for your account has been exceeded.', |
| 207 | 'zh-CN': '请求已超过推理接入点 TPM 限制,请稍后重试。', |
| 208 | }, |
| 209 | 'ModelAccountRpmRateLimitExceeded': { |
| 210 | 'en-US': 'RPM limit of the model is exceeded.', |
| 211 | 'zh-CN': '请求已超过账户模型 RPM 限制,请稍后重试。', |
| 212 | }, |
| 213 | 'ModelAccountTpmRateLimitExceeded': { |
| 214 | 'en-US': 'TPM limit of the model is exceeded.', |
| 215 | 'zh-CN': '请求已超过账户模型 TPM 限制,请稍后重试。', |
| 216 | }, |
| 217 | 'APIAccountRpmRateLimitExceeded': { |
| 218 | 'en-US': 'The RPM limit for the API on your account has been exceeded.', |
| 219 | 'zh-CN': '当前账号该接口的 RPM 限制已超出,请稍后重试。', |
| 220 | }, |
| 221 | 'ModelAccountIpmRateLimitExceeded': { |
| 222 | 'en-US': 'IPM limit of the model is exceeded.', |
| 223 | 'zh-CN': '请求已超过账户模型 IPM 限制,请稍后重试。', |
| 224 | }, |
| 225 | 'QuotaExceeded': { |
| 226 | 'en-US': 'The request has exceeded the quota.', |
| 227 | 'zh-CN': '当前账号额度已用尽或当前排队任务数超过限制,请稍后重试或检查额度配置。', |
| 228 | }, |
| 229 | 'ServerOverloaded': { |
| 230 | 'en-US': 'The service is currently unable to handle additional requests due to server overload. Please retry later.', |
| 231 | 'zh-CN': '服务资源紧张,请稍后重试。', |
| 232 | }, |
| 233 | 'RequestBurstTooFast': { |
| 234 | 'en-US': 'System protection triggered by request burst. Please slow down traffic growth and increase requests gradually before retrying.', |
| 235 | 'zh-CN': '请求量激增触发系统保护,请放缓流量提升速度后再试。', |
| 236 | }, |
| 237 | 'SetLimitExceeded': { |
| 238 | 'en-US': 'Your account has reached the set inference limit for the model, and the model service has been paused.', |
| 239 | 'zh-CN': '已达到当前模型设置的推理限额,请调整限额或关闭安心体验模式后重试。', |
| 240 | }, |
| 241 | 'InflightBatchsizeExceeded': { |
| 242 | 'en-US': 'The Inflight Batchsize limit has been exceeded.', |
| 243 | 'zh-CN': '已达到当前充值金额下的最大并发数限制,请降低并发或充值后重试。', |
| 244 | }, |
| 245 | 'AccountRateLimitExceeded': { |
| 246 | 'en-US': 'Requests are too frequent. Please reduce your request frequency, wait a short moment, and retry your request.', |
| 247 | 'zh-CN': '请求过于频繁,请稍后重试。', |
| 248 | }, |
| 249 | 'InternalServiceError': { |
| 250 | 'en-US': 'The service encountered an unexpected internal error. Please retry later.', |
| 251 | 'zh-CN': '内部系统异常,请稍后重试。', |
| 252 | }, |
| 253 | } |
| 254 | |
| 255 | const OFFICIAL_MESSAGE_BY_PREFIX: Array<{ prefix: string, message: OfficialMessage }> = [ |
| 256 | { |
| 257 | prefix: 'MissingParameter.', |
| 258 | message: { |
| 259 | 'en-US': 'The required parameter is missing.', |
| 260 | 'zh-CN': '缺少必要的请求参数,请确认请求参数后重试。', |
| 261 | }, |
| 262 | }, |
| 263 | { |
| 264 | prefix: 'InvalidParameter.', |
| 265 | message: { |
| 266 | 'en-US': 'The specified parameter is invalid.', |
| 267 | 'zh-CN': '请求参数值不合法,请检查参数值的正确性后重试。', |
| 268 | }, |
| 269 | }, |
| 270 | ] |
| 271 | |
| 272 | function extractResponseData(error: AxiosError): VolcengineErrorResponseData | undefined { |
| 273 | const data = error.response?.data |
| 274 | if (data == null || typeof data !== 'object') { |
| 275 | return undefined |
| 276 | } |
| 277 | return data as VolcengineErrorResponseData |
| 278 | } |
| 279 | |
| 280 | function extractRequestId(rawMessage?: string): string | undefined { |
| 281 | if (!rawMessage) { |
| 282 | return undefined |
| 283 | } |
| 284 | |
| 285 | const match = rawMessage.match(/(?:ARKRequest ID|Request ID)\s*[::]\s*([A-Z0-9-]+)/i) |
| 286 | return match?.[1] |
| 287 | } |
| 288 | |
| 289 | function stripRequestIdFragment(rawMessage?: string): string | undefined { |
| 290 | if (!rawMessage) { |
| 291 | return undefined |
| 292 | } |
| 293 | |
| 294 | return rawMessage |
| 295 | .replace(/\s*(?:ARKRequest ID|Request ID)\s*[::]\s*[A-Z0-9-]+(?:[;;].*)?$/i, '') |
| 296 | .trim() |
| 297 | } |
| 298 | |
| 299 | function resolveOfficialMessage(code: string | number | undefined, locale: Locale): string | undefined { |
| 300 | if (code == null) { |
| 301 | return undefined |
| 302 | } |
| 303 | |
| 304 | const normalizedCode = String(code) |
| 305 | const exactMessage = OFFICIAL_MESSAGE_BY_CODE[normalizedCode] |
| 306 | if (exactMessage) { |
| 307 | return exactMessage[locale] |
| 308 | } |
| 309 | |
| 310 | const prefixMatch = OFFICIAL_MESSAGE_BY_PREFIX.find(item => normalizedCode.startsWith(item.prefix)) |
| 311 | return prefixMatch?.message[locale] |
| 312 | } |
| 313 | |
| 314 | function buildMessage(baseMessage: string, requestId?: string, locale: Locale = getLocale()): string { |
| 315 | if (!requestId) { |
| 316 | return baseMessage |
| 317 | } |
| 318 | |
| 319 | return locale === 'zh-CN' |
| 320 | ? `${baseMessage}(Request ID: ${requestId})` |
| 321 | : `${baseMessage} (Request ID: ${requestId})` |
| 322 | } |
| 323 | |
| 324 | function buildResponseBody(input: { |
| 325 | statusCode: number |
| 326 | message: string |
| 327 | operation: string |
| 328 | providerCode?: string | number |
| 329 | providerMessage?: string |
| 330 | providerType?: string |
| 331 | providerParam?: string |
| 332 | requestId?: string |
| 333 | }) { |
| 334 | const data: Record<string, unknown> = { |
| 335 | operation: input.operation, |
| 336 | } |
| 337 | |
| 338 | if (input.providerCode !== undefined) { |
| 339 | data['providerCode'] = input.providerCode |
| 340 | } |
| 341 | if (input.providerMessage !== undefined) { |
| 342 | data['providerMessage'] = input.providerMessage |
| 343 | } |
| 344 | if (input.providerType !== undefined) { |
| 345 | data['providerType'] = input.providerType |
| 346 | } |
| 347 | if (input.providerParam !== undefined) { |
| 348 | data['providerParam'] = input.providerParam |
| 349 | } |
| 350 | if (input.requestId !== undefined) { |
| 351 | data['requestId'] = input.requestId |
| 352 | } |
| 353 | |
| 354 | return { |
| 355 | code: input.statusCode, |
| 356 | message: input.message, |
| 357 | data, |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | export class VolcengineException extends HttpException { |
| 362 | readonly operation: string |
| 363 | readonly httpStatus: number |
| 364 | readonly providerCode?: string | number |
| 365 | readonly providerMessage?: string |
| 366 | readonly providerType?: string |
| 367 | readonly providerParam?: string |
| 368 | readonly requestId?: string |
| 369 | readonly raw?: unknown |
| 370 | |
| 371 | constructor(input: VolcengineExceptionInput) { |
| 372 | const statusCode = input.httpStatus ?? HttpStatus.INTERNAL_SERVER_ERROR |
| 373 | const locale = getLocale() |
| 374 | const message = buildMessage( |
| 375 | resolveOfficialMessage(input.providerCode, locale) |
| 376 | ?? stripRequestIdFragment(input.providerMessage) |
| 377 | ?? DEFAULT_MESSAGE[locale], |
| 378 | input.requestId, |
| 379 | locale, |
| 380 | ) |
| 381 | |
| 382 | super( |
| 383 | buildResponseBody({ |
| 384 | statusCode, |
| 385 | message, |
| 386 | operation: input.operation, |
| 387 | providerCode: input.providerCode, |
| 388 | providerMessage: input.providerMessage, |
| 389 | providerType: input.providerType, |
| 390 | providerParam: input.providerParam, |
| 391 | requestId: input.requestId, |
| 392 | }), |
| 393 | statusCode, |
| 394 | ) |
| 395 | |
| 396 | this.message = message |
| 397 | this.name = 'VolcengineException' |
| 398 | this.operation = input.operation |
| 399 | this.httpStatus = statusCode |
| 400 | this.providerCode = input.providerCode |
| 401 | this.providerMessage = input.providerMessage |
| 402 | this.providerType = input.providerType |
| 403 | this.providerParam = input.providerParam |
| 404 | this.requestId = input.requestId |
| 405 | this.raw = input.raw |
| 406 | } |
| 407 | |
| 408 | static buildFromError(error: unknown, operation: string): VolcengineException { |
| 409 | if (error instanceof VolcengineException) { |
| 410 | return error |
| 411 | } |
| 412 | |
| 413 | if (!isAxiosError(error)) { |
| 414 | return new VolcengineException({ |
| 415 | operation, |
| 416 | providerMessage: getErrorMessage(error), |
| 417 | raw: error, |
| 418 | }) |
| 419 | } |
| 420 | |
| 421 | const responseData = extractResponseData(error) |
| 422 | const payload = responseData?.error |
| 423 | |
| 424 | return new VolcengineException({ |
| 425 | operation, |
| 426 | httpStatus: error.response?.status ?? HttpStatus.INTERNAL_SERVER_ERROR, |
| 427 | providerCode: payload?.code, |
| 428 | providerMessage: payload?.message ?? error.message, |
| 429 | providerType: payload?.type, |
| 430 | providerParam: payload?.param, |
| 431 | requestId: extractRequestId(payload?.message) ?? extractRequestId(error.message), |
| 432 | raw: error, |
| 433 | }) |
| 434 | } |
| 435 | } |
| 436 |