image_task_handler.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. package handler
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "io"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "time"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  15. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  16. "github.com/Wei-Shaw/sub2api/internal/service"
  17. "github.com/gin-gonic/gin"
  18. "go.uber.org/zap"
  19. )
  20. type AsyncImageHandler struct {
  21. tasks *service.ImageTaskService
  22. openAI *OpenAIGatewayHandler
  23. execute func(platform string, c *gin.Context)
  24. }
  25. func NewAsyncImageHandler(tasks *service.ImageTaskService, openAI *OpenAIGatewayHandler) *AsyncImageHandler {
  26. h := &AsyncImageHandler{tasks: tasks, openAI: openAI}
  27. h.execute = h.executeWithGateway
  28. return h
  29. }
  30. // enabled reports whether the async image task feature is available. Object
  31. // storage is the enablement gate: without it the endpoints are fully disabled
  32. // so that large base64 results never land in Redis.
  33. func (h *AsyncImageHandler) enabled() bool {
  34. return h != nil && h.tasks != nil && h.tasks.Enabled()
  35. }
  36. // pollable reports whether task lookups can be served. It is deliberately weaker
  37. // than enabled(): results already written to Redis stay readable after the
  38. // feature is switched off, so an in-flight task is never stranded.
  39. func (h *AsyncImageHandler) pollable() bool {
  40. return h != nil && h.tasks != nil && h.tasks.Pollable()
  41. }
  42. // Submit accepts the same payload as the synchronous Images endpoint and
  43. // returns before the upstream image generation begins.
  44. func (h *AsyncImageHandler) Submit(c *gin.Context) {
  45. if !h.enabled() {
  46. imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "async image tasks are not enabled")
  47. return
  48. }
  49. apiKey, ok := middleware2.GetAPIKeyFromContext(c)
  50. if !ok || apiKey == nil || apiKey.UserID <= 0 || apiKey.ID <= 0 {
  51. imageTaskError(c, service.ErrImageTaskForbidden)
  52. return
  53. }
  54. platform := ""
  55. if apiKey.Group != nil {
  56. platform = apiKey.Group.Platform
  57. }
  58. if platform != service.PlatformOpenAI && platform != service.PlatformGrok {
  59. imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "Images API is not supported for this platform")
  60. return
  61. }
  62. if !service.GroupAllowsImageGeneration(apiKey.Group) {
  63. imageTaskJSONError(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
  64. return
  65. }
  66. if h == nil || h.tasks == nil || h.execute == nil {
  67. imageTaskError(c, service.ErrImageTaskUnavailable)
  68. return
  69. }
  70. body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
  71. if err != nil {
  72. if maxErr, ok := extractMaxBytesError(err); ok {
  73. imageTaskJSONError(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
  74. return
  75. }
  76. imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
  77. return
  78. }
  79. if len(body) == 0 {
  80. imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
  81. return
  82. }
  83. if asyncImageRequestStreams(c.GetHeader("Content-Type"), body) {
  84. imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "streaming image requests cannot be submitted as asynchronous tasks")
  85. return
  86. }
  87. if err := h.validateRequest(c, platform, body); err != nil {
  88. imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
  89. return
  90. }
  91. if !h.checkSecurityAuditBeforeSubmit(c, apiKey, platform, body) {
  92. return
  93. }
  94. taskCtx, recorder, cancel := newAsyncImageContext(c, body, h.tasks.ExecutionTimeout())
  95. task, err := h.tasks.Create(c.Request.Context(), service.ImageTaskOwner{UserID: apiKey.UserID, APIKeyID: apiKey.ID})
  96. if err != nil {
  97. cancel()
  98. imageTaskError(c, err)
  99. return
  100. }
  101. pollURL := imageTaskPollURL(c.Request.URL.Path, task.ID)
  102. c.Header("Cache-Control", "no-store")
  103. c.Header("Location", pollURL)
  104. c.Header("Retry-After", "3")
  105. c.JSON(http.StatusAccepted, gin.H{
  106. "id": task.ID,
  107. "task_id": task.TaskID,
  108. "object": task.Object,
  109. "status": task.Status,
  110. "created_at": task.CreatedAt,
  111. "expires_at": task.ExpiresAt,
  112. "poll_url": pollURL,
  113. })
  114. go h.run(task.ID, platform, taskCtx, recorder, cancel)
  115. }
  116. func (h *AsyncImageHandler) checkSecurityAuditBeforeSubmit(c *gin.Context, apiKey *service.APIKey, platform string, body []byte) bool {
  117. if h == nil || h.openAI == nil {
  118. return true
  119. }
  120. subject, ok := middleware2.GetAuthSubjectFromContext(c)
  121. if !ok {
  122. imageTaskJSONError(c, http.StatusInternalServerError, "api_error", "User context not found")
  123. return false
  124. }
  125. model := ""
  126. moderationBody := body
  127. if platform == service.PlatformGrok {
  128. parsed := service.ParseGrokMediaRequest(c.GetHeader("Content-Type"), body)
  129. model, moderationBody = parsed.Model, parsed.ModerationBody()
  130. } else if h.openAI.gatewayService != nil {
  131. parsed, err := h.openAI.gatewayService.ParseOpenAIImagesRequest(c, body)
  132. if err != nil {
  133. imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
  134. return false
  135. }
  136. model, moderationBody = parsed.Model, parsed.ModerationBody()
  137. }
  138. if len(moderationBody) == 0 {
  139. c.Set(securityAuditCompletedContextKey, true)
  140. return true
  141. }
  142. reqLog := requestLogger(c, "handler.async_image.security_audit",
  143. zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.String("model", model))
  144. decision := h.openAI.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, model, moderationBody)
  145. if decision != nil && !decision.AllowNextStage {
  146. h.openAI.openAISecurityAuditError(c, decision)
  147. return false
  148. }
  149. return true
  150. }
  151. func (h *AsyncImageHandler) Get(c *gin.Context) {
  152. // Polling deliberately does not require the feature to be enabled, only that
  153. // the task store is reachable. Turning the switch off in the admin UI must not
  154. // strand tasks that were already accepted — their results are still in Redis
  155. // and their submitters are still polling.
  156. if !h.pollable() {
  157. imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "async image tasks are not enabled")
  158. return
  159. }
  160. apiKey, ok := middleware2.GetAPIKeyFromContext(c)
  161. if !ok || apiKey == nil || apiKey.UserID <= 0 || apiKey.ID <= 0 {
  162. imageTaskError(c, service.ErrImageTaskForbidden)
  163. return
  164. }
  165. task, err := h.tasks.Get(c.Request.Context(), service.ImageTaskOwner{UserID: apiKey.UserID, APIKeyID: apiKey.ID}, c.Param("task_id"))
  166. if err != nil {
  167. imageTaskError(c, err)
  168. return
  169. }
  170. c.Header("Cache-Control", "no-store")
  171. if task.Status == service.ImageTaskStatusProcessing {
  172. c.Header("Retry-After", "3")
  173. }
  174. c.JSON(http.StatusOK, task)
  175. }
  176. func (h *AsyncImageHandler) validateRequest(c *gin.Context, platform string, body []byte) error {
  177. if h.openAI == nil || h.openAI.gatewayService == nil {
  178. return nil
  179. }
  180. if platform == service.PlatformGrok {
  181. parsed := service.ParseGrokMediaRequest(c.GetHeader("Content-Type"), body)
  182. if strings.TrimSpace(parsed.Model) == "" {
  183. return errors.New("model is required")
  184. }
  185. return nil
  186. }
  187. parsed, err := h.openAI.gatewayService.ParseOpenAIImagesRequest(c, body)
  188. if err != nil {
  189. return err
  190. }
  191. if parsed.Stream {
  192. return errors.New("streaming image requests cannot be submitted as asynchronous tasks")
  193. }
  194. return nil
  195. }
  196. func (h *AsyncImageHandler) executeWithGateway(platform string, c *gin.Context) {
  197. if h.openAI == nil {
  198. imageTaskJSONError(c, http.StatusServiceUnavailable, "api_error", "image gateway is unavailable")
  199. return
  200. }
  201. if platform == service.PlatformGrok {
  202. h.openAI.GrokImages(c)
  203. return
  204. }
  205. h.openAI.Images(c)
  206. }
  207. func (h *AsyncImageHandler) run(taskID, platform string, taskCtx *gin.Context, recorder *httptest.ResponseRecorder, cancel context.CancelFunc) {
  208. defer cancel()
  209. defer func() {
  210. if recovered := recover(); recovered != nil {
  211. logger.L().Error("image_task.execution_panicked", zap.String("task_id", taskID), zap.Any("panic", recovered))
  212. h.failTask(taskID, http.StatusInternalServerError, imageTaskErrorPayload("api_error", "image generation task panicked"))
  213. }
  214. }()
  215. h.execute(platform, taskCtx)
  216. body := bytes.TrimSpace(recorder.Body.Bytes())
  217. if err := taskCtx.Request.Context().Err(); err != nil && len(body) == 0 {
  218. h.failTask(taskID, http.StatusGatewayTimeout, imageTaskErrorPayload("timeout_error", "image generation task timed out"))
  219. return
  220. }
  221. statusCode := recorder.Code
  222. if statusCode == 0 {
  223. statusCode = http.StatusOK
  224. }
  225. if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
  226. if len(body) == 0 || !json.Valid(body) {
  227. h.failTask(taskID, http.StatusBadGateway, imageTaskErrorPayload("api_error", "upstream returned an invalid image response"))
  228. return
  229. }
  230. if err := h.tasks.Complete(context.Background(), taskID, statusCode, json.RawMessage(body)); err != nil {
  231. logger.L().Error("image_task.complete_store_failed", zap.String("task_id", taskID), zap.Error(err))
  232. }
  233. return
  234. }
  235. h.failTask(taskID, statusCode, extractImageTaskError(body))
  236. }
  237. func (h *AsyncImageHandler) failTask(taskID string, statusCode int, taskErr json.RawMessage) {
  238. if err := h.tasks.Fail(context.Background(), taskID, statusCode, taskErr); err != nil {
  239. logger.L().Error("image_task.failure_store_failed", zap.String("task_id", taskID), zap.Error(err))
  240. }
  241. }
  242. func newAsyncImageContext(c *gin.Context, body []byte, timeoutDuration time.Duration) (*gin.Context, *httptest.ResponseRecorder, context.CancelFunc) {
  243. base := context.WithoutCancel(c.Request.Context())
  244. executionCtx, cancel := context.WithTimeout(base, timeoutDuration)
  245. request := c.Request.Clone(executionCtx)
  246. request.Body = io.NopCloser(bytes.NewReader(body))
  247. request.GetBody = func() (io.ReadCloser, error) {
  248. return io.NopCloser(bytes.NewReader(body)), nil
  249. }
  250. request.ContentLength = int64(len(body))
  251. request.URL.Path = strings.TrimSuffix(request.URL.Path, "/async")
  252. taskCtx := c.Copy()
  253. recorder := httptest.NewRecorder()
  254. recorderCtx, _ := gin.CreateTestContext(recorder)
  255. taskCtx.Writer = recorderCtx.Writer
  256. taskCtx.Request = request
  257. return taskCtx, recorder, cancel
  258. }
  259. func asyncImageRequestStreams(contentType string, body []byte) bool {
  260. if isMultipartImagesContentType(contentType) {
  261. return false
  262. }
  263. var envelope struct {
  264. Stream bool `json:"stream"`
  265. }
  266. return json.Unmarshal(body, &envelope) == nil && envelope.Stream
  267. }
  268. func imageTaskPollURL(submitPath, taskID string) string {
  269. if strings.HasPrefix(submitPath, "/v1/") {
  270. return "/v1/images/tasks/" + taskID
  271. }
  272. return "/images/tasks/" + taskID
  273. }
  274. func extractImageTaskError(body []byte) json.RawMessage {
  275. if json.Valid(body) {
  276. var envelope struct {
  277. Error json.RawMessage `json:"error"`
  278. }
  279. if json.Unmarshal(body, &envelope) == nil && len(envelope.Error) > 0 && json.Valid(envelope.Error) {
  280. return envelope.Error
  281. }
  282. return json.RawMessage(body)
  283. }
  284. return imageTaskErrorPayload("api_error", "image generation failed")
  285. }
  286. func imageTaskErrorPayload(errorType, message string) json.RawMessage {
  287. data, _ := json.Marshal(gin.H{"type": errorType, "message": message})
  288. return data
  289. }
  290. func imageTaskError(c *gin.Context, err error) {
  291. status := infraerrors.Code(err)
  292. code := infraerrors.Reason(err)
  293. message := infraerrors.Message(err)
  294. if status <= 0 {
  295. status = http.StatusInternalServerError
  296. }
  297. if strings.TrimSpace(code) == "" {
  298. code = "IMAGE_TASK_ERROR"
  299. }
  300. imageTaskJSONError(c, status, code, message)
  301. }
  302. func imageTaskJSONError(c *gin.Context, status int, code, message string) {
  303. c.Header("Cache-Control", "no-store")
  304. c.JSON(status, gin.H{"error": gin.H{"type": code, "code": code, "message": message}})
  305. }