支持远程url吗
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
} `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)
|
||||
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)
|
||||
}
|
||||
return raw, nil
|
||||
if !downloadResult {
|
||||
return nil, url, nil
|
||||
}
|
||||
if d.URL != "" {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil)
|
||||
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))
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: image response had no b64/url", ErrTemporaryUpstream)
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
return raw, url, err
|
||||
}
|
||||
|
||||
func mapStatus(status int, body []byte) error {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
||||
<div class="card p-6 space-y-3 text-sm text-white/70">
|
||||
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "b64_json": "..." }] }' }}</code> —— 产物以 <strong class="text-white/90">base64</strong> 直接放在 <code class="text-white/85 font-mono">data[0].b64_json</code>(原始 base64、无 <code class="text-white/70">data:</code> 前缀),自行解码保存为图片。<strong class="text-white/90">不返回 URL、服务端不留存</strong>。</p>
|
||||
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "url": "..." }] }' }}</code> —— <code class="text-white/85 font-mono">data[0].url</code> 是产物 URL,服务端不留存(<strong class="text-white/90">不返回 base64</strong>)。多数模型返回上游<strong class="text-white/90">原始直链</strong>;少数上游需鉴权的(如 gpt-image)会返回一个本站转发链 <code class="text-white/85 font-mono">/v1/images/{id}/content</code>,由服务端带账号凭据取回。<strong class="text-white/90">两种链接都会过期</strong>,请<strong class="text-white/90">尽快下载或转存到你自己的存储</strong>。</p>
|
||||
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
|
||||
<li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li>
|
||||
|
||||
Reference in New Issue
Block a user