diff --git a/backend/internal/http/handler/v1.go b/backend/internal/http/handler/v1.go index a743150..93c45c5 100644 --- a/backend/internal/http/handler/v1.go +++ b/backend/internal/http/handler/v1.go @@ -210,6 +210,25 @@ func (h *V1Handler) GetVideoContent(c *gin.Context) { _, _ = io.Copy(c.Writer, body) } +// GetImageContent — GET /v1/images/{id}/content. Streams a no-store image by +// proxying its stored (possibly auth-gated) upstream URL. Never persisted. +func (h *V1Handler) GetImageContent(c *gin.Context) { + principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization")) + if err != nil { + h.writeAuthError(c, err) + return + } + body, contentType, err := h.v1.OpenImageContent(c.Request.Context(), principal, c.Param("id")) + if err != nil { + h.writeV1Error(c, err, nil) + return + } + defer body.Close() + c.Header("Content-Type", contentType) + c.Status(http.StatusOK) + _, _ = io.Copy(c.Writer, body) +} + // readMultipartImages reads the given file fields and returns each as base64. func readMultipartImages(c *gin.Context, keys ...string) []string { var out []string diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index 04014e0..a6fd432 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -55,6 +55,8 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin. engine.POST("/v1/videos", handlers.V1.CreateVideo) engine.GET("/v1/videos/:id", handlers.V1.GetVideo) engine.GET("/v1/videos/:id/content", handlers.V1.GetVideoContent) + // No-store image content proxy (auth-gated upstream URLs, e.g. chatgpt). + engine.GET("/v1/images/:id/content", handlers.V1.GetImageContent) publicAdmin := engine.Group("/admin/api") { diff --git a/backend/internal/provider/adobe/client.go b/backend/internal/provider/adobe/client.go index bf7b58f..70810d1 100644 --- a/backend/internal/provider/adobe/client.go +++ b/backend/internal/provider/adobe/client.go @@ -174,7 +174,7 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by return body, nil, true } -func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string) ([]byte, map[string]any, error) { +func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) { // Only the generate submit goes through the proxy; polling + download run on // the local IP. submitSess, err := c.newTLSClient() @@ -201,7 +201,7 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe for _, payload := range candidates { respBody, pollURL, err := c.submitImage(ctx, submitSess, token, prompt, endpoint, payload) if err == nil { - meta, data, pollErr := c.pollImage(ctx, pollSess, token, pollURL) + meta, data, pollErr := c.pollImage(ctx, pollSess, token, pollURL, downloadResult) if pollErr != nil { return nil, nil, pollErr } @@ -531,7 +531,7 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp return respBody, "", errors.New("submit ok but no poll url") } -func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL string) (map[string]any, []byte, error) { +func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL string, downloadResult bool) (map[string]any, []byte, error) { for { if err := ctx.Err(); err != nil { return nil, nil, fmt.Errorf("adobe generation timed out: %w", err) @@ -587,6 +587,11 @@ func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL if first, ok := outputs[0].(map[string]any); ok { if image, ok := first["image"].(map[string]any); ok { if url := strings.TrimSpace(stringValue(image["presignedUrl"])); url != "" { + // Expose the presigned URL for callers that want it directly. + payload["image_url"] = url + if !downloadResult { + return payload, nil, nil + } data, err := c.download(ctx, sess, url) if err != nil { return nil, nil, err diff --git a/backend/internal/provider/chatgpt/client.go b/backend/internal/provider/chatgpt/client.go index fb5f09f..80f0170 100644 --- a/backend/internal/provider/chatgpt/client.go +++ b/backend/internal/provider/chatgpt/client.go @@ -68,7 +68,7 @@ func (c *Client) SetProxy(proxy string) { c.proxy = strings.TrimSpace(proxy) } -func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, aspectRatio, resolution string, refs [][]byte) ([]byte, map[string]any, error) { +func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, aspectRatio, resolution string, refs [][]byte, downloadResult bool) ([]byte, map[string]any, error) { // Everything except the generation submit egresses on the local IP. Only // startImageGeneration (the /backend-api/f/conversation POST) goes through // the proxy; the bootstrap / chat-requirements / reference upload / prepare @@ -130,6 +130,17 @@ func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, if len(urls) == 0 { return nil, nil, errors.New("no image urls resolved") } + meta := map[string]any{ + "provider": "chatgpt", + "model": model, + "conversation_id": conversationID, + "image_url": urls[0], // auth-gated (files.oaiusercontent.com) — needs the account token to fetch + } + // downloadResult=false: skip the (auth-gated) download and return just the URL; + // the caller proxies it via OpenAsset with the account token. + if !downloadResult { + return nil, meta, nil + } images, err := c.downloadBytes(ctx, session, accessToken, urls) if err != nil { return nil, nil, err @@ -137,11 +148,36 @@ func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, if len(images) == 0 { return nil, nil, errors.New("download produced no bytes") } - return images[0], map[string]any{ - "provider": "chatgpt", - "model": model, - "conversation_id": conversationID, - }, nil + return images[0], meta, nil +} + +// OpenAsset streams an auth-gated ChatGPT image URL (files.oaiusercontent.com) +// using the generating account's token — a plain GET 403s. Mirrors downloadBytes +// but returns a live stream instead of buffering. +func (c *Client) OpenAsset(ctx context.Context, accessToken, rawURL string) (io.ReadCloser, string, error) { + session, err := c.newDirectSession(accessToken) + if err != nil { + return nil, "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, "", err + } + req.Header = c.baseHeaders(accessToken) + req.Header.Set("accept", "*/*") + resp, err := session.Do(req) + if err != nil { + return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + if resp.StatusCode != 200 { + resp.Body.Close() + return nil, "", fmt.Errorf("%w: chatgpt asset status %d", ErrTemporaryUpstream, resp.StatusCode) + } + ct := strings.TrimSpace(resp.Header.Get("Content-Type")) + if ct == "" { + ct = "image/png" + } + return resp.Body, ct, nil } func ExtractAccountInfo(token string) map[string]any { diff --git a/backend/internal/provider/custom/client.go b/backend/internal/provider/custom/client.go index e4cdad5..e47e747 100644 --- a/backend/internal/provider/custom/client.go +++ b/backend/internal/provider/custom/client.go @@ -8,7 +8,6 @@ package custom import ( "bytes" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -61,10 +60,10 @@ func httpClient() *http.Client { return &http.Client{Timeout: 10 * time.Minute} // GenerateImage calls the upstream OpenAI image API. With reference images it // uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns // the raw image bytes (decoded from b64_json, or downloaded from url). -func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prompt, size, quality string, refs [][]byte) ([]byte, error) { +func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prompt, size, quality string, refs [][]byte, downloadResult bool) ([]byte, string, error) { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") if baseURL == "" || apiKey == "" { - return nil, ErrAuth + return nil, "", ErrAuth } var req *http.Request var err error @@ -73,24 +72,26 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom w := multipart.NewWriter(body) _ = w.WriteField("model", model) _ = w.WriteField("prompt", prompt) + // Ask the upstream for a URL (not base64) so we can pass it through directly. + _ = w.WriteField("response_format", "url") if size != "" { _ = w.WriteField("size", size) } for i, r := range refs { fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1)) if e != nil { - return nil, e + return nil, "", e } _, _ = fw.Write(r) } _ = w.Close() req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body) if err != nil { - return nil, err + return nil, "", err } req.Header.Set("Content-Type", w.FormDataContentType()) } else { - payload := map[string]any{"model": model, "prompt": prompt, "n": 1} + payload := map[string]any{"model": model, "prompt": prompt, "n": 1, "response_format": "url"} if size != "" { payload["size"] = size } @@ -100,7 +101,7 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom raw, _ := json.Marshal(payload) req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw)) if err != nil { - return nil, err + return nil, "", err } req.Header.Set("Content-Type", "application/json") } @@ -109,14 +110,14 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom resp, err := httpClient().Do(req) if err != nil { - return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) + return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if e := mapStatus(resp.StatusCode, body); e != nil { - return nil, e + return nil, "", e } - return imageBytesFromResponse(ctx, body) + return imageFromResponse(ctx, body, downloadResult) } // GenerateVideo drives the upstream Sora-style async video API: @@ -239,34 +240,34 @@ func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, erro // imageBytesFromResponse extracts image bytes from an OpenAI images response: // data[0].b64_json (preferred) or data[0].url (downloaded). -func imageBytesFromResponse(ctx context.Context, body []byte) ([]byte, error) { +// imageFromResponse parses an OpenAI image response and returns the upstream URL. +// We always request response_format=url, so the response must carry a URL — a +// base64-only response is treated as an error (no base64 pass-through). With +// downloadResult=false the URL is returned directly (no download). +func imageFromResponse(ctx context.Context, body []byte, downloadResult bool) ([]byte, string, error) { var out struct { Data []struct { - B64JSON string `json:"b64_json"` - URL string `json:"url"` + URL string `json:"url"` } `json:"data"` } if err := json.Unmarshal(body, &out); err != nil || len(out.Data) == 0 { - return nil, fmt.Errorf("%w: bad image response: %s", ErrTemporaryUpstream, clip(body, 160)) + return nil, "", fmt.Errorf("%w: bad image response: %s", ErrTemporaryUpstream, clip(body, 160)) } - d := out.Data[0] - if d.B64JSON != "" { - raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(d.B64JSON)) - if err != nil { - return nil, fmt.Errorf("%w: bad b64: %v", ErrTemporaryUpstream, err) - } - return raw, nil + url := strings.TrimSpace(out.Data[0].URL) + if url == "" { + return nil, "", fmt.Errorf("%w: image response had no url (upstream ignored response_format=url)", ErrTemporaryUpstream) } - if d.URL != "" { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil) - resp, err := httpClient().Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) - } - defer resp.Body.Close() - return io.ReadAll(resp.Body) + if !downloadResult { + return nil, url, nil } - return nil, fmt.Errorf("%w: image response had no b64/url", ErrTemporaryUpstream) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + resp, err := httpClient().Do(req) + if err != nil { + return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + return raw, url, err } func mapStatus(status int, body []byte) error { diff --git a/backend/internal/provider/imagine/image.go b/backend/internal/provider/imagine/image.go index 07a126b..e539002 100644 --- a/backend/internal/provider/imagine/image.go +++ b/backend/internal/provider/imagine/image.go @@ -18,7 +18,7 @@ import ( // the org objects feed until the batch finishes, then download the produced // image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K). // HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs). -func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string) ([]byte, map[string]any, error) { +func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string, downloadResult bool) ([]byte, map[string]any, error) { cr, ok := parseCred(cred) if !ok { return nil, nil, ErrAuth @@ -85,11 +85,15 @@ func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, re if err != nil { return nil, nil, err } + meta := map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID} + if !downloadResult { + return nil, meta, nil + } data, err := c.download(ctx, imageURL) if err != nil { return nil, nil, err } - return data, map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}, nil + return data, meta, nil } // pollImage polls the org objects feed until the entry for our batch finishes, diff --git a/backend/internal/provider/krea/image.go b/backend/internal/provider/krea/image.go index 86199ed..fe2c374 100644 --- a/backend/internal/provider/krea/image.go +++ b/backend/internal/provider/krea/image.go @@ -89,7 +89,7 @@ func (c *Client) uploadImage(ctx context.Context, cookie string, img []byte) (st // GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i) // upload reference images, submit the job, poll until done, then resolve and // download the produced image. 402 INSUFFICIENT_BALANCE → ErrQuotaExhausted. -func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width, height int, refImages [][]byte) ([]byte, map[string]any, error) { +func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width, height int, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) { // Ensure the daily free balance is granted (load /app) before generating, so a // not-yet-activated account doesn't 402 INSUFFICIENT_BALANCE. Lock-guarded and // once-per-daily-reset — concurrent gens wait for the first activation, already @@ -169,11 +169,15 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width if err != nil { return nil, nil, err } + meta := map[string]any{"job_id": jobID, "image_url": imageURL, "project": projectID} + if !downloadResult { + return nil, meta, nil + } data, err := c.download(ctx, imageURL) if err != nil { return nil, nil, err } - return data, map[string]any{"job_id": jobID, "image_url": imageURL, "project": projectID}, nil + return data, meta, nil } // pollImage polls job-status until the job leaves the queue, then matches the diff --git a/backend/internal/provider/leonardo/image.go b/backend/internal/provider/leonardo/image.go index fb0d26d..2aa8287 100644 --- a/backend/internal/provider/leonardo/image.go +++ b/backend/internal/provider/leonardo/image.go @@ -136,7 +136,7 @@ func (c *Client) uploadInitImage(ctx context.Context, accessToken string, img [] // mint a JWT, (for image-to-image) upload each reference image, submit the // Generate mutation, poll until COMPLETE, then download the first produced image. // Returns the image bytes, an info map, and a classified error. -func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string, width, height int, styleIDs []string, refImages [][]byte) ([]byte, map[string]any, error) { +func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string, width, height int, styleIDs []string, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) { sess, err := c.GetSession(ctx, cookie) if err != nil { return nil, nil, err @@ -226,16 +226,19 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string return nil, nil, err } - // 3. download bytes - data, err := c.downloadImage(ctx, imageURL) - if err != nil { - return nil, nil, err - } info := map[string]any{ "generation_id": genID, "image_url": imageURL, "user_id": sess.UserID, } + if !downloadResult { + return nil, info, nil + } + // 3. download bytes + data, err := c.downloadImage(ctx, imageURL) + if err != nil { + return nil, nil, err + } return data, info, nil } diff --git a/backend/internal/provider/runway/image.go b/backend/internal/provider/runway/image.go index 8f15347..a8292b6 100644 --- a/backend/internal/provider/runway/image.go +++ b/backend/internal/provider/runway/image.go @@ -20,7 +20,7 @@ import ( // anything else → workflow_gemini_image / gemini-3-pro-image-preview. imageSize // is the "1K"/"2K"/"4K" tier. teamID is the workspace id; if empty it's derived // from the token. refs may be empty (pure text-to-image). -func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prompt, aspectRatio, imageSize string, refs [][]byte) ([]byte, map[string]any, error) { +func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prompt, aspectRatio, imageSize string, refs [][]byte, downloadResult bool) ([]byte, map[string]any, error) { token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")) if token == "" { return nil, nil, ErrAuth @@ -86,16 +86,21 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom if err != nil { return nil, nil, err } - data, err := c.download(ctx, directClient, artifactURL) - if err != nil { - return nil, nil, err - } meta := map[string]any{ "provider": "runway", "task_id": taskID, "team_id": teamID, "image_url": artifactURL, } + // downloadResult=false (API-key url-only mode): return the upstream artifact + // URL without downloading the PNG. + if !downloadResult { + return nil, meta, nil + } + data, err := c.download(ctx, directClient, artifactURL) + if err != nil { + return nil, nil, err + } return data, meta, nil } diff --git a/backend/internal/repo/event_repo.go b/backend/internal/repo/event_repo.go index 64b1432..177ee6f 100644 --- a/backend/internal/repo/event_repo.go +++ b/backend/internal/repo/event_repo.go @@ -552,6 +552,16 @@ func (r *EventRepository) MarkVideoReady(ctx context.Context, eventID, fileURL s return res.Error } +// SetFile stores an arbitrary file reference (relative path OR upstream URL) on +// an event WITHOUT touching status/counters — used for already-succeeded no-store +// events whose auth-gated upstream URL is proxied on demand. +func (r *EventRepository) SetFile(ctx context.Context, eventID, fileURL string) error { + return r.db.WithContext(ctx). + Model(&model.EventLog{}). + Where("id = ?", eventID). + Update("file", fileURL).Error +} + func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, errMsg string, elapsedMS int) error { patch := map[string]any{ "status": status, diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index 3a59ece..3a8db91 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -453,6 +453,13 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri if !noStore { fileURL, relativePath = s.allocateOutput(principal, "png", in.BaseURL) } + // upstreamURL is the provider's original artifact URL. For API-key (source + // "v1") requests we return it instead of base64. When gatedURL is true the URL + // is auth-gated (chatgpt files.oaiusercontent.com — a plain GET 403s), so we + // store it on the event and hand the caller a proxy URL + // ({base}/v1/images/{eventID}/content) that re-fetches with the account token. + var upstreamURL string + var gatedURL bool eventID, err := s.logPendingEvent(ctx, "image", modelItem, principal, in.Prompt, aspectRatio, resolution, "", refCount, price, relativePath, source, refFiles, in.DeAI) if err != nil { s.cleanupReferenceImages(ctx, "", refFiles) @@ -470,7 +477,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri var imageBytes []byte switch s.effectiveProvider(genCtx, modelItem) { case "adobe": - b, execErr := s.generateAdobeImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + b, u, execErr := s.generateAdobeImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore) if execErr != nil { _ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) @@ -486,8 +493,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri } } imageBytes = b + upstreamURL = u case "chatgpt": - b, execErr := s.generateChatGPTImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + b, u, execErr := s.generateChatGPTImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore) if execErr != nil { _ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) @@ -503,8 +511,10 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri } } imageBytes = b + upstreamURL = u + gatedURL = true // chatgpt URL needs the account token → proxy it case "leonardo": - b, execErr := s.generateLeonardoImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + b, u, execErr := s.generateLeonardoImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore) if execErr != nil { _ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) @@ -520,8 +530,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri } } imageBytes = b + upstreamURL = u case "krea": - b, execErr := s.generateKreaImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + b, u, execErr := s.generateKreaImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore) if execErr != nil { _ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) @@ -537,8 +548,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri } } imageBytes = b + upstreamURL = u case "imagine": - b, execErr := s.generateImagineImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + b, u, execErr := s.generateImagineImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore) if execErr != nil { _ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) @@ -554,8 +566,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri } } imageBytes = b + upstreamURL = u case "runway": - b, execErr := s.generateRunwayImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + b, u, execErr := s.generateRunwayImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore) if execErr != nil { _ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) @@ -571,8 +584,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri } } imageBytes = b + upstreamURL = u case "custom": - b, execErr := s.generateCustomImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + b, u, execErr := s.generateCustomImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore) if execErr != nil { _ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) @@ -588,6 +602,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri } } imageBytes = b + upstreamURL = u default: _ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) @@ -626,6 +641,32 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri _ = s.maybeGrantInviteReward(ctx, principal) } if noStore { + // Prefer the provider's original URL — return it directly, no base64. + // (API-key requests don't support DeAI, so there's no post-processing that + // would invalidate the upstream URL.) + if strings.TrimSpace(upstreamURL) != "" { + outURL := upstreamURL + if gatedURL { + // Auth-gated URL (chatgpt): store it on the event and return a proxy + // URL that re-fetches with the account token (see OpenImageContent). + _ = s.events.SetFile(ctx, eventID, upstreamURL) + if base := strings.TrimRight(strings.TrimSpace(in.BaseURL), "/"); base != "" { + outURL = base + "/v1/images/" + eventID + "/content" + } + } + return map[string]any{ + "created": time.Now().Unix(), + "data": []map[string]any{{"url": outURL}}, + "model": modelItem.EffectiveName(), + "provider": modelItem.Provider, + "kind": "image", + "url": outURL, + "elapsed_ms": elapsedMS, + "charged": price, + "credits": principalCredits(principal), + }, nil + } + // Fallback: providers without an upstream URL still return base64. b64 := base64.StdEncoding.EncodeToString(imageBytes) return map[string]any{ "created": time.Now().Unix(), @@ -716,17 +757,24 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri defer s.cleanupReferenceImages(ctx, eventID, refFiles) startedAt := time.Now() + // API-key (noStore) requests return the upstream video URL directly. + // downloadResult=false skips the download. grok asset URLs are auth-gated + // (a plain GET 403s) → gatedVideoURL routes them through the /content proxy. + prov := s.effectiveProvider(genCtx, modelItem) + urlOnly := noStore + gatedVideoURL := prov == "grok" var videoBytes []byte + var videoURL string var execErr error - switch s.effectiveProvider(genCtx, modelItem) { + switch prov { case "adobe": - videoBytes, _, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true) + videoBytes, videoURL, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly) case "runway": - videoBytes, _, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), true) + videoBytes, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), !urlOnly) case "grok": - videoBytes, _, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true) + videoBytes, videoURL, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly) case "custom": - videoBytes, _, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true) + videoBytes, videoURL, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly) default: _ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) @@ -777,6 +825,28 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri if charge { _ = s.maybeGrantInviteReward(ctx, principal) } + if noStore && strings.TrimSpace(videoURL) != "" { + // Return the upstream video URL. grok URLs are auth-gated → store on the + // event and hand back the /content proxy (re-fetches with the account token). + outURL := videoURL + if gatedVideoURL { + _ = s.events.SetFile(ctx, eventID, videoURL) + if base := strings.TrimRight(strings.TrimSpace(in.BaseURL), "/"); base != "" { + outURL = base + "/v1/videos/" + eventID + "/content" + } + } + return map[string]any{ + "created": time.Now().Unix(), + "data": []map[string]any{{"url": outURL}}, + "model": modelItem.EffectiveName(), + "provider": modelItem.Provider, + "kind": "video", + "url": outURL, + "elapsed_ms": elapsedMS, + "charged": price, + "credits": principalCredits(principal), + }, nil + } if noStore { b64 := base64.StdEncoding.EncodeToString(videoBytes) return map[string]any{ @@ -952,6 +1022,55 @@ func (s *V1Service) OpenVideoContent(ctx context.Context, principal *APIPrincipa return resp.Body, ct, nil } +// OpenImageContent streams a no-store image by proxying the stored upstream URL. +// chatgpt URLs are auth-gated (files.oaiusercontent.com — a plain GET 403s), so +// they're fetched through the generating account's token; other providers' +// URLs are public and proxied directly. Never persisted. +func (s *V1Service) OpenImageContent(ctx context.Context, principal *APIPrincipal, id string) (io.ReadCloser, string, error) { + ev, err := s.events.GetByID(ctx, strings.TrimSpace(id)) + if err != nil { + return nil, "", err + } + if ev == nil || ev.Kind != "image" { + return nil, "", ErrVideoJobNotFound + } + if principal != nil && principal.User != nil && ev.UserID != principal.User.ID { + return nil, "", ErrVideoJobNotFound + } + if ev.Status != "success" || strings.TrimSpace(ev.File) == "" { + return nil, "", ErrVideoNotReady + } + if ev.Provider == "chatgpt" && s.chatgpt != nil { + if s.settings != nil { + if proxy, perr := s.settings.GetValue(ctx, "proxy.url"); perr == nil { + s.chatgpt.SetProxy(proxy) + } + } + acct, _ := s.tokens.Get(ctx, "chatgpt", ev.AccountID) + if acct == nil || strings.TrimSpace(acct.Value) == "" { + return nil, "", fmt.Errorf("%w: chatgpt account no longer available for this image", ErrProviderTemporary) + } + return s.chatgpt.OpenAsset(ctx, acct.Value, ev.File) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ev.File, nil) + if err != nil { + return nil, "", err + } + resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req) + if err != nil { + return nil, "", fmt.Errorf("%w: fetch upstream image: %v", ErrProviderTemporary, err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, "", fmt.Errorf("%w: upstream image status %d", ErrProviderTemporary, resp.StatusCode) + } + ct := strings.TrimSpace(resp.Header.Get("Content-Type")) + if ct == "" { + ct = "image/png" + } + return resp.Body, ct, nil +} + func (s *V1Service) videoEventForUser(ctx context.Context, principal *APIPrincipal, id string) (*model.EventLog, error) { ev, err := s.events.GetByID(ctx, strings.TrimSpace(id)) if err != nil { @@ -1532,9 +1651,12 @@ func adobeErrClass(e error) (bool, bool, bool, bool) { return errors.Is(e, adobe.ErrAuth), errors.Is(e, adobe.ErrQuotaExhausted), errors.Is(e, adobe.ErrTemporaryUpstream), errors.Is(e, adobe.ErrDeadUpstream) } -func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { +// noStore url-only mode: adobe returns a presigned image URL (meta["image_url"]); +// skip the download and return it directly. +func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) { + urlOnly := noStore if s.adobe == nil { - return nil, errors.New("adobe client not configured") + return nil, "", errors.New("adobe client not configured") } if s.settings != nil { if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { @@ -1544,7 +1666,7 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode items, err := s.tokens.ListByPool(ctx, "adobe") if err != nil { - return nil, err + return nil, "", err } var active []model.TokenAccount for _, item := range items { @@ -1558,20 +1680,21 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode } active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { - return nil, ErrNoProviderAccount + return nil, "", ErrNoProviderAccount } s.rotateRoundRobin("adobe", active) refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages)) if err != nil { - return nil, err + return nil, "", err } // Round-robin order. Adobe uses tempFailover=true: a temporary upstream error // ("system under load") fails over to the next account without penalizing the // current one, capped at maxTempDeadAccounts; auth/quota also fail over - // (see runPoolWithFailover). - return s.runPoolWithFailover(ctx, eventID, "adobe", active, "image", func(token model.TokenAccount) ([]byte, error) { + // (see runPoolWithFailover). imageURL is captured from the successful attempt. + var imageURL string + data, err := s.runPoolWithFailover(ctx, eventID, "adobe", active, "image", func(token model.TokenAccount) ([]byte, error) { var blobIDs []string for _, ref := range refs { id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", "") @@ -1580,11 +1703,15 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode } blobIDs = append(blobIDs, id) } - data, _, genErr := s.adobe.GenerateImage(ctx, token.Value, modelItem.ID, in.Prompt, aspectRatio, resolution, blobIDs) - return data, genErr + d, meta, genErr := s.adobe.GenerateImage(ctx, token.Value, modelItem.ID, in.Prompt, aspectRatio, resolution, blobIDs, !urlOnly) + if genErr == nil { + imageURL = strings.TrimSpace(stringValue(meta["image_url"])) + } + return d, genErr }, adobeErrClass, func(id string) (model.TokenAccount, bool) { return s.refreshAdobeToken(ctx, id) }, true) + return data, imageURL, err } func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) { @@ -1827,21 +1954,22 @@ func (s *V1Service) effectiveProvider(ctx context.Context, modelItem *model.Mode // generateCustomImage forwards an image generation to an OpenAI-compatible // upstream. The upstream (custom account) is matched by model id; calls go direct // (no proxy). Billing uses the local model price. -func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { +func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) { + urlOnly := noStore if s.custom == nil { - return nil, errors.New("custom client not configured") + return nil, "", errors.New("custom client not configured") } refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages)) if err != nil { - return nil, err + return nil, "", err } active, err := s.customActive(ctx, modelItem.ID) if err != nil { - return nil, err + return nil, "", err } active = pinTestAccount(active, active, in.AccountID) if len(active) == 0 { - return nil, ErrNoProviderAccount + return nil, "", ErrNoProviderAccount } size := upstreamSize(aspectRatio, resolution) quality := upstreamQuality(resolution) @@ -1853,17 +1981,19 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod continue } var data []byte + var imgURL string done, failover := func() (bool, bool) { defer s.acctRelease(ctx, token.ID, eventID) _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.tokens.TouchLastUsed(ctx, token.ID) baseURL := stringValue(token.Meta["base_url"]) - d, genErr := s.custom.GenerateImage(ctx, baseURL, token.Value, modelItem.ID, in.Prompt, size, quality, refs) + d, u, genErr := s.custom.GenerateImage(ctx, baseURL, token.Value, modelItem.ID, in.Prompt, size, quality, refs, !urlOnly) if genErr == nil { _, _ = s.tokens.Update(ctx, "custom", token.ID, map[string]any{ "last_used_at": time.Now(), "success_total": gorm.Expr("success_total + 1"), "fails": 0, }) data = d + imgURL = u return true, false } lastErr = genErr @@ -1881,20 +2011,20 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod } }() if done { - return data, nil + return data, imgURL, nil } if failover { continue } - return nil, lastErr + return nil, "", lastErr } if lastErr == nil { if busy > 0 { - return nil, ErrConcurrencyFull + return nil, "", ErrConcurrencyFull } lastErr = ErrProviderExecution } - return nil, lastErr + return nil, "", lastErr } // generateCustomVideo forwards a video generation to an OpenAI-compatible @@ -2144,9 +2274,14 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model // 401 — marked dead (status=disabled) and skipped — because Runway credits don't // refill daily, so a "quota" mark (which the maintenance loop would revive) is // wrong. Reference images (up to the model's max) are uploaded per attempt. -func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { +// noStore url-only mode (API-key requests without DeAI): skip the artifact +// download and return the upstream image URL directly, no bytes. +func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) { + // API-key (noStore) requests don't support DeAI (only the web drawing board + // does), so url-only mode == noStore — skip the download, return the URL. + urlOnly := noStore if s.runway == nil { - return nil, errors.New("runway client not configured") + return nil, "", errors.New("runway client not configured") } if s.settings != nil { if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { @@ -2156,12 +2291,12 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages)) if err != nil { - return nil, err + return nil, "", err } items, err := s.tokens.ListByPool(ctx, "runway") if err != nil { - return nil, err + return nil, "", err } var active []model.TokenAccount for _, item := range items { @@ -2178,7 +2313,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod } active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { - return nil, ErrNoProviderAccount + return nil, "", ErrNoProviderAccount } s.rotateRoundRobin("runway", active) @@ -2195,6 +2330,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod continue } var data []byte + var artURL string done, failover := func() (bool, bool) { defer s.acctRelease(ctx, token.ID, eventID) _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) @@ -2203,7 +2339,9 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod if token.Meta != nil { teamID = strings.TrimSpace(stringValue(token.Meta["team_id"])) } - d, _, genErr := s.runway.GenerateImage(ctx, token.Value, teamID, modelItem.ID, in.Prompt, aspectRatio, imageSize, refs) + // downloadResult=false in url-only mode → skip the artifact download and + // just return meta["image_url"]. + d, meta, genErr := s.runway.GenerateImage(ctx, token.Value, teamID, modelItem.ID, in.Prompt, aspectRatio, imageSize, refs, !urlOnly) if genErr == nil { _, _ = s.tokens.Update(ctx, "runway", token.ID, map[string]any{ "last_used_at": time.Now(), @@ -2211,6 +2349,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod "fails": 0, }) data = d + artURL = strings.TrimSpace(stringValue(meta["image_url"])) return true, false } lastErr = genErr @@ -2228,20 +2367,20 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod } }() if done { - return data, nil + return data, artURL, nil } if failover { continue } - return nil, lastErr + return nil, "", lastErr } if lastErr == nil { if busy > 0 { - return nil, ErrConcurrencyFull + return nil, "", ErrConcurrencyFull } lastErr = ErrProviderExecution } - return nil, lastErr + return nil, "", lastErr } // reconcileChatGPTQuota re-reads OpenAI's image_gen remaining right after a @@ -2277,9 +2416,13 @@ func (s *V1Service) reconcileChatGPTQuota(ctx context.Context, tokenID, accessTo _, _ = s.tokens.Update(ctx, "chatgpt", tokenID, patch) } -func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { +// chatgpt image URLs are auth-gated (files.oaiusercontent.com — a plain GET +// 403s), so url-only mode returns the URL for the caller to proxy via +// OpenImageContent using the generating account's token. +func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) { + urlOnly := noStore if s.chatgpt == nil { - return nil, errors.New("chatgpt client not configured") + return nil, "", errors.New("chatgpt client not configured") } if s.settings != nil { if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { @@ -2289,7 +2432,7 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo items, err := s.tokens.ListByPool(ctx, "chatgpt") if err != nil { - return nil, err + return nil, "", err } var active []model.TokenAccount for _, item := range items { @@ -2299,7 +2442,7 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo } active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { - return nil, ErrNoProviderAccount + return nil, "", ErrNoProviderAccount } s.rotateRoundRobin("chatgpt", active) @@ -2309,24 +2452,27 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo } refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) if err != nil { - return nil, err + return nil, "", err } // Round-robin order; on a transient upstream error (e.g. "image generation // did not start (no async marker)") FAIL OVER to the next account // (tempFailover=true, capped at maxTempDeadAccounts) — never mark the // account dead. Auth/quota fail over immediately (see runPoolWithFailover). - return s.runPoolWithFailover(ctx, eventID, "chatgpt", active, "image", func(token model.TokenAccount) ([]byte, error) { - data, _, genErr := s.chatgpt.GenerateImage(ctx, token.Value, in.Prompt, modelItem.ID, aspectRatio, resolution, refs) + var imageURL string + data, err := s.runPoolWithFailover(ctx, eventID, "chatgpt", active, "image", func(token model.TokenAccount) ([]byte, error) { + d, meta, genErr := s.chatgpt.GenerateImage(ctx, token.Value, in.Prompt, modelItem.ID, aspectRatio, resolution, refs, !urlOnly) if genErr == nil { + imageURL = strings.TrimSpace(stringValue(meta["image_url"])) // Sync the real OpenAI quota BEFORE the concurrency gate releases, so the // freshly-decremented remaining (and 限额 flip at 0) gates the next pick. s.reconcileChatGPTQuota(ctx, token.ID, token.Value) } - return data, genErr + return d, genErr }, func(e error) (bool, bool, bool, bool) { return errors.Is(e, chatgpt.ErrAuth), errors.Is(e, chatgpt.ErrQuotaExhausted), errors.Is(e, chatgpt.ErrTemporaryUpstream), false }, nil, true) // chatgpt token IS the credential — no cookie to refresh; switch accounts on transient errors + return data, imageURL, err } // leonardoResetAfter returns when a Leonardo account's daily free tokens renew. @@ -2380,9 +2526,10 @@ func leonardoDimensions(resolution, aspectRatio string) (int, int) { } } -func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { +func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) { + urlOnly := noStore if s.leonardo == nil { - return nil, errors.New("leonardo client not configured") + return nil, "", errors.New("leonardo client not configured") } if s.settings != nil { if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { @@ -2392,7 +2539,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m items, err := s.tokens.ListByPool(ctx, "leonardo") if err != nil { - return nil, err + return nil, "", err } var active []model.TokenAccount for _, item := range items { @@ -2408,7 +2555,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m } active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { - return nil, ErrNoProviderAccount + return nil, "", ErrNoProviderAccount } s.rotateRoundRobin("leonardo", active) @@ -2424,12 +2571,13 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m } refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) if err != nil { - return nil, err + return nil, "", err } // token.Value is the cookie; GenerateImage mints a fresh JWT each attempt, so an // auth failure means the cookie itself is dead — no refresher (nil). - return s.runPoolWithFailover(ctx, eventID, "leonardo", active, "image", func(token model.TokenAccount) ([]byte, error) { + var imageURL string + data, err := s.runPoolWithFailover(ctx, eventID, "leonardo", active, "image", func(token model.TokenAccount) ([]byte, error) { // Atomically pre-deduct the per-generation cost so concurrent picks of the // same near-empty account can't over-commit it. A known-insufficient // balance surfaces as quota → the driver fails over to the next account. @@ -2440,7 +2588,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m if !allowed { return nil, leonardo.ErrQuotaExhausted } - data, _, genErr := s.leonardo.GenerateImage(ctx, token.Value, upstreamModel, in.Prompt, width, height, nil, refs) + data, meta, genErr := s.leonardo.GenerateImage(ctx, token.Value, upstreamModel, in.Prompt, width, height, nil, refs, !urlOnly) if genErr != nil { // Release the hold so a failed render doesn't burn credits. if deducted { @@ -2448,6 +2596,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m } return nil, genErr } + imageURL = strings.TrimSpace(stringValue(meta["image_url"])) // Success → overwrite the held value with the REAL upstream balance and // sink to 限额 if below the floor (best-effort; never fails a done render). s.reconcileLeonardoCredits(ctx, token.ID, token.Value) @@ -2455,6 +2604,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m }, func(e error) (bool, bool, bool, bool) { return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream), false }, nil, true) + return data, imageURL, err } // reconcileLeonardoCredits re-fetches an account's real token balance after a @@ -2536,9 +2686,10 @@ func kreaDimensions(resolution, aspectRatio string) (int, int) { } } -func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { +func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) { + urlOnly := noStore if s.krea == nil { - return nil, errors.New("krea client not configured") + return nil, "", errors.New("krea client not configured") } if s.settings != nil { if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { @@ -2548,7 +2699,7 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model items, err := s.tokens.ListByPool(ctx, "krea") if err != nil { - return nil, err + return nil, "", err } var active []model.TokenAccount for _, item := range items { @@ -2560,7 +2711,7 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model } active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { - return nil, ErrNoProviderAccount + return nil, "", ErrNoProviderAccount } s.rotateRoundRobin("krea", active) @@ -2571,21 +2722,26 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model } refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) if err != nil { - return nil, err + return nil, "", err } - return s.runPoolWithFailover(ctx, eventID, "krea", active, "image", func(token model.TokenAccount) ([]byte, error) { + var imageURL string + data, err := s.runPoolWithFailover(ctx, eventID, "krea", active, "image", func(token model.TokenAccount) ([]byte, error) { // Refresh the (rotating) Supabase token if expired and persist the new // cookie, then generate with the fresh cookie. cookie, rerr := kreaRefreshAndPersist(ctx, s.krea, s.tokens, token.ID, token.Value) if rerr != nil { return nil, rerr } - data, _, genErr := s.krea.GenerateImage(ctx, cookie, in.Prompt, width, height, refs) + data, meta, genErr := s.krea.GenerateImage(ctx, cookie, in.Prompt, width, height, refs, !urlOnly) + if genErr == nil { + imageURL = strings.TrimSpace(stringValue(meta["image_url"])) + } return data, genErr }, func(e error) (bool, bool, bool, bool) { return errors.Is(e, krea.ErrAuth), errors.Is(e, krea.ErrQuotaExhausted), errors.Is(e, krea.ErrTemporaryUpstream), false }, nil, true) + return data, imageURL, err } // imagineRefreshAndPersist ensures the account's Imagine credential has a valid @@ -2613,9 +2769,10 @@ func imagineStyle(modelID string) (int, string) { return 41001, "2K" } -func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { +func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) { + urlOnly := noStore if s.imagine == nil { - return nil, errors.New("imagine client not configured") + return nil, "", errors.New("imagine client not configured") } if s.settings != nil { if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { @@ -2625,7 +2782,7 @@ func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, mo items, err := s.tokens.ListByPool(ctx, "imagine") if err != nil { - return nil, err + return nil, "", err } var active []model.TokenAccount for _, item := range items { @@ -2637,28 +2794,31 @@ func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, mo } active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { - return nil, ErrNoProviderAccount + return nil, "", ErrNoProviderAccount } s.rotateRoundRobin("imagine", active) // Each model supports exactly one resolution (2K / 4K) — force it per model. styleID, res := imagineStyle(modelItem.ID) - return s.runPoolWithFailover(ctx, eventID, "imagine", active, "image", func(token model.TokenAccount) ([]byte, error) { + var imageURL string + data, err := s.runPoolWithFailover(ctx, eventID, "imagine", active, "image", func(token model.TokenAccount) ([]byte, error) { // Refresh the (rotating) access token if expired and persist the new // credential, then generate with the fresh token. cred, rerr := imagineRefreshAndPersist(ctx, s.imagine, s.tokens, token.ID, token.Value) if rerr != nil { return nil, rerr } - data, _, genErr := s.imagine.GenerateImage(ctx, cred, styleID, res, aspectRatio, in.Prompt) + data, meta, genErr := s.imagine.GenerateImage(ctx, cred, styleID, res, aspectRatio, in.Prompt, !urlOnly) if genErr != nil { return nil, genErr } + imageURL = strings.TrimSpace(stringValue(meta["image_url"])) return data, nil }, func(e error) (bool, bool, bool, bool) { return errors.Is(e, imagine.ErrAuth), errors.Is(e, imagine.ErrQuotaExhausted), errors.Is(e, imagine.ErrTemporaryUpstream), false }, nil, true) + return data, imageURL, err } func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal, eventID string, price float64) error { diff --git a/frontend/src/views/DocsView.vue b/frontend/src/views/DocsView.vue index 55648b0..54dc283 100644 --- a/frontend/src/views/DocsView.vue +++ b/frontend/src/views/DocsView.vue @@ -113,7 +113,7 @@ const examples = computed(() => [ { title: '文生图 · Python (openai SDK)', code: -`import base64 +`import urllib.request from openai import OpenAI client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1") @@ -123,9 +123,8 @@ resp = client.images.generate( prompt="a corgi running in a golden wheat field, cinematic", size="2048x2048", # 2K · 1:1,见下方对照表 ) -# 结果是 base64(无 URL) -with open("out.png", "wb") as f: - f.write(base64.b64decode(resp.data[0].b64_json))`, +# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存) +urllib.request.urlretrieve(resp.data[0].url, "out.png")`, }, { title: '图生图 / 参考图 · curl (multipart)', @@ -141,7 +140,7 @@ with open("out.png", "wb") as f: { title: '图生图 · Python (openai SDK)', code: -`import base64 +`import urllib.request from openai import OpenAI client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1") @@ -151,8 +150,8 @@ resp = client.images.edit( image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")] prompt="把这张图改成赛博朋克风格", ) -with open("out.png", "wb") as f: - f.write(base64.b64decode(resp.data[0].b64_json))`, +# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存) +urllib.request.urlretrieve(resp.data[0].url, "out.png")`, }, { title: '视频 · curl(创建 → 轮询 → 下载)', @@ -424,7 +423,7 @@ async function copy(text) {

响应 & 计费

-

图像(generations / edits)返回 OpenAI 图片格式:{{ '{ "created": ..., "data": [{ "b64_json": "..." }] }' }} —— 产物以 base64 直接放在 data[0].b64_json(原始 base64、无 data: 前缀),自行解码保存为图片。不返回 URL、服务端不留存

+

图像(generations / edits)返回 OpenAI 图片格式:{{ '{ "created": ..., "data": [{ "url": "..." }] }' }} —— data[0].url 是产物 URL,服务端不留存(不返回 base64)。多数模型返回上游原始直链;少数上游需鉴权的(如 gpt-image)会返回一个本站转发链 /v1/images/{id}/content,由服务端带账号凭据取回。两种链接都会过期,请尽快下载或转存到你自己的存储

视频(异步,Sora 风格三步):

  1. POST /v1/videos 立即返回任务对象 {{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}