支持远程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) _, _ = 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. // readMultipartImages reads the given file fields and returns each as base64.
func readMultipartImages(c *gin.Context, keys ...string) []string { func readMultipartImages(c *gin.Context, keys ...string) []string {
var out []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.POST("/v1/videos", handlers.V1.CreateVideo)
engine.GET("/v1/videos/:id", handlers.V1.GetVideo) engine.GET("/v1/videos/:id", handlers.V1.GetVideo)
engine.GET("/v1/videos/:id/content", handlers.V1.GetVideoContent) 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") 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 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 // Only the generate submit goes through the proxy; polling + download run on
// the local IP. // the local IP.
submitSess, err := c.newTLSClient() submitSess, err := c.newTLSClient()
@@ -201,7 +201,7 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe
for _, payload := range candidates { for _, payload := range candidates {
respBody, pollURL, err := c.submitImage(ctx, submitSess, token, prompt, endpoint, payload) respBody, pollURL, err := c.submitImage(ctx, submitSess, token, prompt, endpoint, payload)
if err == nil { 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 { if pollErr != nil {
return nil, nil, pollErr 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") 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 { for {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("adobe generation timed out: %w", err) 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 first, ok := outputs[0].(map[string]any); ok {
if image, ok := first["image"].(map[string]any); ok { if image, ok := first["image"].(map[string]any); ok {
if url := strings.TrimSpace(stringValue(image["presignedUrl"])); url != "" { 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) data, err := c.download(ctx, sess, url)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
+42 -6
View File
@@ -68,7 +68,7 @@ func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy) 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 // Everything except the generation submit egresses on the local IP. Only
// startImageGeneration (the /backend-api/f/conversation POST) goes through // startImageGeneration (the /backend-api/f/conversation POST) goes through
// the proxy; the bootstrap / chat-requirements / reference upload / prepare // 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 { if len(urls) == 0 {
return nil, nil, errors.New("no image urls resolved") 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) images, err := c.downloadBytes(ctx, session, accessToken, urls)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -137,11 +148,36 @@ func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model,
if len(images) == 0 { if len(images) == 0 {
return nil, nil, errors.New("download produced no bytes") return nil, nil, errors.New("download produced no bytes")
} }
return images[0], map[string]any{ return images[0], meta, nil
"provider": "chatgpt", }
"model": model,
"conversation_id": conversationID, // OpenAsset streams an auth-gated ChatGPT image URL (files.oaiusercontent.com)
}, nil // 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 { func ExtractAccountInfo(token string) map[string]any {
+31 -30
View File
@@ -8,7 +8,6 @@ package custom
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "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 // GenerateImage calls the upstream OpenAI image API. With reference images it
// uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns // uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns
// the raw image bytes (decoded from b64_json, or downloaded from url). // 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), "/") baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" || apiKey == "" { if baseURL == "" || apiKey == "" {
return nil, ErrAuth return nil, "", ErrAuth
} }
var req *http.Request var req *http.Request
var err error var err error
@@ -73,24 +72,26 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom
w := multipart.NewWriter(body) w := multipart.NewWriter(body)
_ = w.WriteField("model", model) _ = w.WriteField("model", model)
_ = w.WriteField("prompt", prompt) _ = 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 != "" { if size != "" {
_ = w.WriteField("size", size) _ = w.WriteField("size", size)
} }
for i, r := range refs { for i, r := range refs {
fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1)) fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1))
if e != nil { if e != nil {
return nil, e return nil, "", e
} }
_, _ = fw.Write(r) _, _ = fw.Write(r)
} }
_ = w.Close() _ = w.Close()
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body) req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Content-Type", w.FormDataContentType())
} else { } 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 != "" { if size != "" {
payload["size"] = size payload["size"] = size
} }
@@ -100,7 +101,7 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom
raw, _ := json.Marshal(payload) raw, _ := json.Marshal(payload)
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw)) req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw))
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
req.Header.Set("Content-Type", "application/json") 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) resp, err := httpClient().Do(req)
if err != nil { 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() defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
if e := mapStatus(resp.StatusCode, body); e != nil { 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: // 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: // imageBytesFromResponse extracts image bytes from an OpenAI images response:
// data[0].b64_json (preferred) or data[0].url (downloaded). // 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 { var out struct {
Data []struct { Data []struct {
B64JSON string `json:"b64_json"` URL string `json:"url"`
URL string `json:"url"`
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal(body, &out); err != nil || len(out.Data) == 0 { 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] url := strings.TrimSpace(out.Data[0].URL)
if d.B64JSON != "" { if url == "" {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(d.B64JSON)) return nil, "", fmt.Errorf("%w: image response had no url (upstream ignored response_format=url)", ErrTemporaryUpstream)
if err != nil {
return nil, fmt.Errorf("%w: bad b64: %v", ErrTemporaryUpstream, err)
}
return raw, nil
} }
if d.URL != "" { if !downloadResult {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil) return nil, 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)
} }
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 { 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 // 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). // image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K).
// HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs). // 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) cr, ok := parseCred(cred)
if !ok { if !ok {
return nil, nil, ErrAuth return nil, nil, ErrAuth
@@ -85,11 +85,15 @@ func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, re
if err != nil { if err != nil {
return nil, nil, err 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) data, err := c.download(ctx, imageURL)
if err != nil { if err != nil {
return nil, nil, err 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, // 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) // GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i)
// upload reference images, submit the job, poll until done, then resolve and // upload reference images, submit the job, poll until done, then resolve and
// download the produced image. 402 INSUFFICIENT_BALANCE → ErrQuotaExhausted. // 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 // 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 // not-yet-activated account doesn't 402 INSUFFICIENT_BALANCE. Lock-guarded and
// once-per-daily-reset — concurrent gens wait for the first activation, already // 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 { if err != nil {
return nil, nil, err 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) data, err := c.download(ctx, imageURL)
if err != nil { if err != nil {
return nil, nil, err 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 // 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 // mint a JWT, (for image-to-image) upload each reference image, submit the
// Generate mutation, poll until COMPLETE, then download the first produced image. // Generate mutation, poll until COMPLETE, then download the first produced image.
// Returns the image bytes, an info map, and a classified error. // 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) sess, err := c.GetSession(ctx, cookie)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -226,16 +226,19 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string
return nil, nil, err return nil, nil, err
} }
// 3. download bytes
data, err := c.downloadImage(ctx, imageURL)
if err != nil {
return nil, nil, err
}
info := map[string]any{ info := map[string]any{
"generation_id": genID, "generation_id": genID,
"image_url": imageURL, "image_url": imageURL,
"user_id": sess.UserID, "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 return data, info, nil
} }
+10 -5
View File
@@ -20,7 +20,7 @@ import (
// anything else → workflow_gemini_image / gemini-3-pro-image-preview. imageSize // 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 // 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). // 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 ")) token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" { if token == "" {
return nil, nil, ErrAuth return nil, nil, ErrAuth
@@ -86,16 +86,21 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
data, err := c.download(ctx, directClient, artifactURL)
if err != nil {
return nil, nil, err
}
meta := map[string]any{ meta := map[string]any{
"provider": "runway", "provider": "runway",
"task_id": taskID, "task_id": taskID,
"team_id": teamID, "team_id": teamID,
"image_url": artifactURL, "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 return data, meta, nil
} }
+10
View File
@@ -552,6 +552,16 @@ func (r *EventRepository) MarkVideoReady(ctx context.Context, eventID, fileURL s
return res.Error 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 { func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, errMsg string, elapsedMS int) error {
patch := map[string]any{ patch := map[string]any{
"status": status, "status": status,
+229 -69
View File
@@ -453,6 +453,13 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
if !noStore { if !noStore {
fileURL, relativePath = s.allocateOutput(principal, "png", in.BaseURL) 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) eventID, err := s.logPendingEvent(ctx, "image", modelItem, principal, in.Prompt, aspectRatio, resolution, "", refCount, price, relativePath, source, refFiles, in.DeAI)
if err != nil { if err != nil {
s.cleanupReferenceImages(ctx, "", refFiles) s.cleanupReferenceImages(ctx, "", refFiles)
@@ -470,7 +477,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
var imageBytes []byte var imageBytes []byte
switch s.effectiveProvider(genCtx, modelItem) { switch s.effectiveProvider(genCtx, modelItem) {
case "adobe": 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 { if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
@@ -486,8 +493,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
} }
} }
imageBytes = b imageBytes = b
upstreamURL = u
case "chatgpt": 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 { if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
@@ -503,8 +511,10 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
} }
} }
imageBytes = b imageBytes = b
upstreamURL = u
gatedURL = true // chatgpt URL needs the account token → proxy it
case "leonardo": 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 { if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
@@ -520,8 +530,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
} }
} }
imageBytes = b imageBytes = b
upstreamURL = u
case "krea": 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 { if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
@@ -537,8 +548,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
} }
} }
imageBytes = b imageBytes = b
upstreamURL = u
case "imagine": 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 { if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
@@ -554,8 +566,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
} }
} }
imageBytes = b imageBytes = b
upstreamURL = u
case "runway": 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 { if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
@@ -571,8 +584,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
} }
} }
imageBytes = b imageBytes = b
upstreamURL = u
case "custom": 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 { if execErr != nil {
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
@@ -588,6 +602,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
} }
} }
imageBytes = b imageBytes = b
upstreamURL = u
default: default:
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) _ = 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) _ = s.maybeGrantInviteReward(ctx, principal)
} }
if noStore { 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) b64 := base64.StdEncoding.EncodeToString(imageBytes)
return map[string]any{ return map[string]any{
"created": time.Now().Unix(), "created": time.Now().Unix(),
@@ -716,17 +757,24 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
defer s.cleanupReferenceImages(ctx, eventID, refFiles) defer s.cleanupReferenceImages(ctx, eventID, refFiles)
startedAt := time.Now() 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 videoBytes []byte
var videoURL string
var execErr error var execErr error
switch s.effectiveProvider(genCtx, modelItem) { switch prov {
case "adobe": 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": 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": 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": 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: default:
_ = s.refundIfNeeded(ctx, principal, eventID, price) _ = s.refundIfNeeded(ctx, principal, eventID, price)
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) _ = 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 { if charge {
_ = s.maybeGrantInviteReward(ctx, principal) _ = 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 { if noStore {
b64 := base64.StdEncoding.EncodeToString(videoBytes) b64 := base64.StdEncoding.EncodeToString(videoBytes)
return map[string]any{ return map[string]any{
@@ -952,6 +1022,55 @@ func (s *V1Service) OpenVideoContent(ctx context.Context, principal *APIPrincipa
return resp.Body, ct, nil 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) { func (s *V1Service) videoEventForUser(ctx context.Context, principal *APIPrincipal, id string) (*model.EventLog, error) {
ev, err := s.events.GetByID(ctx, strings.TrimSpace(id)) ev, err := s.events.GetByID(ctx, strings.TrimSpace(id))
if err != nil { 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) 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 { 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 s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == 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") items, err := s.tokens.ListByPool(ctx, "adobe")
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
var active []model.TokenAccount var active []model.TokenAccount
for _, item := range items { for _, item := range items {
@@ -1558,20 +1680,21 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
} }
active = pinTestAccount(items, active, in.AccountID) active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 { if len(active) == 0 {
return nil, ErrNoProviderAccount return nil, "", ErrNoProviderAccount
} }
s.rotateRoundRobin("adobe", active) s.rotateRoundRobin("adobe", active)
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages)) refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
// Round-robin order. Adobe uses tempFailover=true: a temporary upstream error // Round-robin order. Adobe uses tempFailover=true: a temporary upstream error
// ("system under load") fails over to the next account without penalizing the // ("system under load") fails over to the next account without penalizing the
// current one, capped at maxTempDeadAccounts; auth/quota also fail over // current one, capped at maxTempDeadAccounts; auth/quota also fail over
// (see runPoolWithFailover). // (see runPoolWithFailover). imageURL is captured from the successful attempt.
return s.runPoolWithFailover(ctx, eventID, "adobe", active, "image", func(token model.TokenAccount) ([]byte, error) { var imageURL string
data, err := s.runPoolWithFailover(ctx, eventID, "adobe", active, "image", func(token model.TokenAccount) ([]byte, error) {
var blobIDs []string var blobIDs []string
for _, ref := range refs { for _, ref := range refs {
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", "") 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) blobIDs = append(blobIDs, id)
} }
data, _, genErr := s.adobe.GenerateImage(ctx, token.Value, modelItem.ID, in.Prompt, aspectRatio, resolution, blobIDs) d, meta, genErr := s.adobe.GenerateImage(ctx, token.Value, modelItem.ID, in.Prompt, aspectRatio, resolution, blobIDs, !urlOnly)
return data, genErr if genErr == nil {
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
}
return d, genErr
}, adobeErrClass, func(id string) (model.TokenAccount, bool) { }, adobeErrClass, func(id string) (model.TokenAccount, bool) {
return s.refreshAdobeToken(ctx, id) return s.refreshAdobeToken(ctx, id)
}, true) }, 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) { 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 // generateCustomImage forwards an image generation to an OpenAI-compatible
// upstream. The upstream (custom account) is matched by model id; calls go direct // upstream. The upstream (custom account) is matched by model id; calls go direct
// (no proxy). Billing uses the local model price. // (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 { 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)) refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
active, err := s.customActive(ctx, modelItem.ID) active, err := s.customActive(ctx, modelItem.ID)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
active = pinTestAccount(active, active, in.AccountID) active = pinTestAccount(active, active, in.AccountID)
if len(active) == 0 { if len(active) == 0 {
return nil, ErrNoProviderAccount return nil, "", ErrNoProviderAccount
} }
size := upstreamSize(aspectRatio, resolution) size := upstreamSize(aspectRatio, resolution)
quality := upstreamQuality(resolution) quality := upstreamQuality(resolution)
@@ -1853,17 +1981,19 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod
continue continue
} }
var data []byte var data []byte
var imgURL string
done, failover := func() (bool, bool) { done, failover := func() (bool, bool) {
defer s.acctRelease(ctx, token.ID, eventID) defer s.acctRelease(ctx, token.ID, eventID)
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
_ = s.tokens.TouchLastUsed(ctx, token.ID) _ = s.tokens.TouchLastUsed(ctx, token.ID)
baseURL := stringValue(token.Meta["base_url"]) 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 { if genErr == nil {
_, _ = s.tokens.Update(ctx, "custom", token.ID, map[string]any{ _, _ = s.tokens.Update(ctx, "custom", token.ID, map[string]any{
"last_used_at": time.Now(), "success_total": gorm.Expr("success_total + 1"), "fails": 0, "last_used_at": time.Now(), "success_total": gorm.Expr("success_total + 1"), "fails": 0,
}) })
data = d data = d
imgURL = u
return true, false return true, false
} }
lastErr = genErr lastErr = genErr
@@ -1881,20 +2011,20 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod
} }
}() }()
if done { if done {
return data, nil return data, imgURL, nil
} }
if failover { if failover {
continue continue
} }
return nil, lastErr return nil, "", lastErr
} }
if lastErr == nil { if lastErr == nil {
if busy > 0 { if busy > 0 {
return nil, ErrConcurrencyFull return nil, "", ErrConcurrencyFull
} }
lastErr = ErrProviderExecution lastErr = ErrProviderExecution
} }
return nil, lastErr return nil, "", lastErr
} }
// generateCustomVideo forwards a video generation to an OpenAI-compatible // 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 // 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 // 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. // 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 { 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 s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == 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)) refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
items, err := s.tokens.ListByPool(ctx, "runway") items, err := s.tokens.ListByPool(ctx, "runway")
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
var active []model.TokenAccount var active []model.TokenAccount
for _, item := range items { for _, item := range items {
@@ -2178,7 +2313,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
} }
active = pinTestAccount(items, active, in.AccountID) active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 { if len(active) == 0 {
return nil, ErrNoProviderAccount return nil, "", ErrNoProviderAccount
} }
s.rotateRoundRobin("runway", active) s.rotateRoundRobin("runway", active)
@@ -2195,6 +2330,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
continue continue
} }
var data []byte var data []byte
var artURL string
done, failover := func() (bool, bool) { done, failover := func() (bool, bool) {
defer s.acctRelease(ctx, token.ID, eventID) defer s.acctRelease(ctx, token.ID, eventID)
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = 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 { if token.Meta != nil {
teamID = strings.TrimSpace(stringValue(token.Meta["team_id"])) 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 { if genErr == nil {
_, _ = s.tokens.Update(ctx, "runway", token.ID, map[string]any{ _, _ = s.tokens.Update(ctx, "runway", token.ID, map[string]any{
"last_used_at": time.Now(), "last_used_at": time.Now(),
@@ -2211,6 +2349,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
"fails": 0, "fails": 0,
}) })
data = d data = d
artURL = strings.TrimSpace(stringValue(meta["image_url"]))
return true, false return true, false
} }
lastErr = genErr lastErr = genErr
@@ -2228,20 +2367,20 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
} }
}() }()
if done { if done {
return data, nil return data, artURL, nil
} }
if failover { if failover {
continue continue
} }
return nil, lastErr return nil, "", lastErr
} }
if lastErr == nil { if lastErr == nil {
if busy > 0 { if busy > 0 {
return nil, ErrConcurrencyFull return nil, "", ErrConcurrencyFull
} }
lastErr = ErrProviderExecution lastErr = ErrProviderExecution
} }
return nil, lastErr return nil, "", lastErr
} }
// reconcileChatGPTQuota re-reads OpenAI's image_gen remaining right after a // 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) _, _ = 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 { 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 s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == 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") items, err := s.tokens.ListByPool(ctx, "chatgpt")
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
var active []model.TokenAccount var active []model.TokenAccount
for _, item := range items { for _, item := range items {
@@ -2299,7 +2442,7 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo
} }
active = pinTestAccount(items, active, in.AccountID) active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 { if len(active) == 0 {
return nil, ErrNoProviderAccount return nil, "", ErrNoProviderAccount
} }
s.rotateRoundRobin("chatgpt", active) s.rotateRoundRobin("chatgpt", active)
@@ -2309,24 +2452,27 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo
} }
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
// Round-robin order; on a transient upstream error (e.g. "image generation // Round-robin order; on a transient upstream error (e.g. "image generation
// did not start (no async marker)") FAIL OVER to the next account // did not start (no async marker)") FAIL OVER to the next account
// (tempFailover=true, capped at maxTempDeadAccounts) — never mark the // (tempFailover=true, capped at maxTempDeadAccounts) — never mark the
// account dead. Auth/quota fail over immediately (see runPoolWithFailover). // account dead. Auth/quota fail over immediately (see runPoolWithFailover).
return s.runPoolWithFailover(ctx, eventID, "chatgpt", active, "image", func(token model.TokenAccount) ([]byte, error) { var imageURL string
data, _, genErr := s.chatgpt.GenerateImage(ctx, token.Value, in.Prompt, modelItem.ID, aspectRatio, resolution, refs) 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 { if genErr == nil {
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
// Sync the real OpenAI quota BEFORE the concurrency gate releases, so the // Sync the real OpenAI quota BEFORE the concurrency gate releases, so the
// freshly-decremented remaining (and 限额 flip at 0) gates the next pick. // freshly-decremented remaining (and 限额 flip at 0) gates the next pick.
s.reconcileChatGPTQuota(ctx, token.ID, token.Value) s.reconcileChatGPTQuota(ctx, token.ID, token.Value)
} }
return data, genErr return d, genErr
}, func(e error) (bool, bool, bool, bool) { }, func(e error) (bool, bool, bool, bool) {
return errors.Is(e, chatgpt.ErrAuth), errors.Is(e, chatgpt.ErrQuotaExhausted), errors.Is(e, chatgpt.ErrTemporaryUpstream), false 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 }, 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. // 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 { 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 s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == 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") items, err := s.tokens.ListByPool(ctx, "leonardo")
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
var active []model.TokenAccount var active []model.TokenAccount
for _, item := range items { for _, item := range items {
@@ -2408,7 +2555,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
} }
active = pinTestAccount(items, active, in.AccountID) active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 { if len(active) == 0 {
return nil, ErrNoProviderAccount return nil, "", ErrNoProviderAccount
} }
s.rotateRoundRobin("leonardo", active) s.rotateRoundRobin("leonardo", active)
@@ -2424,12 +2571,13 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
} }
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
// token.Value is the cookie; GenerateImage mints a fresh JWT each attempt, so an // 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). // 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 // Atomically pre-deduct the per-generation cost so concurrent picks of the
// same near-empty account can't over-commit it. A known-insufficient // same near-empty account can't over-commit it. A known-insufficient
// balance surfaces as quota → the driver fails over to the next account. // 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 { if !allowed {
return nil, leonardo.ErrQuotaExhausted 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 { if genErr != nil {
// Release the hold so a failed render doesn't burn credits. // Release the hold so a failed render doesn't burn credits.
if deducted { if deducted {
@@ -2448,6 +2596,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
} }
return nil, genErr return nil, genErr
} }
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
// Success → overwrite the held value with the REAL upstream balance and // Success → overwrite the held value with the REAL upstream balance and
// sink to 限额 if below the floor (best-effort; never fails a done render). // sink to 限额 if below the floor (best-effort; never fails a done render).
s.reconcileLeonardoCredits(ctx, token.ID, token.Value) 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) { }, func(e error) (bool, bool, bool, bool) {
return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream), false return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream), false
}, nil, true) }, nil, true)
return data, imageURL, err
} }
// reconcileLeonardoCredits re-fetches an account's real token balance after a // 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 { 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 s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == 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") items, err := s.tokens.ListByPool(ctx, "krea")
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
var active []model.TokenAccount var active []model.TokenAccount
for _, item := range items { for _, item := range items {
@@ -2560,7 +2711,7 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model
} }
active = pinTestAccount(items, active, in.AccountID) active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 { if len(active) == 0 {
return nil, ErrNoProviderAccount return nil, "", ErrNoProviderAccount
} }
s.rotateRoundRobin("krea", active) s.rotateRoundRobin("krea", active)
@@ -2571,21 +2722,26 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model
} }
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
if err != nil { 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 // Refresh the (rotating) Supabase token if expired and persist the new
// cookie, then generate with the fresh cookie. // cookie, then generate with the fresh cookie.
cookie, rerr := kreaRefreshAndPersist(ctx, s.krea, s.tokens, token.ID, token.Value) cookie, rerr := kreaRefreshAndPersist(ctx, s.krea, s.tokens, token.ID, token.Value)
if rerr != nil { if rerr != nil {
return nil, rerr 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 return data, genErr
}, func(e error) (bool, bool, bool, bool) { }, func(e error) (bool, bool, bool, bool) {
return errors.Is(e, krea.ErrAuth), errors.Is(e, krea.ErrQuotaExhausted), errors.Is(e, krea.ErrTemporaryUpstream), false return errors.Is(e, krea.ErrAuth), errors.Is(e, krea.ErrQuotaExhausted), errors.Is(e, krea.ErrTemporaryUpstream), false
}, nil, true) }, nil, true)
return data, imageURL, err
} }
// imagineRefreshAndPersist ensures the account's Imagine credential has a valid // imagineRefreshAndPersist ensures the account's Imagine credential has a valid
@@ -2613,9 +2769,10 @@ func imagineStyle(modelID string) (int, string) {
return 41001, "2K" 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 { 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 s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == 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") items, err := s.tokens.ListByPool(ctx, "imagine")
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
var active []model.TokenAccount var active []model.TokenAccount
for _, item := range items { for _, item := range items {
@@ -2637,28 +2794,31 @@ func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, mo
} }
active = pinTestAccount(items, active, in.AccountID) active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 { if len(active) == 0 {
return nil, ErrNoProviderAccount return nil, "", ErrNoProviderAccount
} }
s.rotateRoundRobin("imagine", active) s.rotateRoundRobin("imagine", active)
// Each model supports exactly one resolution (2K / 4K) — force it per model. // Each model supports exactly one resolution (2K / 4K) — force it per model.
styleID, res := imagineStyle(modelItem.ID) 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 // Refresh the (rotating) access token if expired and persist the new
// credential, then generate with the fresh token. // credential, then generate with the fresh token.
cred, rerr := imagineRefreshAndPersist(ctx, s.imagine, s.tokens, token.ID, token.Value) cred, rerr := imagineRefreshAndPersist(ctx, s.imagine, s.tokens, token.ID, token.Value)
if rerr != nil { if rerr != nil {
return nil, rerr 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 { if genErr != nil {
return nil, genErr return nil, genErr
} }
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
return data, nil return data, nil
}, func(e error) (bool, bool, bool, bool) { }, func(e error) (bool, bool, bool, bool) {
return errors.Is(e, imagine.ErrAuth), errors.Is(e, imagine.ErrQuotaExhausted), errors.Is(e, imagine.ErrTemporaryUpstream), false return errors.Is(e, imagine.ErrAuth), errors.Is(e, imagine.ErrQuotaExhausted), errors.Is(e, imagine.ErrTemporaryUpstream), false
}, nil, true) }, nil, true)
return data, imageURL, err
} }
func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal, eventID string, price float64) error { func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal, eventID string, price float64) error {
+7 -8
View File
@@ -113,7 +113,7 @@ const examples = computed(() => [
{ {
title: '文生图 · Python (openai SDK)', title: '文生图 · Python (openai SDK)',
code: code:
`import base64 `import urllib.request
from openai import OpenAI from openai import OpenAI
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1") 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", prompt="a corgi running in a golden wheat field, cinematic",
size="2048x2048", # 2K · 1:1,见下方对照表 size="2048x2048", # 2K · 1:1,见下方对照表
) )
# 结果是 base64(无 URL) # 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
with open("out.png", "wb") as f: urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
f.write(base64.b64decode(resp.data[0].b64_json))`,
}, },
{ {
title: '图生图 / 参考图 · curl (multipart)', title: '图生图 / 参考图 · curl (multipart)',
@@ -141,7 +140,7 @@ with open("out.png", "wb") as f:
{ {
title: '图生图 · Python (openai SDK)', title: '图生图 · Python (openai SDK)',
code: code:
`import base64 `import urllib.request
from openai import OpenAI from openai import OpenAI
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1") 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")] image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")]
prompt="把这张图改成赛博朋克风格", prompt="把这张图改成赛博朋克风格",
) )
with open("out.png", "wb") as f: # 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
f.write(base64.b64decode(resp.data[0].b64_json))`, urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
}, },
{ {
title: '视频 · curl(创建 → 轮询 → 下载)', title: '视频 · curl(创建 → 轮询 → 下载)',
@@ -424,7 +423,7 @@ async function copy(text) {
<section> <section>
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2> <h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
<div class="card p-6 space-y-3 text-sm text-white/70"> <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> <p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1"> <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> <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>