支持远程url吗

This commit is contained in:
2026-07-16 00:50:27 +08:00
parent 7a781fecfc
commit 60be9201e5
12 changed files with 379 additions and 131 deletions
+19
View File
@@ -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
+2
View File
@@ -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")
{
+8 -3
View File
@@ -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
+42 -6
View File
@@ -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 {
+31 -30
View File
@@ -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 {
+6 -2
View File
@@ -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,
+6 -2
View File
@@ -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
+9 -6
View File
@@ -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
}
+10 -5
View File
@@ -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
}
+10
View File
@@ -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,
+229 -69
View File
@@ -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 {