更新runway
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
|||||||
http "github.com/bogdanfinn/fhttp"
|
http "github.com/bogdanfinn/fhttp"
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
tlsclient "github.com/bogdanfinn/tls-client"
|
||||||
"github.com/bogdanfinn/tls-client/profiles"
|
"github.com/bogdanfinn/tls-client/profiles"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,8 +30,46 @@ const (
|
|||||||
// registration + generation in the reference HAR (Edge 150 on Windows).
|
// registration + generation in the reference HAR (Edge 150 on Windows).
|
||||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0"
|
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0"
|
||||||
secChUA = `"Not;A=Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"`
|
secChUA = `"Not;A=Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"`
|
||||||
|
|
||||||
|
// sourceApp / buildHash are the x-runway-source-application[-version] the real
|
||||||
|
// web app stamps on EVERY authed API call (319/336 requests in the reference
|
||||||
|
// HAR). buildHash is the web bundle's git sha; it also appears as the
|
||||||
|
// `sentry-release` segment of the baggage header. Runway anti-abuse keys on
|
||||||
|
// these being present + client-id (see clientIDFromToken): a write/upload that
|
||||||
|
// lacks them is judged a non-web client and the account's free credits are
|
||||||
|
// zeroed on the FIRST upload ("上传清零积分"). buildHash tracks Runway's web
|
||||||
|
// releases — refresh it from a current HAR if uploads start getting flagged.
|
||||||
|
sourceApp = "web"
|
||||||
|
buildHash = "3e96b2f0f85b8c0cafb7c0dcd7a6878305aaa0f0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// clientIDOverride, when non-empty, forces the x-runway-client-id (used to pin an
|
||||||
|
// account to the exact persistent device id its real browser session used).
|
||||||
|
var clientIDOverride string
|
||||||
|
|
||||||
|
// clientIDUUIDNamespace is a fixed namespace so clientIDFromToken is stable
|
||||||
|
// across processes/restarts — the same account always derives the same
|
||||||
|
// x-runway-client-id, exactly like the browser's localStorage-persisted id.
|
||||||
|
var clientIDUUIDNamespace = uuid.MustParse("6ba7b811-9dad-11d1-80b4-00c04fd430c8")
|
||||||
|
|
||||||
|
// clientIDFromToken derives the persistent per-account x-runway-client-id. The
|
||||||
|
// real SPA generates this UUID once and stores it in localStorage; every request
|
||||||
|
// from that browser reuses the SAME value (5b89c786-… appears on all 319 authed
|
||||||
|
// calls in the HAR). We reproduce that stability by deterministically deriving a
|
||||||
|
// v5 UUID from the account's JWT "id" claim, so one account == one client-id for
|
||||||
|
// its whole lifetime without needing a DB column. Falls back to the raw token if
|
||||||
|
// the id claim is missing.
|
||||||
|
func clientIDFromToken(token string) string {
|
||||||
|
if clientIDOverride != "" {
|
||||||
|
return clientIDOverride
|
||||||
|
}
|
||||||
|
seed := TeamIDFromToken(token)
|
||||||
|
if seed == "" {
|
||||||
|
seed = strings.TrimPrefix(token, "Bearer ")
|
||||||
|
}
|
||||||
|
return uuid.NewSHA1(clientIDUUIDNamespace, []byte("runway-client-id:"+seed)).String()
|
||||||
|
}
|
||||||
|
|
||||||
// randHex returns n random bytes hex-encoded (2n chars), for sentry trace ids.
|
// randHex returns n random bytes hex-encoded (2n chars), for sentry trace ids.
|
||||||
func randHex(n int) string {
|
func randHex(n int) string {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
@@ -52,7 +91,7 @@ func browserHeaders(token, teamID string) http.Header {
|
|||||||
"accept": {"application/json"},
|
"accept": {"application/json"},
|
||||||
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
||||||
"authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")},
|
"authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")},
|
||||||
"baggage": {"sentry-environment=production,sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID},
|
"baggage": {"sentry-environment=production,sentry-release=" + buildHash + ",sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID},
|
||||||
"content-type": {"application/json"},
|
"content-type": {"application/json"},
|
||||||
"origin": {origin},
|
"origin": {origin},
|
||||||
"priority": {"u=1, i"},
|
"priority": {"u=1, i"},
|
||||||
@@ -65,13 +104,21 @@ func browserHeaders(token, teamID string) http.Header {
|
|||||||
"sec-fetch-site": {"same-site"},
|
"sec-fetch-site": {"same-site"},
|
||||||
"sentry-trace": {traceID + "-" + spanID},
|
"sentry-trace": {traceID + "-" + spanID},
|
||||||
"user-agent": {userAgent},
|
"user-agent": {userAgent},
|
||||||
|
// x-runway-* identify this as the real web app to Runway's anti-abuse.
|
||||||
|
// client-id must be persistent per account (see clientIDFromToken);
|
||||||
|
// omitting these is what zeroes an account's credits on first upload.
|
||||||
|
"x-runway-client-id": {clientIDFromToken(token)},
|
||||||
|
"x-runway-source-application": {sourceApp},
|
||||||
|
"x-runway-source-application-version": {buildHash},
|
||||||
"x-runway-workspace": {teamID},
|
"x-runway-workspace": {teamID},
|
||||||
http.HeaderOrderKey: {
|
http.HeaderOrderKey: {
|
||||||
"accept", "accept-language", "authorization", "baggage",
|
"accept", "accept-language", "authorization", "baggage",
|
||||||
"content-type", "origin", "priority", "referer",
|
"content-type", "origin", "priority", "referer",
|
||||||
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
||||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||||
"sentry-trace", "user-agent", "x-runway-workspace",
|
"sentry-trace", "user-agent",
|
||||||
|
"x-runway-client-id", "x-runway-source-application",
|
||||||
|
"x-runway-source-application-version", "x-runway-workspace",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(teamID) == "" {
|
if strings.TrimSpace(teamID) == "" {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
tlsclient "github.com/bogdanfinn/tls-client"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GenerateImage runs a Runway gemini image text/image-to-image pipeline:
|
// GenerateImage runs a Runway gemini image text/image-to-image pipeline:
|
||||||
@@ -48,6 +47,7 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
var refImages []map[string]any
|
var refImages []map[string]any
|
||||||
|
var refAssetIDs []string
|
||||||
for i, raw := range refs {
|
for i, raw := range refs {
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -62,11 +62,23 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom
|
|||||||
"assetId": assetID,
|
"assetId": assetID,
|
||||||
"url": url,
|
"url": url,
|
||||||
})
|
})
|
||||||
|
refAssetIDs = append(refAssetIDs, assetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
assetGroupID, _ := c.assetGroupID(ctx, directClient, token, teamID) // best-effort
|
// Browser order: create the real session FIRST, attach the references and its
|
||||||
|
// own asset group, THEN submit the task into it (see createSession). The task
|
||||||
|
// carries this session's assetGroupId, NOT the account-wide "Generations"
|
||||||
|
// group.
|
||||||
|
sessionID, err := c.createSession(ctx, submitClient, token, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, aid := range refAssetIDs {
|
||||||
|
c.attachReference(ctx, submitClient, token, teamID, sessionID, aid)
|
||||||
|
}
|
||||||
|
assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort
|
||||||
|
|
||||||
taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, assetGroupID, refImages)
|
taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID, refImages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -111,13 +123,19 @@ func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClien
|
|||||||
return assetID, refURL, nil
|
return assetID, refURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createImageTask creates a gemini image task (workflow_gemini_image for Pro,
|
// createImageTask submits a gemini image task (workflow_gemini_image for Pro,
|
||||||
// gemini_3_1_flash_image for Nano Banana 2) and returns its id.
|
// gemini_3_1_flash_image for Nano Banana 2) INTO the pre-created session and
|
||||||
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, assetGroupID string, refImages []map[string]any) (string, error) {
|
// returns its id. sessionID must be a real server-side session (see
|
||||||
|
// createSession) and assetGroupID must be that session's own group.
|
||||||
|
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID string, refImages []map[string]any) (string, error) {
|
||||||
|
if strings.TrimSpace(aspectRatio) == "" {
|
||||||
|
aspectRatio = "16:9"
|
||||||
|
}
|
||||||
taskType := "workflow_gemini_image"
|
taskType := "workflow_gemini_image"
|
||||||
opts := map[string]any{
|
opts := map[string]any{
|
||||||
"name": "Nano Banana Pro - " + prompt,
|
"name": "Nano Banana Pro - " + prompt,
|
||||||
"text_prompt": prompt,
|
"text_prompt": prompt,
|
||||||
|
"aspect_ratio": aspectRatio,
|
||||||
"num_images": 1,
|
"num_images": 1,
|
||||||
"image_size": imageSize,
|
"image_size": imageSize,
|
||||||
"model": "gemini-3-pro-image-preview",
|
"model": "gemini-3-pro-image-preview",
|
||||||
@@ -128,25 +146,21 @@ func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClien
|
|||||||
taskType = "gemini_3_1_flash_image"
|
taskType = "gemini_3_1_flash_image"
|
||||||
opts["name"] = "Nano Banana 2 - " + prompt
|
opts["name"] = "Nano Banana 2 - " + prompt
|
||||||
opts["model"] = "gemini-3.1-flash-image-preview"
|
opts["model"] = "gemini-3.1-flash-image-preview"
|
||||||
if strings.TrimSpace(aspectRatio) == "" {
|
|
||||||
aspectRatio = "16:9"
|
|
||||||
}
|
}
|
||||||
opts["aspect_ratio"] = aspectRatio
|
|
||||||
}
|
|
||||||
// assetGroupId is present on every real browser task submit; omitting it is a
|
|
||||||
// bot tell. Best-effort — only attach when we resolved the "Generations"
|
|
||||||
// group for this workspace.
|
|
||||||
if assetGroupID != "" {
|
if assetGroupID != "" {
|
||||||
opts["assetGroupId"] = assetGroupID
|
opts["assetGroupId"] = assetGroupID
|
||||||
}
|
}
|
||||||
if len(refImages) > 0 {
|
if len(refImages) > 0 {
|
||||||
opts["reference_images"] = refImages
|
opts["reference_images"] = refImages
|
||||||
}
|
}
|
||||||
|
// Pre-flight cost estimate, exactly as the web app does before every spend.
|
||||||
|
c.estimateCost(ctx, client, token, teamID, "gemini_image", opts)
|
||||||
|
|
||||||
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
|
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
|
||||||
"taskType": taskType,
|
"taskType": taskType,
|
||||||
"options": opts,
|
"options": opts,
|
||||||
"asTeamId": jsonNumberOrString(teamID),
|
"asTeamId": jsonNumberOrString(teamID),
|
||||||
"sessionId": uuid.NewString(),
|
"sessionId": sessionID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -156,5 +170,7 @@ func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClien
|
|||||||
if id == "" {
|
if id == "" {
|
||||||
return "", fmt.Errorf("%w: image task missing id", ErrTemporaryUpstream)
|
return "", fmt.Errorf("%w: image task missing id", ErrTemporaryUpstream)
|
||||||
}
|
}
|
||||||
|
// Post-submit: generation record + session play (session already exists).
|
||||||
|
c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts)
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
http "github.com/bogdanfinn/fhttp"
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
tlsclient "github.com/bogdanfinn/tls-client"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size.
|
// ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size.
|
||||||
@@ -96,9 +95,16 @@ func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspec
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
assetGroupID, _ := c.assetGroupID(ctx, directClient, token, teamID) // best-effort
|
// Browser order: real session first, attach the first-frame asset + its own
|
||||||
|
// asset group, THEN submit the task into it (see createSession).
|
||||||
|
sessionID, err := c.createSession(ctx, submitClient, token, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
c.attachReference(ctx, submitClient, token, teamID, sessionID, assetID)
|
||||||
|
assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort
|
||||||
|
|
||||||
taskID, err := c.createTask(ctx, submitClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio, seconds)
|
taskID, err := c.createTask(ctx, submitClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio, seconds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -195,7 +201,7 @@ func (c *Client) assetGroupID(ctx context.Context, client tlsclient.HttpClient,
|
|||||||
return strings.TrimSpace(stringValue(ag["id"])), nil
|
return strings.TrimSpace(stringValue(ag["id"])), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio string, seconds int) (string, error) {
|
func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio string, seconds int) (string, error) {
|
||||||
w, h := ratioDimensions(aspectRatio)
|
w, h := ratioDimensions(aspectRatio)
|
||||||
opts := map[string]any{
|
opts := map[string]any{
|
||||||
"route": "i2v",
|
"route": "i2v",
|
||||||
@@ -218,7 +224,7 @@ func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, to
|
|||||||
"taskType": "gen4_turbo",
|
"taskType": "gen4_turbo",
|
||||||
"options": opts,
|
"options": opts,
|
||||||
"asTeamId": jsonNumberOrString(teamID),
|
"asTeamId": jsonNumberOrString(teamID),
|
||||||
"sessionId": uuid.NewString(),
|
"sessionId": sessionID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -228,6 +234,8 @@ func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, to
|
|||||||
if id == "" {
|
if id == "" {
|
||||||
return "", fmt.Errorf("%w: task missing id", ErrTemporaryUpstream)
|
return "", fmt.Errorf("%w: task missing id", ErrTemporaryUpstream)
|
||||||
}
|
}
|
||||||
|
// Post-submit: generation record + session play (session already exists).
|
||||||
|
c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts)
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +280,98 @@ func (c *Client) pollTask(ctx context.Context, client tlsclient.HttpClient, toke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// estimateCost fires the /v1/billing/estimate_feature_cost_credits pre-flight the
|
||||||
|
// web app ALWAYS does before a spend (52 times in the reference HAR, always with
|
||||||
|
// the exact taskOptions right before /v1/tasks). It returns no reservation token,
|
||||||
|
// so its purpose is purely to mark the spend as coming from the real UI flow;
|
||||||
|
// submitting a task with no preceding estimate for that spec is a scripted-abuse
|
||||||
|
// tell. Best-effort — the estimate itself costs nothing.
|
||||||
|
func (c *Client) estimateCost(ctx context.Context, client tlsclient.HttpClient, token, teamID, feature string, taskOptions map[string]any) {
|
||||||
|
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/billing/estimate_feature_cost_credits", map[string]any{
|
||||||
|
"feature": feature,
|
||||||
|
"count": 1,
|
||||||
|
"asTeamId": jsonNumberOrString(teamID),
|
||||||
|
"taskOptions": taskOptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSession creates an EMPTY tool-mode session (POST /v1/sessions with an
|
||||||
|
// empty taskIds array) and returns its real server-side id. This is the crux of
|
||||||
|
// the whole flow: the browser creates the session BEFORE submitting the first
|
||||||
|
// task, then submits the task INTO that real session. Submitting /v1/tasks with a
|
||||||
|
// sessionId that was never created server-side is a forgery tell that makes
|
||||||
|
// Runway revoke the account's free credits (permitted numPlanCredits 500 -> 0)
|
||||||
|
// the instant the task lands ("提交生图就清零"). Note taskIds must be [] — passing
|
||||||
|
// a bogus id yields "Invalid task IDs".
|
||||||
|
func (c *Client) createSession(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) {
|
||||||
|
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions", map[string]any{
|
||||||
|
"asTeamId": jsonNumberOrString(teamID),
|
||||||
|
"taskIds": []string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sess, _ := res["session"].(map[string]any)
|
||||||
|
id := strings.TrimSpace(stringValue(sess["id"]))
|
||||||
|
if id == "" {
|
||||||
|
return "", fmt.Errorf("%w: session missing id", ErrTemporaryUpstream)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachReference attaches an uploaded asset to the session (the browser does
|
||||||
|
// this before the task, so the task's reference_images belong to a real session).
|
||||||
|
func (c *Client) attachReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID, assetID string) {
|
||||||
|
if strings.TrimSpace(assetID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost,
|
||||||
|
"/v1/sessions/"+sessionID+"/references", map[string]any{
|
||||||
|
"assetId": assetID,
|
||||||
|
"asTeamId": jsonNumberOrString(teamID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionAssetGroup creates the session's OWN asset group (POST
|
||||||
|
// /v1/sessions/{id}/assetGroup) and returns its id — this, NOT the account-wide
|
||||||
|
// "Generations" group, is the assetGroupId the browser puts on the task.
|
||||||
|
func (c *Client) sessionAssetGroup(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID string) (string, error) {
|
||||||
|
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost,
|
||||||
|
"/v1/sessions/"+sessionID+"/assetGroup", map[string]any{"asTeamId": jsonNumberOrString(teamID)})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ag, _ := res["assetGroup"].(map[string]any)
|
||||||
|
return strings.TrimSpace(stringValue(ag["id"])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordGeneration is the post-submit lifecycle the browser fires AFTER the task
|
||||||
|
// lands: the generation record (POST /v1/generations, recordingEnabled=true) and
|
||||||
|
// a session /play. The session itself already exists (createSession ran before
|
||||||
|
// the task), so this no longer creates it. Best-effort.
|
||||||
|
func (c *Client) recordGeneration(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID, sessionID, prompt string, taskOptions map[string]any) {
|
||||||
|
settings := map[string]any{"taskId": taskID, "recordingEnabled": true}
|
||||||
|
for k, v := range taskOptions {
|
||||||
|
if k == "exploreMode" { // not present on the browser's generation record
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings[k] = v
|
||||||
|
}
|
||||||
|
// The generation record carries the model id WITHOUT the "-preview" suffix
|
||||||
|
// the task uses (gemini-3-pro-image-preview -> gemini-3-pro-image in the HAR).
|
||||||
|
if m, ok := settings["model"].(string); ok {
|
||||||
|
settings["model"] = strings.TrimSuffix(m, "-preview")
|
||||||
|
}
|
||||||
|
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/generations", map[string]any{
|
||||||
|
"toolId": "generate",
|
||||||
|
"prompt": prompt,
|
||||||
|
"outputs": map[string]any{"outputUrls": []any{}},
|
||||||
|
"settings": settings,
|
||||||
|
})
|
||||||
|
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions/"+sessionID+"/play",
|
||||||
|
map[string]any{"asTeamId": jsonNumberOrString(teamID), "taskId": taskID})
|
||||||
|
}
|
||||||
|
|
||||||
// submitTask POSTs a /v1/tasks create. Transient (network / 5xx) failures
|
// submitTask POSTs a /v1/tasks create. Transient (network / 5xx) failures
|
||||||
// surface as ErrTemporaryUpstream so the pool fails over to the NEXT account
|
// surface as ErrTemporaryUpstream so the pool fails over to the NEXT account
|
||||||
// (换号重试) instead of retrying this one.
|
// (换号重试) instead of retrying this one.
|
||||||
@@ -293,6 +393,21 @@ func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token
|
|||||||
}
|
}
|
||||||
req = req.WithContext(ctx)
|
req = req.WithContext(ctx)
|
||||||
req.Header = browserHeaders(token, teamID)
|
req.Header = browserHeaders(token, teamID)
|
||||||
|
// The generate-submit endpoint is the ONE authed call the real web bundle
|
||||||
|
// sends WITHOUT x-runway-client-id / source-application[-version] — every
|
||||||
|
// other endpoint carries them, /v1/tasks carries only x-runway-workspace
|
||||||
|
// (verified across the whole reference HAR). Leaving them on /v1/tasks is a
|
||||||
|
// bot tell that gets the account's free credits revoked (permitted
|
||||||
|
// numPlanCredits 500 -> 0) the instant a task is submitted. Strip them here so
|
||||||
|
// the submit matches the browser exactly.
|
||||||
|
if method == http.MethodPost && path == "/v1/tasks" {
|
||||||
|
// NOTE: raw map delete, NOT req.Header.Del() — fhttp canonicalizes the key
|
||||||
|
// in Del() ("X-Runway-Client-Id") but our headers are stored lowercase (so
|
||||||
|
// they go on the h2 wire lowercase like Chrome), so Del() silently no-ops.
|
||||||
|
delete(req.Header, "x-runway-client-id")
|
||||||
|
delete(req.Header, "x-runway-source-application")
|
||||||
|
delete(req.Header, "x-runway-source-application-version")
|
||||||
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||||
|
|||||||
Reference in New Issue
Block a user