feat: add Grok (grok.com) video provider; runway nano-banana image; unify runway/grok pool policy
- grok provider: imagine text/image-to-video (media.post.create → conversations/new), GetGrokCreditsConfig credit query (remaining = 100 - used) + weekly reset, spoofed x-statsig-id (no Cloudflare clearance needed), /api/auth/session email lookup, 6 reference images, 10 concurrent jobs/account, no token refresh (dead = dead) - runway: nano-banana-2 image flow (Nano Banana 2); drop pre-deduct + post-success reconcile; out-of-credits/403 → dead (no revive); 10-ratio support - imagine: drop post-success credit reconcile (consistent with krea) - account gate: per-account N-concurrency (grok=10, others=1) - admin: provider health lists all 7 providers; frontend import auto-detects Grok SSO - docs: README (CN/EN) updated to 7 providers + Grok Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -139,6 +139,8 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Per ops decision a rate-limit (403) is treated as a dead account too, same as
|
||||
// a 401 — a throttled Runway token is considered done.
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package runway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GenerateImage runs the Runway "Nano Banana 2" (gemini_3_1_flash_image)
|
||||
// text/image-to-image pipeline: upload each reference image (DATASET +
|
||||
// DATASET_PREVIEW → dataset) to obtain its {assetId, url}, create a gemini image
|
||||
// task and poll it to completion, then download the rendered PNG. teamID is the
|
||||
// workspace id; if empty it's derived from the token. aspectRatio is passed
|
||||
// through as-is (e.g. "16:9"); imageSize is the "1K"/"2K"/"4K" tier. refs may be
|
||||
// empty (pure text-to-image).
|
||||
func (c *Client) GenerateImage(ctx context.Context, token, teamID, prompt, aspectRatio, imageSize string, refs [][]byte) ([]byte, map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if teamID == "" {
|
||||
teamID = TeamIDFromToken(token)
|
||||
}
|
||||
if teamID == "" {
|
||||
return nil, nil, errors.New("runway: no team id")
|
||||
}
|
||||
if strings.TrimSpace(aspectRatio) == "" {
|
||||
aspectRatio = "16:9"
|
||||
}
|
||||
if strings.TrimSpace(imageSize) == "" {
|
||||
imageSize = "1K"
|
||||
}
|
||||
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var refImages []map[string]any
|
||||
for i, raw := range refs {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
filename := fmt.Sprintf("ref_%s_%d.png", time.Now().UTC().Format("20060102_150405"), i+1)
|
||||
assetID, url, upErr := c.uploadReference(ctx, client, token, teamID, filename, raw)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
refImages = append(refImages, map[string]any{
|
||||
"tag": fmt.Sprintf("IMG_%d", i+1),
|
||||
"assetId": assetID,
|
||||
"url": url,
|
||||
})
|
||||
}
|
||||
|
||||
taskID, err := c.createImageTask(ctx, client, token, teamID, prompt, aspectRatio, imageSize, refImages)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
artifactURL, err := c.pollTask(ctx, client, token, teamID, taskID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
data, err := c.download(ctx, client, artifactURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{
|
||||
"provider": "runway",
|
||||
"task_id": taskID,
|
||||
"team_id": teamID,
|
||||
"image_url": artifactURL,
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// uploadReference uploads one reference image through the dataset pipeline
|
||||
// (DATASET_PREVIEW + DATASET uploads → /v1/datasets) and returns its asset id
|
||||
// (= dataset id) and the cloudfront URL the task references.
|
||||
func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename string, data []byte) (string, string, error) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", "", errors.New("runway: failed to decode reference image")
|
||||
}
|
||||
previewUploadID, _, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET_PREVIEW", data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// The DATASET upload's completed URL is exactly what the task references.
|
||||
datasetUploadID, refURL, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET", data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
assetID, _, err := c.createDataset(ctx, client, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return assetID, refURL, nil
|
||||
}
|
||||
|
||||
// createImageTask creates a gemini_3_1_flash_image task and returns its id.
|
||||
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, aspectRatio, imageSize string, refImages []map[string]any) (string, error) {
|
||||
opts := map[string]any{
|
||||
"name": "Nano Banana 2 - " + prompt,
|
||||
"text_prompt": prompt,
|
||||
"aspect_ratio": aspectRatio,
|
||||
"num_images": 1,
|
||||
"image_size": imageSize,
|
||||
"model": "gemini-3.1-flash-image-preview",
|
||||
"exploreMode": false,
|
||||
"creationSource": "tool-mode",
|
||||
}
|
||||
if len(refImages) > 0 {
|
||||
opts["reference_images"] = refImages
|
||||
}
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/tasks", map[string]any{
|
||||
"taskType": "gemini_3_1_flash_image",
|
||||
"options": opts,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"sessionId": uuid.NewString(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
task, _ := res["task"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(task["id"]))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: image task missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -300,6 +300,7 @@ func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
// Rate-limit (403) is treated as a dead account too, same as a 401.
|
||||
return nil, fmt.Errorf("%w: %s %d %s", ErrAuth, path, resp.StatusCode, clip(raw, 200))
|
||||
case resp.StatusCode == 429:
|
||||
return nil, fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 200))
|
||||
|
||||
Reference in New Issue
Block a user