// Package runway implements the Runway (runwayml.com) provider client. For now // it only covers account management — JWT detection, workspace/team id // extraction and credit-balance probing — mirroring the curl_cffi reference in // query_credits.py with tls-client so the JA3/JA4 fingerprint matches Chrome. package runway import ( "context" crand "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "strconv" "strings" http "github.com/bogdanfinn/fhttp" tlsclient "github.com/bogdanfinn/tls-client" "github.com/bogdanfinn/tls-client/profiles" "github.com/google/uuid" ) const ( apiBase = "https://api.runwayml.com" origin = "https://app.runwayml.com" // userAgent / secChUA mirror the real browser that produced a successful // 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" 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. func randHex(n int) string { b := make([]byte, n) if _, err := crand.Read(b); err != nil { return strings.Repeat("0", n*2) } return hex.EncodeToString(b) } // browserHeaders builds the full Chrome/Edge header set the Runway web app sends // on its JSON API calls. tls-client only spoofs the TLS/JA3 fingerprint — it does // NOT inject a User-Agent or the client-hint / fetch-metadata headers. Without // them a request looks like a headless bot and Runway's anti-abuse revokes the // account's free credits within minutes ("秒死"). teamID is optional. func browserHeaders(token, teamID string) http.Header { traceID := randHex(16) spanID := randHex(8) h := http.Header{ "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"}, "authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")}, "baggage": {"sentry-environment=production,sentry-release=" + buildHash + ",sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID}, "content-type": {"application/json"}, "origin": {origin}, "priority": {"u=1, i"}, "referer": {origin + "/"}, "sec-ch-ua": {secChUA}, "sec-ch-ua-mobile": {"?0"}, "sec-ch-ua-platform": {`"Windows"`}, "sec-fetch-dest": {"empty"}, "sec-fetch-mode": {"cors"}, "sec-fetch-site": {"same-site"}, "sentry-trace": {traceID + "-" + spanID}, "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}, http.HeaderOrderKey: { "accept", "accept-language", "authorization", "baggage", "content-type", "origin", "priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "sentry-trace", "user-agent", "x-runway-client-id", "x-runway-source-application", "x-runway-source-application-version", "x-runway-workspace", }, } if strings.TrimSpace(teamID) == "" { h.Del("x-runway-workspace") } return h } // browserAssetHeaders is the lighter header set the browser sends on cross-site // asset transfers (presigned S3 upload / CloudFront artifact download): a // User-Agent and client hints, but no Runway authorization. func browserAssetHeaders() http.Header { return http.Header{ "accept": {"*/*"}, "accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"}, "origin": {origin}, "referer": {origin + "/"}, "sec-ch-ua": {secChUA}, "sec-ch-ua-mobile": {"?0"}, "sec-ch-ua-platform": {`"Windows"`}, "sec-fetch-dest": {"empty"}, "sec-fetch-mode": {"cors"}, "sec-fetch-site": {"cross-site"}, "user-agent": {userAgent}, } } var ( ErrAuth = errors.New("runway auth failed") ErrQuotaExhausted = errors.New("runway quota exhausted") ErrTemporaryUpstream = errors.New("runway upstream temporary error") ) type Client struct { proxy string } func NewClient(proxy string) *Client { return &Client{proxy: strings.TrimSpace(proxy)} } func (c *Client) SetProxy(proxy string) { c.proxy = strings.TrimSpace(proxy) } // IsRunwayToken reports whether a JWT looks like a Runway access token: a // top-level numeric "id" plus an "sso" claim, and crucially NO OpenAI // (https://api.openai.com/*) claims — that's what disambiguates it from a // ChatGPT token, which is otherwise also an opaque three-part JWT. func IsRunwayToken(token string) bool { claims := decodeJWTPayload(token) if len(claims) == 0 { return false } for k := range claims { if strings.HasPrefix(k, "https://api.openai.com/") { return false } } _, hasSSO := claims["sso"] return hasSSO && claims["id"] != nil } // TeamIDFromToken returns the Runway workspace/team id, which equals the JWT // "id" claim (query_credits.py / gen_video.py both derive teamId this way). func TeamIDFromToken(token string) string { claims := decodeJWTPayload(token) switch v := claims["id"].(type) { case float64: return strconv.FormatInt(int64(v), 10) case json.Number: return v.String() case string: return strings.TrimSpace(v) default: return "" } } // ExtractAccountInfo decodes the free (no-network) JWT claims for the accounts // view: email, team id and expiry. func ExtractAccountInfo(token string) map[string]any { claims := decodeJWTPayload(token) return map[string]any{ "email": emptyStringNil(strings.TrimSpace(stringValue(claims["email"]))), "team_id": emptyStringNil(TeamIDFromToken(token)), "expires_at": claims["exp"], } } // FetchCreditsBalance probes the account's plan credits via /v1/profile/features // (query_credits.py). Returns a normalized map mirroring the Adobe client so the // TokenService quota plumbing can treat all providers uniformly. A 401/403 maps // to ErrAuth (token dead); any other failure is reported as unknown without // killing the account. func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) { token = strings.TrimSpace(token) if token == "" { return unknownBalance("empty token"), nil } teamID := TeamIDFromToken(token) if teamID == "" { return unknownBalance("no team id"), nil } client, err := c.newDirectTLSClient() if err != nil { return nil, err } url := apiBase + "/v1/profile/features?asTeamId=" + teamID req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } req = req.WithContext(ctx) req.Header = browserHeaders(token, teamID) resp, err := client.Do(req) if err != nil { return unknownBalance("network: " + err.Error()), nil } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) 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 } if resp.StatusCode != 200 { return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(body, 160))), nil } var payload map[string]any if err := json.Unmarshal(body, &payload); err != nil { return unknownBalance("non-json"), nil } features, _ := payload["features"].(map[string]any) permitted, _ := features["permitted"].(map[string]any) used, _ := features["used"].(map[string]any) total := intValue(permitted["numPlanCredits"]) spent := intValue(used["numPlanCredits"]) remaining := total - spent if remaining < 0 { remaining = 0 } return map[string]any{ "remaining": remaining, "used": spent, "total": total, "unknown": false, "error": nil, }, nil } func unknownBalance(reason string) map[string]any { return map[string]any{ "remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason, } } func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) } // newDirectTLSClient egresses on the local IP (never the proxy). Used for // reference-image upload, polling and result download. func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) } func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) { options := []tlsclient.HttpClientOption{ // Whole-request timeout, incl. reading the response body. 30s was too // tight for downloading a rendered image/video artifact off a slow CDN, // surfacing as "request canceled (Client.Timeout ... while reading body)". // The caller's genCtx still bounds the overall generation. tlsclient.WithTimeoutSeconds(300), tlsclient.WithClientProfile(profiles.Chrome_133), tlsclient.WithRandomTLSExtensionOrder(), } if useProxy && c.proxy != "" { options = append(options, tlsclient.WithProxyUrl(c.proxy)) } return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) } func decodeJWTPayload(token string) map[string]any { parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")), ".") if len(parts) < 2 { return map[string]any{} } raw, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return map[string]any{} } var out map[string]any if err := json.Unmarshal(raw, &out); err != nil { return map[string]any{} } return out } func stringValue(v any) string { switch x := v.(type) { case string: return x case nil: return "" default: b, _ := json.Marshal(x) return strings.TrimSpace(string(b)) } } func intValue(v any) int { switch x := v.(type) { case int: return x case int64: return int(x) case float64: return int(x) case json.Number: n, _ := x.Int64() return int(n) case string: n, _ := strconv.Atoi(strings.TrimSpace(x)) return n default: return 0 } } func emptyStringNil(v string) any { v = strings.TrimSpace(v) if v == "" { return nil } return v } func clip(b []byte, n int) string { s := strings.TrimSpace(string(b)) if len(s) > n { return s[:n] } return s }