feat!: 账号池改渠道网关,对外协议换成 vivid /v1

- 新增 channels/channel_models:上游 URL + AES-GCM 加密 key +
  并发/权重/本地统计,模型经渠道 /v1/models 导入绑定
- internal/upstream:vivid 协议客户端(NDJSON 流 + jobId 断流重连 +
  uploads 预签名 + models + balance + 错误分类)
- service/v1 重写:权重分组调度、Redis 并发槽 conc:c:、故障转移,
  站内/测试/对外统一走渠道执行器
- 对外端点换成 vivid 四件套:POST /v1/generate(NDJSON+jobId 重连)、
  POST /v1/uploads(SigV4 预签名 PUT)、GET /v1/models、GET /v1/balance,
  错误包 {"error":{"code","message"}}
- API key 新发改 vk_+48hex;删除全部内置 provider 与 token 池
- 管理端:渠道 CRUD/拉模型/导入/余额端点,前端账号页换渠道页,
  概览/文档/测试弹窗适配

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-16 21:14:18 +08:00
co-authored by Devin
parent ecdbc0b281
commit b4efe6b192
75 changed files with 3598 additions and 23848 deletions
@@ -1,162 +0,0 @@
package adobe
import (
"encoding/base64"
"encoding/json"
"regexp"
"strings"
"testing"
)
// captured from live firefly.adobe.com traffic:
//
// ark: 91818c89a54748463.1048135404|r=ap-southeast-1|…|rid=84|ag=101|…
// ftr: 40d7ba7c70ff478e9c212073ec8254c4_1791795834777_27064_UDF43-mnf-a4_31ck_s/Clo68HDL4=-3629-v2_tt
//
// The Arkose slot is deliberately emitted empty rather than synthesized — see
// buildARPSessionID — so the expected ftr ends in "_31ck__tt".
var (
arkPat = regexp.MustCompile(`^[0-9a-f]{17}\.[1-9][0-9]{9}\|r=ap-southeast-1\|.*\|rid=[0-9]{1,2}\|ag=101\|`)
ftrPat = regexp.MustCompile(`^[0-9a-f]{32}_[0-9]{13}_[0-9]{4,5}_UDF43-mnf-a4_31ck__tt$`)
)
func TestARPSessionIDShape(t *testing.T) {
raw, err := base64.StdEncoding.DecodeString(buildARPSessionID("tok-a"))
if err != nil {
t.Fatalf("not base64: %v", err)
}
var got struct{ Sid, Ark, Ftr, Bfp string }
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("not json: %v", err)
}
if len(got.Bfp) != 36 {
t.Errorf("bfp not a uuid: %q", got.Bfp)
}
if !arkPat.MatchString(got.Ark) {
t.Errorf("ark shape mismatch:\n%s", got.Ark)
}
if !ftrPat.MatchString(got.Ftr) {
t.Errorf("ftr shape mismatch:\n%s", got.Ftr)
}
if len(got.Sid) != 36 {
t.Errorf("sid not a uuid: %q", got.Sid)
}
}
// ark must differ per call — a frozen blob is a cross-account correlation key.
func TestARKVariesAcrossCalls(t *testing.T) {
if buildARKBlob() == buildARKBlob() {
t.Error("ark is constant across calls")
}
}
// pid is stable per token but distinct across tokens.
func TestPIDStablePerToken(t *testing.T) {
defer ReleasePID("tok-x")
defer ReleasePID("tok-y")
if allocPID("tok-x") != allocPID("tok-x") {
t.Error("pid changed for the same token")
}
if allocPID("tok-x") == allocPID("tok-y") {
t.Error("two tokens share a pid")
}
}
// A real browser keeps sid / devHex / bfp for the whole session; only the ftr
// timestamp advances. Re-randomizing them per call is a bot signal.
func TestIdentityStablePerToken(t *testing.T) {
decode := func(token string) map[string]string {
raw, err := base64.StdEncoding.DecodeString(buildARPSessionID(token))
if err != nil {
t.Fatalf("not base64: %v", err)
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("not json: %v", err)
}
return m
}
a, b := decode("tok-s"), decode("tok-s")
if a["sid"] != b["sid"] || a["bfp"] != b["bfp"] {
t.Error("sid/bfp changed across calls for the same token")
}
if a["ftr"][:39] != b["ftr"][:39] {
t.Error("ftr device hex changed across calls for the same token")
}
}
// CacheARP stores a cookie-derived arp which getARPSessionID prefers over
// synthesis.
func TestCacheARPPreferred(t *testing.T) {
c := NewClient("", "")
CacheARP("tok-c", "eyJjb29raWUiOiJhYXJkZWYa")
if got := c.getARPSessionID("tok-c"); got != "eyJjb29raWUiOiJhYXJkZWYa" {
t.Errorf("cached arp not preferred: %q", got)
}
}
// refreshARPTimestamp bumps only the ftr timestamp segment; devHex, pid, the
// Arkose slot, and every other payload field pass through untouched.
func TestRefreshARPTimestamp(t *testing.T) {
mk := func(ftr string) string {
b, _ := json.Marshal(map[string]string{
"sid": "4e54df9a-79cf-4569-b40d-fa793e38b727",
"ftr": ftr,
"ark": "ark_blob",
"bfp": "bf",
})
return base64.StdEncoding.EncodeToString(b)
}
old := "40d7ba7c70ff478e9c212073ec8254c4_1791795834777_27064_UDF43-mnf-a4_31ck__tt"
raw, err := base64.StdEncoding.DecodeString(refreshARPTimestamp(mk(old)))
if err != nil {
t.Fatalf("refreshed arp not base64: %v", err)
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("not json: %v", err)
}
parts := strings.Split(m["ftr"], "_")
if len(parts) != 7 {
t.Fatalf("ftr segment count changed: %q", m["ftr"])
}
if parts[0] != "40d7ba7c70ff478e9c212073ec8254c4" || parts[2] != "27064" || parts[4] != "31ck" || parts[6] != "tt" {
t.Errorf("ftr fields mangled: %q", m["ftr"])
}
if parts[1] == "1791795834777" {
t.Error("ftr timestamp not bumped")
}
if m["sid"] != "4e54df9a-79cf-4569-b40d-fa793e38b727" || m["ark"] != "ark_blob" || m["bfp"] != "bf" {
t.Errorf("non-ftr fields mangled: %v", m)
}
// malformed inputs pass through unchanged
if got := refreshARPTimestamp("!!!not-base64"); got != "!!!not-base64" {
t.Errorf("garbage input changed: %q", got)
}
if got := refreshARPTimestamp(mk("too_short")); got != mk("too_short") {
t.Error("short ftr changed")
}
}
// ARPFromCookie returns the sherlockToken verbatim when present, and rebuilds
// the JSON from component cookies otherwise.
func TestARPFromCookie(t *testing.T) {
if got := ARPFromCookie("a=1; sherlockToken=eyJhIjoxfQ==; b=2"); got != "eyJhIjoxfQ==" {
t.Errorf("sherlockToken not returned verbatim: %q", got)
}
if got := ARPFromCookie("a=1"); got != "" {
t.Errorf("expected empty for cookie without arp pieces, got %q", got)
}
raw, err := base64.StdEncoding.DecodeString(ARPFromCookie(
"ff_session_guid=4e54df9a-79cf-4569-b40d-fa793e38b727; forterToken=ft_x%3D-v2; arkose=ark_blob; bfp=bf"))
if err != nil {
t.Fatalf("reassembled arp not base64: %v", err)
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("not json: %v", err)
}
if m["sid"] != "4e54df9a-79cf-4569-b40d-fa793e38b727" || m["ftr"] != "ft_x=-v2" || m["ark"] != "ark_blob" || m["bfp"] != "bf" {
t.Errorf("reassembled arp mismatch: %v", m)
}
}
-44
View File
@@ -1,44 +0,0 @@
package adobe
import (
"context"
"errors"
"net/http"
"strings"
)
const (
refreshURL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"
clientID = "projectx_webapp"
scopeValue = "AdobeID,firefly_api,openid"
// clioClientID / clioScopeValue mint the firefly.adobe.com token that is
// entitled to the Firefly-native seedance models (creative_production scope).
// Unlike the Express token this exchange requires user_id and no guest_allowed.
clioClientID = "clio-playground-web"
clioScopeValue = "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext,additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations,additional_info.roles,account_cluster.read,creative_production,tk_platform,tk_platform_sync,profile"
)
var ErrAdobeCookieEmpty = errors.New("cookie is empty")
type CookieExchangeResult struct {
AccessToken string
ExpiresIn int
Raw map[string]any
}
func ExchangeCookieToAccessToken(ctx context.Context, client *http.Client, cookie string) (*CookieExchangeResult, error) {
_ = client
tlsClient, err := NewClient(clientID, "").newTLSClient()
if err != nil {
return nil, err
}
return exchangeCookieWithTLSClient(ctx, tlsClient, cookie)
}
func normalizeCookie(v string) string {
v = strings.TrimSpace(v)
if strings.HasPrefix(strings.ToLower(v), "cookie:") {
v = strings.TrimSpace(v[len("cookie:"):])
}
return v
}
File diff suppressed because it is too large Load Diff
-517
View File
@@ -1,517 +0,0 @@
package adobe
import (
"encoding/json"
"math/rand"
"strings"
)
type modelSpec struct {
UpstreamModelID string
UpstreamModelVersion string
}
var lumaSize = map[string]map[string][2]int{
"720p": {
"21:9": {1280, 548}, "16:9": {1280, 720}, "4:3": {960, 720},
"1:1": {720, 720}, "3:4": {720, 960}, "9:16": {720, 1280}, "9:21": {548, 1280},
},
"1080p": {
"21:9": {1920, 822}, "16:9": {1920, 1080}, "4:3": {1440, 1080},
"1:1": {1080, 1080}, "3:4": {1080, 1440}, "9:16": {1080, 1920}, "9:21": {822, 1920},
},
"4k": {
"21:9": {3840, 1646}, "16:9": {3840, 2160}, "4:3": {2880, 2160},
"1:1": {2160, 2160}, "3:4": {2160, 2880}, "9:16": {2160, 3840}, "9:21": {1646, 3840},
},
}
var gptImageSize = map[string]map[string][2]int{
"1K": {"1:1": {1024, 1024}, "5:4": {1120, 896}, "9:16": {720, 1280}, "21:9": {1456, 624}, "16:9": {1280, 720}, "4:3": {1152, 864}, "3:2": {1248, 832}, "4:5": {896, 1120}, "3:4": {864, 1152}, "2:3": {832, 1248}},
"2K": {"1:1": {2048, 2048}, "5:4": {2240, 1792}, "9:16": {1440, 2560}, "21:9": {3024, 1296}, "16:9": {2560, 1440}, "4:3": {2304, 1728}, "3:2": {2496, 1664}, "4:5": {1792, 2240}, "3:4": {1728, 2304}, "2:3": {1664, 2496}},
"4K": {"1:1": {2880, 2880}, "5:4": {3200, 2560}, "9:16": {2160, 3840}, "21:9": {3696, 1584}, "16:9": {3840, 2160}, "4:3": {3264, 2448}, "3:2": {3504, 2336}, "4:5": {2560, 3200}, "3:4": {2448, 3264}, "2:3": {2336, 3504}},
}
var fluxSize = map[string][2]int{
"1:1": {1024, 1024},
"16:9": {1408, 768},
"9:16": {768, 1408},
"4:3": {1280, 896},
"3:4": {896, 1280},
}
var defaultSize = map[string]map[string][2]int{
"1K": {"1:1": {1024, 1024}, "1:8": {384, 3072}, "1:4": {512, 2048}, "16:9": {1360, 768}, "9:16": {768, 1360}, "4:1": {2048, 512}, "4:3": {1152, 864}, "3:4": {864, 1152}, "8:1": {3072, 384}},
"2K": {"1:1": {2048, 2048}, "1:8": {768, 6144}, "1:4": {1024, 4096}, "16:9": {2752, 1536}, "9:16": {1536, 2752}, "4:1": {4096, 1024}, "4:3": {2048, 1536}, "3:4": {1536, 2048}, "8:1": {6144, 768}},
"4K": {"1:1": {4096, 4096}, "1:8": {1536, 12288}, "1:4": {2048, 8192}, "16:9": {5504, 3072}, "9:16": {3072, 5504}, "4:1": {8192, 2048}, "4:3": {4096, 3072}, "3:4": {3072, 4096}, "8:1": {12288, 1536}},
}
func ResolveModelSpec(modelID string) modelSpec {
switch modelID {
case "firefly-gpt-image", "firefly-gpt-image-2":
return modelSpec{UpstreamModelID: "gpt-image", UpstreamModelVersion: "2"}
case "flux-kontext-max":
return modelSpec{UpstreamModelID: "flux", UpstreamModelVersion: "fluxKontextMax"}
case "nano-banana-2":
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-3"}
case "nano-banana-pro":
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-2"}
default:
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "gemini-3-pro-image-preview"}
}
}
// buildImage5Payload builds the Adobe Firefly Image 5 request. It uses a distinct
// schema from the firefly-3p models: NO modelId/size, a top-level aspectRatio
// string label and a resolutionLevel (1K→1MP, 2K→4MP). Mirrors a captured
// working image-v5.ff.adobe.io request.
func buildImage5Payload(prompt, aspectRatio, resolution string, blobIDs []string) map[string]any {
p := map[string]any{
"n": 1,
"seeds": []int{rand.Intn(999999)},
"output": map[string]any{"storeInputs": true},
"prompt": prompt,
"referenceBlobs": []any{},
"modelSpecificPayload": map[string]any{"locale": "en-US", "prompt_reasoner": "quality"},
"modelVersion": "image5",
"resolutionLevel": image5ResolutionLevel(resolution),
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
}
if len(blobIDs) > 0 {
// Instruct-edit: aspect ratio is derived from the reference image; sending
// aspectRatio is rejected with a validation_error.
p["referenceBlobs"] = blobRefs(blobIDs, "general")
} else {
p["aspectRatio"] = defaultString(aspectRatio, "1:1")
}
return p
}
// image5ResolutionLevel maps the UI resolution tier to Image 5's megapixel level.
func image5ResolutionLevel(resolution string) string {
switch strings.ToUpper(strings.TrimSpace(resolution)) {
case "1K":
return "1MP"
case "2K":
return "4MP"
default:
return "4MP"
}
}
func BuildImagePayloadCandidates(modelID, prompt, aspectRatio, outputResolution string, blobIDs []string) []map[string]any {
spec := ResolveModelSpec(modelID)
ratio := defaultString(aspectRatio, "1:1")
resolution := defaultString(outputResolution, "2K")
switch spec.UpstreamModelID {
case "gpt-image":
return buildGPTImagePayloads(spec, prompt, ratio, resolution, blobIDs)
case "flux":
return buildFluxPayloads(spec, prompt, ratio, blobIDs)
default:
return buildDefaultPayloads(spec, prompt, ratio, resolution, blobIDs)
}
}
func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
size := getSize(gptImageSize, resolution, ratio, "1:1")
// Mirrors the captured working gpt-image request shape: modelSpecificPayload.size,
// generationSettings.detailLevel 3, and NO top-level size / outputResolution
// (sending those got 403). Keeps the chosen size via modelSpecificPayload.size
// ("WxH") rather than "auto".
base := map[string]any{
"modelId": spec.UpstreamModelID,
"modelVersion": spec.UpstreamModelVersion,
"n": 1,
"prompt": prompt,
"seeds": []int{rand.Intn(999999)},
"output": map[string]any{"storeInputs": true},
"referenceBlobs": []any{},
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
"modelSpecificPayload": map[string]any{"size": sizeString(size)},
"generationSettings": map[string]any{"detailLevel": 3},
}
if len(blobIDs) == 0 {
return []map[string]any{base}
}
subject := cloneMap(base)
subject["referenceBlobs"] = blobRefs(blobIDs, "subject")
return []map[string]any{subject}
}
func buildFluxPayloads(spec modelSpec, prompt, ratio string, blobIDs []string) []map[string]any {
size := fluxSize[ratio]
if size == [2]int{} {
size = fluxSize["1:1"]
}
base := map[string]any{
"modelId": spec.UpstreamModelID,
"modelVersion": spec.UpstreamModelVersion,
"n": 1,
"prompt": prompt,
"size": map[string]any{"width": size[0], "height": size[1]},
"seeds": []int{rand.Intn(999999)},
"output": map[string]any{"storeInputs": true},
"referenceBlobs": []any{},
"modelSpecificPayload": map[string]any{
"prompt_upsampling": true,
"safety_tolerance": 2,
"aspect_ratio": ratio,
},
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
}
if len(blobIDs) == 0 {
return []map[string]any{base}
}
edited := cloneMap(base)
edited["generationMetadata"] = map[string]any{"module": "image2image", "submodule": "ff-image-generate"}
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
return []map[string]any{edited}
}
func buildDefaultPayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
size := getSize(defaultSize, resolution, ratio, "16:9")
// Shape mirrors a captured working firefly.adobe.com request exactly: top-level
// size object, modelSpecificPayload only {parameters:{addWatermark:false}},
// groundSearch:false, module "text2image" (even with a reference blob). NO
// skipCai and NO modelSpecificPayload.aspectRatio — sending those got 403.
base := map[string]any{
"modelId": spec.UpstreamModelID,
"modelVersion": spec.UpstreamModelVersion,
"n": 1,
"prompt": prompt,
"size": map[string]any{"width": size[0], "height": size[1]},
"seeds": []int{rand.Intn(999999)},
"groundSearch": false,
"output": map[string]any{"storeInputs": true},
"generationMetadata": map[string]any{
"module": "text2image",
"submodule": "ff-image-generate",
},
"modelSpecificPayload": map[string]any{
"parameters": map[string]any{"addWatermark": false},
},
}
if len(blobIDs) == 0 {
base["referenceBlobs"] = []any{}
return []map[string]any{base}
}
edited := cloneMap(base)
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
return []map[string]any{edited}
}
func getSize(table map[string]map[string][2]int, resolution, ratio, fallbackRatio string) [2]int {
level := defaultString(resolution, "2K")
levelTable, ok := table[level]
if !ok {
levelTable = table["2K"]
}
size, ok := levelTable[ratio]
if !ok {
size = levelTable[fallbackRatio]
}
return size
}
func sizeString(size [2]int) string {
return itoa(size[0]) + "x" + itoa(size[1])
}
func blobRefs(ids []string, usage string) []any {
out := make([]any, 0, len(ids))
for _, id := range ids {
out = append(out, map[string]any{"id": id, "usage": usage})
}
return out
}
func referenceImagesByID(ids []string) []any {
out := make([]any, 0, len(ids))
for _, id := range ids {
out = append(out, map[string]any{"id": id})
}
return out
}
func referenceImagesByLocal(ids []string) []any {
out := make([]any, 0, len(ids))
for _, id := range ids {
out = append(out, map[string]any{"localBlobRef": id})
}
return out
}
func cloneMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string) map[string]any {
seedVal := rand.Intn(999999)
engine = defaultString(engine, "sora2")
resolution = defaultString(resolution, "720p")
aspectRatio = defaultString(aspectRatio, "16:9")
if durationSeconds <= 0 {
durationSeconds = 5
}
switch engine {
case "firefly-video":
// Firefly-native video model — a distinct schema (mirrors a captured
// working video-v1.ff.adobe.io request): sizes[] carries width/height +
// numFrames (numFrames encodes duration, ~25.6fps so 5s = 128), and
// reference frames go under image.conditions with placement.start
// (0 = first frame / 首帧, 1 = last frame / 末帧). NO modelId / version /
// engine / duration / referenceBlobs fields.
w, h, frames := fireflyVideoSize(aspectRatio, resolution, durationSeconds)
payload := map[string]any{
"addOnTransparentBackground": false,
"prompt": prompt,
"seeds": []int{seedVal},
"sizes": []any{map[string]any{"width": w, "height": h, "numFrames": frames}},
"videoSettings": map[string]any{},
"locale": "en-US",
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
"output": map[string]any{"storeInputs": true},
}
if len(blobIDs) > 0 {
conds := make([]any, 0, 2)
conds = append(conds, map[string]any{
"source": map[string]any{"id": blobIDs[0]},
"placement": map[string]any{"start": 0},
})
if len(blobIDs) > 1 {
conds = append(conds, map[string]any{
"source": map[string]any{"id": blobIDs[1]},
"placement": map[string]any{"start": 1},
})
}
payload["image"] = map[string]any{"conditions": conds}
}
return payload
case "veo31-fast", "veo31-standard":
modelVersion := "3.1-fast-generate"
if engine == "veo31-standard" {
modelVersion = "3.1-generate"
}
payload := map[string]any{
"modelId": "veo",
"modelVersion": modelVersion,
"size": videoSize(aspectRatio, resolution),
"seeds": []int{seedVal},
"prompt": prompt,
"negativePrompt": "",
"duration": durationSeconds,
"generateAudio": false,
"generationMetadata": map[string]any{
"module": "text2video",
"submodule": "ff-video-generate",
},
"output": map[string]any{"storeInputs": true},
"referenceBlobs": []any{},
}
if len(blobIDs) > 0 {
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
refs := make([]any, 0, min(len(blobIDs), 3))
if referenceMode == "frame" {
for idx, id := range blobIDs[:min(len(blobIDs), 3)] {
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
}
} else {
for _, id := range blobIDs[:min(len(blobIDs), 3)] {
refs = append(refs, map[string]any{"id": id, "usage": "asset"})
}
}
payload["referenceBlobs"] = refs
}
return payload
case "seedance-2.0-fast", "seedance-2.0":
modelVersion := "seedance_2.0"
if engine == "seedance-2.0-fast" {
modelVersion = "seedance_2.0_fast"
}
payload := map[string]any{
"modelId": "seedance",
"modelVersion": modelVersion,
"size": videoSize(aspectRatio, resolution),
"seeds": []int{seedVal},
"prompt": prompt,
"negativePrompt": "",
"duration": durationSeconds,
"generateAudio": true,
"generationSettings": map[string]any{"aspectRatio": aspectRatio},
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
"output": map[string]any{"storeInputs": true},
"referenceBlobs": []any{},
}
if len(blobIDs) > 0 {
// Seedance keeps module "text2video" even with reference blobs
// (HAR-confirmed) — unlike veo/luma it does not switch to image2video.
if referenceMode == "frame" {
refs := make([]any, 0, min(len(blobIDs), 2))
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
}
payload["referenceBlobs"] = refs
} else {
refs := make([]any, 0, min(len(blobIDs), 9))
for _, id := range blobIDs[:min(len(blobIDs), 9)] {
refs = append(refs, map[string]any{"id": id, "usage": "style"})
}
payload["referenceBlobs"] = refs
}
}
// Video/audio source refs (seedance)
for _, id := range videoBlobIDs[:min(len(videoBlobIDs), 3)] {
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
}
for _, id := range audioBlobIDs[:min(len(audioBlobIDs), 3)] {
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
}
return payload
case "luma":
payload := map[string]any{
"modelId": "luma",
"modelVersion": "3.14-ray",
"size": lumaVideoSize(aspectRatio, resolution),
"mode": "flex_2",
"prompt": prompt,
"negativePrompt": "",
"duration": durationSeconds,
"generationMetadata": map[string]any{
"module": "text2video",
"submodule": "ff-video-generate",
},
"modelSpecificPayload": map[string]any{
"resolution": strings.ToLower(resolution),
"aspect_ratio": aspectRatio,
},
"output": map[string]any{"storeInputs": true},
}
if len(blobIDs) > 0 {
payload["generationMetadata"] = map[string]any{
"module": "image2video",
"submodule": "ff-video-generate",
}
refs := make([]any, 0, min(len(blobIDs), 2))
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
}
payload["referenceBlobs"] = refs
}
return payload
default:
upstream := defaultString(upstreamModel, "openai:firefly:colligo:sora2")
payload := map[string]any{
"n": 1,
"seeds": []int{seedVal},
"modelId": "sora",
"modelVersion": "sora-2",
"size": videoSize(aspectRatio, resolution),
"duration": durationSeconds,
"fps": 24,
"prompt": buildVideoPromptJSON(prompt, durationSeconds),
"generationMetadata": map[string]any{"module": "text2video"},
"model": upstream,
"generateAudio": true,
"generateLoop": false,
"transparentBackground": false,
"seed": itoa(seedVal),
"locale": "en-US",
"camera": map[string]any{"angle": "none", "shotSize": "none", "motion": nil, "promptStyle": nil},
"negativePrompt": "",
"jobMode": "standard",
"debugGenerationEndpoint": "",
"referenceBlobs": []any{},
"referenceFrames": []any{},
"referenceVideo": nil,
"cameraMotionReferenceVideo": nil,
"characterReference": nil,
"editReferenceVideo": nil,
"output": map[string]any{"storeInputs": true},
}
if len(blobIDs) > 0 {
firstID := blobIDs[0]
payload["generationMetadata"] = map[string]any{"module": "image2video"}
payload["referenceBlobs"] = []any{
map[string]any{"id": firstID, "usage": "general", "promptReference": 1},
}
payload["referenceFrames"] = []any{map[string]any{"localBlobRef": firstID}, nil}
}
return payload
}
}
// fireflyVideoSizeTable maps the firefly-video resolution tier + aspect ratio to
// pixel dimensions. Only 1080p 9:16 (1080x1920) is HAR-confirmed; the rest follow
// the standard 540p/720p/1080p grid for each ratio.
var fireflyVideoSizeTable = map[string]map[string][2]int{
"540p": {"16:9": {960, 540}, "1:1": {540, 540}, "9:16": {540, 960}},
"720p": {"16:9": {1280, 720}, "1:1": {720, 720}, "9:16": {720, 1280}},
"1080p": {"16:9": {1920, 1080}, "1:1": {1080, 1080}, "9:16": {1080, 1920}},
}
// fireflyVideoSize returns width, height and numFrames. numFrames encodes the
// clip length (~25.6fps; 5s = 128 frames, HAR-confirmed).
func fireflyVideoSize(aspectRatio, resolution string, durationSeconds int) (int, int, int) {
table, ok := fireflyVideoSizeTable[strings.ToLower(defaultString(resolution, "1080p"))]
if !ok {
table = fireflyVideoSizeTable["1080p"]
}
wh, ok := table[defaultString(aspectRatio, "9:16")]
if !ok {
wh = table["9:16"]
}
frames := durationSeconds * 128 / 5
if frames <= 0 {
frames = 128
}
return wh[0], wh[1], frames
}
func videoSize(aspectRatio, resolution string) map[string]any {
if strings.EqualFold(resolution, "1080p") {
if aspectRatio == "16:9" {
return map[string]any{"width": 1920, "height": 1080}
}
return map[string]any{"width": 1080, "height": 1920}
}
if aspectRatio == "16:9" {
return map[string]any{"width": 1280, "height": 720}
}
return map[string]any{"width": 720, "height": 1280}
}
func lumaVideoSize(aspectRatio, resolution string) map[string]any {
table, ok := lumaSize[strings.ToLower(defaultString(resolution, "720p"))]
if !ok {
table = lumaSize["720p"]
}
size, ok := table[defaultString(aspectRatio, "16:9")]
if !ok {
size = table["16:9"]
}
return map[string]any{"width": size[0], "height": size[1]}
}
func buildVideoPromptJSON(prompt string, durationSeconds int) string {
payload := map[string]any{
"id": 1,
"duration_sec": durationSeconds,
"prompt_text": prompt,
}
b, _ := json.Marshal(payload)
return string(b)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
-386
View File
@@ -1,386 +0,0 @@
package adobe
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"math/big"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// adobeUserIDPat matches Adobe IMS user IDs embedded in cookies (e.g.
// "4BDA81F069FC6DA40A495FAB@AdobeID").
var adobeUserIDPat = regexp.MustCompile(`[A-Fa-f0-9]{20,}@AdobeID`)
// PID pool: one PID per token, reused across requests for the same account.
var (
arpPIDMu sync.Mutex
arpTokenPID = map[string]int{} // token → pid
arpPIDToken = map[int]string{} // pid → token
)
func extractUserIDFromCookie(cookie string) string {
decoded, err := url.QueryUnescape(cookie)
if err != nil {
decoded = cookie
}
return adobeUserIDPat.FindString(decoded)
}
func stringValue(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
return strings.TrimSpace(strings.ReplaceAll(toJSONScalar(x), "\n", " "))
}
}
func toJSONScalar(v any) string {
b, err := json.Marshal(v)
if err != nil {
return ""
}
return 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 float32:
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 defaultString(v, fallback string) string {
v = strings.TrimSpace(v)
if v == "" {
return fallback
}
return v
}
func itoa(v int) string {
return strconv.Itoa(v)
}
func decodeJWTPayload(token string) map[string]any {
parts := strings.Split(strings.TrimSpace(token), ".")
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
}
// arkHead and arkTail are the site-constant halves of the ark blob, as captured
// from firefly.adobe.com. The leading "<17hex>.<10digits>" pair and the rid
// field sit between them and vary per session, so buildARKBlob randomizes those
// instead of shipping one frozen blob for every account.
const (
arkHead = "|r=ap-southeast-1|meta=3|metabgclr=transparent|metaiconclr=%23757575|guitextcolor=%23000000|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40|sup=1"
arkTail = "|ag=101|cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|surl=https%3A%2F%2Farks-client.adobe.com|smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager"
)
func buildARKBlob() string {
return randomHex(9)[:17] + "." + randomDigits(10) +
arkHead + "|rid=" + strconv.Itoa(randomInt(1, 99)) + arkTail
}
// Per-token stable session identity: a real browser keeps sid / the ftr device
// hex / bfp constant for the whole session, only the ftr timestamp advances.
// Re-randomizing them every call is a bot signal, so they're cached per token
// alongside the pid.
var (
arpIdentMu sync.Mutex
arpIdent = map[string]arpSessionIdent{}
)
type arpSessionIdent struct {
sid string
devHex string
bfp string
arp string // cookie-derived value (sherlockToken), preferred over synthesis
}
// cookieValue extracts one cookie value from a raw cookie blob.
func cookieValue(cookie, name string) string {
decoded, err := url.QueryUnescape(cookie)
if err != nil {
decoded = cookie
}
for _, part := range strings.Split(decoded, ";") {
part = strings.TrimSpace(part)
if k, v, ok := strings.Cut(part, "="); ok && k == name {
return v
}
}
return ""
}
// ARPFromCookie rebuilds x-arp-session-id from a browser cookie blob. The
// sherlockToken cookie IS the arp value verbatim; when it's absent the same
// {sid,ark,ftr,bfp} JSON is reassembled from the component cookies —
// forterToken is the complete ftr (with the real Arkose payload), arkose the
// ark blob, ff_session_guid the sid, bfp the fingerprint id. Returns "" when
// no usable pieces are found.
func ARPFromCookie(cookie string) string {
if st := cookieValue(cookie, "sherlockToken"); st != "" {
return st
}
ftr, ark := cookieValue(cookie, "forterToken"), cookieValue(cookie, "arkose")
if ftr == "" {
ftr = cookieValue(cookie, "forter")
}
if ftr == "" || ark == "" {
return ""
}
payload := map[string]any{"ftr": ftr, "ark": ark}
if v := cookieValue(cookie, "ff_session_guid"); v != "" {
payload["sid"] = v
}
if v := cookieValue(cookie, "bfp"); v != "" {
payload["bfp"] = v
}
b, _ := json.Marshal(payload)
return base64.StdEncoding.EncodeToString(b)
}
// refreshARPTimestamp decodes a cookie-derived arp payload and bumps the ftr
// timestamp field to now before re-encoding. ftr layout is
// <devHex>_<unix_ms>_<pid>_<ver>_31ck_<arkose>_tt — a stale timestamp reads as
// a dead session to the gateway; every other field stays verbatim so the
// Forter/Arkose linkage is preserved. Returns arp unchanged on any shape
// mismatch.
func refreshARPTimestamp(arp string) string {
raw, err := base64.StdEncoding.DecodeString(arp)
if err != nil {
return arp
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return arp
}
ftr, _ := m["ftr"].(string)
parts := strings.Split(ftr, "_")
if len(parts) < 3 {
return arp
}
parts[1] = strconv.FormatInt(time.Now().UnixMilli(), 10)
m["ftr"] = strings.Join(parts, "_")
b, err := json.Marshal(m)
if err != nil {
return arp
}
return base64.StdEncoding.EncodeToString(b)
}
// CacheARP stores a cookie-derived arp value for token so getARPSessionID
// prefers the real (Arkose-bearing) value over the synthesized fallback.
func CacheARP(token, arp string) {
token, arp = strings.TrimSpace(token), strings.TrimSpace(arp)
if token == "" || arp == "" {
return
}
arpIdentMu.Lock()
defer arpIdentMu.Unlock()
id := arpIdent[token]
id.arp = arp
arpIdent[token] = id
}
// buildARPSessionID returns the x-arp-session-id value: base64 of
// {"sid","ark","ftr","bfp"}, shaped to match live firefly.adobe.com traffic:
//
// ark: <17hex>.<10digits>|…|rid=<n>|…
// ftr: <32hex>_<unix_ms>_<pid>_UDF43-mnf-a4_31ck__tt
//
// pid, sid, the ftr device hex and bfp stay stable per token — a real browser
// keeps them for the whole session and re-randomizing is a bot signal.
//
// The Arkose segment is left empty on purpose. In a real browser that slot
// carries a "<b64>=-<4digits>-v2" payload emitted by Adobe's Arkose bot-manager
// script; a synthesized one has the right shape but fails Adobe's server-side
// validation, and 3p-images/generate-async rejects it with a misleading
// {"error_code":"timeout_error","message":"system under load"}. Sending the
// slot empty reads as "no Arkose payload yet" and is accepted by the native
// endpoints; 3p models may require the cookie-derived value (see CacheARP).
func buildARPSessionID(token string) string {
ftr := strings.Join([]string{
arpIdentity(token).devHex,
strconv.FormatInt(time.Now().UnixMilli(), 10),
strconv.Itoa(allocPID(token)),
"UDF43-mnf-a4",
"31ck",
"",
"tt",
}, "_")
id := arpIdentity(token)
b, _ := json.Marshal(map[string]any{
"sid": id.sid,
"ark": buildARKBlob(),
"ftr": ftr,
"bfp": id.bfp,
})
return base64.StdEncoding.EncodeToString(b)
}
// arpIdentity returns (creating on first use) the stable per-token identity
// pieces, preferring a cached cookie-derived arp value when one exists.
func arpIdentity(token string) arpSessionIdent {
arpIdentMu.Lock()
defer arpIdentMu.Unlock()
id, ok := arpIdent[token]
if !ok || id.sid == "" {
id.sid = uuid.NewString()
}
if !ok || id.devHex == "" {
id.devHex = randomHex(16)
}
if !ok || id.bfp == "" {
id.bfp = uuid.NewString()
}
arpIdent[token] = id
return id
}
// randomDigits returns n decimal digits with a non-zero leading digit, so the
// rendered value keeps a constant width.
func randomDigits(n int) string {
if n <= 0 {
return ""
}
var b strings.Builder
b.Grow(n)
b.WriteByte(byte('1' + randomInt(0, 8)))
for i := 1; i < n; i++ {
b.WriteByte(byte('0' + randomInt(0, 9)))
}
return b.String()
}
func randomBase64(n int) string {
buf := make([]byte, n)
rand.Read(buf)
return base64.RawURLEncoding.EncodeToString(buf)
}
func allocPID(token string) int {
arpPIDMu.Lock()
defer arpPIDMu.Unlock()
if pid, ok := arpTokenPID[token]; ok {
return pid
}
for {
pid := randomInt(1000, 99999)
if _, used := arpPIDToken[pid]; !used {
arpPIDToken[pid] = token
arpTokenPID[token] = pid
return pid
}
}
}
// ReleasePID releases the PID bound to token so it can be reused by another
// account.
func ReleasePID(token string) {
arpPIDMu.Lock()
defer arpPIDMu.Unlock()
if pid, ok := arpTokenPID[token]; ok {
delete(arpPIDToken, pid)
delete(arpTokenPID, token)
}
}
func randomHex(n int) string {
if n <= 0 {
return ""
}
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
now := time.Now().UnixNano()
for i := range buf {
buf[i] = byte(now >> ((i % 8) * 8))
}
}
return hex.EncodeToString(buf)
}
func randomInt(min, max int) int {
if max <= min {
return min
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
if err != nil {
return min
}
return min + int(n.Int64())
}
func intOrNil(v any) any {
switch x := v.(type) {
case nil:
return nil
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case float32:
return int(x)
case json.Number:
n, err := x.Int64()
if err != nil {
return nil
}
return int(n)
case string:
n, err := strconv.Atoi(strings.TrimSpace(x))
if err != nil {
return nil
}
return n
default:
return nil
}
}
func emptyStringNil(v string) any {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return v
}
File diff suppressed because it is too large Load Diff
-151
View File
@@ -1,151 +0,0 @@
package chatgpt
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"math/rand"
"strconv"
"strings"
"time"
"golang.org/x/crypto/sha3"
)
var (
cores = []int{8, 16, 24, 32}
documentKeys = []string{"__reactContainer$fzelfjyxej8", "_reactListening5dehydibo78", "location"}
screenResolutions = [][2]int{{1920, 1080}, {1440, 900}, {2560, 1440}, {3840, 2160}}
navKeys = []string{
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
"storage−[object StorageManager]",
"locks−[object LockManager]",
"appCodeName−Mozilla",
"permissions−[object Permissions]",
"share−function share() { [native code] }",
"webdriver−false",
"vendor−Google Inc.",
"mediaDevices−[object MediaDevices]",
"cookieEnabled−true",
"onLine−true",
"mimeTypes−[object MimeTypeArray]",
"credentials−[object CredentialsContainer]",
"serviceWorker−[object ServiceWorkerContainer]",
"keyboard−[object Keyboard]",
"gpu−[object GPU]",
"doNotTrack",
"language−zh-CN",
"geolocation−[object Geolocation]",
"hardwareConcurrency−32",
}
winKeys = []string{
"0", "window", "self", "document", "name", "location", "history",
"navigation", "innerWidth", "innerHeight", "screen", "chrome",
"navigator", "performance", "crypto", "indexedDB", "sessionStorage",
"localStorage", "fetch", "matchMedia", "postMessage", "setTimeout",
"caches", "__NEXT_DATA__",
}
)
func buildLegacyRequirementsToken(userAgent string, scriptSources []string, dataBuild string) string {
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
body, _ := json.Marshal(cfg)
return "gAAAAAC" + base64.StdEncoding.EncodeToString(body)
}
func buildProofToken(seed, difficulty, userAgent string, scriptSources []string, dataBuild string) (string, error) {
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
answer, solved := powGenerate(seed, difficulty, cfg, 500000)
if !solved {
return "", errors.New("failed to solve proof token")
}
return "gAAAAAB" + answer, nil
}
func buildPOWConfig(userAgent string, scriptSources []string, dataBuild string) []any {
// scriptSources/dataBuild are no longer part of the sentinel config array
// (the current chatgpt.com client dropped them); kept in the signature for
// call-site compatibility.
_ = scriptSources
_ = dataBuild
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
screen := screenResolutions[rng.Intn(len(screenResolutions))]
loc := time.FixedZone("GMT+0800", 8*3600)
nowLocal := time.Now().In(loc).Format("Mon Jan 02 2006 15:04:05") + " GMT+0800 (中国标准时间)"
perf := float64(time.Now().UnixNano()%1_000_000_000) / 1_000_000
return []any{
screen[0] + screen[1], // [0]
nowLocal, // [1] local time, JS Date.toString() shape
4395630592, // [2]
1, // [3] overwritten by powGenerate counter
userAgent, // [4]
nil, // [5] (was script source; now null)
defaultClientVersion, // [6] oai-client-version, must match header
"zh-CN", // [7] matches oai-language
"zh-CN,en,en-GB,en-US", // [8]
rng.Float64(), // [9] overwritten by powGenerate counter
navKeys[rng.Intn(len(navKeys))],
documentKeys[rng.Intn(len(documentKeys))],
winKeys[rng.Intn(len(winKeys))],
perf, // [13]
newUUID(), // [14]
"", // [15]
cores[rng.Intn(len(cores))], // [16]
float64(timeMillis()) - perf, // [17]
0, 0, 0, 0, 0, 0,
0,
}
}
func powGenerate(seed, difficulty string, cfg []any, limit int) (string, bool) {
target, err := hex.DecodeString(strings.TrimSpace(difficulty))
if err != nil {
return "", false
}
diffLen := len(strings.TrimSpace(difficulty)) / 2
seedBytes := []byte(seed)
head1, _ := json.Marshal(cfg[:3])
head2, _ := json.Marshal(cfg[4:9])
head3, _ := json.Marshal(cfg[10:])
static1 := []byte(string(head1[:len(head1)-1]) + ",")
static2 := []byte("," + string(head2[1:len(head2)-1]) + ",")
static3 := []byte("," + string(head3[1:]))
for i := 0; i < limit; i++ {
finalJSON := append([]byte{}, static1...)
finalJSON = append(finalJSON, []byte(strconvItoa(i))...)
finalJSON = append(finalJSON, static2...)
finalJSON = append(finalJSON, []byte(strconvItoa(i>>1))...)
finalJSON = append(finalJSON, static3...)
encoded := base64.StdEncoding.EncodeToString(finalJSON)
sum := sha3.Sum512(append(seedBytes, []byte(encoded)...))
if bytesCompare(sum[:diffLen], target) <= 0 {
return encoded, true
}
}
fallback := "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + base64.StdEncoding.EncodeToString([]byte(`"`+seed+`"`))
return fallback, false
}
func bytesCompare(a, b []byte) int {
for i := 0; i < len(a) && i < len(b); i++ {
if a[i] < b[i] {
return -1
}
if a[i] > b[i] {
return 1
}
}
if len(a) < len(b) {
return -1
}
if len(a) > len(b) {
return 1
}
return 0
}
func strconvItoa(v int) string {
return strconv.Itoa(v)
}
@@ -1,182 +0,0 @@
package chatgpt
import (
"encoding/base64"
"encoding/json"
"math/rand"
"strings"
"time"
)
type orderedMap struct {
keys []string
values map[string]any
}
func newOrderedMap() *orderedMap {
return &orderedMap{values: map[string]any{}}
}
func (m *orderedMap) add(key string, value any) {
if _, ok := m.values[key]; !ok {
m.keys = append(m.keys, key)
}
m.values[key] = value
}
func solveTurnstileToken(dx, p string) string {
decoded, err := base64.StdEncoding.DecodeString(dx)
if err != nil {
return ""
}
var tokenList [][]any
if err := json.Unmarshal([]byte(xorString(string(decoded), p)), &tokenList); err != nil {
return ""
}
processMap := map[int]any{16: p}
start := time.Now()
result := ""
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
toStr := func(value any) string {
if value == nil {
return "undefined"
}
if s, ok := value.(string); ok {
special := map[string]string{
"window.Math": "[object Math]",
"window.Reflect": "[object Reflect]",
"window.performance": "[object Performance]",
"window.localStorage": "[object Storage]",
"window.Object": "function Object() { [native code] }",
"window.Reflect.set": "function set() { [native code] }",
"window.performance.now": "function () { [native code] }",
"window.Object.create": "function create() { [native code] }",
"window.Object.keys": "function keys() { [native code] }",
"window.Math.random": "function random() { [native code] }",
}
if specialValue, ok := special[s]; ok {
return specialValue
}
return s
}
if list, ok := value.([]string); ok {
return strings.Join(list, ",")
}
return stringValue(value)
}
for _, token := range tokenList {
if len(token) == 0 {
continue
}
op := intValue(token[0])
switch op {
case 2:
if len(token) >= 3 {
processMap[intValue(token[1])] = token[2]
}
case 3:
if len(token) >= 2 {
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
}
case 5:
if len(token) >= 3 {
e := intValue(token[1])
t := intValue(token[2])
cur := processMap[e]
inc := processMap[t]
if list, ok := cur.([]any); ok {
processMap[e] = append(list, inc)
} else if _, ok := cur.(string); ok {
processMap[e] = toStr(cur) + toStr(inc)
} else {
processMap[e] = "NaN"
}
}
case 6, 24:
if len(token) >= 4 {
e := intValue(token[1])
t := toStr(processMap[intValue(token[2])])
n := toStr(processMap[intValue(token[3])])
v := t + "." + n
if op == 6 && v == "window.document.location" {
v = "https://chatgpt.com/"
}
processMap[e] = v
}
case 8:
if len(token) >= 3 {
processMap[intValue(token[1])] = processMap[intValue(token[2])]
}
case 14:
if len(token) >= 3 {
var parsed any
if err := json.Unmarshal([]byte(toStr(processMap[intValue(token[2])])), &parsed); err == nil {
processMap[intValue(token[1])] = parsed
}
}
case 15:
if len(token) >= 3 {
b, _ := json.Marshal(processMap[intValue(token[2])])
processMap[intValue(token[1])] = string(b)
}
case 17:
if len(token) >= 3 {
e := intValue(token[1])
target := toStr(processMap[intValue(token[2])])
switch target {
case "window.performance.now":
processMap[e] = float64(time.Since(start).Nanoseconds())/1e6 + rng.Float64()
case "window.Object.create":
processMap[e] = newOrderedMap()
case "window.Object.keys":
processMap[e] = []string{
"STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V4",
"STATSIG_LOCAL_STORAGE_STABLE_ID",
"client-correlated-secret",
"oai/apps/capExpiresAt",
"oai-did",
"STATSIG_LOCAL_STORAGE_LOGGING_REQUEST",
"UiState.isNavigationCollapsed.1",
}
case "window.Math.random":
processMap[e] = rng.Float64()
}
}
case 18:
if len(token) >= 2 {
raw, err := base64.StdEncoding.DecodeString(toStr(processMap[intValue(token[1])]))
if err == nil {
processMap[intValue(token[1])] = string(raw)
}
}
case 19:
if len(token) >= 2 {
processMap[intValue(token[1])] = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
}
case 20:
if len(token) >= 4 {
if toStr(processMap[intValue(token[1])]) == toStr(processMap[intValue(token[2])]) {
if intValue(token[3]) == 3 && len(token) >= 5 {
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[4])])))
}
}
}
}
}
return result
}
func xorString(text, key string) string {
if key == "" {
return text
}
out := make([]rune, 0, len(text))
keyRunes := []rune(key)
for i, ch := range text {
out = append(out, ch^keyRunes[i%len(keyRunes)])
}
return string(out)
}
-325
View File
@@ -1,325 +0,0 @@
package chatgpt
import (
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
const (
baseURL = "https://chatgpt.com"
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0"
defaultClientVersion = "prod-db390ebea64862bf1899c420a4c736e0cf639747"
defaultClientBuildNumber = "7904904"
defaultPOWScript = "https://chatgpt.com/backend-api/sentinel/sdk.js"
// sseIdleGrace bounds how long startImageGeneration keeps reading the image
// SSE with no activity at all before giving up. ChatGPT's inline image pipeline
// holds the stream open with periodic ": ping" keepalives (~15s apart) while
// the image renders, so this is an *idle* timeout reset on every received line
// (see the watchdog in startImageGeneration): it only fires when the stream is
// genuinely silent, not on the normal render wait.
sseIdleGrace = 45 * time.Second
// pictureV2Command is the "@创建图片" ecosystem mention the ChatGPT web app
// prepends to an image (picture_v2) prompt; the server identifies the image
// command from this mention plus its custom_symbol_offset, so we send it to
// match the browser exactly.
pictureV2Command = "@创建图片"
// pictureV2MentionEnd is the UTF-16 length of pictureV2Command — the
// ecosystemMention custom_symbol_offset endIndex the web app reports.
pictureV2MentionEnd = 5
// submitConnectRetries / submitRetryBackoff bound how many times the image
// submit (the proxied /backend-api/f/conversation POST) is retried on a bare
// connection error (EOF / reset) before surfacing it. The proxy hop
// occasionally drops the connection; a fresh retry usually succeeds without
// failing over to another account.
submitConnectRetries = 2
submitRetryBackoff = 500 * time.Millisecond
)
var (
fileServiceIDPattern = regexp.MustCompile(`file-service://([A-Za-z0-9_-]+)`)
sedimentIDPattern = regexp.MustCompile(`sediment://([A-Za-z0-9_-]+)`)
realImageIDPattern = regexp.MustCompile(`\bfile_00000000[a-f0-9]{24}\b`)
conversationIDRE = regexp.MustCompile(`"conversation_id"\s*:\s*"([^"]+)"`)
scriptSrcRE = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`)
htmlDataBuildRE = regexp.MustCompile(`<html[^>]*data-build="([^"]*)"`)
// asyncMarkers signal that ChatGPT accepted the prompt and switched to the
// async image pipeline (image is delivered later via conversation polling
// rather than inline in the SSE stream). Their presence means "generating —
// keep polling", NOT failure.
asyncMarkers = []string{"image_gen_async", "image_gen_task_id", "trigger_async_ux", "ImageGenToolTemporal", "image_gen_title"}
// contentPolicyMarkers are stable substrings of ChatGPT's content-audit
// refusal message. When one appears in an assistant turn the prompt was
// rejected upstream — no image will ever arrive, so we must fail fast
// instead of polling until timeout.
contentPolicyMarkers = []string{
"this request may violate our content polic",
"this prompt may violate our content polic",
"may violate our content policies",
"i can't help with",
"i can\u2019t help with",
"i cannot help with",
"i can't assist with",
"i can\u2019t assist with",
"i cannot assist with",
"i'm unable to help with",
"i\u2019m unable to help with",
"can't create images",
"can\u2019t create images",
"cannot create images",
"can't generate images",
"can\u2019t generate images",
"cannot generate images",
"unable to create images",
"unable to generate images",
}
)
// containsAsyncMarker reports whether the SSE payload indicates the async image
// pipeline was engaged.
func containsAsyncMarker(text string) bool {
for _, m := range asyncMarkers {
if strings.Contains(text, m) {
return true
}
}
return false
}
// detectContentPolicyRejection reports whether text contains a ChatGPT content
// audit refusal. Matching is case-insensitive for the English variants.
func detectContentPolicyRejection(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
for _, m := range contentPolicyMarkers {
if strings.Contains(text, m) || strings.Contains(lower, m) {
return true
}
}
return false
}
// collectText concatenates every string found under value (recursively) into sb.
func collectText(value any, sb *strings.Builder) {
switch x := value.(type) {
case string:
sb.WriteString(x)
sb.WriteByte('\n')
case map[string]any:
for _, item := range x {
collectText(item, sb)
}
case []any:
for _, item := range x {
collectText(item, sb)
}
}
}
// conversationRejected scans assistant turns of a fetched conversation for a
// content-policy refusal.
func conversationRejected(conversation map[string]any) bool {
mapping, _ := conversation["mapping"].(map[string]any)
for _, rawNode := range mapping {
node, _ := rawNode.(map[string]any)
message, _ := node["message"].(map[string]any)
if message == nil {
continue
}
author, _ := message["author"].(map[string]any)
role := strings.ToLower(strings.TrimSpace(stringValue(author["role"])))
if role != "assistant" {
continue
}
var sb strings.Builder
collectText(message["content"], &sb)
if detectContentPolicyRejection(sb.String()) {
return true
}
}
return false
}
// conversationEndedWithoutImage reports whether the model finished its turn
// with a plain-text answer and never engaged the async image pipeline (no tool
// turn, no image_gen task). ChatGPT localizes audit refusals, so this catches
// rejections in ANY language structurally: a closed text-only turn can never
// produce an image, and polling further only burns the budget.
func conversationEndedWithoutImage(conversation map[string]any) bool {
mapping, _ := conversation["mapping"].(map[string]any)
if len(mapping) == 0 {
return false
}
for _, rawNode := range mapping {
node, _ := rawNode.(map[string]any)
message, _ := node["message"].(map[string]any)
if message == nil {
continue
}
if metadata, _ := message["metadata"].(map[string]any); metadata != nil {
if stringValue(metadata["image_gen_task_id"]) != "" || metadata["image_gen_async"] == true {
return false
}
}
author, _ := message["author"].(map[string]any)
role := strings.ToLower(strings.TrimSpace(stringValue(author["role"])))
if role == "tool" {
return false
}
if role == "assistant" {
if content, _ := message["content"].(map[string]any); content != nil {
// Only tool-invoking content types mean generation is underway.
// Context nodes (model_editable_context, thoughts, …) also appear
// on refused turns and must NOT suppress the detection.
switch stringValue(content["content_type"]) {
case "code", "multimodal_text":
return false
}
}
}
}
for _, rawNode := range mapping {
node, _ := rawNode.(map[string]any)
message, _ := node["message"].(map[string]any)
if message == nil {
continue
}
author, _ := message["author"].(map[string]any)
if strings.ToLower(strings.TrimSpace(stringValue(author["role"]))) != "assistant" {
continue
}
if message["end_turn"] != true || stringValue(message["status"]) != "finished_successfully" {
continue
}
var sb strings.Builder
collectText(message["content"], &sb)
if strings.TrimSpace(sb.String()) != "" {
return true
}
}
return false
}
// isConnResetErr reports whether err is a bare connection-level failure (the
// peer/proxy closed the connection before responding) rather than an HTTP-level
// error. Such failures are safe to retry: no response was received, so no
// conversation was created upstream.
func isConnResetErr(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
for _, m := range []string{"eof", "connection reset", "reset by peer", "broken pipe", "connection refused"} {
if strings.Contains(msg, m) {
return true
}
}
return false
}
func stringValue(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
return fmt.Sprint(v)
}
}
func intValue(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case float32:
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 decodeJWTPayload(token string) map[string]any {
parts := strings.Split(strings.TrimSpace(token), ".")
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 newUUID() string {
return uuid.NewString()
}
func clip(v []byte, n int) string {
s := strings.TrimSpace(string(v))
if len(s) <= n {
return s
}
return s[:n]
}
func parsePOWResources(html string) ([]string, string) {
matches := scriptSrcRE.FindAllStringSubmatch(html, -1)
sources := make([]string, 0, len(matches))
dataBuild := ""
for _, match := range matches {
if len(match) < 2 {
continue
}
src := strings.TrimSpace(match[1])
if src == "" {
continue
}
sources = append(sources, src)
if dataBuild == "" {
if path := dataBuildPathRE.FindString(src); path != "" {
dataBuild = path
}
}
}
if dataBuild == "" {
if match := htmlDataBuildRE.FindStringSubmatch(html); len(match) >= 2 {
dataBuild = strings.TrimSpace(match[1])
}
}
if len(sources) == 0 {
sources = []string{defaultPOWScript}
}
return sources, dataBuild
}
func timeMillis() int64 {
return time.Now().UnixMilli()
}
@@ -1,629 +0,0 @@
// Package creativefabrica implements the Creative Fabrica Studio
// (studio.creativefabrica.com) video-generation upstream.
//
// One-shot accounts: every account's coins are just enough for exactly one
// generation, so a successful render kills the account. The credential is a
// .creativefabrica.com session cookie; a short-lived JWT is minted from it on
// demand via GraphQL /query/userAuth, and every model request authenticates
// with that JWT (the cookie is sent along as a fallback).
package creativefabrica
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
var (
ErrAuth = errors.New("creativefabrica auth failed")
ErrQuotaExhausted = errors.New("creativefabrica quota exhausted")
ErrTemporaryUpstream = errors.New("creativefabrica upstream temporary error")
ErrDeadUpstream = errors.New("creativefabrica upstream fatal error")
ErrRateLimited = errors.New("creativefabrica rate limited")
// ErrPaymentRequired marks an account whose payment intent is in a failed
// state — it can never generate (the studio answers 400 failed_precondition
// "payment required ... COIN_PAYMENT_INTENT_STATUS_FAILED"). It wraps ErrAuth
// so the pool kills the account and fails over instead of burning retries.
ErrPaymentRequired = fmt.Errorf("%w: payment required", ErrAuth)
)
// isPaymentRequired reports whether a non-200 body is the account-level
// "payment required" rejection rather than a request-level parameter error.
//
// Connect unary errors don't always carry the marker in plaintext: the studio
// answers failed_precondition with the real detail base64-protobuf-encoded in
// details[].value ("payment required. payment status: COIN_PAYMENT_INTENT_...").
// Decode those values and scan the decoded bytes, so a failed coin intent still
// kills the account instead of being misread as a request-level 400.
func isPaymentRequired(status int, body string) bool {
if status != 400 {
return false
}
b := strings.ToLower(body)
if strings.Contains(b, "payment required") || strings.Contains(b, "coin_payment_intent") {
return true
}
var env struct {
Details []struct {
Value string `json:"value"`
} `json:"details"`
}
if err := json.Unmarshal([]byte(body), &env); err != nil {
return false
}
for _, d := range env.Details {
raw, err := base64.StdEncoding.DecodeString(d.Value)
if err != nil {
continue
}
low := strings.ToLower(string(raw))
if strings.Contains(low, "payment required") ||
strings.Contains(low, "coin_payment_intent") ||
strings.Contains(low, "coin_error_code_payment_required") {
return true
}
}
return false
}
const (
graphQLHost = "https://graphql-gw.creativefabrica.com"
mediaMatrixHost = "https://studio-media-matrix.creativefabrica.com"
userAuthPath = "/query/userAuth"
userBalancePath = "/query/userBalance"
userPath = "/query/user"
initiatePath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/InitiateSession"
listSessionsPath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/ListSessions"
origin = "https://studio.creativefabrica.com"
pollInterval = 5 * time.Second
pollTimeout = 16 * time.Minute
downloadTimeout = 3 * time.Minute
videoServiceType = "SERVICE_TYPE_VIDEO_GENERATOR"
videoFrameRef = "VIDEO_FRAME_TYPE_REFERENCE"
visibilityPrivate = "SESSION_VISIBILITY_PRIVATE"
)
// Model is one Creative Fabrica video model: the local catalog id, the upstream
// enum, the fixed duration in seconds, and the upstream resolution label.
type Model struct {
ID string // local model_configs id, e.g. "seedance-2.0"
Enum string // upstream enum, e.g. VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2
Duration int // fixed seconds (account plan is fixed-length)
Resolution string // upstream resolution label, e.g. 720p
}
// Models returns the two Creative Fabrica seedance models. The upstream enum
// differs between the two (SEEDANCE_2_FAST vs SEEDDREAM_2), matching the
// studio frontend's InitiateSession payloads.
func Models() map[string]Model {
return map[string]Model{
"seedance-2.0-fast": {
ID: "seedance-2.0-fast",
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDANCE_2_FAST",
Duration: 14,
Resolution: "720p",
},
"seedance-2.0": {
ID: "seedance-2.0",
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2",
Duration: 10,
Resolution: "720p",
},
}
}
// LookupModel resolves a local model id to its upstream config. Returns ok=false
// when the id isn't a Creative Fabrica model.
func LookupModel(modelID string) (Model, bool) {
m, ok := Models()[modelID]
return m, ok
}
// Client talks to the Creative Fabrica Studio API through a Chrome-fingerprinted
// TLS client so the Cloudflare-protected Connect endpoints don't reject us.
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)
}
// ExchangeToken mints the short-lived JWT from the account cookie via GraphQL
// /query/userAuth. Returns the token and the user id. A null / missing me means
// the cookie no longer authenticates → ErrAuth.
func (c *Client) ExchangeToken(ctx context.Context, cookie string) (token, userID string, err error) {
query := `{"query":"\n query userAuth {\n me {\n token\n user {\n id\n isTemporary\n }\n }\n}\n "}`
var payload struct {
Data struct {
Me *struct {
Token string `json:"token"`
User struct {
ID string `json:"id"`
} `json:"user"`
} `json:"me"`
} `json:"data"`
}
body, err := c.postGraphQL(ctx, cookie, "", userAuthPath, query)
if err != nil {
return "", "", err
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", "", fmt.Errorf("%w: bad token response: %s", ErrAuth, clip(body, 300))
}
if payload.Data.Me == nil || strings.TrimSpace(payload.Data.Me.Token) == "" {
return "", "", fmt.Errorf("%w: cookie did not authenticate", ErrAuth)
}
return payload.Data.Me.Token, payload.Data.Me.User.ID, nil
}
// FetchBalance reads the coin balance via GraphQL /query/userBalance (the
// request authenticates with the cookie alone). Negative value on error.
func (c *Client) FetchBalance(ctx context.Context, cookie string) (int64, error) {
query := `{"query":"\nquery userBalance {\n userBalance {\n balance\n }\n}\n\n"}`
var payload struct {
Data struct {
UserBalance *struct {
Balance json.Number `json:"balance"`
} `json:"userBalance"`
} `json:"data"`
}
body, err := c.postGraphQL(ctx, cookie, "", userBalancePath, query)
if err != nil {
return -1, err
}
if err := json.Unmarshal(body, &payload); err != nil {
return -1, fmt.Errorf("bad balance response: %s", clip(body, 300))
}
if payload.Data.UserBalance == nil {
return -1, ErrAuth
}
b, _ := payload.Data.UserBalance.Balance.Int64()
return b, nil
}
// FetchUser reads the profile (email, name) via GraphQL /query/user, which the
// studio browser hits on every page load. The request authenticates with the
// cookie alone. Returns the email (empty on error); a null me means the cookie
// no longer authenticates → ErrAuth.
func (c *Client) FetchUser(ctx context.Context, cookie string) (string, error) {
query := `{"query":"\n query user {\n me {\n token\n user {\n id\n email\n }\n }\n}\n "}`
var payload struct {
Data struct {
Me *struct {
Token string `json:"token"`
User struct {
ID string `json:"id"`
Email string `json:"email"`
} `json:"user"`
} `json:"me"`
} `json:"data"`
}
body, err := c.postGraphQL(ctx, cookie, "", userPath, query)
if err != nil {
return "", err
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", fmt.Errorf("%w: bad user response: %s", ErrAuth, clip(body, 300))
}
if payload.Data.Me == nil || payload.Data.Me.User.ID == "" {
return "", ErrAuth
}
return strings.TrimSpace(payload.Data.Me.User.Email), nil
}
// GenerateVideo runs the full generation: InitiateSession → PUT reference
// images to the presigned S3 URLs → poll ListSessions until COMPLETED → (when
// downloadResult) fetch the MP4. Returns the bytes (nil when url-only) and the
// previewMediaUrl. durationSeconds is ignored: the plan fixes the length per
// model (LookupModel.Duration).
func (c *Client) GenerateVideo(ctx context.Context, cookie, token, modelID, prompt, aspectRatio string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
m, ok := LookupModel(modelID)
if !ok {
return nil, "", fmt.Errorf("creativefabrica: unknown model %q", modelID)
}
sessionID, uploads, err := c.initiateSession(ctx, cookie, token, m, prompt, aspectRatio, refs)
if err != nil {
return nil, "", err
}
// Each ref uploads to the presigned URL the session returned for it.
for i, u := range uploads {
if err := c.putS3(ctx, u, refs[i]); err != nil {
return nil, "", err
}
}
videoURL, err := c.pollSession(ctx, cookie, token, sessionID)
if err != nil {
return nil, "", err
}
if !downloadResult {
return nil, videoURL, nil
}
data, err := c.download(ctx, videoURL)
if err != nil {
return nil, "", err
}
return data, videoURL, nil
}
// initiateSession submits the generation and returns the session id plus the
// presigned upload URLs (one per reference image).
func (c *Client) initiateSession(ctx context.Context, cookie, token string, m Model, prompt, aspectRatio string, refs [][]byte) (string, []string, error) {
frames := make([]any, 0, len(refs))
refPrompt := strings.TrimSpace(prompt)
for i := range refs {
ref := fmt.Sprintf("img%d", i+1)
frames = append(frames, map[string]any{
"type": videoFrameRef,
"fileSize": fmt.Sprintf("%d", len(refs[i])),
"fileName": randomFileName(i + 1),
"ref": ref,
})
if !strings.Contains(refPrompt, "["+ref+"]") {
refPrompt += " Use [" + ref + "]"
}
}
reqBody := map[string]any{
"visibility": visibilityPrivate,
"sessionRequestPromptToVideoGeneratorContent": map[string]any{
"serviceType": videoServiceType,
"promptContent": map[string]any{"prompt": refPrompt},
"resolution": resolutionEnum(m.Resolution),
"model": m.Enum,
"frames": frames,
"aspectRatio": aspectRatioEnum(aspectRatio),
"directorConfig": map[string]any{
"filmStock": map[string]any{"color": "DIRECTOR_FILM_STOCK_COLOR_FULL_COLOR"},
},
"videoDuration": map[string]any{"inSeconds": m.Duration},
},
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", nil, err
}
resp, err := c.postConnect(ctx, cookie, token, initiatePath, body, true)
if err != nil {
return "", nil, err
}
var payload struct {
Session struct {
ID string `json:"id"`
Status string `json:"status"`
// The presigned upload URLs are nested under promptToVideoGeneratorContent.frames
// (each frame echoes the request frame + the S3 presigned `url`), not under a
// top-level session.frames. Reading the wrong level yields 0 uploads.
PromptToVideoGeneratorContent struct {
Frames []struct {
URL string `json:"url"`
} `json:"frames"`
} `json:"promptToVideoGeneratorContent"`
} `json:"session"`
}
if err := json.Unmarshal(resp, &payload); err != nil {
return "", nil, fmt.Errorf("creativefabrica bad initiate response: %s", clip(resp, 300))
}
if strings.TrimSpace(payload.Session.ID) == "" {
return "", nil, fmt.Errorf("creativefabrica initiate missing session: %s", clip(resp, 300))
}
respFrames := payload.Session.PromptToVideoGeneratorContent.Frames
uploads := make([]string, 0, len(respFrames))
for _, f := range respFrames {
if u := strings.TrimSpace(f.URL); u != "" {
uploads = append(uploads, u)
}
}
if len(uploads) != len(refs) {
return "", nil, fmt.Errorf("creativefabrica initiate returned %d upload urls for %d refs", len(uploads), len(refs))
}
return payload.Session.ID, uploads, nil
}
// pollSession polls ListSessions until the session is COMPLETED / FAILED and
// returns previewMediaUrl on success.
func (c *Client) pollSession(ctx context.Context, cookie, token, sessionID string) (string, error) {
reqBody, _ := json.Marshal(map[string]any{
"serviceType": videoServiceType,
"pagination": map[string]any{"take": 100},
"surface": "SURFACE_STUDIO",
})
deadline := time.Now().Add(pollTimeout)
for {
if err := ctx.Err(); err != nil {
return "", err
}
if time.Now().After(deadline) {
return "", fmt.Errorf("creativefabrica generation timed out after %v", pollTimeout)
}
resp, err := c.postConnect(ctx, cookie, token, listSessionsPath, reqBody, false)
if err != nil {
return "", err
}
var payload struct {
Sessions []map[string]any `json:"sessions"`
}
if err := json.Unmarshal(resp, &payload); err != nil {
return "", fmt.Errorf("creativefabrica bad list response: %s", clip(resp, 300))
}
for _, s := range payload.Sessions {
id, _ := s["id"].(string)
if id != sessionID {
continue
}
status, _ := s["status"].(string)
switch status {
case "SESSION_STATUS_COMPLETED":
if u := strings.TrimSpace(stringValue(s["previewMediaUrl"])); u != "" {
return u, nil
}
return "", fmt.Errorf("creativefabrica session completed without preview url")
case "SESSION_STATUS_FAILED", "SESSION_STATUS_CANCELLED", "SESSION_STATUS_ERROR":
detail := ""
for _, k := range []string{"errorMessage", "failureReason", "error", "message"} {
if v, ok := s[k]; ok {
if d := strings.TrimSpace(stringValue(v)); d != "" {
detail = d
break
}
}
}
return "", fmt.Errorf("creativefabrica session %s%s", status, withDetail(detail))
}
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(pollInterval):
}
}
}
// putS3 uploads a reference image to the presigned URL. S3 doesn't care about
// TLS fingerprinting, so a plain client is fine here.
func (c *Client) putS3(ctx context.Context, presigned string, data []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, presigned, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "image/png")
req.Header.Set("Origin", origin)
req.Header.Set("Referer", origin+"/")
req.Header.Set("User-Agent", defaultUA())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("creativefabrica s3 upload: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("creativefabrica s3 upload failed: %d %s", resp.StatusCode, clip(body, 200))
}
return nil
}
// download fetches the finished MP4 from the public video-v2 URL.
func (c *Client) download(parent context.Context, videoURL string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), downloadTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", defaultUA())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: download: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("creativefabrica download failed: %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrTemporaryUpstream, err)
}
return data, nil
}
// postGraphQL calls a graphql-gw endpoint with the raw JSON body. Authenticated
// by cookie (balance) and optionally the JWT too. Direct connection (no proxy).
func (c *Client) postGraphQL(ctx context.Context, cookie, token, path, body string) ([]byte, error) {
return c.postJSON(ctx, graphQLHost+path, token, cookie, []byte(body), false, true, false)
}
// postConnect calls a Connect unary endpoint on the media-matrix service. The
// Connect-Protocol-Version header marks the POST as a Connect RPC (distinct from
// a plain JSON REST POST) — the studio browser always sends it. Only the
// InitiateSession call (下单) egresses through the proxy; polling runs direct.
func (c *Client) postConnect(ctx context.Context, cookie, token, path string, body []byte, useProxy bool) ([]byte, error) {
return c.postJSON(ctx, mediaMatrixHost+path, token, cookie, body, true, false, useProxy)
}
func (c *Client) postJSON(ctx context.Context, url, token, cookie string, body []byte, connect, graphql, useProxy bool) ([]byte, error) {
sess, err := c.newTLSClient(useProxy)
if err != nil {
return nil, err
}
req, err := fhttp.NewRequest(fhttp.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = fhttp.Header{
"content-type": {"application/json"},
"accept": {"*/*"},
"origin": {origin},
"referer": {origin + "/"},
"user-agent": {defaultUA()},
}
if graphql {
req.Header.Set("accept", "application/json, multipart/mixed")
}
if connect {
req.Header.Set("connect-protocol-version", "1")
}
if cookie != "" {
req.Header.Set("cookie", cookie)
}
if token != "" {
req.Header.Set("authorization", "Bearer "+token)
}
resp, err := sess.client.Do(req)
if err != nil {
return nil, fmt.Errorf("creativefabrica request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
switch {
case resp.StatusCode == 401 || resp.StatusCode == 403:
return nil, fmt.Errorf("%w (%d: %s)", ErrAuth, resp.StatusCode, clip(data, 300))
case isPaymentRequired(resp.StatusCode, string(data)):
return nil, fmt.Errorf("%w (%d: %s)", ErrPaymentRequired, resp.StatusCode, clip(data, 300))
case resp.StatusCode == 429:
return nil, fmt.Errorf("%w (429)", ErrRateLimited)
case resp.StatusCode >= 500:
return nil, fmt.Errorf("%w (%d: %s)", ErrDeadUpstream, resp.StatusCode, clip(data, 300))
case resp.StatusCode != 200:
return nil, fmt.Errorf("creativefabrica %d: %s", resp.StatusCode, clip(data, 300))
}
return data, nil
}
func resolutionEnum(res string) string {
switch strings.ToLower(strings.TrimSpace(res)) {
case "720p":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
case "1080p":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_1080P"
default:
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
}
}
func aspectRatioEnum(ratio string) string {
switch strings.ReplaceAll(strings.TrimSpace(ratio), " ", "") {
case "16:9":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
case "9:16":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_9_16"
case "1:1":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_1_1"
case "4:3":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_4_3"
case "3:4":
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_3_4"
default:
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
}
}
// randomFileName mimics the studio's client-generated reference name
// ("_<base36-ish id>_<n>.png"). The value only needs to be unique per upload.
func randomFileName(n int) string {
return "_" + randomID() + "_" + fmt.Sprintf("%d", n) + ".png"
}
func randomID() string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 26)
for i := range b {
v, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
b[i] = alphabet[v.Int64()]
}
return string(b)
}
// TLS client plumbing: a Chrome-fingerprinted client so the Cloudflare front of
// the Connect endpoints sees a plausible browser handshake.
var fingerprints = []profiles.ClientProfile{
profiles.Chrome_146,
profiles.Chrome_144,
profiles.Chrome_133,
profiles.Chrome_131,
}
type tlsSession struct {
client tlsclient.HttpClient
}
func (c *Client) newTLSClient(useProxy bool) (*tlsSession, error) {
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(fingerprints))))
options := []tlsclient.HttpClientOption{
tlsclient.WithTimeoutSeconds(60),
tlsclient.WithClientProfile(fingerprints[idx.Int64()]),
tlsclient.WithNotFollowRedirects(),
tlsclient.WithRandomTLSExtensionOrder(),
}
if useProxy && c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
if err != nil {
return nil, err
}
return &tlsSession{client: client}, nil
}
func defaultUA() string {
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
}
func clip(v []byte, n int) string {
s := strings.TrimSpace(string(v))
if len(s) <= n {
return s
}
return s[:n]
}
// stringValue coerces a decoded JSON value to its string form.
func stringValue(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return fmt.Sprintf("%.0f", x)
case json.Number:
return x.String()
case bool:
if x {
return "true"
}
return "false"
case nil:
return ""
default:
return fmt.Sprintf("%v", x)
}
}
// withDetail appends a failure detail to an error message when present.
func withDetail(detail string) string {
if detail == "" {
return ""
}
return " (" + detail + ")"
}
@@ -1,27 +0,0 @@
package creativefabrica
import (
"context"
"os"
"testing"
"time"
)
func TestBalance(t *testing.T) {
cookie := os.Getenv("CF_COOKIE")
if cookie == "" {
t.Skip("CF_COOKIE not set")
}
c := NewClient("")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
tok, uid, err := c.ExchangeToken(ctx, cookie)
if err != nil {
t.Fatalf("exchange: %v", err)
}
bal, err := c.FetchBalance(ctx, cookie)
if err != nil {
t.Fatalf("balance: %v", err)
}
t.Logf("user=%s token=%d bal=%d", uid, len(tok), bal)
}
-721
View File
@@ -1,721 +0,0 @@
// Package custom implements a generic OpenAI-compatible upstream client. A
// "custom" model forwards generation to any OpenAI-compatible API: the upstream
// base_url + api_key live on a custom account (pool="custom"), the upstream model
// name on the model config (UpstreamModel). Calls go DIRECT (no tls-client, no
// proxy) — the upstream is a normal API with no anti-bot.
package custom
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
var (
ErrAuth = errors.New("custom upstream auth failed")
ErrQuotaExhausted = errors.New("custom upstream quota exhausted")
ErrTemporaryUpstream = errors.New("custom upstream temporary error")
)
type Client struct{}
func NewClient() *Client { return &Client{} }
// sanitizeErr strips the upstream URL/host from a network error so a user's
// private upstream URL never leaks into the event log / API response.
func sanitizeErr(err error) string {
if err == nil {
return ""
}
s := err.Error()
switch {
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
return "request timeout"
case strings.Contains(s, "connection refused"):
return "connection refused"
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
return "cannot reach upstream"
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
return "TLS error"
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
return "connection reset"
}
var ue *url.Error
if errors.As(err, &ue) {
return strings.ToLower(ue.Op) + " upstream failed"
}
return "upstream request failed"
}
func httpClient() *http.Client { return &http.Client{Timeout: 30 * time.Minute} }
// GenerateImage calls the upstream OpenAI image API. With reference images it
// uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns
// the raw image bytes (decoded from b64_json, or downloaded from url).
func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prompt, size, quality string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" || apiKey == "" {
return nil, "", ErrAuth
}
var req *http.Request
var err error
if len(refs) > 0 {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
_ = w.WriteField("model", model)
_ = w.WriteField("prompt", prompt)
// Ask the upstream for a URL (not base64) so we can pass it through directly.
_ = w.WriteField("response_format", "url")
if size != "" {
_ = w.WriteField("size", size)
}
for i, r := range refs {
fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1))
if e != nil {
return nil, "", e
}
_, _ = fw.Write(r)
}
_ = w.Close()
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body)
if err != nil {
return nil, "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
} else {
payload := map[string]any{"model": model, "prompt": prompt, "n": 1, "response_format": "url"}
if size != "" {
payload["size"] = size
}
if quality != "" {
payload["quality"] = quality
}
raw, _ := json.Marshal(payload)
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw))
if err != nil {
return nil, "", err
}
req.Header.Set("Content-Type", "application/json")
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient().Do(req)
if err != nil {
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if e := mapStatus(resp.StatusCode, body); e != nil {
return nil, "", e
}
return imageFromResponse(ctx, body, downloadResult)
}
// ── Vivid 协议 ──────────────────────────────────────────────────────────────
// Vivid 上游用自家 API:POST /v1/generate 返回 application/x-ndjson 流 —— 首行
// {"jobId":...,"status":"running"},等待期间每 25s 一个空行心跳,末行为终态
// (succeeded 带 result 预签名 URL / failed 带 error)。参考素材走两步:
// POST /v1/uploads {kind,mime} → PUT 直传预签名 URL → 拿 key 放进 refs。
// 断流可携 jobId 重发重连。错误体为 {"error":{"code":"4011|4001|4002|4293|5001"}}。
// GenerateVividImage submits an image job to a Vivid-protocol upstream.
// quality is the tier string the upstream expects ("1K"/"2K"/"4K"), ratio the
// "W:H" aspect. refs are pre-uploaded via /v1/uploads into key strings.
func (c *Client) GenerateVividImage(ctx context.Context, baseURL, apiKey, model, prompt, ratio, quality string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
payload, err := c.vividPayload(ctx, baseURL, apiKey, model, prompt, ratio, quality, 0, refs)
if err != nil {
return nil, "", err
}
return c.vividGenerate(ctx, baseURL, apiKey, payload, downloadResult)
}
// GenerateVividVideo submits a video job (duration required by ranged models)
// to a Vivid-protocol upstream.
func (c *Client) GenerateVividVideo(ctx context.Context, baseURL, apiKey, model, prompt, ratio, quality string, durationSeconds int, refs [][]byte, downloadResult bool) ([]byte, string, error) {
payload, err := c.vividPayload(ctx, baseURL, apiKey, model, prompt, ratio, quality, durationSeconds, refs)
if err != nil {
return nil, "", err
}
return c.vividGenerate(ctx, baseURL, apiKey, payload, downloadResult)
}
func (c *Client) vividPayload(ctx context.Context, baseURL, apiKey, model, prompt, ratio, quality string, durationSeconds int, refs [][]byte) (map[string]any, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" || apiKey == "" {
return nil, ErrAuth
}
payload := map[string]any{"model": model, "prompt": prompt}
if quality != "" {
payload["quality"] = quality
}
if ratio != "" {
payload["ratio"] = ratio
}
if durationSeconds > 0 {
payload["duration"] = durationSeconds
}
if len(refs) > 0 {
keys := make([]string, 0, len(refs))
for _, ref := range refs {
key, err := c.vividUploadRef(ctx, baseURL, apiKey, ref)
if err != nil {
return nil, err
}
keys = append(keys, key)
}
payload["refs"] = keys
}
return payload, nil
}
// vividUploadRef runs the two-step upload: POST /v1/uploads {kind,mime} → PUT
// the bytes to the presigned url → returns the key for refs[]. kind is image /
// video / audio, derived from the file magic (same classes the upstream
// accepts: png/jpeg/webp · mp4/webm/mov · mp3/wav/m4a/ogg).
func (c *Client) vividUploadRef(ctx context.Context, baseURL, apiKey string, data []byte) (string, error) {
kind, mime := vividRefKindMime(data)
raw, _ := json.Marshal(map[string]any{"kind": kind, "mime": mime})
up, err := c.doJSON(ctx, http.MethodPost, baseURL+"/v1/uploads", apiKey, raw)
if err != nil {
return "", err
}
putURL, key := vividUploadSession(up)
if putURL == "" || key == "" {
raw, _ := json.Marshal(up)
return "", fmt.Errorf("%w: upload session missing url/key (got %s)", ErrTemporaryUpstream, clip(raw, 200))
}
req, err := http.NewRequest(http.MethodPut, putURL, bytes.NewReader(data))
if err != nil {
return "", err
}
req = req.WithContext(ctx)
req.Header.Set("Content-Type", mime) // must match the signed mime exactly
resp, err := httpClient().Do(req)
if err != nil {
return "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%w: ref upload %d %s", ErrTemporaryUpstream, resp.StatusCode, clip(body, 120))
}
return key, nil
}
// vividUploadSession extracts the presigned PUT url + object key from a
// /v1/uploads response. Vivid-protocol implementations vary: some return the
// pair at top level, others wrap it in {"data":{...}} / {"result":{...}}, and
// field spellings differ (upload_url / presigned_url / put_url; key /
// file_key / object_key). Search top level first, then one layer deep.
func vividUploadSession(up map[string]any) (putURL, key string) {
urlKeys := []string{"url", "upload_url", "uploadUrl", "presigned_url", "presignedUrl", "put_url", "putUrl", "signed_url", "signedUrl"}
keyKeys := []string{"key", "file_key", "fileKey", "object_key", "objectKey", "upload_key", "uploadKey"}
pick := func(m map[string]any) (string, string) {
var u, k string
for _, f := range urlKeys {
if u = strings.TrimSpace(stringValue(m[f])); u != "" {
break
}
}
for _, f := range keyKeys {
if k = strings.TrimSpace(stringValue(m[f])); k != "" {
break
}
}
return u, k
}
levels := []map[string]any{up}
for _, nest := range []string{"data", "result", "upload", "session", "upload_session"} {
if sub, ok := up[nest].(map[string]any); ok {
levels = append(levels, sub)
}
}
// url 和 key 允许跨层出现(比如 url 在顶层、key 在 data 里)。
for _, m := range levels {
u, k := pick(m)
if putURL == "" {
putURL = u
}
if key == "" {
key = k
}
}
return putURL, key
}
// vividRefKindMime classifies a reference blob into the vivid "kind" (image /
// video / audio) plus a concrete mime. Magic bytes first (DetectContentType
// misses mov/m4a/ogg-audio), then sniffing, then a png default.
func vividRefKindMime(data []byte) (kind, mime string) {
n := len(data)
// ISOBMFF box: ftyp — mp4/mov/m4a share the container; the brand decides.
if n >= 12 && string(data[4:8]) == "ftyp" {
brand := string(data[8:12])
switch {
case strings.Contains(brand, "M4A"), strings.Contains(brand, "M4B"):
return "audio", "audio/mp4"
case strings.Contains(brand, "qt"):
return "video", "video/quicktime"
default:
return "video", "video/mp4"
}
}
// WebM/MKV EBML
if n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3 {
return "video", "video/webm"
}
// MP3: ID3 tag or MPEG sync
if n >= 3 && string(data[:3]) == "ID3" {
return "audio", "audio/mpeg"
}
if n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 {
return "audio", "audio/mpeg"
}
// WAV / OGG / FLAC
if n >= 4 && string(data[:4]) == "RIFF" {
return "audio", "audio/wav"
}
if n >= 4 && string(data[:4]) == "OggS" {
return "audio", "audio/ogg"
}
if n >= 4 && string(data[:4]) == "fLaC" {
return "audio", "audio/x-flac"
}
// Sniffed type (images land here; also catches anything odd above missed).
if m := http.DetectContentType(data); m != "" && m != "application/octet-stream" {
switch {
case strings.HasPrefix(m, "video/"):
return "video", m
case strings.HasPrefix(m, "audio/"), m == "application/ogg":
return "audio", m
case strings.HasPrefix(m, "image/"):
return "image", m
}
}
return "image", "image/png"
}
// vividGenerate POSTs /v1/generate and reads the NDJSON stream to the terminal
// frame. If the stream drops before terminal, it reconnects once with the
// jobId (same-request resume — no new task, no double charge).
func (c *Client) vividGenerate(ctx context.Context, baseURL, apiKey string, payload map[string]any, downloadResult bool) ([]byte, string, error) {
var last map[string]any
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
if sleepCtx(ctx, 2*time.Second) != nil {
return nil, "", ctx.Err()
}
}
term, err := c.vividStreamOnce(ctx, baseURL, apiKey, payload)
if err == nil && term != nil {
last = term
break
}
if err != nil && !errors.Is(err, ErrTemporaryUpstream) {
return nil, "", err
}
// Temporary error mid-stream: if we hold a jobId, reconnect mode retries
// without creating a new task.
if job := strings.TrimSpace(stringValue(payload["jobId"])); job != "" {
payload = map[string]any{"jobId": job}
continue
}
if attempt == 2 {
return nil, "", err
}
}
if last == nil {
return nil, "", fmt.Errorf("%w: no terminal frame", ErrTemporaryUpstream)
}
status := strings.ToLower(strings.TrimSpace(stringValue(last["status"])))
switch status {
case "succeeded", "completed", "success":
resultURL := strings.TrimSpace(stringValue(last["result"]))
if resultURL == "" {
return nil, "", fmt.Errorf("%w: succeeded frame missing result", ErrTemporaryUpstream)
}
if !downloadResult {
return nil, resultURL, nil
}
data, err := c.vividDownload(ctx, resultURL)
if err != nil {
return nil, "", err
}
return data, resultURL, nil
default:
reason := strings.TrimSpace(stringValue(last["error"]))
if reason == "" {
reason = "upstream job " + status
}
if isCreditError(reason) || strings.Contains(reason, "积分不足") || strings.Contains(reason, "余额") {
return nil, "", fmt.Errorf("%w: %s", ErrQuotaExhausted, clip([]byte(reason), 160))
}
return nil, "", errors.New("custom: " + clip([]byte(reason), 160))
}
}
// vividStreamOnce performs one /v1/generate request and scans the NDJSON body
// to the last frame. Returns the terminal frame, or a temporary error on
// transport failure (caller's reconnect logic decides what to do with jobId).
func (c *Client) vividStreamOnce(ctx context.Context, baseURL, apiKey string, payload map[string]any) (map[string]any, error) {
raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, baseURL+"/v1/generate", bytes.NewReader(raw))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Wait-Seconds", "7200")
resp, err := httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return nil, vividError(resp.StatusCode, body)
}
var last map[string]any
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := bytes.TrimSpace(sc.Bytes())
if len(line) == 0 {
continue // 25s heartbeat empty line
}
var frame map[string]any
if json.Unmarshal(line, &frame) != nil {
continue
}
last = frame
// Remember the jobId for potential reconnect.
if job := strings.TrimSpace(stringValue(frame["jobId"])); job != "" {
payload["jobId"] = job
}
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
return last, nil
}
// vividError maps the {"error":{"code":"NNNN"}} envelope to our error classes.
func vividError(status int, body []byte) error {
var e struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
code := ""
msg := clip(body, 160)
if json.Unmarshal(body, &e) == nil {
code = strings.TrimSpace(e.Error.Code)
if m := strings.TrimSpace(e.Error.Message); m != "" {
msg = clip([]byte(m), 160)
}
}
switch code {
case "4011":
return fmt.Errorf("%w: %s", ErrAuth, msg)
case "4002":
return fmt.Errorf("%w: %s", ErrQuotaExhausted, msg)
case "4293", "5001":
return fmt.Errorf("%w: %s", ErrTemporaryUpstream, msg)
case "4001":
return errors.New("custom: " + msg) // 参数/业务拒绝 → 永久错,不换号
}
return mapStatus(status, body)
}
// vividDownload fetches the presigned result URL — no auth header (it's signed).
func (c *Client) vividDownload(ctx context.Context, url string) ([]byte, error) {
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()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: result download %d", ErrTemporaryUpstream, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("%w: empty result", ErrTemporaryUpstream)
}
return data, nil
}
// GenerateVideo drives the upstream Sora-style async video API:
// POST /v1/videos → poll GET /v1/videos/{id} → GET /v1/videos/{id}/content.
// Reference frames (image-to-video / first-last frames) are sent as multipart
// input_reference[] files, matching the OpenAI videos API. When downloadResult
// is false it returns the upstream content URL instead.
func (c *Client) GenerateVideo(ctx context.Context, baseURL, apiKey, model, prompt, size string, seconds int, frames [][]byte, downloadResult bool) ([]byte, string, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" || apiKey == "" {
return nil, "", ErrAuth
}
var created map[string]any
var err error
if len(frames) > 0 {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
_ = w.WriteField("model", model)
_ = w.WriteField("prompt", prompt)
if size != "" {
_ = w.WriteField("size", size)
}
if seconds > 0 {
_ = w.WriteField("seconds", fmt.Sprintf("%d", seconds))
}
for i, f := range frames {
fw, e := w.CreateFormFile("input_reference[]", fmt.Sprintf("frame_%d.png", i+1))
if e != nil {
return nil, "", e
}
_, _ = fw.Write(f)
}
_ = w.Close()
created, err = c.doMultipart(ctx, baseURL+"/v1/videos", apiKey, body, w.FormDataContentType())
} else {
payload := map[string]any{"model": model, "prompt": prompt}
if size != "" {
payload["size"] = size
}
if seconds > 0 {
payload["seconds"] = fmt.Sprintf("%d", seconds)
}
raw, _ := json.Marshal(payload)
created, err = c.doJSON(ctx, http.MethodPost, baseURL+"/v1/videos", apiKey, raw)
}
if err != nil {
return nil, "", err
}
jobID := strings.TrimSpace(stringValue(created["id"]))
if jobID == "" {
return nil, "", fmt.Errorf("%w: video create missing id", ErrTemporaryUpstream)
}
// Poll until terminal.
for {
if err := ctx.Err(); err != nil {
return nil, "", err
}
job, err := c.doJSON(ctx, http.MethodGet, baseURL+"/v1/videos/"+jobID, apiKey, nil)
if err != nil {
if errors.Is(err, ErrTemporaryUpstream) {
if sleepCtx(ctx, 5*time.Second) != nil {
return nil, "", ctx.Err()
}
continue
}
return nil, "", err
}
switch strings.ToLower(strings.TrimSpace(stringValue(job["status"]))) {
case "completed", "succeeded", "success":
contentURL := baseURL + "/v1/videos/" + jobID + "/content"
if !downloadResult {
return nil, contentURL, nil
}
data, err := c.download(ctx, contentURL, apiKey)
if err != nil {
return nil, "", err
}
return data, contentURL, nil
case "failed", "error", "canceled", "cancelled":
reason := stringValue(job["error"])
if isCreditError(reason) {
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip([]byte(reason), 160))
}
return nil, "", fmt.Errorf("custom: video %s", clip([]byte(reason), 160))
}
if sleepCtx(ctx, 5*time.Second) != nil {
return nil, "", ctx.Err()
}
}
}
func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, body []byte) (map[string]any, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if e := mapStatus(resp.StatusCode, raw); e != nil {
return nil, e
}
var out map[string]any
if len(raw) == 0 {
return map[string]any{}, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
}
return out, nil
}
func (c *Client) doMultipart(ctx context.Context, url, apiKey string, body io.Reader, contentType string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodPost, url, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", contentType)
resp, err := httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if e := mapStatus(resp.StatusCode, raw); e != nil {
return nil, e
}
var out map[string]any
if len(raw) == 0 {
return map[string]any{}, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
}
return out, nil
}
func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, error) {
req, _ := http.NewRequest(http.MethodGet, url, nil)
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("%w: empty download", ErrTemporaryUpstream)
}
return data, nil
}
// imageBytesFromResponse extracts image bytes from an OpenAI images response:
// data[0].b64_json (preferred) or data[0].url (downloaded).
// imageFromResponse parses an OpenAI image response and returns the upstream URL.
// We always request response_format=url, so the response must carry a URL — a
// base64-only response is treated as an error (no base64 pass-through). With
// downloadResult=false the URL is returned directly (no download).
func imageFromResponse(ctx context.Context, body []byte, downloadResult bool) ([]byte, string, error) {
var out struct {
Data []struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil || len(out.Data) == 0 {
return nil, "", fmt.Errorf("%w: bad image response: %s", ErrTemporaryUpstream, clip(body, 160))
}
url := strings.TrimSpace(out.Data[0].URL)
if url == "" {
return nil, "", fmt.Errorf("%w: image response had no url (upstream ignored response_format=url)", ErrTemporaryUpstream)
}
if !downloadResult {
return nil, url, nil
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := httpClient().Do(req)
if err != nil {
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
return raw, url, err
}
func mapStatus(status int, body []byte) error {
switch {
case status >= 200 && status < 300:
return nil
case status == 401 || status == 403:
return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160))
case status == 429:
// Custom upstreams have NO "quota exhausted" lock — a 429 is just rate
// limiting, treated as a temporary error (fail over, account stays active).
return fmt.Errorf("%w: 429 %s", ErrTemporaryUpstream, clip(body, 160))
case status >= 500:
return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160))
default:
if isCreditError(string(body)) {
return fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip(body, 160))
}
return fmt.Errorf("custom: %d %s", status, clip(body, 160))
}
}
func isCreditError(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "insufficient") || strings.Contains(s, "quota") ||
strings.Contains(s, "credit") || strings.Contains(s, "balance")
}
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 clip(b []byte, n int) string {
s := strings.TrimSpace(string(b))
if len(s) > n {
return s[:n]
}
return s
}
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
@@ -1,229 +0,0 @@
package custom
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// vividServer serves /v1/uploads, /v1/generate (ndjson) and a fake presigned
// result URL, all under one httptest server.
func vividServer(t *testing.T, frames []string, httpStatus int, errCode string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/uploads":
var req struct {
Kind string `json:"kind"`
Mime string `json:"mime"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Kind != "image" || req.Mime == "" {
t.Errorf("bad upload request: %+v", req)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"uuid":"u1","key":"u/refs/image/u1.png","url":"%s","expiresAt":9999999999}`,
// presigned URL points back at this server's /put endpoint
"http://"+r.Host+"/put")
case "/put":
if r.Method != http.MethodPut {
t.Errorf("expected PUT, got %s", r.Method)
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
t.Errorf("bad content-type %q", ct)
}
io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusOK)
case "/v1/generate":
if httpStatus != 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus)
fmt.Fprintf(w, `{"error":{"code":%q,"message":"boom"}}`, errCode)
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
for _, f := range frames {
fmt.Fprintln(w, f)
fmt.Fprintln(w) // heartbeat line
}
case "/result.png":
w.Write([]byte("PNGDATA"))
default:
http.NotFound(w, r)
}
}))
}
func TestVividGenerateImageSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/generate":
w.Header().Set("Content-Type", "application/x-ndjson")
fmt.Fprintln(w, `{"jobId":"j1","status":"running"}`)
fmt.Fprintln(w) // heartbeat
fmt.Fprintf(w, `{"jobId":"j1","status":"succeeded","result":"http://%s/result.png","creditsCharged":10}`+"\n", r.Host)
case "/result.png":
w.Write([]byte("PNGDATA"))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
data, url, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", nil, true)
if err != nil {
t.Fatalf("generate: %v", err)
}
if string(data) != "PNGDATA" {
t.Fatalf("data=%q", data)
}
if url == "" {
t.Fatal("empty url")
}
}
func TestVividGenerateImageURLPassthrough(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
fmt.Fprintln(w, `{"jobId":"j1","status":"running"}`)
fmt.Fprintln(w, `{"jobId":"j1","status":"succeeded","result":"https://fs.example.com/x.png"}`)
}))
defer srv.Close()
data, url, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", nil, false)
if err != nil {
t.Fatalf("generate: %v", err)
}
if data != nil {
t.Fatalf("expected no bytes, got %d", len(data))
}
if url != "https://fs.example.com/x.png" {
t.Fatalf("url=%q", url)
}
}
func TestVividGenerateFailedFrame(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
fmt.Fprintln(w, `{"jobId":"j1","status":"running"}`)
fmt.Fprintln(w, `{"jobId":"j1","status":"failed","error":"积分不足","refunded":true}`)
}))
defer srv.Close()
_, _, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", nil, false)
if !errors.Is(err, ErrQuotaExhausted) {
t.Fatalf("want quota, got %v", err)
}
}
func TestVividErrorCodes(t *testing.T) {
cases := []struct {
status int
code string
want error
}{
{401, "4011", ErrAuth},
{402, "4002", ErrQuotaExhausted},
{429, "4293", ErrTemporaryUpstream},
{500, "5001", ErrTemporaryUpstream},
}
for _, tc := range cases {
srv := vividServer(t, nil, tc.status, tc.code)
_, _, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", nil, false)
srv.Close()
if !errors.Is(err, tc.want) {
t.Fatalf("code %s: want %v, got %v", tc.code, tc.want, err)
}
}
}
func TestVividRefKindMime(t *testing.T) {
cases := []struct {
name string
data []byte
kind string
}{
{"png", []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4}, "image"},
{"jpeg", []byte{0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3, 4, 5, 6}, "image"},
{"mp4", []byte{0, 0, 0, 0, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, "video"},
{"mov", []byte{0, 0, 0, 0, 'f', 't', 'y', 'p', 'q', 't', ' ', ' '}, "video"},
{"webm", []byte{0x1A, 0x45, 0xDF, 0xA3, 1, 2, 3, 4}, "video"},
{"mp3-id3", []byte{'I', 'D', '3', 4, 0, 0, 1, 2}, "audio"},
{"mp3-sync", []byte{0xFF, 0xFB, 1, 2, 3, 4}, "audio"},
{"wav", []byte{'R', 'I', 'F', 'F', 1, 2, 3, 4, 'W', 'A', 'V', 'E'}, "audio"},
{"ogg", []byte{'O', 'g', 'g', 'S', 1, 2, 3, 4}, "audio"},
{"m4a", []byte{0, 0, 0, 0, 'f', 't', 'y', 'p', 'M', '4', 'A', ' '}, "audio"},
{"empty", []byte{}, "image"},
}
for _, tc := range cases {
kind, mime := vividRefKindMime(tc.data)
if kind != tc.kind {
t.Errorf("%s: kind=%s mime=%s", tc.name, kind, mime)
}
}
}
func TestVividUploadRefKindVideo(t *testing.T) {
var gotKind, gotMime string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/uploads" {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
gotKind, _ = req["kind"].(string)
gotMime, _ = req["mime"].(string)
fmt.Fprintf(w, `{"key":"k1","url":"http://%s/up"}`, r.Host)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
webm := []byte{0x1A, 0x45, 0xDF, 0xA3, 1, 2, 3, 4, 5, 6, 7, 8}
key, err := NewClient().vividUploadRef(context.Background(), srv.URL, "vk", webm)
if err != nil {
t.Fatalf("upload: %v", err)
}
if key != "k1" || gotKind != "video" || gotMime != "video/webm" {
t.Fatalf("key=%q kind=%q mime=%q", key, gotKind, gotMime)
}
}
func TestVividUploadRefFlow(t *testing.T) {
var putOK bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/uploads":
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"key":"k123","url":"http://%s/up"}`, r.Host)
case "/up":
if r.Method == http.MethodPut {
putOK = true
}
w.WriteHeader(http.StatusOK)
case "/v1/generate":
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
refs, _ := req["refs"].([]any)
if len(refs) != 1 || refs[0] != "k123" {
t.Errorf("refs=%v", req["refs"])
}
w.Header().Set("Content-Type", "application/x-ndjson")
fmt.Fprintln(w, `{"jobId":"j1","status":"succeeded","result":"https://x/1.png"}`)
}
}))
defer srv.Close()
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // fake png magic
_, url, err := NewClient().GenerateVividImage(context.Background(), srv.URL, "vk_test", "m1", "hi", "1:1", "1K", [][]byte{png}, false)
if err != nil {
t.Fatalf("generate: %v", err)
}
if !putOK {
t.Fatal("PUT never happened")
}
if url == "" {
t.Fatal("no url")
}
}
-935
View File
@@ -1,935 +0,0 @@
// Package grok implements the Grok (grok.com / xAI) provider client. Auth is the
// website "sso" session cookie (a JWT whose only claim is a session_id — no exp,
// no refresh: when the session dies upstream the account is simply dead, never
// renewed). grok.com gates requests with an x-statsig-id header; its value
// is a 70-byte anti-bot record — header[49] | counter_le32 |
// sha256("METHOD!path!counter"+suffix)[:16] | trailer — XOR-masked with one
// random byte and base64-encoded; we reproduce it exactly in statsigID (the
// build-specific header/suffix/trailer are env-overridable when grok rotates
// them). Uses tls-client so the JA3/JA4 fingerprint matches Chrome.
package grok
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"math/rand/v2"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
"github.com/google/uuid"
)
const (
apiBase = "https://grok.com"
origin = "https://grok.com"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
// fullCredits is the weekly grant — UI shows "100 满额".
fullCredits = 100
)
var (
ErrAuth = errors.New("grok auth failed")
ErrQuotaExhausted = errors.New("grok quota exhausted")
ErrTemporaryUpstream = errors.New("grok 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)
}
// IsGrokToken reports whether a JWT looks like a Grok website "sso" cookie: a
// payload whose ONLY claim is "session_id". That disambiguates it from a runway
// token (id + sso claims) or a chatgpt token (openai.com claims).
func IsGrokToken(token string) bool {
claims := decodeJWTPayload(token)
if len(claims) == 0 {
return false
}
if _, ok := claims["session_id"]; !ok {
return false
}
// Reject tokens that ALSO carry other-provider markers.
for k := range claims {
if k == "session_id" {
continue
}
if k == "sso" || k == "id" || strings.HasPrefix(k, "https://api.openai.com/") {
return false
}
}
return true
}
// SessionIDFromToken returns the sso session id (for dedup / display).
func SessionIDFromToken(token string) string {
return strings.TrimSpace(stringValue(decodeJWTPayload(token)["session_id"]))
}
// ExtractAccountInfo returns the free (no-network) account view. grok sso has no
// email/exp claim, so identity falls back to the session id.
func ExtractAccountInfo(token string) map[string]any {
sid := SessionIDFromToken(token)
return map[string]any{
"email": emptyStringNil(sid),
"session_id": emptyStringNil(sid),
"expires_at": nil,
}
}
// FetchCreditsBalance reads the account's live credit balance via the billing
// gRPC-web endpoint GetGrokCreditsConfig (empty request). The response carries
// the remaining credits (field 1, a float32) and the weekly reset timestamp
// (field 5). A 401/403 maps to ErrAuth (the session is dead). Returns the
// normalized map the TokenService quota plumbing expects.
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return unknownBalance("empty token"), nil
}
client, err := c.newDirectTLSClient()
if err != nil {
return nil, err
}
c.ensureChallenge(ctx, client, token)
// gRPC-web empty message frame: 1-byte flag + 4-byte length (both zero).
body := []byte{0, 0, 0, 0, 0}
req, err := http.NewRequest(http.MethodPost, apiBase+"/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, map[string]string{
"content-type": "application/grpc-web+proto",
"x-grpc-web": "1",
"accept": "application/grpc-web+proto",
})
resp, err := client.Do(req)
if err != nil {
return unknownBalance("network: " + err.Error()), nil
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, ErrAuth
}
if resp.StatusCode != 200 {
return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(raw, 160))), nil
}
// GetGrokCreditsConfig field #1 is the credits USED this period (not remaining):
// an exhausted account reads 100, a fresh one reads ~0. Remaining = 100 - used.
// Field #5 carries the credits' own reset timestamp (weekly grant refill) —
// this is the 恢复时间 we surface, NOT the subscription's billing-period end.
used, resetUnix, ok := parseCreditsConfig(raw)
if !ok {
return unknownBalance("unparsable credits config"), nil
}
if used < 0 {
used = 0
}
if used > fullCredits {
used = fullCredits
}
remaining := fullCredits - used
// 恢复时间: the credits-config weekly reset (when the free grant refills).
reset := strings.TrimSpace(resetUnix)
return map[string]any{
"remaining": remaining,
"used": used,
"total": fullCredits,
"reset_after": emptyStringNil(reset),
"unknown": false,
"error": nil,
}, nil
}
// Subscription is the membership view parsed from GET /rest/subscriptions.
type Subscription struct {
Member bool // a subscription with status ACTIVE exists (INACTIVE entries don't count)
Tier string // e.g. SUBSCRIPTION_TIER_GROK_PRO ("" for free)
Status string // e.g. SUBSCRIPTION_STATUS_ACTIVE
BillingPeriodEnd string // RFC3339; when the plan renews / credits reset
FreeTrial bool // currently in a free-trial offer
}
// FetchSubscription reads GET /rest/subscriptions and reports the account's
// membership. Member is true only when an entry with SUBSCRIPTION_STATUS_ACTIVE
// exists: a lapsed membership keeps its entry but flips to
// SUBSCRIPTION_STATUS_INACTIVE, and an empty array means never subscribed —
// both read as Member=false (the entry's tier/status are still surfaced).
// A 401/403 maps to ErrAuth; other transport/HTTP errors are returned so callers
// can treat them as best-effort (they already have the credit balance).
func (c *Client) FetchSubscription(ctx context.Context, token string) (*Subscription, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, ErrAuth
}
client, err := c.newDirectTLSClient()
if err != nil {
return nil, err
}
c.ensureChallenge(ctx, client, token)
req, err := http.NewRequest(http.MethodGet, apiBase+"/rest/subscriptions", nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, nil)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, ErrAuth
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%w: subscriptions http %d", ErrTemporaryUpstream, resp.StatusCode)
}
var body struct {
Subscriptions []struct {
Tier string `json:"tier"`
Status string `json:"status"`
BillingPeriodEnd string `json:"billingPeriodEnd"`
ActiveOffer struct {
FreeTrial *struct {
TrialDays int `json:"trialDays"`
} `json:"freeTrial"`
} `json:"activeOffer"`
} `json:"subscriptions"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return nil, fmt.Errorf("%w: subscriptions non-json", ErrTemporaryUpstream)
}
out := &Subscription{}
// Surface the ACTIVE subscription if any (falling back to the first entry
// for tier/status info), but only an ACTIVE one sets Member.
for i, s := range body.Subscriptions {
active := strings.EqualFold(s.Status, "SUBSCRIPTION_STATUS_ACTIVE")
if i == 0 || active {
out.Member = active
out.Tier = strings.TrimSpace(s.Tier)
out.Status = strings.TrimSpace(s.Status)
out.BillingPeriodEnd = strings.TrimSpace(s.BillingPeriodEnd)
out.FreeTrial = s.ActiveOffer.FreeTrial != nil
if active {
break
}
}
}
return out, nil
}
// FetchSession reads the account profile via GET /api/auth/session and returns
// (email, userID). A 401/403 means the sso session is dead → ErrAuth.
func (c *Client) FetchSession(ctx context.Context, token string) (email, userID string, err error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return "", "", ErrAuth
}
client, err := c.newDirectTLSClient()
if err != nil {
return "", "", err
}
c.ensureChallenge(ctx, client, token)
req, err := http.NewRequest(http.MethodGet, apiBase+"/api/auth/session", nil)
if err != nil {
return "", "", err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, nil)
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return "", "", ErrAuth
}
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("%w: session http %d", ErrTemporaryUpstream, resp.StatusCode)
}
var body struct {
Status string `json:"status"`
Session struct {
Email string `json:"email"`
UserID string `json:"userId"`
} `json:"session"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return "", "", fmt.Errorf("%w: session non-json", ErrTemporaryUpstream)
}
// A dead sso cookie answers 200 {"status":"unauthenticated"} rather than 401.
if !strings.EqualFold(body.Status, "authenticated") {
return "", "", ErrAuth
}
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
}
// grok's x-statsig-id is a per-session anti-bot token. Its 3-byte "F" tail is a
// browser fingerprint the server recomputes from the homepage seed + curve set,
// and the byte-indexing that derives it ROTATES on every grok web reship — so any
// hand-ported algorithm goes stale within a day (403 anti-bot). The durable path
// therefore runs grok's OWN signer in goja (statsig_engine.go): we fetch the seed
// + curves from the homepage and let grok's code do all the (rotating) indexing.
// statsigID prefers that engine; the hand-ported computeStatsigTail below and the
// static env-overridable defaults are only a last-resort fallback. See the package
// doc and statsig_engine.go for the full picture.
// statsigEpoch is the challenge epoch (2023-05-01 00:00 UTC).
const (
statsigEpoch = 1682924400
defaultStatsigHeader = "00a1adb5012bd32f844f4426c62680d91c6129361eb9459a759710e179a888a99b21678e1f0b1e8952de6a6b3ca019f74b"
defaultStatsigSuffix = "obfiowerehiringd244100f5c28f5c28f5c047ae147ae147b047ae147ae147b0f5c28f5c28f5c00"
defaultStatsigTrailer = 3
statsigSaltPrefix = "obfiowerehiring"
statsigAnimDuration = 4096
statsigTTL = 5 * time.Minute
)
var (
statsigHeader = resolveStatsigHeader()
statsigSuffix = envOr("GROK_STATSIG_SUFFIX", defaultStatsigSuffix)
statsigTrailer = resolveStatsigTrailer()
statsigMetaRe = regexp.MustCompile(`name="grok[^"]*verification"[^>]*content="([^"]+)"`)
statsigMu sync.Mutex
statsigCache = map[string]statsigChallenge{} // keyed by sso token
)
// statsigChallenge is a resolved, self-consistent (header, salt) pair for one
// grok session, derived from the homepage seed + curves.
type statsigChallenge struct {
header []byte
suffix string
trailer byte
fetchedAt time.Time
// Inputs for the durable goja signer (statsig_js.go): the raw <meta> seed
// content and the curves JSON. Empty when parsing failed (engine then skipped).
seedB64 string
curvesJSON string
}
// statsigCurve is one entry of the per-load curve set injected via the Next.js
// RSC stream; the server uses it (with the seed) to recompute F.
type statsigCurve struct {
Color []int `json:"color"`
Deg int `json:"deg"`
Bezier []int `json:"bezier"`
}
func resolveStatsigHeader() []byte {
h := envOr("GROK_STATSIG_HEADER_HEX", defaultStatsigHeader)
b, err := hex.DecodeString(h)
if err != nil || len(b) != 49 {
b, _ = hex.DecodeString(defaultStatsigHeader)
}
return b
}
func resolveStatsigTrailer() byte {
if v := strings.TrimSpace(os.Getenv("GROK_STATSIG_TRAILER")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 255 {
return byte(n)
}
}
return defaultStatsigTrailer
}
func envOr(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
// ensureChallenge refreshes the cached (header, salt) for the session if missing
// or stale. Any failure is non-fatal: statsigID then falls back to the static
// defaults. An explicit env override disables dynamic fetching entirely.
func (c *Client) ensureChallenge(ctx context.Context, client tlsclient.HttpClient, token string) {
if token == "" || client == nil {
return
}
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
return
}
statsigMu.Lock()
cur, ok := statsigCache[token]
fresh := ok && time.Since(cur.fetchedAt) < statsigTTL
statsigMu.Unlock()
if fresh {
return
}
ch, err := fetchStatsigChallenge(ctx, client)
if err != nil {
// Silent fallback to static defaults is the #1 cause of a recurring
// "403 anti-bot": the homepage structure changed and we never notice.
// Surface it so the failure mode (fetch/parse broke vs. offsets rotated)
// is diagnosable from logs instead of guessing.
log.Printf("grok statsig: self-heal failed, using stale static defaults (403 likely): %v", err)
return
}
log.Printf("grok statsig: self-heal ok header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
statsigMu.Lock()
statsigCache[token] = ch
statsigMu.Unlock()
}
// fetchStatsigChallenge does a browser-free homepage GET and derives a
// self-consistent (header, salt) pair: header = 0x00 + seed, salt = prefix + F.
//
// The request is ANONYMOUS (no sso cookie): grok redirects a request carrying an
// sso cookie to the sign-in page, whose HTML omits the anti-bot seed <meta> and
// the curves array — so a cookied fetch silently loses the challenge inputs and
// statsigID falls back to the stale static recipe (403 anti-bot). The seed/curves
// are per-build and account-independent (the seed is transmitted inside the
// x-statsig-id header the server re-verifies), so the anonymous landing page is
// the reliable source for every session.
func fetchStatsigChallenge(ctx context.Context, client tlsclient.HttpClient) (statsigChallenge, error) {
req, err := http.NewRequest(http.MethodGet, apiBase+"/", nil)
if err != nil {
return statsigChallenge{}, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
"accept-language": {"en-US,en;q=0.9"},
"user-agent": {userAgent},
http.HeaderOrderKey: {"accept", "accept-language", "user-agent"},
}
resp, err := client.Do(req)
if err != nil {
return statsigChallenge{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return statsigChallenge{}, err
}
html := string(raw)
mm := statsigMetaRe.FindStringSubmatch(html)
if mm == nil {
return statsigChallenge{}, errors.New("statsig: seed meta not found")
}
curves, err := parseStatsigCurves(html)
if err != nil {
return statsigChallenge{}, err
}
curvesJSON, err := json.Marshal(curves)
if err != nil {
return statsigChallenge{}, err
}
// Primary path is the goja signer (statsig_js.go); set it up / refresh it for
// this build. Static (header, salt) below is only a last-resort fallback.
ensureEngine(ctx, client, html)
ch := statsigChallenge{
header: statsigHeader,
suffix: statsigSuffix,
trailer: statsigTrailer,
fetchedAt: time.Now(),
seedB64: mm[1],
curvesJSON: string(curvesJSON),
}
// NOTE: the old hand-ported per-session derivation (0x00+seed header +
// computeStatsigTail salt) is intentionally NOT applied here. Verified
// 2026-07-13 against /rest/app-chat/conversations/new: that dynamic token is
// rejected ("Request rejected by anti-bot rules.", 403), while the static
// (header, salt) defaults are accepted (200) — so we keep the static values
// as the fallback. When the goja signer (ensureEngine above) locates and
// verifies grok's own chunk it still takes over via seedB64/curvesJSON in
// statsigID; computeStatsigTail is retained only for reference/tests.
return ch, nil
}
func decodeStatsigSeed(s string) ([]byte, error) {
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 48 {
return b, nil
}
b, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "="))
if err != nil {
return nil, fmt.Errorf("statsig: seed decode: %w", err)
}
if len(b) != 48 {
return nil, fmt.Errorf("statsig: seed len %d", len(b))
}
return b, nil
}
// parseStatsigCurves extracts the [[{color,deg,bezier}...]...] array from the
// RSC-escaped homepage HTML (the one immediately followed by color/bezier keys).
func parseStatsigCurves(html string) ([][]statsigCurve, error) {
marker := -1
for from := 0; ; {
i := strings.Index(html[from:], "curves")
if i < 0 {
break
}
i += from
end := i + 160
if end > len(html) {
end = len(html)
}
w := html[i:end]
if strings.Contains(w, "color") && strings.Contains(w, "bezier") {
marker = i
break
}
from = i + 6
}
if marker < 0 {
return nil, errors.New("statsig: curves not found")
}
rel := strings.IndexByte(html[marker:], '[')
if rel < 0 {
return nil, errors.New("statsig: curves array start not found")
}
start := marker + rel
depth, stop := 0, -1
for k := start; k < len(html); k++ {
switch html[k] {
case '[':
depth++
case ']':
depth--
if depth == 0 {
stop = k + 1
}
}
if stop > 0 {
break
}
}
if stop < 0 {
return nil, errors.New("statsig: curves array end not found")
}
sub := strings.ReplaceAll(html[start:stop], `\`, "")
var out [][]statsigCurve
if err := json.Unmarshal([]byte(sub), &out); err != nil {
return nil, fmt.Errorf("statsig: curves json: %w", err)
}
return out, nil
}
// computeStatsigTail reproduces the browser's 3-byte color + 6-number transform
// matrix "F" tail: it selects a curve by the seed, samples the curve's keyframe
// animation (color lerp + rotate) at a seed-derived paused currentTime, and
// serializes getComputedStyle(color)+getComputedStyle(transform) exactly as the
// signer does (each number -> Number(v.toFixed(2)).toString(16), '.'/'-' stripped).
func computeStatsigTail(seed []byte, curves [][]statsigCurve) (string, error) {
if len(seed) < 46 {
return "", errors.New("statsig: short seed")
}
if len(curves) == 0 {
return "", errors.New("statsig: no curves")
}
group := int(seed[5]) % len(curves)
if len(curves[group]) == 0 {
return "", errors.New("statsig: empty curve group")
}
idx := int(seed[45]) % len(curves[group])
cv := curves[group][idx]
if len(cv.Color) < 6 || len(cv.Bezier) < 4 {
return "", errors.New("statsig: malformed curve")
}
n := (int(seed[1]) % 16) * (int(seed[6]) % 16) * (int(seed[13]) % 16)
currentTime := jsRound(float64(n)/10) * 10
progress := float64(currentTime) / statsigAnimDuration
x1 := toFixed2(float64(cv.Bezier[0]) / 255)
y1 := toFixed2(float64(cv.Bezier[1])*2/255 - 1)
x2 := toFixed2(float64(cv.Bezier[2]) / 255)
y2 := toFixed2(float64(cv.Bezier[3])*2/255 - 1)
eased := cubicBezierEase(x1, y1, x2, y2, progress)
nums := make([]float64, 0, 9)
for k := 0; k < 3; k++ {
v := jsRound(float64(cv.Color[k]) + (float64(cv.Color[k+3])-float64(cv.Color[k]))*eased)
if v < 0 {
v = 0
}
if v > 255 {
v = 255
}
nums = append(nums, float64(v))
}
theta := int(math.Floor(float64(cv.Deg)*300/255 + 60))
rad := float64(theta) * eased * math.Pi / 180
cos, sin := math.Cos(rad), math.Sin(rad)
nums = append(nums, cos, sin, -sin, cos, 0, 0)
var b strings.Builder
for _, v := range nums {
b.WriteString(jsHex(v))
}
out := strings.NewReplacer(".", "", "-", "").Replace(b.String())
return out, nil
}
// jsRound matches JavaScript Math.round (round half up toward +Inf).
func jsRound(x float64) int {
return int(math.Floor(x + 0.5))
}
// toFixed2 matches JavaScript Number(v.toFixed(2)).
func toFixed2(v float64) float64 {
f, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'f', 2, 64), 64)
return f
}
// jsHex matches JavaScript Number(v.toFixed(2)).toString(16).
func jsHex(v float64) string {
v = toFixed2(v)
neg := ""
if v < 0 {
neg = "-"
v = -v
}
ip := int64(math.Floor(v))
frac := v - float64(ip)
s := neg + strconv.FormatInt(ip, 16)
if frac == 0 {
return s
}
const digits = "0123456789abcdef"
var b strings.Builder
b.WriteString(s)
b.WriteByte('.')
for i := 0; i < 20 && frac != 0; i++ {
frac *= 16
d := int(frac)
b.WriteByte(digits[d])
frac -= float64(d)
}
return b.String()
}
// cubicBezierEase evaluates a CSS cubic-bezier(x1,y1,x2,y2) easing at input
// fraction p: solve X(t)=p for t (bisection), then return Y(t).
func cubicBezierEase(x1, y1, x2, y2, p float64) float64 {
bez := func(t, a, b float64) float64 {
mt := 1 - t
return 3*a*mt*mt*t + 3*b*mt*t*t + t*t*t
}
lo, hi := 0.0, 1.0
for i := 0; i < 100; i++ {
mid := (lo + hi) / 2
if bez(mid, x1, x2) < p {
lo = mid
} else {
hi = mid
}
}
return bez((lo+hi)/2, y1, y2)
}
// statsigID reproduces grok.com's x-statsig-id anti-bot token for a request. The
// token binds to the request METHOD and URL path and to a coarse timestamp, so
// it must be regenerated per request. See the package doc for the layout. It uses
// the session's self-healed (header, salt) when available, else static defaults.
func statsigID(path, method, token string) string {
header, suffix, trailer := statsigHeader, statsigSuffix, statsigTrailer
statsigMu.Lock()
ch, ok := statsigCache[token]
statsigMu.Unlock()
if ok {
header, suffix, trailer = ch.header, ch.suffix, ch.trailer
// Primary: run grok's own signer in goja (durable across reships). Falls
// through to the static computation below on any failure.
if ch.seedB64 != "" && ch.curvesJSON != "" {
if id, err := signWithEngine(ch.seedB64, ch.curvesJSON, path, method); err == nil {
return id
} else if !errors.Is(err, errEngineNotReady) {
log.Printf("grok statsig: js signer failed, using static fallback: %v", err)
}
}
}
counter := uint32(time.Now().Unix() - statsigEpoch)
sig := fmt.Sprintf("%s!%s!%d%s", method, path, counter, suffix)
hash := sha256.Sum256([]byte(sig))
raw := make([]byte, 0, 70)
raw = append(raw, header...)
raw = binary.LittleEndian.AppendUint32(raw, counter)
raw = append(raw, hash[:16]...)
raw = append(raw, trailer)
key := byte(rand.IntN(256))
for i := range raw {
raw[i] ^= key
}
return base64.RawStdEncoding.EncodeToString(raw)
}
// applyHeaders sets the browser-like header set + sso cookie + spoofed statsig id.
// extra overrides/adds per-request headers (e.g. content-type).
func (c *Client) applyHeaders(req *http.Request, token string, extra map[string]string) {
h := http.Header{
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"content-type": {"application/json"},
"origin": {origin},
"referer": {origin + "/"},
"user-agent": {userAgent},
"x-statsig-id": {statsigID(req.URL.Path, req.Method, token)},
"x-xai-request-id": {uuid.NewString()},
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
"sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {`"Windows"`},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
}
for k, v := range extra {
h[k] = []string{v}
}
h[http.HeaderOrderKey] = []string{
"accept", "accept-language", "content-type", "origin", "referer",
"user-agent", "x-statsig-id", "x-xai-request-id", "x-grpc-web",
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
}
req.Header = h
}
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
// reference-frame upload and result (video) download; only the generate submit
// (/rest/app-chat/conversations/new) uses the proxy.
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
options := []tlsclient.HttpClientOption{
// Video generation streams inline until progress=100; a 15s clip can take
// several minutes, so allow up to 10m (caller's genCtx caps at 12m).
tlsclient.WithTimeoutSeconds(600),
tlsclient.WithClientProfile(profiles.Chrome_133),
tlsclient.WithRandomTLSExtensionOrder(),
}
if useProxy && c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
}
// --- gRPC-web / protobuf decoding for GetGrokCreditsConfig ---
// parseCreditsConfig extracts (remaining credits, reset RFC3339-ish unix string)
// from the gRPC-web framed protobuf. Layout (reverse-engineered):
//
// frame: 1-byte flag + 4-byte big-endian length + payload
// payload: field 1 (message) {
// field 1: float32 -> remaining credits
// field 5: message { field 1: varint -> reset unix seconds }
// }
func parseCreditsConfig(buf []byte) (remaining int, resetUnix string, ok bool) {
for len(buf) >= 5 {
flag := buf[0]
ln := int(buf[1])<<24 | int(buf[2])<<16 | int(buf[3])<<8 | int(buf[4])
buf = buf[5:]
if ln > len(buf) {
break
}
payload := buf[:ln]
buf = buf[ln:]
if flag&0x80 != 0 { // trailer frame (grpc-status), skip
continue
}
// payload: expect field 1 (wire type 2) wrapping the config message.
fn, wt, val, rest, good := readField(payload)
if !good || fn != 1 || wt != 2 {
continue
}
rem, reset, found := scanConfigMessage(val)
_ = rest
if found {
return rem, reset, true
}
}
return 0, "", false
}
func scanConfigMessage(msg []byte) (used int, resetUnix string, ok bool) {
var usedF float32
seen := false
for len(msg) > 0 {
fn, wt, val, rest, good := readField(msg)
if !good {
break
}
msg = rest
seen = true
switch {
case fn == 1 && wt == 5: // float32 credits USED this period
usedF = float32FromLE(val)
case fn == 5 && wt == 2: // reset timestamp message { #1 varint=seconds }
if sec, sok := firstVarint(val); sok {
resetUnix = strconv.FormatInt(sec, 10)
}
}
}
// A valid config message may OMIT field #1 when used == 0 (proto3 drops zero
// scalars) — a full-quota account. So as long as the message had any field,
// treat it as parsed with used defaulting to 0 (= 100 remaining).
return int(usedF), resetUnix, seen
}
// readField reads one protobuf field: returns (fieldNum, wireType, value, rest, ok).
// For wt 2 value is the length-delimited bytes; wt 5 the 4 LE bytes; wt 0 the
// raw varint bytes; wt 1 the 8 bytes.
func readField(b []byte) (fn int, wt int, val []byte, rest []byte, ok bool) {
tag, n := readVarint(b)
if n == 0 {
return 0, 0, nil, b, false
}
b = b[n:]
fn = int(tag >> 3)
wt = int(tag & 7)
switch wt {
case 0:
_, m := readVarint(b)
if m == 0 {
return 0, 0, nil, b, false
}
return fn, wt, b[:m], b[m:], true
case 1:
if len(b) < 8 {
return 0, 0, nil, b, false
}
return fn, wt, b[:8], b[8:], true
case 2:
ln, m := readVarint(b)
if m == 0 || int(ln) > len(b)-m {
return 0, 0, nil, b, false
}
return fn, wt, b[m : m+int(ln)], b[m+int(ln):], true
case 5:
if len(b) < 4 {
return 0, 0, nil, b, false
}
return fn, wt, b[:4], b[4:], true
default:
return 0, 0, nil, b, false
}
}
func firstVarint(b []byte) (int64, bool) {
fn, wt, val, _, ok := readField(b)
if !ok || fn != 1 || wt != 0 {
return 0, false
}
v, _ := readVarint(val)
return int64(v), true
}
func readVarint(b []byte) (uint64, int) {
var v uint64
var s uint
for i := 0; i < len(b); i++ {
v |= uint64(b[i]&0x7f) << s
if b[i]&0x80 == 0 {
return v, i + 1
}
s += 7
}
return 0, 0
}
func float32FromLE(b []byte) float32 {
if len(b) < 4 {
return 0
}
bits := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
return math.Float32frombits(bits)
}
// --- small helpers (mirror the other provider clients) ---
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 emptyStringNil(v string) any {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return v
}
func unknownBalance(reason string) map[string]any {
return map[string]any{
"remaining": nil, "used": nil, "total": nil,
"unknown": true, "error": reason,
}
}
func clip(b []byte, n int) string {
s := strings.TrimSpace(string(b))
if len(s) > n {
return s[:n]
}
return s
}
-609
View File
@@ -1,609 +0,0 @@
package grok
// Grok Console (console.x.ai) is xAI's own product console. It serves the same
// grok-imagine media models as grok.com, but over a plain JSON API that is NOT
// anti-bot gated: no x-statsig-id, no media-post + streaming-conversation dance.
// Auth reuses the very same website "sso" cookie — POST /v1/dpop/token exchanges
// it for a short-lived access token bound to a locally generated P-256 key, and
// every call then carries "Authorization: DPoP <token>" plus a per-request ES256
// DPoP proof (RFC 9449) over (method, url, token hash).
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strings"
"sync"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/google/uuid"
)
const (
consoleBase = "https://console.x.ai"
consoleOrigin = "https://console.x.ai"
// Upstream model ids of the Console media catalog. The -quality image model
// is what the reference-image (edit) path uses.
ConsoleVideoModel = "grok-imagine-video"
ConsoleImageModel = "grok-imagine-image"
ConsoleImageQualityModel = "grok-imagine-image-quality"
// consoleTokenSkew refreshes a DPoP token slightly before it expires.
consoleTokenSkew = 20 * time.Second
// consoleMaxEditImages is the upstream cap for image-to-image references
// (video takes a single 首图).
consoleMaxEditImages = 3
consoleVideoPollEvery = 2 * time.Second
consoleVideoDeadline = 30 * time.Minute
)
// consoleSession is one minted DPoP token plus the key it is bound to. skew is
// (server time − local time) learned from the mint response Date header; the
// proof iat is shifted by it so a drifting host clock doesn't fail every proof.
type consoleSession struct {
accessToken string
key *ecdsa.PrivateKey
jwk consoleJWK
expiresAt time.Time
skew time.Duration
}
type consoleJWK struct {
Crv string `json:"crv"`
Kty string `json:"kty"`
X string `json:"x"`
Y string `json:"y"`
}
var (
consoleMu sync.Mutex
consoleSessions = map[string]consoleSession{} // keyed by sso token hash
)
// GenerateConsoleVideo runs Console's video pipeline: POST /v1/videos/generations
// returns a request_id, GET /v1/videos/{id} is polled until the clip is rendered
// and reports its vidgen.x.ai URL. frames is optional (image-to-video, one 首图
// max) and is inlined as a data URL — Console takes the image in the request, so
// there is no separate upload step. When downloadResult is false, returns nil
// bytes and the artifact URL in meta["video_url"]; otherwise downloads the mp4.
func (c *Client) GenerateConsoleVideo(ctx context.Context, token, prompt, aspectRatio, resolution string, seconds int, frames [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, nil, ErrAuth
}
prompt = strings.TrimSpace(prompt)
if prompt == "" && len(frames) == 0 {
return nil, nil, fmt.Errorf("grok console: prompt required")
}
if seconds < 1 || seconds > 15 {
seconds = 10
}
submitClient, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
directClient, err := c.newDirectTLSClient()
if err != nil {
return nil, nil, err
}
payload := map[string]any{"model": ConsoleVideoModel, "duration": seconds}
if prompt != "" {
payload["prompt"] = prompt
}
if ratio := strings.TrimSpace(aspectRatio); ratio != "" {
payload["aspect_ratio"] = ratio
}
if res := consoleVideoResolution(resolution); res != "" {
payload["resolution"] = res
}
for _, f := range frames {
if len(f) == 0 {
continue
}
payload["image"] = map[string]any{"url": dataURL(f)}
break // 上游只吃 1 张首图
}
created, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, "/v1/videos/generations", payload)
if err != nil {
return nil, nil, err
}
requestID := strings.TrimSpace(stringValue(created["request_id"]))
if requestID == "" {
return nil, nil, fmt.Errorf("%w: video create missing request_id", ErrTemporaryUpstream)
}
deadline := time.Now().Add(consoleVideoDeadline)
videoURL := ""
for {
status, pollErr := c.consoleJSON(ctx, submitClient, token, http.MethodGet, "/v1/videos/"+url.PathEscape(requestID), nil)
if pollErr != nil {
return nil, nil, pollErr
}
url, done, sErr := parseConsoleVideoStatus(status)
if sErr != nil {
return nil, nil, sErr
}
if done {
videoURL = url
break
}
if time.Now().After(deadline) {
return nil, nil, fmt.Errorf("%w: video render did not complete in time", ErrTemporaryUpstream)
}
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-time.After(consoleVideoPollEvery):
}
}
meta := map[string]any{
"provider": "grok",
"request_id": requestID,
"video_url": videoURL,
}
if !downloadResult {
return nil, meta, nil
}
data, err := c.download(ctx, directClient, token, videoURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// GenerateConsoleImage runs Console's image pipeline: /v1/images/generations on
// grok-imagine-image for text-to-image, and /v1/images/edits on the
// grok-imagine-image-quality model (up to 3 inlined reference images) as soon as
// references are supplied — 带参考图自动走 quality 那个上游模型. Returns the
// artifact URL in meta["image_url"]; the bytes are downloaded unless urlOnly.
func (c *Client) GenerateConsoleImage(ctx context.Context, token, prompt, aspectRatio, resolution string, refs [][]byte, urlOnly bool) ([]byte, map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, nil, ErrAuth
}
if strings.TrimSpace(prompt) == "" {
return nil, nil, fmt.Errorf("grok console: prompt required")
}
submitClient, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
directClient, err := c.newDirectTLSClient()
if err != nil {
return nil, nil, err
}
payload := map[string]any{
"model": ConsoleImageModel, "prompt": strings.TrimSpace(prompt),
"n": 1, "response_format": "url",
}
if ratio := consoleImageAspectRatio(aspectRatio); ratio != "" {
payload["aspect_ratio"] = ratio
}
if res := consoleImageResolution(resolution); res != "" {
payload["resolution"] = res
}
path := "/v1/images/generations"
var images []map[string]any
for _, r := range refs {
if len(r) == 0 || len(images) >= consoleMaxEditImages {
continue
}
images = append(images, map[string]any{"type": "image_url", "url": dataURL(r)})
}
if len(images) > 0 {
path = "/v1/images/edits"
payload["model"] = ConsoleImageQualityModel
if len(images) == 1 {
payload["image"] = images[0]
} else {
payload["images"] = images
}
}
res, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, path, payload)
if err != nil {
return nil, nil, err
}
items, _ := res["data"].([]any)
imageURL := ""
for _, item := range items {
entry, _ := item.(map[string]any)
if entry == nil {
continue
}
if v := strings.TrimSpace(stringValue(entry["url"])); v != "" {
imageURL = v
break
}
}
if imageURL == "" {
return nil, nil, fmt.Errorf("%w: image response missing url", ErrTemporaryUpstream)
}
meta := map[string]any{"provider": "grok", "image_url": imageURL}
if urlOnly {
return nil, meta, nil
}
data, err := c.download(ctx, directClient, token, imageURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// consoleJSON does an authed Console JSON call and parses the response object. A
// 401 means the DPoP token went stale (or the sso died): the cached session is
// dropped and the call retried once with a freshly minted token.
func (c *Client) consoleJSON(ctx context.Context, client tlsclient.HttpClient, token, method, path string, body any) (map[string]any, error) {
var payload []byte
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return nil, err
}
payload = encoded
}
for attempt := 0; attempt < 2; attempt++ {
session, err := c.consoleSession(ctx, client, token)
if err != nil {
return nil, err
}
var reader io.Reader
if len(payload) > 0 {
reader = strings.NewReader(string(payload))
}
req, err := http.NewRequest(method, consoleBase+path, reader)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyConsoleHeaders(req, token, session); err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
raw, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == 401 && attempt == 0 {
consoleForget(token, session.accessToken)
continue
}
if readErr != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, readErr)
}
if e := consoleMapStatus(path, resp.StatusCode, raw); e != nil {
return nil, e
}
out := map[string]any{}
if len(raw) == 0 {
return out, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
}
return out, nil
}
return nil, fmt.Errorf("%w: console retry state invalid", ErrTemporaryUpstream)
}
// consoleSession returns a live DPoP session for the sso token, minting one when
// the cache is empty or the token is about to expire.
func (c *Client) consoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
key := consoleCacheKey(token)
consoleMu.Lock()
cached, ok := consoleSessions[key]
consoleMu.Unlock()
if ok && cached.expiresAt.After(time.Now().Add(consoleTokenSkew)) {
return cached, nil
}
session, err := c.mintConsoleSession(ctx, client, token)
if err != nil {
return consoleSession{}, err
}
consoleMu.Lock()
consoleSessions[key] = session
consoleMu.Unlock()
return session, nil
}
// consoleForget drops a cached session (only if it is still the one that failed,
// so a concurrent refresh isn't thrown away).
func consoleForget(token, accessToken string) {
key := consoleCacheKey(token)
consoleMu.Lock()
if current, ok := consoleSessions[key]; ok && current.accessToken == accessToken {
delete(consoleSessions, key)
}
consoleMu.Unlock()
}
func consoleCacheKey(token string) string {
sum := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", sum[:])
}
// mintConsoleSession posts the freshly generated public JWK to /v1/dpop/token
// with the sso cookie and keeps the returned DPoP-bound access token.
func (c *Client) mintConsoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return consoleSession{}, err
}
jwk := consoleJWK{
Crv: "P-256", Kty: "EC",
X: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.FillBytes(make([]byte, 32))),
Y: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.FillBytes(make([]byte, 32))),
}
body, err := json.Marshal(map[string]any{"jwk": jwk})
if err != nil {
return consoleSession{}, err
}
req, err := http.NewRequest(http.MethodPost, consoleBase+"/v1/dpop/token", strings.NewReader(string(body)))
if err != nil {
return consoleSession{}, err
}
req = req.WithContext(ctx)
c.applyConsoleBrowserHeaders(req, token, nil)
before := time.Now().UTC()
resp, err := client.Do(req)
if err != nil {
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
after := time.Now().UTC()
raw, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
if e := consoleMapStatus("/v1/dpop/token", resp.StatusCode, raw); e != nil {
return consoleSession{}, e
}
var out struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return consoleSession{}, fmt.Errorf("%w: dpop token non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
}
if strings.TrimSpace(out.AccessToken) == "" || !strings.EqualFold(strings.TrimSpace(out.TokenType), "DPoP") {
return consoleSession{}, fmt.Errorf("%w: dpop token response invalid", ErrTemporaryUpstream)
}
if out.ExpiresIn <= 0 || out.ExpiresIn > 3600 {
out.ExpiresIn = 300
}
return consoleSession{
accessToken: out.AccessToken,
key: privateKey,
jwk: jwk,
expiresAt: time.Now().Add(time.Duration(out.ExpiresIn) * time.Second),
skew: consoleClockSkew(resp.Header.Get("Date"), before, after),
}, nil
}
// applyConsoleHeaders sets the browser-like header set plus the DPoP
// Authorization header and its per-request proof.
func (c *Client) applyConsoleHeaders(req *http.Request, token string, session consoleSession) error {
proof, err := consoleProof(session, req)
if err != nil {
return err
}
c.applyConsoleBrowserHeaders(req, token, map[string]string{
"authorization": "DPoP " + session.accessToken,
"dpop": proof,
})
return nil
}
func (c *Client) applyConsoleBrowserHeaders(req *http.Request, token string, extra map[string]string) {
h := http.Header{
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"content-type": {"application/json"},
"origin": {consoleOrigin},
"referer": {consoleOrigin + "/"},
"user-agent": {userAgent},
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
"sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {`"Windows"`},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
}
for k, v := range extra {
h[k] = []string{v}
}
h[http.HeaderOrderKey] = []string{
"accept", "accept-language", "authorization", "content-type", "dpop",
"origin", "referer", "user-agent", "sec-ch-ua", "sec-ch-ua-mobile",
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
}
req.Header = h
}
// consoleProof builds the ES256 DPoP proof JWT for one request: it binds the
// method + URL (without query) and the access token hash (ath), signed with the
// key the token was issued against.
func consoleProof(session consoleSession, req *http.Request) (string, error) {
if session.key == nil || strings.TrimSpace(session.accessToken) == "" || req == nil || req.URL == nil {
return "", errors.New("grok console: dpop session invalid")
}
path := req.URL.EscapedPath()
if path == "" {
path = "/"
}
ath := sha256.Sum256([]byte(session.accessToken))
header := map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": session.jwk}
claims := map[string]any{
"jti": uuid.NewString(),
"htm": strings.ToUpper(req.Method),
"htu": req.URL.Scheme + "://" + req.URL.Host + path,
"iat": time.Now().Add(session.skew).UTC().Unix(),
"ath": base64.RawURLEncoding.EncodeToString(ath[:]),
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(claimsJSON)
digest := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, session.key, digest[:])
if err != nil {
return "", err
}
signature := make([]byte, 64)
r.FillBytes(signature[:32])
s.FillBytes(signature[32:])
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
// consoleClockSkew mirrors the console.x.ai frontend: proof iat is local time
// corrected by (server Date − local time) so host clock drift doesn't break
// every proof. before/after bound the RTT around the mint response.
func consoleClockSkew(dateHeader string, before, after time.Time) time.Duration {
dateHeader = strings.TrimSpace(dateHeader)
if dateHeader == "" {
return 0
}
serverTime, err := http.ParseTime(dateHeader)
if err != nil {
return 0
}
if after.Before(before) {
after = before
}
mid := before.Add(after.Sub(before) / 2)
return serverTime.UTC().Sub(mid.UTC()).Round(time.Second)
}
// parseConsoleVideoStatus reads one poll response: (url, done, error).
func parseConsoleVideoStatus(res map[string]any) (string, bool, error) {
status := strings.ToLower(strings.TrimSpace(stringValue(res["status"])))
switch status {
case "done", "completed", "succeeded", "success", "ready":
video, _ := res["video"].(map[string]any)
url := ""
if video != nil {
url = strings.TrimSpace(stringValue(video["url"]))
}
if url == "" {
return "", false, fmt.Errorf("%w: video done without content url", ErrTemporaryUpstream)
}
return url, true, nil
case "pending", "processing", "in_progress", "queued", "":
return "", false, nil
default:
message := consoleErrorMessage(res["error"])
if isCreditError(message) {
return "", false, fmt.Errorf("%w: %s", ErrQuotaExhausted, message)
}
if message == "" {
message = status
}
return "", false, fmt.Errorf("grok console: video generation failed: %s", message)
}
}
// consoleMapStatus maps a Console HTTP status to the shared provider sentinels.
// Console is not anti-bot gated, so a 403 is a real rejection (dead sso / no
// access) unless the body is a Cloudflare interstitial.
func consoleMapStatus(path string, status int, raw []byte) error {
body := string(raw)
switch {
case status >= 200 && status < 300:
return nil
case status == 401:
return fmt.Errorf("%w: %s 401 %s", ErrAuth, path, clip(raw, 160))
case status == 403:
if isBotChallenge(body) {
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
}
return fmt.Errorf("%w: %s 403 %s", ErrAuth, path, clip(raw, 160))
case status == 429:
if isCreditError(body) {
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
}
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
case status >= 500:
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
default:
if isCreditError(body) {
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
}
return fmt.Errorf("grok console: %s %d %s", path, status, clip(raw, 160))
}
}
func consoleErrorMessage(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case map[string]any:
for _, key := range []string{"message", "code", "type"} {
if text := strings.TrimSpace(stringValue(v[key])); text != "" {
return text
}
}
}
return ""
}
// consoleImageAspectRatio keeps only the ratios Console's image models accept;
// anything else is dropped so the upstream picks its default instead of 400ing.
func consoleImageAspectRatio(value string) string {
switch strings.TrimSpace(value) {
case "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2":
return strings.TrimSpace(value)
}
return ""
}
// consoleImageResolution maps our tier to the Console enum (only 1k / 2k exist).
func consoleImageResolution(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "1K":
return "1k"
case "2K", "4K":
return "2k"
}
return ""
}
// consoleVideoResolution clamps to the two tiers Console accepts.
func consoleVideoResolution(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "480p":
return "480p"
default:
return "720p"
}
}
// dataURL inlines reference image bytes; Console takes images in the request
// body, so no upload endpoint is involved.
func dataURL(img []byte) string {
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
}
@@ -1,398 +0,0 @@
package grok
// This file makes x-statsig-id durable across grok web reships by executing grok's
// OWN obfuscated signer (a Turbopack chunk) inside an embedded JS engine (goja),
// under a synthesized DOM + Web-Animations getComputedStyle shim (statsig_shim.js).
// grok's code does all the per-build byte-indexing / curve-selection; we only supply
// the stable browser primitives. This replaces the brittle hand-ported byte-offset
// algorithm in computeStatsigTail (kept as a last-resort fallback). See the package
// doc and the grok-statsig-signer memory for the reverse-engineering details.
import (
"context"
"crypto/sha256"
"crypto/sha512"
_ "embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"regexp"
"strconv"
"strings"
"sync"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/dop251/goja"
)
//go:embed statsig_shim.js
var statsigShimJS string
const sigPoolSize = 4
// errEngineNotReady means the durable signer engine has not been built yet (no
// homepage fetched, or chunk location failed). Callers fall back to the static path.
var errEngineNotReady = errors.New("statsig engine not ready")
// locateConcurrency bounds parallel chunk fetches during signer discovery.
const locateConcurrency = 24
var (
// chunkPathRe matches chunk URLs in the homepage; allChunkRefRe additionally
// matches the loader-manifest's lazy refs (which drop the /_next/ prefix).
// The Turbopack content-hash alphabet includes '~' (e.g. 0~4v1h2zpw7n0.js) -
// the signer chunk is frequently one of these, so the class MUST list it or
// the signer is never fetched/located (403 anti-bot, static fallback goes stale).
chunkPathRe = regexp.MustCompile(`/_next/static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
allChunkRefRe = regexp.MustCompile(`(?:/_next/)?static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
// goja's parser tries to fetch //# sourceMappingURL=... from disk and errors.
sourceMapRe = regexp.MustCompile(`(?m)//[#@]\s*sourceMappingURL=\S*`)
sigMgrMu sync.Mutex
sigBuildKey string // hash of the homepage chunk list; changes on reship
sigChunkSrc string // current signer chunk source
sigPool chan *sigEngine // pool of ready engines for sigChunkSrc
)
// sigEngine wraps one goja runtime with grok's signer chunk loaded. A goja runtime
// is not safe for concurrent use; the pool hands each engine to one goroutine at a
// time so no per-engine locking is needed.
type sigEngine struct {
rt *goja.Runtime
fire goja.Callable // __grokSignInto
}
func newSigEngine(chunkSrc string) (*sigEngine, error) {
rt := goja.New()
// SHA-256 bridge for crypto.subtle.digest.
if err := rt.Set("__goSha256", func(call goja.FunctionCall) goja.Value {
data := jsBytes(rt, call.Argument(0))
sum := sha256.Sum256(data)
return rt.ToValue(rt.NewArrayBuffer(sum[:]))
}); err != nil {
return nil, err
}
if _, err := rt.RunString(statsigShimJS); err != nil {
return nil, fmt.Errorf("shim: %w", err)
}
if _, err := rt.RunString(sourceMapRe.ReplaceAllString(chunkSrc, "")); err != nil {
return nil, fmt.Errorf("chunk eval: %w", err)
}
if _, err := rt.RunString("__grokBootstrap()"); err != nil {
return nil, fmt.Errorf("bootstrap: %w", err)
}
fire, ok := goja.AssertFunction(rt.Get("__grokSignInto"))
if !ok {
return nil, errors.New("statsig js: __grokSignInto missing")
}
return &sigEngine{rt: rt, fire: fire}, nil
}
// statsigID runs grok's signer for one request. seedB64 is the raw <meta> content;
// curvesJSON is [[{color,deg,bezier}...]...].
func (e *sigEngine) statsigID(seedB64, curvesJSON, path, method string) (string, error) {
_ = e.rt.Set("__SEED", seedB64)
_ = e.rt.Set("__CURVES", curvesJSON)
_ = e.rt.Set("__PATH", path)
_ = e.rt.Set("__METHOD", method)
// RunString drains goja's microtask queue, settling the async signer's promise.
if _, err := e.fire(goja.Undefined()); err != nil {
return "", err
}
if errv := e.rt.Get("__grokErr"); errv != nil && !goja.IsNull(errv) && !goja.IsUndefined(errv) {
return "", fmt.Errorf("statsig js: %s", errv.String())
}
res := e.rt.Get("__grokResult")
if res == nil || goja.IsNull(res) || goja.IsUndefined(res) {
return "", errors.New("statsig js: promise did not settle")
}
id := res.String()
if id == "" {
return "", errors.New("statsig js: empty id")
}
return id, nil
}
// jsBytes extracts the byte contents of a JS Uint8Array / ArrayBuffer value.
func jsBytes(rt *goja.Runtime, v goja.Value) []byte {
if ab, ok := v.Export().(goja.ArrayBuffer); ok {
return ab.Bytes()
}
obj := v.ToObject(rt)
if buf := obj.Get("buffer"); buf != nil {
if ab, ok := buf.Export().(goja.ArrayBuffer); ok {
return ab.Bytes()
}
}
n := int(obj.Get("length").ToInteger())
out := make([]byte, n)
for i := 0; i < n; i++ {
out[i] = byte(obj.Get(strconv.Itoa(i)).ToInteger())
}
return out
}
// signWithEngine borrows an engine from the pool (building one on demand), signs,
// and returns it. Returns an error if the engine subsystem is not ready.
func signWithEngine(seedB64, curvesJSON, path, method string) (string, error) {
sigMgrMu.Lock()
src, pool := sigChunkSrc, sigPool
sigMgrMu.Unlock()
if src == "" || pool == nil {
return "", errEngineNotReady
}
var eng *sigEngine
select {
case eng = <-pool:
default:
var err error
if eng, err = newSigEngine(src); err != nil {
return "", err
}
}
id, err := eng.statsigID(seedB64, curvesJSON, path, method)
select {
case pool <- eng:
default:
}
return id, err
}
// ensureEngine refreshes the global engine pool when the homepage's chunk set
// changes (i.e. grok reshipped). It locates the signer chunk build-agnostically and
// rebuilds the pool. Cheap no-op when the build is unchanged.
func ensureEngine(ctx context.Context, client tlsclient.HttpClient, homeHTML string) {
paths := chunkPathRe.FindAllString(homeHTML, -1)
if len(paths) == 0 {
return
}
key := hashStrings(paths)
sigMgrMu.Lock()
unchanged := key == sigBuildKey && sigPool != nil
sigMgrMu.Unlock()
if unchanged {
return
}
// Inputs for build-agnostic behavioral verification of candidate chunks.
mm := statsigMetaRe.FindStringSubmatch(homeHTML)
if mm == nil {
log.Printf("grok statsig: no seed meta in homepage; cannot locate signer")
return
}
seed, err := decodeStatsigSeed(mm[1])
if err != nil {
log.Printf("grok statsig: seed decode failed: %v", err)
return
}
curves, err := parseStatsigCurves(homeHTML)
if err != nil {
log.Printf("grok statsig: curves parse failed: %v", err)
return
}
cj, err := json.Marshal(curves)
if err != nil {
return
}
src, err := locateSignerChunk(ctx, client, dedupe(paths), mm[1], string(cj), seed)
if err != nil {
log.Printf("grok statsig: locate signer chunk failed (will use static fallback): %v", err)
return
}
// smoke-test: a build must produce a loadable engine before we commit to it.
eng, err := newSigEngine(src)
if err != nil {
log.Printf("grok statsig: signer chunk did not load in goja (static fallback): %v", err)
return
}
pool := make(chan *sigEngine, sigPoolSize)
pool <- eng // reuse the smoke-test engine instead of discarding it
sigMgrMu.Lock()
sigBuildKey = key
sigChunkSrc = src
sigPool = pool
sigMgrMu.Unlock()
log.Printf("grok statsig: self-heal engine ready (build %s..)", key[:8])
}
// locateSignerChunk finds grok's obfuscated anti-bot signer chunk build-agnostically,
// with no dependency on rotating literals (header name, class names, module ids).
// It screens every reachable chunk with a cheap obfuscator.io fingerprint, then
// confirms the true signer by RUNNING it in goja and checking its output embeds the
// homepage seed (signerEmbedsSeed). The signer is lazily loaded (not referenced in
// the HTML directly), so candidates also include the chunk paths named inside the
// homepage's Turbopack loader manifest.
func locateSignerChunk(ctx context.Context, client tlsclient.HttpClient, homeChunks []string, seedB64, curvesJSON string, seed []byte) (string, error) {
seen := map[string]bool{}
var lazy []string
for _, p := range homeChunks {
seen[p] = true
}
// Pass 1 (sequential): fetch the homepage chunks, verify them directly, and
// harvest every chunk path they reference (the loader manifest lists the signer).
for _, p := range homeChunks {
body, err := fetchChunk(ctx, client, p)
if err != nil {
continue
}
if src, ok := verifySignerChunk(body, seedB64, curvesJSON, seed); ok {
return src, nil
}
for _, ref := range allChunkRefRe.FindAllString(body, -1) {
np := normalizeChunkPath(ref)
if !seen[np] {
seen[np] = true
lazy = append(lazy, np)
}
}
}
// Pass 2 (concurrent): fetch the lazily-referenced chunks, cheap-fingerprint each,
// behaviorally verify the matches, and stop at the first chunk that round-trips seed.
ctx2, cancel := context.WithCancel(ctx)
defer cancel()
found := make(chan string, 1)
sem := make(chan struct{}, locateConcurrency)
var wg sync.WaitGroup
for _, p := range lazy {
if ctx2.Err() != nil {
break
}
sem <- struct{}{}
wg.Add(1)
go func(p string) {
defer wg.Done()
defer func() { <-sem }()
if ctx2.Err() != nil {
return
}
body, err := fetchChunk(ctx2, client, p)
if err != nil {
return
}
if src, ok := verifySignerChunk(body, seedB64, curvesJSON, seed); ok {
select {
case found <- src:
cancel()
default:
}
}
}(p)
}
go func() { wg.Wait(); close(found) }()
if src, ok := <-found; ok {
return src, nil
}
return "", fmt.Errorf("statsig signer chunk not found among %d candidates", len(homeChunks)+len(lazy))
}
// verifySignerChunk returns the goja-ready source if body is grok's signer: it must
// carry the obfuscator.io fingerprint AND, when executed, produce an x-statsig-id
// whose decoded record embeds the homepage seed.
func verifySignerChunk(body, seedB64, curvesJSON string, seed []byte) (string, bool) {
if !isObfuscatedSigner(body) {
return "", false
}
clean := sourceMapRe.ReplaceAllString(body, "")
eng, err := newSigEngine(clean)
if err != nil {
return "", false
}
id, err := eng.statsigID(seedB64, curvesJSON, "/rest/app-chat/conversations/new", "POST")
if err != nil {
return "", false
}
if !signerEmbedsSeed(id, seed) {
return "", false
}
return clean, true
}
// normalizeChunkPath turns a bare or /_next/-prefixed chunk ref into a fetch path.
func normalizeChunkPath(ref string) string {
return "/_next/" + strings.TrimPrefix(ref, "/_next/")
}
// isObfuscatedSigner cheaply screens a chunk for the obfuscator.io string-array
// decoder that grok applies ONLY to its anti-bot signer (the rest of the app is
// plain Turbopack output). The RC4-style byte decoder (`...^...%256`) is the stable
// tell; it matches a handful of chunks, which behavioral verification then narrows
// to exactly one. This is deliberately build-agnostic (no rotating class/id/header).
func isObfuscatedSigner(src string) bool {
return strings.Contains(src, "%256") &&
strings.Contains(src, "String.fromCharCode") &&
strings.Contains(src, "charCodeAt")
}
// signerEmbedsSeed decodes a candidate x-statsig-id, strips the per-call XOR mask
// (plaintext[0] is 0x00, so masked[0] is the key), and checks the plaintext record
// begins with 0x00 + the exact homepage seed. Only grok's real signer round-trips
// OUR seed, so this uniquely identifies the signer chunk regardless of obfuscation.
func signerEmbedsSeed(id string, seed []byte) bool {
raw, err := base64.RawStdEncoding.DecodeString(id)
if err != nil || len(raw) < 1+len(seed) {
return false
}
key := raw[0] // plaintext[0] is 0x00, so the mask key == masked byte 0
for i := 0; i < len(seed); i++ {
if raw[1+i]^key != seed[i] {
return false
}
}
return true
}
func fetchChunk(ctx context.Context, client tlsclient.HttpClient, path string) (string, error) {
if !strings.HasPrefix(path, "http") {
path = apiBase + path
}
req, err := http.NewRequest(http.MethodGet, path, nil)
if err != nil {
return "", err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"user-agent": {userAgent},
http.HeaderOrderKey: {"accept", "user-agent"},
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("chunk http %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
return string(b), err
}
func hashStrings(ss []string) string {
uniq := dedupe(ss)
h := sha512.New()
for _, s := range uniq {
_, _ = io.WriteString(h, s)
_, _ = io.WriteString(h, "\n")
}
return fmt.Sprintf("%x", h.Sum(nil))
}
func dedupe(ss []string) []string {
seen := map[string]bool{}
out := ss[:0:0]
for _, s := range ss {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
@@ -1,300 +0,0 @@
package grok
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"log"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)
// The x-statsig-id recipe (49-byte header + salt suffix) encodes grok's per-load
// browser fingerprint "F". grok recomputes F server-side from the homepage
// seed+curves and the byte-indexing that derives it ROTATES on every web reship,
// so any hand-ported or baked-in recipe goes stale (403 "Request rejected by
// anti-bot rules.") within days. The durable fix is to let grok's OWN signer
// produce the recipe: we drive a headless Chrome to grok.com, read the exact
// string grok feeds to crypto.subtle.digest (reveals the current suffix) and one
// of the x-statsig-id headers its client attaches (reveals the current 49-byte
// header + trailer), then feed those into statsigID's existing per-request
// computation. Because we read grok's live output we never reverse-engineer the
// (rotating) obfuscation; a reship just needs a re-capture, which the auto-refresh
// loop does on a timer and whenever a live request hits an anti-bot 403.
//
// The captured recipe is persisted (site settings) so a restart keeps the last
// good values, and every failure is non-fatal — statsigID keeps using whatever
// recipe is currently live (persisted → last capture → static defaults).
// hookJS is installed before grok's own scripts run. It records (a) every
// crypto.subtle.digest input that contains the statsig salt marker — the plaintext
// "METHOD!/path!counter<suffix>" — and (b) every 70-byte x-statsig-id header grok
// attaches to its API calls. Both are only produced once grok's signer succeeds
// (after its keyframe animation settles), so a short wait yields valid values.
const statsigHookJS = `
(() => {
window.__statsigDigests = []; window.__statsigCaps = [];
try {
const td = new TextDecoder('utf-8', {fatal:false});
const orig = crypto.subtle.digest.bind(crypto.subtle);
crypto.subtle.digest = function(alg, data) {
try {
let buf = data; if (data && data.buffer) buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
const txt = td.decode(new Uint8Array(buf.slice ? buf.slice(0) : buf));
if (txt.indexOf('` + statsigSaltPrefix + `') >= 0) window.__statsigDigests.push(txt);
} catch (e) {}
return orig(alg, data);
};
const of = window.fetch;
window.fetch = function(input, init) {
try {
let hdrs = (init && init.headers); let sid = null;
if (hdrs) { if (typeof hdrs.get === 'function') sid = hdrs.get('x-statsig-id'); else for (const k in hdrs) if (k.toLowerCase() === 'x-statsig-id') sid = hdrs[k]; }
if (sid && sid.length === 94) window.__statsigCaps.push(sid);
} catch (e) {}
return of.apply(this, arguments);
};
} catch (e) {}
})();
`
var (
statsigRefreshTrigger = make(chan struct{}, 1)
statsigRefreshStarted sync.Once
statsigLastCapture time.Time
statsigLastCaptureMu sync.Mutex
)
// SetStatsigRecipe atomically replaces the live (header, suffix, trailer) used by
// statsigID and drops the per-session challenge cache so the next request adopts
// the new values. headerHex must decode to 49 bytes; suffix must be non-empty.
func SetStatsigRecipe(headerHex, suffix string, trailer int) error {
h, err := hex.DecodeString(strings.TrimSpace(headerHex))
if err != nil {
return err
}
if len(h) != 49 {
return errors.New("statsig: header must be 49 bytes")
}
if strings.TrimSpace(suffix) == "" {
return errors.New("statsig: empty suffix")
}
if trailer < 0 || trailer > 255 {
return errors.New("statsig: trailer out of range")
}
statsigMu.Lock()
statsigHeader = h
statsigSuffix = suffix
statsigTrailer = byte(trailer)
statsigCache = map[string]statsigChallenge{}
statsigMu.Unlock()
return nil
}
// chromeExecPath resolves the Chrome/Chromium binary for headless capture, from
// GROK_STATSIG_CHROME or the usual names on PATH. Empty means none available.
func chromeExecPath() string {
if p := strings.TrimSpace(os.Getenv("GROK_STATSIG_CHROME")); p != "" {
return p
}
for _, name := range []string{"google-chrome-stable", "google-chrome", "chromium", "chromium-browser"} {
if p, err := exec.LookPath(name); err == nil {
return p
}
}
return ""
}
type statsigCapResult struct {
Digs []string `json:"digs"`
Caps []string `json:"caps"`
}
// CaptureStatsigRecipe drives a headless Chrome to grok.com and reads grok's own
// signer output, returning the current (headerHex, suffix, trailer). It never
// mutates global state. Returns an error if Chrome is unavailable or grok's
// signer output could not be observed (e.g. blocked before the signer ran).
func CaptureStatsigRecipe(ctx context.Context) (headerHex, suffix string, trailer int, err error) {
chromePath := chromeExecPath()
if chromePath == "" {
return "", "", 0, errors.New("statsig: no chrome binary (set GROK_STATSIG_CHROME)")
}
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.ExecPath(chromePath),
chromedp.Flag("headless", "new"),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.UserAgent(userAgent),
)
if proxy := strings.TrimSpace(os.Getenv("GROK_STATSIG_PROXY")); proxy != "" {
opts = append(opts, chromedp.ProxyServer(proxy))
}
allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...)
defer cancel()
bctx, cancel2 := chromedp.NewContext(allocCtx)
defer cancel2()
var res statsigCapResult
runErr := chromedp.Run(bctx,
chromedp.ActionFunc(func(ctx context.Context) error {
_, err := page.AddScriptToEvaluateOnNewDocument(statsigHookJS).Do(ctx)
return err
}),
chromedp.Navigate(apiBase+"/"),
chromedp.Sleep(14*time.Second),
chromedp.Evaluate(`({digs: window.__statsigDigests || [], caps: window.__statsigCaps || []})`, &res),
)
if runErr != nil {
return "", "", 0, runErr
}
return parseStatsigCapture(res)
}
// parseStatsigCapture extracts (headerHex, suffix, trailer) from the observed
// digest plaintexts and x-statsig-id headers of one page load. Both must be
// present and are self-consistent (same per-load fingerprint F).
func parseStatsigCapture(res statsigCapResult) (headerHex, suffix string, trailer int, err error) {
for _, d := range res.Digs {
if i := strings.Index(d, statsigSaltPrefix); i >= 0 {
suffix = d[i:]
break
}
}
if suffix == "" {
return "", "", 0, errors.New("statsig: no signer digest observed (blocked before signer ran?)")
}
var header []byte
for _, sid := range res.Caps {
raw, derr := base64.RawStdEncoding.DecodeString(sid)
if derr != nil || len(raw) != 70 {
continue
}
key := raw[0] // header[0] is 0x00, so the XOR mask key == byte 0
r := make([]byte, 70)
for i := range raw {
r[i] = raw[i] ^ key
}
if r[0] != 0x00 {
continue
}
header = r[0:49]
trailer = int(r[69])
break
}
if header == nil {
return "", "", 0, errors.New("statsig: no valid x-statsig-id header observed")
}
return hex.EncodeToString(header), suffix, trailer, nil
}
// TriggerStatsigRefresh asks the auto-refresh loop to re-capture soon (coalesced).
// Called when a live request hits an anti-bot 403 — the recipe likely went stale.
func TriggerStatsigRefresh() {
select {
case statsigRefreshTrigger <- struct{}{}:
default:
}
}
// StartStatsigAutoRefresh launches the headless capture loop once. It is
// event-driven, NOT a polling loop: it seeds the live recipe from persisted
// values (load), captures once at startup so the process starts fresh, and then
// re-captures ONLY when a live request hits an anti-bot 403 (TriggerStatsigRefresh)
// — i.e. exactly when the recipe actually went stale on a grok reship. The recipe
// has no fixed clock expiry, so there is nothing useful to poll on. An optional
// safetyInterval>0 adds a slow backstop re-capture; pass 0 to disable it (default).
// Captured recipes are handed to `save` for persistence. No-op when a manual
// GROK_STATSIG_* override is set or no Chrome binary is available.
func StartStatsigAutoRefresh(ctx context.Context, safetyInterval time.Duration,
load func(context.Context) (headerHex, suffix string, trailer int, ok bool),
save func(ctx context.Context, headerHex, suffix string, trailer int)) {
statsigRefreshStarted.Do(func() {
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
log.Printf("grok statsig: manual GROK_STATSIG_* override set — headless auto-refresh disabled")
return
}
if chromeExecPath() == "" {
log.Printf("grok statsig: no chrome binary — headless auto-refresh disabled, using static recipe (set GROK_STATSIG_CHROME to enable)")
return
}
if load != nil {
if h, s, t, ok := load(ctx); ok {
if err := SetStatsigRecipe(h, s, t); err != nil {
log.Printf("grok statsig: persisted recipe invalid, ignoring: %v", err)
} else {
log.Printf("grok statsig: loaded persisted recipe header[:6]=%s", safePrefix(h, 12))
}
}
}
go statsigRefreshLoop(ctx, safetyInterval, save)
})
}
func statsigRefreshLoop(ctx context.Context, safetyInterval time.Duration, save func(context.Context, string, string, int)) {
refresh := func(reason string) {
statsigLastCaptureMu.Lock()
if time.Since(statsigLastCapture) < 30*time.Second {
statsigLastCaptureMu.Unlock()
return // debounce bursts (e.g. a 403 storm across concurrent requests)
}
statsigLastCapture = time.Now()
statsigLastCaptureMu.Unlock()
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
h, s, t, err := CaptureStatsigRecipe(cctx)
if err != nil {
log.Printf("grok statsig: headless capture (%s) failed, keeping current recipe: %v", reason, err)
return
}
if err := SetStatsigRecipe(h, s, t); err != nil {
log.Printf("grok statsig: captured recipe invalid: %v", err)
return
}
if save != nil {
save(ctx, h, s, t)
}
log.Printf("grok statsig: headless recipe refreshed (%s) header[:6]=%s suffix[:24]=%s", reason, safePrefix(h, 12), safePrefix(s, 24))
}
initial := time.NewTimer(10 * time.Second)
defer initial.Stop()
// Optional slow backstop only; disabled (nil channel blocks forever) when
// safetyInterval<=0 so the loop stays purely event-driven.
var tickC <-chan time.Time
if safetyInterval > 0 {
ticker := time.NewTicker(safetyInterval)
defer ticker.Stop()
tickC = ticker.C
}
for {
select {
case <-ctx.Done():
return
case <-initial.C:
refresh("startup")
case <-tickC:
refresh("safety-interval")
case <-statsigRefreshTrigger:
refresh("anti-bot 403")
}
}
}
func safePrefix(s string, n int) string {
if len(s) < n {
return s
}
return s[:n]
}
@@ -1,175 +0,0 @@
package grok
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
)
// Ground-truth (seed, curves, F) triples. The first row is server-verified
// (captured from a real grok browser token that returned 200); the remainder
// exercise every curve group / currentTime branch and are checked against the
// reference implementation the algorithm was reverse-engineered against.
var statsigTriples = []struct {
seedHex string
wantM string
}{
{seedHex: "732c9800d181e47c9b2a2705713306f42a51a10d69c6a6c32e53b26d1599b0b75399035a9953fedf05903cb9eb70a989", wantM: "ff75470a3d70a3d70a3d80c51eb851eb8520c51eb851eb8520a3d70a3d70a3d800"},
{seedHex: "732c9800d1802530bb1d6d057133064d2aa5a1ca69c6a6c32e53b26d1599b0b75399035a9953fe1805903cb9eb70a989", wantM: "27fa10fd70a3d70a3d7028f5c28f5c28f6028f5c28f5c28f60fd70a3d70a3d700"},
{seedHex: "732c9800d180237b2ed91e057133062c2a13a1de69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "079ff0d1eb851eb851e80947ae147ae14780947ae147ae14780d1eb851eb851e800"},
{seedHex: "732c9800d180197117449405713306722a3fa11f69c6a6c32e53b26d1599b0b75399035a9953fecb05903cb9eb70a989", wantM: "195fe0fae147ae147ae035c28f5c28f5c2035c28f5c28f5c20fae147ae147ae00"},
{seedHex: "732c9800d1815c3460be3105713306492ad6a13c69c6a6c32e53b26d1599b0b75399035a9953fe9d05903cb9eb70a989", wantM: "ff6ab5035c28f5c28f5c20fae147ae147ae0fae147ae147ae035c28f5c28f5c200"},
{seedHex: "732c9800d181daa0eee8b9057133061e2a20a16969c6a6c32e53b26d1599b0b75399035a9953fefe05903cb9eb70a989", wantM: "3da0d70e3d70a3d70a3d8075c28f5c28f5c4075c28f5c28f5c40e3d70a3d70a3d800"},
{seedHex: "732c9800d1812999fdafe5057133067f2a99a15c69c6a6c32e53b26d1599b0b75399035a9953fe7c05903cb9eb70a989", wantM: "1cac4c0451eb851eb8520f5c28f5c28f5c0f5c28f5c28f5c0451eb851eb85200"},
{seedHex: "732c9800d18254af4dfad705713306252a93a13c69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "b40480d70a3d70a3d70808a3d70a3d70a408a3d70a3d70a40d70a3d70a3d70800"},
{seedHex: "732c9800d182b3fee9232f05713306272a14a1a069c6a6c32e53b26d1599b0b75399035a9953feae05903cb9eb70a989", wantM: "13757a100100"},
{seedHex: "732c9800d1829ee491c5b105713306f22a8aa12169c6a6c32e53b26d1599b0b75399035a9953fe1f05903cb9eb70a989", wantM: "eb4fb9100a3d70a3d70a3d800a3d70a3d70a3d8100"},
{seedHex: "732c9800d1833bfc1e6f9305713306ec2a0ba1b569c6a6c32e53b26d1599b0b75399035a9953fe5605903cb9eb70a989", wantM: "8a56650fae147ae147ae02b851eb851eb8602b851eb851eb860fae147ae147ae00"},
{seedHex: "732c9800d183fe2955e5cd057133067e2a42a1cb69c6a6c32e53b26d1599b0b75399035a9953fec805903cb9eb70a989", wantM: "b242950deb851eb851eb807d70a3d70a3d707d70a3d70a3d70deb851eb851eb800"},
{seedHex: "732c9800d183d4b7c2764d05713306462a8ea1dc69c6a6c32e53b26d1599b0b75399035a9953fe8e05903cb9eb70a989", wantM: "1e70d8070a3d70a3d70a40e66666666666680e6666666666668070a3d70a3d70a400"}}
const statsigTestCurves = `[[{"color":[48,44,6,37,198,15],"deg":192,"bezier":[118,76,158,16]},{"color":[224,216,196,111,43,97],"deg":119,"bezier":[67,167,95,219]},{"color":[90,235,250,5,223,64],"deg":104,"bezier":[100,57,106,204]},{"color":[6,109,253,44,29,224],"deg":151,"bezier":[204,60,142,122]},{"color":[81,0,121,208,228,133],"deg":181,"bezier":[182,89,105,123]},{"color":[31,3,160,181,226,184],"deg":98,"bezier":[207,150,215,136]},{"color":[231,243,81,28,109,131],"deg":175,"bezier":[17,103,7,81]},{"color":[222,250,130,169,55,247],"deg":141,"bezier":[21,238,12,84]},{"color":[74,62,116,145,209,185],"deg":109,"bezier":[0,74,58,233]},{"color":[204,168,203,138,107,125],"deg":214,"bezier":[41,13,239,45]},{"color":[246,116,162,162,84,234],"deg":127,"bezier":[160,202,52,76]},{"color":[138,202,210,36,61,195],"deg":234,"bezier":[31,50,177,229]},{"color":[47,46,196,176,79,255],"deg":221,"bezier":[39,14,9,76]},{"color":[245,164,227,71,201,110],"deg":190,"bezier":[193,208,125,9]},{"color":[199,253,44,33,130,240],"deg":191,"bezier":[168,247,61,37]},{"color":[19,91,5,180,202,243],"deg":216,"bezier":[96,152,42,123]}],[{"color":[42,154,230,170,197,128],"deg":108,"bezier":[160,52,34,184]},{"color":[224,132,83,0,231,162],"deg":238,"bezier":[227,37,122,102]},{"color":[24,82,237,199,100,74],"deg":130,"bezier":[186,106,188,209]},{"color":[47,50,169,65,124,44],"deg":228,"bezier":[198,22,146,225]},{"color":[111,131,95,195,131,73],"deg":207,"bezier":[134,146,202,172]},{"color":[192,10,166,28,236,3],"deg":219,"bezier":[85,77,157,235]},{"color":[236,139,199,72,233,250],"deg":197,"bezier":[180,9,79,147]},{"color":[146,195,78,16,231,211],"deg":93,"bezier":[123,18,87,27]},{"color":[168,211,49,42,124,18],"deg":172,"bezier":[232,171,120,118]},{"color":[43,146,96,11,203,53],"deg":146,"bezier":[11,4,83,198]},{"color":[183,97,101,38,115,41],"deg":159,"bezier":[69,223,197,236]},{"color":[78,73,207,132,109,134],"deg":49,"bezier":[59,18,133,168]},{"color":[239,153,225,207,127,157],"deg":194,"bezier":[13,206,154,182]},{"color":[19,146,127,191,68,199],"deg":165,"bezier":[148,212,7,16]},{"color":[93,225,249,144,88,255],"deg":215,"bezier":[62,30,77,69]},{"color":[203,79,164,145,3,20],"deg":81,"bezier":[228,28,93,130]}],[{"color":[171,53,4,125,232,43],"deg":122,"bezier":[136,9,128,97]},{"color":[143,97,205,57,63,69],"deg":192,"bezier":[232,20,219,189]},{"color":[28,173,18,107,158,44],"deg":255,"bezier":[93,187,184,198]},{"color":[170,17,62,142,200,32],"deg":29,"bezier":[14,31,12,97]},{"color":[19,117,122,173,239,66],"deg":74,"bezier":[117,176,139,212]},{"color":[213,151,230,112,224,255],"deg":25,"bezier":[1,223,72,233]},{"color":[153,131,51,105,69,47],"deg":108,"bezier":[123,177,126,140]},{"color":[120,114,44,151,88,83],"deg":165,"bezier":[16,104,134,75]},{"color":[245,145,194,75,120,26],"deg":142,"bezier":[79,235,38,43]},{"color":[147,63,50,255,239,106],"deg":190,"bezier":[122,143,160,150]},{"color":[233,78,184,130,25,123],"deg":54,"bezier":[87,18,184,226]},{"color":[162,180,233,70,57,249],"deg":87,"bezier":[123,238,61,124]},{"color":[146,95,56,171,38,240],"deg":239,"bezier":[241,134,228,44]},{"color":[111,11,149,62,208,177],"deg":70,"bezier":[103,149,4,37]},{"color":[159,128,118,21,197,153],"deg":175,"bezier":[246,215,172,236]},{"color":[194,131,68,247,215,108],"deg":30,"bezier":[23,91,151,231]}],[{"color":[239,129,141,243,85,208],"deg":38,"bezier":[252,248,245,195]},{"color":[205,56,138,49,126,99],"deg":107,"bezier":[72,85,228,91]},{"color":[185,54,148,122,170,158],"deg":192,"bezier":[33,88,51,136]},{"color":[7,14,75,26,23,41],"deg":93,"bezier":[153,21,55,147]},{"color":[245,211,213,64,5,253],"deg":49,"bezier":[253,19,106,155]},{"color":[144,232,165,21,114,130],"deg":200,"bezier":[193,179,133,226]},{"color":[34,196,100,42,114,0],"deg":52,"bezier":[4,4,71,65]},{"color":[195,8,130,102,201,141],"deg":210,"bezier":[8,173,23,33]},{"color":[86,136,44,95,223,62],"deg":249,"bezier":[220,98,68,113]},{"color":[205,48,9,247,236,71],"deg":75,"bezier":[163,240,28,25]},{"color":[43,190,29,239,55,135],"deg":146,"bezier":[109,245,34,188]},{"color":[146,91,92,2,3,251],"deg":97,"bezier":[183,188,95,157]},{"color":[40,213,196,70,81,174],"deg":120,"bezier":[153,197,61,201]},{"color":[131,92,180,68,131,214],"deg":251,"bezier":[94,191,198,89]},{"color":[47,113,219,96,115,228],"deg":238,"bezier":[22,35,60,63]},{"color":[246,244,203,196,78,136],"deg":44,"bezier":[88,23,205,184]}]]`
// TestComputeStatsigTail was the offline regression for the hand-ported F
// derivation (computeStatsigTail). That algorithm is now only a last-resort
// fallback: the durable path runs grok's own signer in goja (statsig_engine.go),
// which self-heals across web reships. grok rotates the F algorithm on every
// reship, so these browser-captured (seed,F) triples go stale and CANNOT be
// regenerated offline. TestSelfHealStatsigE2E (live, via goja) is the real
// regression now. Kept skipped so the reference vectors aren't lost.
func TestComputeStatsigTail(t *testing.T) {
t.Skip("static F derivation is a deprecated fallback; see TestSelfHealStatsigE2E (goja)")
var curves [][]statsigCurve
if err := json.Unmarshal([]byte(statsigTestCurves), &curves); err != nil {
t.Fatalf("curves: %v", err)
}
for i, tc := range statsigTriples {
seed, err := hex.DecodeString(tc.seedHex)
if err != nil {
t.Fatalf("[%d] seed hex: %v", i, err)
}
got, err := computeStatsigTail(seed, curves)
if err != nil {
t.Fatalf("[%d] computeStatsigTail: %v", i, err)
}
if got != tc.wantM {
t.Errorf("[%d] group=%d\n got=%s\nwant=%s", i, int(seed[5])%len(curves), got, tc.wantM)
}
}
}
// TestSelfHealStatsigE2E exercises the full browser-free self-healing path:
// fetch the homepage, derive seed+F, cache the challenge, then hit the
// anti-bot-gated conversations/new endpoint. Requires a live GROK_TOK and no
// GROK_STATSIG_* env overrides.
func TestSelfHealStatsigE2E(t *testing.T) {
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
if token == "" {
t.Skip("no GROK_TOK")
}
c := NewClient("")
client, err := c.newTLSClient()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
c.ensureChallenge(ctx, client, token)
statsigMu.Lock()
ch, ok := statsigCache[token]
statsigMu.Unlock()
if !ok {
t.Fatal("challenge not cached (homepage fetch/derive failed)")
}
t.Logf("dynamic header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
body, err := c.postStream(ctx, client, token, "/rest/app-chat/conversations/new", map[string]any{
"temporary": true,
"modelName": "grok-3",
"message": "hi",
})
if err != nil {
t.Fatalf("conversations/new: %v", err)
}
t.Logf("OK bytes=%d head=%.80s", len(body), strings.ReplaceAll(body, "\n", " "))
}
// TestStatsigEngineConcurrent fires many concurrent goja signs through the pool to
// catch data races / engine cross-talk (run with -race). Requires a live GROK_TOK
// so the engine is built and the challenge cached.
func TestStatsigEngineConcurrent(t *testing.T) {
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
if token == "" {
t.Skip("no GROK_TOK")
}
c := NewClient("")
client, err := c.newTLSClient()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
c.ensureChallenge(ctx, client, token)
statsigMu.Lock()
ch, ok := statsigCache[token]
statsigMu.Unlock()
if !ok || ch.seedB64 == "" || ch.curvesJSON == "" {
t.Fatal("engine inputs not cached")
}
const n = 40
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
id, err := signWithEngine(ch.seedB64, ch.curvesJSON, "/rest/app-chat/conversations/new", "POST")
if err != nil {
errs <- err
return
}
if len(id) < 40 {
errs <- errTooShort
}
}(i)
}
wg.Wait()
close(errs)
for e := range errs {
t.Fatalf("concurrent sign: %v", e)
}
}
var errTooShort = errors.New("statsig id too short")
// TestGenerateVideoE2E generates a real grok video using only the dynamic
// self-healed statsig (no env overrides). Requires a live GROK_TOK.
func TestGenerateVideoE2E(t *testing.T) {
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
if token == "" {
t.Skip("no GROK_TOK")
}
c := NewClient("")
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
defer cancel()
data, meta, err := c.GenerateVideo(ctx, token, "a cat playing piano", "16:9", "720p", 6, nil, true)
if err != nil {
t.Fatalf("GenerateVideo: %v", err)
}
t.Logf("video bytes=%d meta=%v", len(data), meta)
if len(data) < 1<<20 {
t.Fatalf("video too small: %d bytes", len(data))
}
if !strings.Contains(string(data[:16]), "ftyp") {
t.Fatalf("not an mp4: % x", data[:16])
}
}
@@ -1,216 +0,0 @@
// statsig_shim.js — browser-environment shim that lets grok.com's own obfuscated
// x-statsig-id signer run inside goja. grok's code does all the (per-build,
// rotating) byte-indexing/curve-selection; we only provide the STABLE browser
// primitives it reads from: the seed <meta>, the .r-aufz1o SVG curve DOM, and a
// standard Web-Animations getComputedStyle sampler. Inputs arrive via globals set
// by Go before each sign: __SEED (base64 str), __CURVES (JSON [[{color,deg,bezier}]]),
// __PATH, __METHOD. Go also injects __goSha256(Uint8Array)->ArrayBuffer.
(function () {
'use strict';
var g = globalThis;
// ---- base64 (goja has no atob/btoa) ----
var B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
g.atob = function (s) {
s = String(s).replace(/=+$/, '');
var out = '', bits = 0, val = 0;
for (var i = 0; i < s.length; i++) {
var c = B64.indexOf(s.charAt(i));
if (c < 0) continue;
val = (val << 6) | c; bits += 6;
if (bits >= 8) { bits -= 8; out += String.fromCharCode((val >> bits) & 0xff); }
}
return out;
};
g.btoa = function (s) {
s = String(s); var out = '';
for (var i = 0; i < s.length; i += 3) {
var b0 = s.charCodeAt(i), b1 = s.charCodeAt(i + 1), b2 = s.charCodeAt(i + 2);
var h0 = b0 >> 2, h1 = ((b0 & 3) << 4) | (b1 >> 4);
var h2 = ((b1 & 15) << 2) | (b2 >> 6), h3 = b2 & 63;
out += B64[h0] + B64[h1];
out += isNaN(b1) ? '=' : B64[h2];
out += isNaN(b2) ? '=' : B64[h3];
}
return out;
};
// ---- TextEncoder (goja has no TextEncoder) ----
if (typeof g.TextEncoder === 'undefined') {
g.TextEncoder = function () {};
g.TextEncoder.prototype.encode = function (str) {
str = String(str);
var bytes = [];
for (var i = 0; i < str.length; i++) {
var c = str.charCodeAt(i);
if (c < 0x80) bytes.push(c);
else if (c < 0x800) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); }
else if (c >= 0xd800 && c <= 0xdbff) { // surrogate pair
var c2 = str.charCodeAt(++i);
var cp = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);
bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
} else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); }
}
return Uint8Array.from(bytes);
};
}
// ---- crypto.subtle.digest, backed by Go SHA-256 ----
g.crypto = g.crypto || {};
g.crypto.subtle = g.crypto.subtle || {};
g.crypto.subtle.digest = function (algo, data) {
// grok only ever asks for sha-256; g.__goSha256 returns an ArrayBuffer
var bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
return Promise.resolve(g.__goSha256(bytes));
};
// ---- Web Animations getComputedStyle sampler (the only real math we own) ----
var K = 4096;
function cubicBezier(x1, y1, x2, y2, p) {
if (p <= 0) return 0; if (p >= 1) return 1;
function bez(t, a, b) { var mt = 1 - t; return 3 * a * mt * mt * t + 3 * b * mt * t * t + t * t * t; }
var lo = 0, hi = 1;
for (var i = 0; i < 100; i++) { var m = (lo + hi) / 2; if (bez(m, x1, x2) < p) lo = m; else hi = m; }
return bez((lo + hi) / 2, y1, y2);
}
function hexToRgb(h) { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
function sample(anim) {
var kf = anim.keyframes, dur = anim.duration || K;
var frac = anim.currentTime / dur; if (frac < 0) frac = 0; if (frac > 1) frac = 1;
var bm = /cubic-bezier\(([^)]+)\)/.exec(kf.easing || '');
var eased = frac;
if (bm) { var p = bm[1].split(',').map(Number); eased = cubicBezier(p[0], p[1], p[2], p[3], frac); }
var c0 = hexToRgb(kf.color[0]), c1 = hexToRgb(kf.color[1]);
var col = [0, 1, 2].map(function (i) { return Math.round(c0[i] + (c1[i] - c0[i]) * eased); });
var d0 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[0])[1]);
var d1 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[1])[1]);
var ang = (d0 + (d1 - d0) * eased) * Math.PI / 180;
var cos = Math.cos(ang), sin = Math.sin(ang);
return { color: 'rgb(' + col[0] + ', ' + col[1] + ', ' + col[2] + ')',
transform: 'matrix(' + cos + ', ' + sin + ', ' + (-sin) + ', ' + cos + ', 0, 0)' };
}
// ---- minimal DOM ----
function makeEl(props) {
var e = {
nodeName: 'DIV', style: {}, childNodes: [], attrs: {}, _anim: null, _parent: null,
setAttribute: function (k, v) { e.attrs[k] = v; },
getAttribute: function (k) { return (k in e.attrs) ? e.attrs[k] : (props && props.attrs && k in props.attrs ? props.attrs[k] : null); },
appendChild: function (c) { e.childNodes.push(c); return c; },
append: function (c) { e.childNodes.push(c); return c; },
removeChild: function (c) { return c; },
remove: function () {},
animate: function (keyframes, opts) {
var anim = {
keyframes: keyframes,
duration: (opts && opts.duration) || (typeof opts === 'number' ? opts : K),
currentTime: 0, pause: function () {}, play: function () {}, cancel: function () {},
effect: { getKeyframes: function () { return Array.isArray(keyframes) ? keyframes : [keyframes]; } },
};
e._anim = anim; return anim;
},
getAnimations: function () { return e._anim ? [e._anim] : []; },
};
if (props) for (var k in props) if (k !== 'attrs') e[k] = props[k];
Object.defineProperty(e, 'parentElement', { get: function () { return e._parent; } });
Object.defineProperty(e, 'innerHTML', { set: function (v) { e._html = v; }, get: function () { return e._html; } });
Object.defineProperty(e, 'textContent', { set: function (v) { e._text = v; }, get: function () { return e._text; } });
return e;
}
// .r-aufz1o group: g.childNodes[0].childNodes[1].getAttribute('d') = svg path whose
// numbers (after substring(9), split('C')) decode back to each curve [c0..c5,deg,b0..b3].
function groupEl(flatCurves) {
var d = '_________' + flatCurves.map(function (c) { return c.join(' '); }).join('C');
var path = makeEl({ attrs: { d: d } });
var inner = makeEl(); inner.childNodes = [makeEl(), path];
var outer = makeEl(); outer.childNodes = [inner]; outer._parent = makeEl();
return outer;
}
var docBody = makeEl();
g.document = {
currentScript: null, body: docBody, head: makeEl(),
createElement: function (tag) { return makeEl({ nodeName: String(tag || 'div').toUpperCase() }); },
querySelectorAll: function (sel) {
sel = String(sel);
// The seed <meta> is selected by a name/verification attribute selector
// (e.g. [name^=gr] or [name*=verification]); the curve group container is
// selected by a per-build hashed CLASS selector (e.g. .r-aufz1o, .r-3nqkqc)
// which rotates on every reship — so match by shape, not literal class.
if (/verification|name/i.test(sel)) {
var seed = g.__SEED;
return [{ nodeName: 'META', getAttribute: function (a) { return a === 'content' ? seed : null; },
get content() { return seed; } }];
}
if (/(^|\s|,)\./.test(sel)) {
var curves = JSON.parse(g.__CURVES);
return curves.map(function (grp) {
return groupEl(grp.map(function (cv) { return cv.color.concat([cv.deg], cv.bezier); }));
});
}
return [];
},
querySelector: function (sel) { var r = this.querySelectorAll(sel); return r[0] || null; },
};
g.window = g;
g.self = g;
g.getComputedStyle = function (el) { return el && el._anim ? sample(el._anim) : { color: 'rgb(0, 0, 0)', transform: 'none' }; };
g.navigator = g.navigator || { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' };
g.location = g.location || { href: 'https://grok.com/', origin: 'https://grok.com', pathname: '/' };
// ---- Turbopack capture + bootstrap ----
var TP = []; TP.push = function (entry) { TP._entry = entry; return 0; };
g.TURBOPACK = TP;
// Called by Go AFTER the signer chunk is eval'd: run the module factory, grab default.
g.__grokBootstrap = function () {
var entry = TP._entry;
if (!entry) throw new Error('turbopack entry not registered');
var factory = entry[2];
var exports = {};
var ctx = {
s: function () {
var flat = Array.prototype.slice.call(arguments).flat(Infinity);
var name = null;
for (var i = 0; i < flat.length; i++) {
var x = flat[i];
if (typeof x === 'string') name = x;
else if (typeof x === 'function' && name != null) {
(function (nm, getter) { Object.defineProperty(exports, nm, { get: getter, configurable: true, enumerable: true }); })(name, x);
name = null;
}
}
},
};
factory(ctx);
// exports.default is a GETTER that invokes the module factory on every read,
// returning a FRESH async signer (path,method)=>Promise<id> with a fresh internal
// curve cache. Keep the exports object; read .default fresh per sign so different
// sessions' curves never leak through the closure cache.
g.__grokExports = exports;
if (typeof exports.default !== 'function') throw new Error('no default export');
};
// Called by Go per sign. Fresh signer each time via the re-invoking getter.
g.__grokSign = function () {
var signer = g.__grokExports.default; // fresh async signer
return signer(g.__PATH, g.__METHOD); // returns Promise<string>
};
// Synchronous bridge: Go calls this via RunString (which drains goja's job queue),
// then reads __grokResult / __grokErr. Works because crypto.subtle.digest resolves
// synchronously (Promise.resolve over a Go SHA-256), so the whole await chain settles
// within the microtask drain.
g.__grokResult = null;
g.__grokErr = null;
g.__grokSignInto = function () {
g.__grokResult = null; g.__grokErr = null;
try {
g.__grokSign().then(
function (r) { g.__grokResult = r; },
function (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
);
} catch (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
};
})();
-528
View File
@@ -1,528 +0,0 @@
package grok
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/textproto"
"os"
"regexp"
"strings"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/google/uuid"
)
// assetBase is where generated media artifacts live (the stream returns a
// path like "users/<uid>/generated/<id>/generated_video.mp4").
const assetBase = "https://assets.grok.com/"
var videoURLRe = regexp.MustCompile(`"videoUrl":"([^"]+)"`)
// GenerateVideo runs grok's imagine video pipeline:
// 1. POST /rest/media/post/create -> a media post id (parentPostId)
// 2. POST /rest/app-chat/conversations/new with modelName "imagine-video-gen"
// and a videoGenModelConfig referencing that post id; the streaming response
// reports progress and, at completion, the artifact videoUrl.
//
// frames (optional, up to the model's max) enable image-to-video: each image is
// uploaded to grok and referenced as an imageReference. aspectRatio is passed
// through ("9:16" etc.); resolution is the tier ("720p"); seconds is the clip
// length (6 or 10). When downloadResult is false, returns nil bytes and the
// artifact URL in meta["video_url"]; otherwise downloads the mp4.
func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio, resolution string, seconds int, frames [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, nil, ErrAuth
}
if strings.TrimSpace(prompt) == "" {
return nil, nil, fmt.Errorf("grok: prompt required")
}
if strings.TrimSpace(aspectRatio) == "" {
aspectRatio = "16:9"
}
if strings.TrimSpace(resolution) == "" {
resolution = "720p"
}
if seconds != 6 && seconds != 10 && seconds != 15 {
seconds = 10
}
// Only the conversations/new submit egresses via the proxy; reference-frame
// upload and the mp4 download run on the local IP.
submitClient, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
directClient, err := c.newDirectTLSClient()
if err != nil {
return nil, nil, err
}
// Self-heal the x-statsig-id anti-bot challenge for THIS session before the
// gated submit. Without it the header falls back to the static defaults,
// which go stale on a new grok web build and make conversations/new answer
// 403 (anti-bot). Fetch on the local IP (the proxy is rotating, so pinning
// the seed to a proxy egress IP is pointless). Failure is non-fatal
// (statsigID then uses defaults).
c.ensureChallenge(ctx, directClient, token)
// Image-to-video: upload each reference frame and collect its asset URL.
var imageRefs []string
for _, f := range frames {
if len(f) == 0 {
continue
}
url, upErr := c.uploadImage(ctx, directClient, token, f)
if upErr != nil {
return nil, nil, upErr
}
imageRefs = append(imageRefs, url)
}
// grok streams progress inline, but the stream is fragile: it regularly dies
// mid-body ("unexpected EOF") after only the conversation object / an early
// progress event — while grok keeps rendering the clip server-side. The
// rendered clip always lands at the deterministic URL
// assets.grok.com/users/{userId}/generated/{postId}/generated_video.mp4, so a
// dead stream is NOT a failed generation: poll that URL until the clip is
// ready instead of failing the job (which would also fail over and burn
// another account's credits for a video that already exists).
postID, userID, err := c.createPost(ctx, submitClient, token, prompt)
if err != nil {
return nil, nil, err
}
videoCfg := map[string]any{
"parentPostId": postID,
"aspectRatio": aspectRatio,
"videoLength": seconds,
"resolutionName": resolution,
"isReferenceToVideo": len(imageRefs) > 0,
}
if len(imageRefs) > 0 {
videoCfg["imageReferences"] = imageRefs
}
payload := map[string]any{
"temporary": true,
"modelName": "imagine-video-gen",
"message": prompt + " --mode=custom",
"enableSideBySide": true,
"responseMetadata": map[string]any{
"modelConfigOverride": map[string]any{
"modelMap": map[string]any{"videoGenModelConfig": videoCfg},
},
},
}
// body may be partial when psErr != nil — still worth scanning.
body, psErr := c.postStream(ctx, submitClient, token, "/rest/app-chat/conversations/new", payload)
if dir := strings.TrimSpace(os.Getenv("GROK_DUMP")); dir != "" {
_ = os.WriteFile(dir+"/grok_body_"+postID+".txt", []byte(body), 0o644)
}
// Out-of-credits surfaces as a stream error (HTTP is still 200) — not retryable.
if strings.Contains(body, "usagePoolExhausted") || strings.Contains(body, "media generation credits") {
return nil, nil, fmt.Errorf("%w: media generation credits exhausted", ErrQuotaExhausted)
}
if psErr != nil && (errors.Is(psErr, ErrAuth) || errors.Is(psErr, ErrQuotaExhausted)) {
return nil, nil, psErr
}
// A fatal stream error means grok definitively rejected this generation
// (content moderation, an unsupported parameter, etc.). Retrying — on this
// or any other account — fails identically and only burns the pool, so fail
// fast with a non-temporary error the pool won't fail over on.
if strings.Contains(body, "STREAM_ERROR_SEVERITY_FATAL") {
return nil, nil, fmt.Errorf("grok: video generation rejected by upstream (fatal stream error): %s", clip([]byte(body), 200))
}
// Fast path: the artifact appears as "videoUrl":"users/.../generated_video.mp4".
artifact := ""
for _, m := range videoURLRe.FindAllStringSubmatch(body, -1) {
if v := strings.TrimSpace(m[1]); v != "" {
artifact = v // keep the last (progress=100) one
}
}
if artifact == "" && userID != "" {
// Stream ended without the artifact (dropped connection or early close):
// the render continues upstream — wait for the deterministic asset URL.
url := fmt.Sprintf("%susers/%s/generated/%s/generated_video.mp4", assetBase, userID, postID)
if werr := c.waitForAsset(ctx, directClient, token, url); werr == nil {
artifact = url
} else if errors.Is(werr, ErrAuth) || errors.Is(werr, context.Canceled) || errors.Is(werr, context.DeadlineExceeded) {
return nil, nil, werr
}
}
if artifact == "" {
if psErr != nil {
return nil, nil, psErr
}
return nil, nil, fmt.Errorf("%w: no video artifact in response: %s", ErrTemporaryUpstream, clip([]byte(body), 200))
}
fullURL := artifact
if !strings.HasPrefix(fullURL, "http") {
fullURL = assetBase + strings.TrimPrefix(artifact, "/")
}
meta := map[string]any{
"provider": "grok",
"post_id": postID,
"video_url": fullURL,
}
if !downloadResult {
return nil, meta, nil
}
data, err := c.download(ctx, directClient, token, fullURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// uploadImage uploads one reference frame via /http/upload-file-v2/direct and
// returns its asset content URL for imageReferences. Cloudflare's bot score is
// per-request, so retry transient failures with backoff instead of failing the
// whole task.
func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (string, error) {
var res map[string]any
var err error
backoffs := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second}
for _, wait := range backoffs {
if wait > 0 {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(wait):
}
}
res, err = c.uploadFileV2(ctx, client, token, img)
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
break
}
}
if err != nil {
return "", err
}
meta, _ := res["fileMetadata"].(map[string]any)
fileURI := ""
if meta != nil {
fileURI = strings.TrimSpace(stringValue(meta["fileUri"]))
}
if fileURI == "" {
return "", fmt.Errorf("%w: upload missing fileUri", ErrTemporaryUpstream)
}
if strings.HasPrefix(fileURI, "http") {
return fileURI, nil
}
return assetBase + strings.TrimPrefix(fileURI, "/"), nil
}
func (c *Client) uploadFileV2(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (map[string]any, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
partHeader := textproto.MIMEHeader{}
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, uuid.NewString()+".png"))
partHeader.Set("Content-Type", "image/png")
part, err := mw.CreatePart(partHeader)
if err != nil {
return nil, err
}
if _, err := part.Write(img); err != nil {
return nil, err
}
if err := mw.WriteField("file_source", "IMAGINE_SELF_UPLOAD_FILE_SOURCE"); err != nil {
return nil, err
}
if err := mw.Close(); err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, apiBase+"/http/upload-file-v2/direct", bytes.NewReader(buf.Bytes()))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, map[string]string{"content-type": mw.FormDataContentType()})
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
if e := mapStatus("/http/upload-file-v2/direct", resp.StatusCode, raw); e != nil {
return nil, e
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: upload non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
}
return out, nil
}
// createPost registers a video media post and returns its id (parentPostId).
// createPost creates the parent media post. grok uses its id as the videoId,
// and the rendered clip is stored under users/{userId}/generated/{postId}/.
func (c *Client) createPost(ctx context.Context, client tlsclient.HttpClient, token, prompt string) (postID, userID string, err error) {
res, err := c.postJSON(ctx, client, token, "/rest/media/post/create", map[string]any{
"mediaType": "MEDIA_POST_TYPE_VIDEO",
"prompt": prompt,
})
if err != nil {
return "", "", err
}
post, _ := res["post"].(map[string]any)
postID = strings.TrimSpace(stringValue(post["id"]))
userID = strings.TrimSpace(stringValue(post["userId"]))
if postID == "" {
return "", "", fmt.Errorf("%w: media post missing id", ErrTemporaryUpstream)
}
return postID, userID, nil
}
// waitForAsset polls a generated-asset URL (ranged GET on the local IP —
// assets.grok.com is not anti-bot gated) until grok finishes rendering it.
// 404 means "still rendering"; auth failures abort.
func (c *Client) waitForAsset(ctx context.Context, client tlsclient.HttpClient, token, url string) error {
deadline := time.Now().Add(30 * time.Minute)
for {
if err := ctx.Err(); err != nil {
return err
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"user-agent": {userAgent},
"referer": {origin + "/"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
"range": {"bytes=0-0"},
}
if resp, derr := client.Do(req); derr == nil {
code := resp.StatusCode
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
switch {
case code == http.StatusOK || code == http.StatusPartialContent:
return nil
case code == http.StatusUnauthorized || code == http.StatusForbidden:
return fmt.Errorf("%w: asset %d", ErrAuth, code)
}
}
if time.Now().After(deadline) {
return fmt.Errorf("%w: video render did not complete in time", ErrTemporaryUpstream)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(4 * time.Second):
}
}
}
// postJSON does an authed JSON POST and parses a single JSON object response.
func (c *Client) postJSON(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) (map[string]any, error) {
raw, status, err := c.doPost(ctx, client, token, path, body)
if err != nil {
return nil, err
}
if e := mapStatus(path, status, raw); e != nil {
return nil, e
}
var out map[string]any
if len(raw) == 0 {
return map[string]any{}, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
}
return out, nil
}
// postStream does an authed JSON POST and returns the full (streamed) text body.
func (c *Client) postStream(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) (string, error) {
raw, status, err := c.doPost(ctx, client, token, path, body)
if err != nil {
return string(raw), err
}
if e := mapStatus(path, status, raw); e != nil {
return string(raw), e
}
return string(raw), nil
}
func (c *Client) doPost(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) ([]byte, int, error) {
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = strings.NewReader(string(b))
}
req, err := http.NewRequest(http.MethodPost, apiBase+path, reader)
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, nil)
resp, err := client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
// Mid-body stream drops ("unexpected EOF", HTTP/2 resets) are common on
// long renders — return what was received so callers can still use it.
return raw, resp.StatusCode, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
return raw, resp.StatusCode, nil
}
// OpenAsset streams a grok asset (e.g. a generated video) authenticated with the
// account token — used by the async /v1/videos /content proxy. The caller MUST
// close the returned ReadCloser.
func (c *Client) OpenAsset(ctx context.Context, token, url string) (io.ReadCloser, string, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, "", ErrAuth
}
// Asset streaming is pure bandwidth (no anti-bot) — egress on the local IP.
client, err := c.newDirectTLSClient()
if err != nil {
return nil, "", err
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"user-agent": {userAgent},
"referer": {origin + "/"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
}
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, "", fmt.Errorf("%w: asset %d", ErrAuth, resp.StatusCode)
}
return nil, "", fmt.Errorf("%w: asset %d", ErrTemporaryUpstream, resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = "video/mp4"
}
return resp.Body, ct, nil
}
// download fetches the rendered artifact. The clip is already generated at this
// point, so a transient failure here (HTTP/2 stream reset, CF hiccup) must not
// fail the whole task — retry with backoff.
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
var data []byte
var err error
for _, wait := range []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second} {
if wait > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
data, err = c.downloadOnce(ctx, client, token, url)
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
break
}
}
return data, err
}
func (c *Client) downloadOnce(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"user-agent": {userAgent},
"referer": {origin + "/"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
if len(data) == 0 {
return nil, fmt.Errorf("%w: empty video download", ErrTemporaryUpstream)
}
return data, nil
}
// mapStatus maps an HTTP status to the shared provider error sentinels.
func mapStatus(path string, status int, raw []byte) error {
switch {
case status == 200:
return nil
case status == 403 && isBotChallenge(string(raw)):
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
// NOT a dead token — transient, so a good account isn't killed by an
// IP/anti-bot hiccup. A statsig anti-bot rejection means the recipe likely
// went stale on a reship; kick the headless refresher to re-capture.
TriggerStatsigRefresh()
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
case status == 401 || status == 403:
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
case status == 429:
// 429 is grok RATE-LIMITING ("Too many requests") — a transient error that
// must NOT kill the account. Only a body that names a credit/usage-pool
// exhaustion is a real quota wall.
if isCreditError(string(raw)) {
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
}
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
case status >= 500:
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
default:
if isCreditError(string(raw)) {
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
}
return fmt.Errorf("grok: %s %d %s", path, status, clip(raw, 160))
}
}
// isBotChallenge reports whether a 403 body is an anti-bot interstitial rather
// than a real auth rejection: grok's own "anti-bot" marker or a Cloudflare
// challenge page ("Just a moment…" / cf-chl / challenge-platform).
func isBotChallenge(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "anti-bot") ||
strings.Contains(s, "just a moment") ||
strings.Contains(s, "cf-chl") ||
strings.Contains(s, "challenge-platform") ||
strings.Contains(s, "cf_chl")
}
func isCreditError(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "usagepoolexhausted") || strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
}
@@ -1,110 +0,0 @@
package grok
import (
"context"
"crypto/sha1"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
http "github.com/bogdanfinn/fhttp"
)
var innerChunkRe = regexp.MustCompile(`static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
func hnew(ctx context.Context, url, token string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
"accept-language": {"en-US,en;q=0.9"},
"user-agent": {userAgent},
"cookie": {"sso=" + token + "; sso-rw=" + token},
http.HeaderOrderKey: {"accept", "accept-language", "user-agent", "cookie"},
}
return req, nil
}
func TestGrokCacheChunks(t *testing.T) {
token := os.Getenv("DIAG_SSO")
if token == "" {
t.Skip("no DIAG_SSO")
}
dir := os.Getenv("DIAG_CACHE")
if dir == "" {
dir = `f:\ai-gateway\vivid-ai\backend\_chunks`
}
os.MkdirAll(dir, 0o755)
logf, _ := os.Create(filepath.Join(dir, "_log.txt"))
defer logf.Close()
lg := func(f string, a ...any) {
fmt.Fprintf(logf, f+"\n", a...)
logf.Sync()
}
c := NewClient(os.Getenv("DIAG_PROXY"))
ctx, cancel := context.WithTimeout(context.Background(), 3000*time.Second)
defer cancel()
client, err := c.newTLSClient()
if err != nil {
t.Fatal(err)
}
hreq, _ := hnew(ctx, apiBase+"/", token)
hresp, err := client.Do(hreq)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(hresp.Body)
hresp.Body.Close()
html := string(body)
os.WriteFile(filepath.Join(dir, "_home.html"), body, 0o644)
seen := map[string]bool{}
queue := dedupe(chunkPathRe.FindAllString(html, -1))
lg("home chunk refs: %d", len(queue))
saved := 0
for len(queue) > 0 && saved < 3000 {
p := queue[0]
queue = queue[1:]
norm := p
if !strings.HasPrefix(norm, "/_next/") {
norm = "/_next/" + strings.TrimPrefix(norm, "/")
}
if seen[norm] {
continue
}
seen[norm] = true
src, err := fetchChunk(ctx, client, norm)
if err != nil {
continue
}
saved++
base := norm[strings.LastIndex(norm, "/")+1:]
fn := fmt.Sprintf("%x_%s", sha1.Sum([]byte(norm)), base)
if len(fn) > 120 {
fn = fn[:120]
}
os.WriteFile(filepath.Join(dir, fn), []byte(src), 0o644)
for _, m := range innerChunkRe.FindAllString(src, -1) {
nn := "/_next/" + m
if !seen[nn] {
queue = append(queue, nn)
}
}
if saved%50 == 0 {
lg("saved=%d queue=%d", saved, len(queue))
}
}
lg("CRAWL DONE saved=%d queue_left=%d", saved, len(queue))
fmt.Printf("CRAWL DONE saved=%d\n", saved)
}
-452
View File
@@ -1,452 +0,0 @@
// Package imagine implements the Imagine.art (vyro.ai) provider client. The
// durable credential is a JSON blob {"token","refreshToken"}: `token` is a ~6h
// access JWT used as Authorization: Bearer for the API, and `refreshToken` is a
// ~7d JWT that mints a fresh pair via /apis/v1/auth/other/refresh/web when the
// access token expires. Both rotate on refresh, so the new pair MUST be saved.
// tls-client gives a Chrome JA3/JA4 so vyro's edge doesn't flag the requests.
package imagine
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"sync"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
const (
apiBase = "https://imagine.vyro.ai"
teamsBase = "https://teams-imagine.vyro.ai"
authBase = "https://auth.vyro.ai"
webOrigin = "https://www.imagine.art"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
var (
ErrAuth = errors.New("imagine auth failed")
ErrQuotaExhausted = errors.New("imagine quota exhausted")
ErrTemporaryUpstream = errors.New("imagine upstream temporary error")
)
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
// (proactive, not lazy at expiry) — the maintenance sweep keeps tokens fresh so a
// dormant account's rotating refreshToken never lapses.
const refreshLeadSeconds = 600 // 10 minutes
type Client struct {
proxy string
// freshest credential per account (key: user id) + a per-account refresh lock,
// so concurrent callers don't each spend the rotating refresh_token — the first
// refreshes, the rest reuse the cached fresh credential.
mu sync.Mutex
creds map[string]string
locks map[string]*sync.Mutex
}
func NewClient(proxy string) *Client {
return &Client{proxy: strings.TrimSpace(proxy), creds: map[string]string{}, locks: map[string]*sync.Mutex{}}
}
func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy)
}
func (c *Client) userLock(userID string) *sync.Mutex {
c.mu.Lock()
defer c.mu.Unlock()
m, ok := c.locks[userID]
if !ok {
m = &sync.Mutex{}
c.locks[userID] = m
}
return m
}
// ---------------------------------------------------------------------------
// Credential helpers
// ---------------------------------------------------------------------------
type credential struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
// Email is the real account email — supplied at import, used for display and
// (pool,email) dedup. It is NOT in the JWT (which only carries userId), so it
// must be carried across refreshes (the refresh response omits it).
Email string `json:"email,omitempty"`
// ParentID is a canvas node the account OWNS, used as the generation's
// parent_id. Imagine rejects any parent the account doesn't own ("user does
// not have access to parent asset") and silently orphans a parent-less
// generation (charged but never produced) — so it's supplied at import and
// carried across refreshes.
ParentID string `json:"parentId,omitempty"`
}
func parseCred(s string) (credential, bool) {
var cr credential
if json.Unmarshal([]byte(strings.TrimSpace(s)), &cr) != nil {
return cr, false
}
if strings.TrimSpace(cr.Token) == "" || strings.TrimSpace(cr.RefreshToken) == "" {
return cr, false
}
return cr, true
}
func buildCred(token, refresh, email, parentID string) string {
b, _ := json.Marshal(credential{
Token: strings.TrimSpace(token),
RefreshToken: strings.TrimSpace(refresh),
Email: strings.TrimSpace(email),
ParentID: strings.TrimSpace(parentID),
})
return string(b)
}
// ParentIDFromCred returns the canvas parent node id supplied at import.
func ParentIDFromCred(cred string) string {
cr, ok := parseCred(cred)
if !ok {
return ""
}
return strings.TrimSpace(cr.ParentID)
}
func looksLikeJWT(s string) bool {
return len(strings.Split(strings.TrimSpace(s), ".")) == 3
}
// IsImagineToken reports whether a pasted credential is an Imagine.art account:
// a JSON object carrying a non-empty token + refreshToken that both look like
// JWTs. Distinguishes it from adobe/leonardo/krea cookies.
func IsImagineToken(value string) bool {
cr, ok := parseCred(value)
if !ok {
return false
}
return looksLikeJWT(cr.Token) && looksLikeJWT(cr.RefreshToken)
}
// jwtClaims base64url-decodes the JWT payload (segment 1) into a claims map.
func jwtClaims(token string) map[string]any {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) < 2 {
return nil
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
// tolerate padded variants
if raw, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
return nil
}
}
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
return nil
}
return m
}
func userIDFromToken(token string) string {
claims := jwtClaims(token)
if claims == nil {
return ""
}
if v := strings.TrimSpace(stringValue(claims["userId"])); v != "" {
return v
}
return strings.TrimSpace(stringValue(claims["sub"]))
}
func tokenExp(token string) int64 {
claims := jwtClaims(token)
if claims == nil {
return 0
}
return toInt64(claims["exp"])
}
// EmailFromCred returns the real account email supplied at import; if absent it
// falls back to the JWT userId so (pool,email) dedup still has a stable key.
func EmailFromCred(cred string) string {
cr, ok := parseCred(cred)
if !ok {
return ""
}
if e := strings.TrimSpace(cr.Email); e != "" {
return e
}
return userIDFromToken(cr.Token)
}
// UserIDFromCred returns the JWT userId (== org_id used for credit/generation).
func UserIDFromCred(cred string) string {
cr, ok := parseCred(cred)
if !ok {
return ""
}
return userIDFromToken(cr.Token)
}
// ---------------------------------------------------------------------------
// Refresh
// ---------------------------------------------------------------------------
// RefreshIfNeeded returns a credential whose access token is still valid: if the
// stored one is (near) expired it spends the refreshToken to mint a fresh pair
// and rebuilds the credential. Returns (cred, changed, err); changed=true means
// the caller must persist the new credential (both tokens rotate). ErrAuth means
// the refreshToken is dead → the account is gone.
func (c *Client) RefreshIfNeeded(ctx context.Context, cred string) (string, bool, error) {
cr, ok := parseCred(cred)
if !ok {
return cred, false, nil // unparseable — let the downstream call surface the error
}
userID := userIDFromToken(cr.Token)
now := time.Now().Unix()
lk := c.userLock(userID)
lk.Lock()
defer lk.Unlock()
// A concurrent caller may already have refreshed this account.
if userID != "" {
c.mu.Lock()
cached := c.creds[userID]
c.mu.Unlock()
if cc, ok := parseCred(cached); ok && tokenExp(cc.Token)-refreshLeadSeconds > now {
return cached, cached != cred, nil
}
}
if tokenExp(cr.Token)-refreshLeadSeconds > now {
return cred, false, nil // still valid
}
respBody, status, err := c.refreshPost(ctx, cr.RefreshToken)
if err != nil {
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
}
if status == 400 || status == 401 || status == 403 {
return "", false, ErrAuth
}
if status != 200 {
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
}
var rb struct {
Result struct {
SessionToken string `json:"sessionToken"`
RefreshToken string `json:"refreshToken"`
} `json:"result"`
}
if json.Unmarshal(respBody, &rb) != nil || strings.TrimSpace(rb.Result.SessionToken) == "" {
return "", false, ErrAuth
}
newRefresh := rb.Result.RefreshToken
if strings.TrimSpace(newRefresh) == "" {
newRefresh = cr.RefreshToken // some responses may omit it — keep the old one
}
newCred := buildCred(rb.Result.SessionToken, newRefresh, cr.Email, cr.ParentID)
if userID != "" {
c.mu.Lock()
c.creds[userID] = newCred
c.mu.Unlock()
}
return newCred, true, nil
}
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
client, err := c.newDirectTLSClient()
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(http.MethodPost, authBase+"/apis/v1/auth/other/refresh/web", nil)
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"application/json, text/plain, */*"},
"authorization": {"Bearer " + refreshToken},
"origin": {webOrigin},
"referer": {webOrigin + "/"},
"user-agent": {userAgent},
http.HeaderOrderKey: {
"accept", "authorization", "origin", "referer", "user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err
}
// ---------------------------------------------------------------------------
// Credits
// ---------------------------------------------------------------------------
// FetchCreditsBalance reads the account's credit balance via /v1/credit.
// remaining is the `total` field. 401/403 → ErrAuth (token dead). Returns the
// normalized map shared by all providers.
func (c *Client) FetchCreditsBalance(ctx context.Context, cred string) (map[string]any, error) {
cr, ok := parseCred(cred)
if !ok {
return unknownBalance("bad credential"), nil
}
userID := userIDFromToken(cr.Token)
body, status, err := c.apiGetP(ctx, cr.Token, apiBase+"/v1/credit?org_id="+userID, false)
if err != nil {
return unknownBalance("network: " + err.Error()), nil
}
if status == 401 || status == 403 {
return nil, ErrAuth
}
if status != 200 {
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
}
var cb struct {
Status string `json:"status"`
Total int `json:"total"`
}
if err := json.Unmarshal(body, &cb); err != nil {
return unknownBalance("non-json"), nil
}
return map[string]any{
"remaining": cb.Total,
"used": nil,
"total": nil,
"unknown": false,
"error": nil,
"email": emptyStringNil(EmailFromCred(cred)),
}, nil
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
func (c *Client) apiGet(ctx context.Context, token, url string) ([]byte, int, error) {
return c.apiGetP(ctx, token, url, true)
}
// apiGetP picks the egress: polling runs direct (local IP).
func (c *Client) apiGetP(ctx context.Context, token, url string, useProxy bool) ([]byte, int, error) {
client, err := c.newTLSClientP(useProxy)
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"application/json, text/plain, */*"},
"authorization": {"Bearer " + token},
"origin": {webOrigin},
"referer": {webOrigin + "/"},
"user-agent": {userAgent},
http.HeaderOrderKey: {
"accept", "authorization", "origin", "referer", "user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err
}
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
// 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{
tlsclient.WithTimeoutSeconds(60),
tlsclient.WithClientProfile(profiles.Chrome_120),
}
if useProxy && c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
}
// ---------------------------------------------------------------------------
// Small util
// ---------------------------------------------------------------------------
func uuid4() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func unknownBalance(reason string) map[string]any {
return map[string]any{
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
}
}
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 toInt64(v any) int64 {
switch x := v.(type) {
case float64:
return int64(x)
case int64:
return x
case int:
return int64(x)
case json.Number:
n, _ := x.Int64()
return n
case string:
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
return n
default:
return 0
}
}
func emptyStringNil(v string) any {
if strings.TrimSpace(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
}
-251
View File
@@ -1,251 +0,0 @@
package imagine
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"strconv"
"strings"
"time"
http "github.com/bogdanfinn/fhttp"
)
// GenerateImage runs the full Imagine.art pipeline: submit the txt2img job, poll
// the org objects feed until the batch finishes, then download the produced
// image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K).
// HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs).
func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string, downloadResult bool) ([]byte, map[string]any, error) {
cr, ok := parseCred(cred)
if !ok {
return nil, nil, ErrAuth
}
userID := userIDFromToken(cr.Token)
metadata, _ := json.Marshal(map[string]any{
"placeholderUuid": uuid4(),
"promptWithoutManipulation": prompt,
"modeId": 0,
})
// parent_id MUST be a canvas node this account owns — the server rejects a
// foreign id ("user does not have access to parent asset") and silently
// orphans a parent-less generation (charged but never produced). It's supplied
// at import (credential.parentId).
fields := map[string]string{
"style_id": strconv.Itoa(styleID),
"aspect_ratio": aspectRatio,
"resolution": resolution,
"variation": "txt2img",
"prompt": prompt,
"is_enhance": "0",
"count": "1",
"clientVersion": "1",
"org_id": userID,
"use_plugin": "false",
"metadata": string(metadata),
}
if pid := strings.TrimSpace(cr.ParentID); pid != "" {
fields["parent_id"] = pid
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
_ = w.WriteField(k, v)
}
_ = w.Close()
body, status, err := c.apiPost(ctx, cr.Token, apiBase+"/v1/image/generations/upload", w.FormDataContentType(), buf.Bytes())
if err != nil {
return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error())
}
if status == 401 || status == 403 {
return nil, nil, ErrAuth
}
if status == 402 {
return nil, nil, ErrQuotaExhausted
}
if status != 200 && status != 201 {
return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
}
var jobs []struct {
BatchID string `json:"batchId"`
ID string `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].BatchID == "" {
return nil, nil, fmt.Errorf("%w: no batch id: %s", ErrTemporaryUpstream, clip(body, 200))
}
batchID := jobs[0].BatchID
imageURL, err := c.pollImage(ctx, cr.Token, userID, batchID)
if err != nil {
return nil, nil, err
}
meta := map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}
if !downloadResult {
return nil, meta, nil
}
data, err := c.download(ctx, imageURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// pollImage polls the org objects feed until the entry for our batch finishes,
// then extracts its asset URL (image_url is a JSON-encoded array string).
func (c *Client) pollImage(ctx context.Context, token, userID, batchID string) (string, error) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
// Poll for the full generation budget (caller's genCtx), leaving headroom for
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
deadline := time.Now().Add(4 * time.Minute)
if dl, ok := ctx.Deadline(); ok {
deadline = dl.Add(-60 * time.Second)
}
url := teamsBase + "/v1/org/" + userID + "/objects?batch=true&limit=50&service=image,chat-image"
for {
body, status, err := c.apiGetP(ctx, token, url, false)
if err == nil && status == 200 {
var resp struct {
Data []struct {
BatchID string `json:"batch_id"`
Status string `json:"status"`
Code int `json:"code"`
// Current shape: the produced asset lives at url.generation[0].
URL struct {
Generation []string `json:"generation"`
} `json:"url"`
ImageURL string `json:"image_url"` // legacy fallback
} `json:"data"`
}
if json.Unmarshal(body, &resp) == nil {
for _, o := range resp.Data {
if o.BatchID != batchID {
continue
}
st := strings.ToLower(strings.TrimSpace(o.Status))
switch {
case st == "finished" || o.Code == 2:
if u := firstNonEmpty(o.URL.Generation); u != "" {
return u, nil
}
if u := firstImageURL(o.ImageURL); u != "" {
return u, nil
}
case st == "failed" || st == "error":
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, st)
}
}
}
} else if status == 401 || status == 403 {
return "", ErrAuth
}
if time.Now().After(deadline) {
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-ticker.C:
}
}
}
// firstNonEmpty returns the first non-blank string in a slice.
func firstNonEmpty(ss []string) string {
for _, s := range ss {
if strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
return ""
}
// firstImageURL parses the image_url field — a JSON-encoded array of URLs — and
// returns the first one. Tolerates a bare string too.
func firstImageURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var urls []string
if json.Unmarshal([]byte(raw), &urls) == nil {
for _, u := range urls {
if strings.TrimSpace(u) != "" {
return strings.TrimSpace(u)
}
}
return ""
}
if strings.HasPrefix(raw, "http") {
return raw
}
return ""
}
func (c *Client) download(ctx context.Context, url string) ([]byte, error) {
client, err := c.newDirectTLSClient()
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
"user-agent": {userAgent},
"referer": {webOrigin + "/"},
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
}
return b, nil
}
// apiPost issues a POST with a raw body + content-type, carrying the bearer token.
func (c *Client) apiPost(ctx context.Context, token, url, contentType string, body []byte) ([]byte, int, error) {
client, err := c.newTLSClient()
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"application/json, text/plain, */*"},
"authorization": {"Bearer " + token},
"content-type": {contentType},
"origin": {webOrigin},
"referer": {webOrigin + "/"},
"user-agent": {userAgent},
http.HeaderOrderKey: {
"accept", "authorization", "content-type", "origin", "referer", "user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err
}
-499
View File
@@ -1,499 +0,0 @@
// Package krea implements the Krea.ai (krea.ai) provider client. The durable
// credential is the browser cookie (Supabase "sb-superb-auth-token"); Krea's own
// Next.js backend reads it directly, so quota and generation just forward the
// cookie — there's no separate token-exchange step. tls-client gives a Chrome
// JA3/JA4 so Krea's Cloudflare edge doesn't flag the requests.
package krea
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"sync"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
// kreaAnonKey is Krea's public Supabase anon key (fixed, embedded in their
// frontend) — required as the apikey/bearer when refreshing a session token.
const kreaAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1Mjc4ODU3LCJleHAiOjE5MzI5NTg4NTd9.NUiqEOd__QsCCMjo3D1zrCAda5dLV2F5p6Kf584sZKc"
const (
apiBase = "https://www.krea.ai"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
var (
ErrAuth = errors.New("krea auth failed")
ErrQuotaExhausted = errors.New("krea quota exhausted")
ErrTemporaryUpstream = errors.New("krea upstream temporary error")
)
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
// (not lazily at expiry). The maintenance sweep refreshes proactively, so a
// dormant account's token never lapses — once the rotating refresh_token is gone
// (expired/consumed) the account can't recover, so we keep it perpetually fresh.
const refreshLeadSeconds = 600 // 10 minutes
type Client struct {
proxy string
// freshest cookie per account (key: user id) + a per-account refresh lock, so
// concurrent callers don't each spend the single-use (rotating) refresh_token —
// the first refreshes, the rest reuse the cached fresh cookie.
mu sync.Mutex
cookies map[string]string
locks map[string]*sync.Mutex
// actAt = last /app activation time per account (key: user id); actLocks gives
// a per-account lock so concurrent generations wait for the first to finish the
// (once-per-daily-reset) activation instead of each loading /app.
actAt map[string]int64
actLocks map[string]*sync.Mutex
}
func NewClient(proxy string) *Client {
return &Client{
proxy: strings.TrimSpace(proxy),
cookies: map[string]string{},
locks: map[string]*sync.Mutex{},
actAt: map[string]int64{},
actLocks: map[string]*sync.Mutex{},
}
}
func (c *Client) userLock(userID string) *sync.Mutex {
c.mu.Lock()
defer c.mu.Unlock()
m, ok := c.locks[userID]
if !ok {
m = &sync.Mutex{}
c.locks[userID] = m
}
return m
}
// RefreshIfNeeded returns a cookie whose access_token is still valid: if the
// stored one is (near) expired it spends the refresh_token to mint a new session
// and rebuilds the cookie. Returns (cookie, changed, err); changed=true means the
// caller must persist the new cookie (the refresh_token rotated). ErrAuth means
// the refresh_token is dead → the account is gone.
func (c *Client) RefreshIfNeeded(ctx context.Context, cookie string) (string, bool, error) {
authVal := authCookieValue(cookie)
sess, ok := decodeSession(authVal)
if !ok {
return cookie, false, nil // unparseable — let the downstream call surface the error
}
userID := nestedStr(sess, "user", "id")
now := time.Now().Unix()
lk := c.userLock(userID)
lk.Lock()
defer lk.Unlock()
// A concurrent caller may already have refreshed this account.
if userID != "" {
c.mu.Lock()
cached := c.cookies[userID]
c.mu.Unlock()
if cs, ok := decodeSession(authCookieValue(cached)); ok && toInt64(cs["expires_at"])-refreshLeadSeconds > now {
return cached, cached != cookie, nil
}
}
if toInt64(sess["expires_at"])-refreshLeadSeconds > now {
return cookie, false, nil // still valid
}
refreshTok := strings.TrimSpace(stringValue(sess["refresh_token"]))
if refreshTok == "" {
return "", false, ErrAuth
}
respBody, status, err := c.refreshPost(ctx, refreshTok)
if err != nil {
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
}
if status == 400 || status == 401 || status == 403 {
return "", false, ErrAuth
}
if status != 200 {
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
}
var ns map[string]any
if json.Unmarshal(respBody, &ns) != nil || strings.TrimSpace(stringValue(ns["access_token"])) == "" {
return "", false, ErrAuth
}
newCookie := replaceAuthCookie(cookie, "base64-"+base64.StdEncoding.EncodeToString(respBody))
if userID != "" {
c.mu.Lock()
c.cookies[userID] = newCookie
c.mu.Unlock()
}
return newCookie, true, nil
}
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
client, err := c.newDirectTLSClient()
if err != nil {
return nil, 0, err
}
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
req, err := http.NewRequest(http.MethodPost, apiBase+"/auth/v1/token?grant_type=refresh_token", bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"content-type": {"application/json;charset=UTF-8"},
"apikey": {kreaAnonKey},
"authorization": {"Bearer " + kreaAnonKey},
"x-client-info": {"supabase-ssr/0.6.1 createBrowserClient"},
"x-supabase-api-version": {"2024-01-01"},
"origin": {apiBase},
"referer": {apiBase + "/"},
"user-agent": {userAgent},
http.HeaderOrderKey: {
"accept", "content-type", "apikey", "authorization", "x-client-info",
"x-supabase-api-version", "origin", "referer", "user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err
}
// chunkSize is supabase-ssr's per-cookie chunk limit; larger sessions (e.g.
// Google-OAuth accounts) are split into sb-superb-auth-token.0/.1/...
const chunkSize = 3600
func cookieVal(cookie, name string) string {
for _, p := range strings.Split(cookie, ";") {
p = strings.TrimSpace(p)
if v, ok := strings.CutPrefix(p, name+"="); ok {
return v
}
}
return ""
}
// authCookieValue returns the full auth value, transparently reassembling a
// chunked cookie (sb-superb-auth-token.0 + .1 + ...) or returning the single one.
func authCookieValue(cookie string) string {
if v := cookieVal(cookie, "sb-superb-auth-token"); v != "" {
return v
}
var b strings.Builder
for i := 0; ; i++ {
v := cookieVal(cookie, fmt.Sprintf("sb-superb-auth-token.%d", i))
if v == "" {
break
}
b.WriteString(v)
}
return b.String()
}
// replaceAuthCookie drops every sb-superb-auth-token[.N] cookie and re-adds the
// new value (chunked the same way supabase-ssr would if it's large), preserving
// all other cookies (krea-workspace-id, etc.).
func replaceAuthCookie(cookie, newValue string) string {
var out []string
for _, p := range strings.Split(cookie, ";") {
t := strings.TrimSpace(p)
if t == "" || strings.HasPrefix(t, "sb-superb-auth-token=") || strings.HasPrefix(t, "sb-superb-auth-token.") {
continue
}
out = append(out, t)
}
if len(newValue) <= chunkSize {
out = append(out, "sb-superb-auth-token="+newValue)
} else {
for i, off := 0, 0; off < len(newValue); i++ {
end := off + chunkSize
if end > len(newValue) {
end = len(newValue)
}
out = append(out, fmt.Sprintf("sb-superb-auth-token.%d=%s", i, newValue[off:end]))
off = end
}
}
return strings.Join(out, "; ")
}
// decodeSession base64-decodes the auth cookie value into the session JSON map.
func decodeSession(authValue string) (map[string]any, bool) {
v := strings.TrimPrefix(strings.TrimSpace(authValue), "base64-")
if v == "" {
return nil, false
}
raw, err := base64.StdEncoding.DecodeString(v)
if err != nil {
raw, err = base64.RawURLEncoding.DecodeString(v)
if err != nil {
return nil, false
}
}
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
return nil, false
}
return m, true
}
func nestedStr(m map[string]any, k1, k2 string) string {
if sub, ok := m[k1].(map[string]any); ok {
return strings.TrimSpace(stringValue(sub[k2]))
}
return ""
}
func toInt64(v any) int64 {
switch x := v.(type) {
case float64:
return int64(x)
case int64:
return x
case int:
return int64(x)
case json.Number:
n, _ := x.Int64()
return n
case string:
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
return n
default:
return 0
}
}
func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy)
}
// IsKreaCookie reports whether a pasted credential is a Krea cookie: it carries
// the Supabase auth cookie. Distinguishes it from adobe/leonardo cookies.
func IsKreaCookie(value string) bool {
return strings.Contains(value, "sb-superb-auth-token")
}
// EmailFromCookie decodes the account email straight out of the cookie's embedded
// Supabase session (no network), handling chunked cookies too.
func EmailFromCookie(cookie string) string {
sess, ok := decodeSession(authCookieValue(cookie))
if !ok {
return ""
}
return nestedStr(sess, "user", "email")
}
// FetchCreditsBalance reads the account's free-credit balance via /api/billing-data.
// remaining is the integer floor of balance.free (per spec: 17.94 → 17). 401 →
// ErrAuth (cookie dead). Returns the normalized map shared by all providers.
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
// Detach from the request ctx so a page refresh can't cancel the probe
// mid-flight (which left accounts stuck at "—").
probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
// NOTE: no /app here — the heavy SSR activation is done separately (on recovery
// and at generation via Activate). This probe just reads the current balance.
body, status, err := c.apiGetP(probeCtx, cookie, "/api/billing-data", false)
if err != nil {
return unknownBalance("network: " + err.Error()), nil
}
if status == 401 || status == 403 {
return nil, ErrAuth
}
if status != 200 {
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
}
// 余额在 balance.free(真实剩余,小数,随用量递减)。krea 的这个字段一直都在,
// 只是排在很长的 entitlements 之后 —— 只读它,不用套餐配额兜底。
var bd struct {
Balance struct {
Free float64 `json:"free"`
Total float64 `json:"total"`
} `json:"balance"`
}
if err := json.Unmarshal(body, &bd); err != nil {
return unknownBalance("non-json"), nil
}
remaining := int(bd.Balance.Free) // floor
return map[string]any{
"remaining": remaining,
"used": nil,
"total": int(bd.Balance.Total),
"unknown": false,
"error": nil,
"email": emptyStringNil(EmailFromCookie(cookie)),
}, nil
}
// Activate loads the authenticated SSR app page (/app), which is what makes krea
// grant the account's DAILY free balance — a cold API-only call (billing-data /
// generate) otherwise sees balance.free=0 and 402s. Done at most ONCE per account
// per daily reset, under a per-account lock: the first caller loads /app while
// concurrent callers wait, then everyone proceeds (no redundant /app). Called
// before each generation and by the daily activation sweep. Best-effort.
func (c *Client) Activate(ctx context.Context, cookie string) {
key := accountKey(cookie)
lastReset := (time.Now().Unix() / 86400) * 86400
c.mu.Lock()
doneToday := key != "" && c.actAt[key] >= lastReset
c.mu.Unlock()
if doneToday {
return
}
lk := c.actLock(key)
lk.Lock()
defer lk.Unlock()
// Re-check after acquiring the lock — another caller may have just activated.
c.mu.Lock()
doneToday = key != "" && c.actAt[key] >= lastReset
c.mu.Unlock()
if doneToday {
return
}
actCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
defer cancel()
_, _, _ = c.apiGetP(actCtx, cookie, "/app", false)
c.mu.Lock()
c.actAt[key] = time.Now().Unix()
c.mu.Unlock()
}
func (c *Client) actLock(key string) *sync.Mutex {
c.mu.Lock()
defer c.mu.Unlock()
m, ok := c.actLocks[key]
if !ok {
m = &sync.Mutex{}
c.actLocks[key] = m
}
return m
}
// accountKey is the stable per-account id (the Supabase user id from the session
// cookie) used to key activation state — survives cookie rotation.
func accountKey(cookie string) string {
if sess, ok := decodeSession(authCookieValue(cookie)); ok {
return nestedStr(sess, "user", "id")
}
return ""
}
// apiGet issues a GET to a krea.ai API path carrying the account cookie.
func (c *Client) apiGet(ctx context.Context, cookie, path string) ([]byte, int, error) {
return c.apiGetP(ctx, cookie, path, true)
}
// apiGetP picks the egress: polling / asset resolution run direct (local IP).
func (c *Client) apiGetP(ctx context.Context, cookie, path string, useProxy bool) ([]byte, int, error) {
client, err := c.newTLSClientP(useProxy)
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(http.MethodGet, apiBase+path, nil)
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"cookie": {cookie},
"referer": {apiBase + "/"},
"user-agent": {userAgent},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
http.HeaderOrderKey: {
"accept", "accept-language", "cookie", "referer", "user-agent",
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err
}
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{
tlsclient.WithTimeoutSeconds(60),
tlsclient.WithClientProfile(profiles.Chrome_120),
}
if useProxy && c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
}
func unknownBalance(reason string) map[string]any {
return map[string]any{
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
}
}
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 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 {
if strings.TrimSpace(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
}
-324
View File
@@ -1,324 +0,0 @@
package krea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"strings"
"time"
http "github.com/bogdanfinn/fhttp"
)
const (
genModel = "flux2-klein4b" // the single exposed Krea model
genEndpoint = "/api/jobs/v2/new/fluxKlein4b"
refStrength = 0.4
)
// ensureProject returns a flux project id for the account: the first existing
// project, or a freshly created one. Generation requires a project.
func (c *Client) ensureProject(ctx context.Context, cookie string) (string, error) {
body, status, err := c.apiGetP(ctx, cookie, "/api/flux-projects", false)
if err != nil {
return "", fmt.Errorf("%w: list projects: %s", ErrTemporaryUpstream, err.Error())
}
if status == 401 || status == 403 {
return "", ErrAuth
}
if status == 200 {
var projs []struct {
ID string `json:"id"`
}
if json.Unmarshal(body, &projs) == nil {
for _, p := range projs {
if strings.TrimSpace(p.ID) != "" {
return p.ID, nil
}
}
}
}
// No project on this account (e.g. brand-new). Try to create one; if that
// fails, fall back to generating WITHOUT a project (Krea assigns a default).
cb, cs, cerr := c.apiPostJSON(ctx, cookie, "/api/flux-projects", map[string]any{"title": "vivid"})
if cerr == nil && (cs == 200 || cs == 201) {
var pr struct {
ID string `json:"id"`
}
if json.Unmarshal(cb, &pr) == nil && pr.ID != "" {
return pr.ID, nil
}
}
return "", nil // generate without an explicit project
}
// uploadImage uploads a reference image (i2i) and returns its app-uploads URL.
func (c *Client) uploadImage(ctx context.Context, cookie string, img []byte) (string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", "image.png")
if err != nil {
return "", err
}
if _, err := fw.Write(img); err != nil {
return "", err
}
_ = w.Close()
body, status, err := c.apiPostP(ctx, cookie, "/api/upload?", w.FormDataContentType(), buf.Bytes(), false)
if err != nil {
return "", fmt.Errorf("%w: upload: %s", ErrTemporaryUpstream, err.Error())
}
if status == 401 || status == 403 {
return "", ErrAuth
}
if status != 200 {
return "", fmt.Errorf("%w: upload http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
}
var ur struct {
ImageURL string `json:"imageUrl"`
}
if json.Unmarshal(body, &ur) != nil || ur.ImageURL == "" {
return "", fmt.Errorf("%w: no imageUrl", ErrTemporaryUpstream)
}
return ur.ImageURL, nil
}
// GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i)
// upload reference images, submit the job, poll until done, then resolve and
// download the produced image. 402 INSUFFICIENT_BALANCE → ErrQuotaExhausted.
func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width, height int, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
// Ensure the daily free balance is granted (load /app) before generating, so a
// not-yet-activated account doesn't 402 INSUFFICIENT_BALANCE. Lock-guarded and
// once-per-daily-reset — concurrent gens wait for the first activation, already
// activated ones skip straight through.
c.Activate(ctx, cookie)
projectID, err := c.ensureProject(ctx, cookie)
if err != nil {
return nil, nil, err
}
var styleImages []map[string]any
for _, img := range refImages {
if len(img) == 0 {
continue
}
url, upErr := c.uploadImage(ctx, cookie, img)
if upErr != nil {
return nil, nil, upErr
}
styleImages = append(styleImages, map[string]any{"url": url, "strength": refStrength, "source": "upload"})
}
payload := map[string]any{
"provider": genModel,
"prompt": prompt,
"width": width,
"height": height,
"strength": 1,
"steps": 28,
"guidance_scale_flux": 3.5,
"presetStyles": []any{},
"batchSize": 2,
"guidance": 3.5,
}
if projectID != "" {
payload["project"] = projectID
}
if len(styleImages) > 0 {
payload["styleImages"] = styleImages
}
payloadJSON, _ := json.Marshal(payload)
// Submit (multipart with a single "payload" field).
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
_ = w.WriteField("payload", string(payloadJSON))
_ = w.Close()
body, status, err := c.apiPost(ctx, cookie, genEndpoint, w.FormDataContentType(), buf.Bytes())
if err != nil {
return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error())
}
if status == 401 || status == 403 {
return nil, nil, ErrAuth
}
if status == 402 || strings.Contains(string(body), "INSUFFICIENT_BALANCE") {
return nil, nil, ErrQuotaExhausted
}
if status != 200 && status != 201 {
return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
}
var jobs []struct {
JobID string `json:"job_id"`
}
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].JobID == "" {
return nil, nil, fmt.Errorf("%w: no job id: %s", ErrTemporaryUpstream, clip(body, 200))
}
// batchSize=2 returns two jobs; keep the SECOND image and discard the first
// (per spec). Fall back to the first if only one came back.
jobID := jobs[0].JobID
if len(jobs) >= 2 && jobs[1].JobID != "" {
jobID = jobs[1].JobID
}
// Poll until terminal, then resolve the produced image.
imageURL, err := c.pollImage(ctx, cookie, jobID)
if err != nil {
return nil, nil, err
}
meta := map[string]any{"job_id": jobID, "image_url": imageURL, "project": projectID}
if !downloadResult {
return nil, meta, nil
}
data, err := c.download(ctx, imageURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// pollImage polls job-status until the job leaves the queue, then matches the
// produced asset by generation_job_id and returns its image URL.
func (c *Client) pollImage(ctx context.Context, cookie, jobID string) (string, error) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
// Poll for the full generation budget (caller's genCtx), leaving headroom for
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
deadline := time.Now().Add(4 * time.Minute)
if dl, ok := ctx.Deadline(); ok {
deadline = dl.Add(-60 * time.Second)
}
for {
body, status, err := c.apiGetP(ctx, cookie, "/api/job-status?id="+jobID, false)
if err == nil && status == 200 {
var js struct {
Status string `json:"status"`
}
if json.Unmarshal(body, &js) == nil {
switch strings.ToLower(js.Status) {
case "complete", "completed", "succeeded", "success", "done", "finished":
if url, e := c.assetForJob(ctx, cookie, jobID); e == nil && url != "" {
return url, nil
}
case "failed", "error", "cancelled", "canceled":
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, js.Status)
}
}
} else if status == 401 || status == 403 {
return "", ErrAuth
}
if time.Now().After(deadline) {
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-ticker.C:
}
}
}
// assetForJob finds the generated asset produced by a job and returns its URL.
func (c *Client) assetForJob(ctx context.Context, cookie, jobID string) (string, error) {
body, status, err := c.apiGetP(ctx, cookie, "/api/assets?filter=generated&offset=0", false)
if err != nil || status != 200 {
return "", fmt.Errorf("assets http %d", status)
}
var assets []struct {
ImageURL string `json:"image_url"`
Metadata struct {
GenerationJobID string `json:"generation_job_id"`
} `json:"metadata"`
}
if json.Unmarshal(body, &assets) != nil {
return "", fmt.Errorf("assets non-json")
}
for _, a := range assets {
if a.Metadata.GenerationJobID == jobID && strings.TrimSpace(a.ImageURL) != "" {
return a.ImageURL, nil
}
}
return "", fmt.Errorf("asset not found yet")
}
func (c *Client) download(ctx context.Context, url string) ([]byte, error) {
client, err := c.newDirectTLSClient()
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
"user-agent": {userAgent},
"referer": {apiBase + "/"},
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
}
return b, nil
}
// apiPost issues a POST with a raw body + content-type, carrying the cookie.
func (c *Client) apiPost(ctx context.Context, cookie, path, contentType string, body []byte) ([]byte, int, error) {
return c.apiPostP(ctx, cookie, path, contentType, body, true)
}
// apiPostP picks the egress: reference-image upload runs direct (local IP), the
// generate submit uses the proxy.
func (c *Client) apiPostP(ctx context.Context, cookie, path, contentType string, body []byte, useProxy bool) ([]byte, int, error) {
client, err := c.newTLSClientP(useProxy)
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(http.MethodPost, apiBase+path, bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"content-type": {contentType},
"cookie": {cookie},
"origin": {apiBase},
"referer": {apiBase + "/"},
"user-agent": {userAgent},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
http.HeaderOrderKey: {
"accept", "accept-language", "content-type", "cookie", "origin",
"referer", "user-agent", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err
}
func (c *Client) apiPostJSON(ctx context.Context, cookie, path string, payload any) ([]byte, int, error) {
// project bootstrap runs on the local IP; only the generate submit uses the proxy.
b, _ := json.Marshal(payload)
return c.apiPostP(ctx, cookie, path, "application/json", b, false)
}
@@ -1,732 +0,0 @@
// Package leonardo implements the Leonardo.ai (app.leonardo.ai) provider client.
// Unlike chatgpt/runway (whose JWT IS the stored credential), Leonardo's durable
// credential is the browser COOKIE (better-auth session): the bearer access token
// it mints lives only ~1h. So every call here takes the cookie and derives a
// fresh JWT on the fly via /api/auth/get-session — there is no long-lived token to
// store or a separate refresh profile to maintain. tls-client gives a Chrome
// JA3/JA4 fingerprint so the requests aren't flagged.
package leonardo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"sync"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
const (
appBase = "https://app.leonardo.ai"
graphqlURL = "https://api.leonardo.ai/v1/graphql"
schemaVersion = "1.255.2"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
// sec-ch-ua must agree with userAgent's major version — a mismatch is itself a
// bot signal.
secChUA = `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`
// get-session only answers with a token once better-auth's cookie cache has
// been refreshed by a cross-origin-cookie call; called cold it returns 200
// null, which looks exactly like a dead cookie. It also sits behind Vercel's
// checkpoint, which 429s machine requests from some exit IPs — so a failed
// attempt is retried, falling back to the proxy for a different exit IP.
getSessionAttempts = 10
getSessionRetryDelay = 2 * time.Second
// A warmed 200 null usually means the session is gone server-side, but the
// same answer also comes back when the checkpoint silently serves a session-
// less page to an exit IP — so retry it a few times (later attempts through
// the proxy) before calling the account dead. Fewer attempts than the 429
// budget: each one costs two requests and a truly dead cookie never recovers.
getSessionNullAttempts = 3
)
var (
ErrAuth = errors.New("leonardo auth failed")
ErrQuotaExhausted = errors.New("leonardo quota exhausted")
ErrTemporaryUpstream = errors.New("leonardo upstream temporary error")
)
type Client struct {
proxy string
// sessions caches the short-lived access token per cookie so we don't hit
// /api/auth/get-session on every call — Leonardo rate-limits that endpoint
// (429) hard, so re-using the ~1h JWT is essential.
mu sync.Mutex
sessions map[string]*Session
// rotated maps a stored cookie to the freshest value Leonardo handed back via
// Set-Cookie (better-auth rotates its session_data cookie cache). The service
// persists it; keeping it here means an unpersisted rotation still works for
// the rest of the process's life.
rotated map[string]string
// refreshing serialises get-session per cookie. Two concurrent refreshes hand
// Cognito the same refresh token twice and its reuse detection revokes the
// whole session — the account then answers 401 forever.
refreshing map[string]*sync.Mutex
}
func NewClient(proxy string) *Client {
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}, rotated: map[string]string{}, refreshing: map[string]*sync.Mutex{}}
}
func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy)
}
// IsLeonardoCookie reports whether a pasted credential is a Leonardo cookie: it
// carries the better-auth session cookie name. This is what disambiguates it from
// an Adobe cookie at import time.
func IsLeonardoCookie(value string) bool {
return strings.Contains(value, "__Secure-better-auth.session_token") ||
strings.Contains(value, "better-auth.session_data")
}
// HasSessionData reports whether the cookie carries better-auth's session_data
// cache. Leonardo authenticates get-session off THAT cookie: session_token alone
// answers 200 null (no bearer), which looks exactly like a dead account — so a
// cookie without it must be rejected at import instead of dying later.
func HasSessionData(value string) bool {
return strings.Contains(value, "better-auth.session_data")
}
// RotatedCookie returns the freshest value for a stored cookie when Leonardo
// rotated its session_data cache, so the caller can persist it.
func (c *Client) RotatedCookie(cookie string) (string, bool) {
key := strings.TrimSpace(cookie)
c.mu.Lock()
defer c.mu.Unlock()
fresh, ok := c.rotated[key]
return fresh, ok && fresh != key
}
// mergeCookies applies a response's Set-Cookie pairs onto a request cookie
// string, keeping the original order and appending new names.
func mergeCookies(cookie string, setCookies []string) string {
if len(setCookies) == 0 {
return cookie
}
updates := map[string]string{}
order := []string{}
for _, sc := range setCookies {
pair := strings.TrimSpace(strings.Split(sc, ";")[0])
name, value, ok := strings.Cut(pair, "=")
name = strings.TrimSpace(name)
if !ok || name == "" {
continue
}
if _, seen := updates[name]; !seen {
order = append(order, name)
}
updates[name] = value
}
if len(updates) == 0 {
return cookie
}
var out []string
used := map[string]bool{}
for _, part := range strings.Split(cookie, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
name, _, _ := strings.Cut(part, "=")
name = strings.TrimSpace(name)
if v, ok := updates[name]; ok {
used[name] = true
if v == "" { // a cleared cookie drops out
continue
}
out = append(out, name+"="+v)
continue
}
out = append(out, part)
}
for _, name := range order {
if used[name] || updates[name] == "" {
continue
}
out = append(out, name+"="+updates[name])
}
return strings.Join(out, "; ")
}
// keepsSession reports whether a merged cookie still carries BOTH components
// get-session needs: the session token and better-auth's session_data cache.
// A merge that loses either one (a Set-Cookie clearing a cache chunk) must be
// discarded — sending it would answer 200 null, i.e. look like a dead account.
func keepsSession(cookie string) bool {
return strings.Contains(cookie, "__Secure-better-auth.session_token") && HasSessionData(cookie)
}
// Session is the result of /api/auth/get-session: the short-lived bearer plus the
// ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed
// and the CDN image path) and the human-facing account fields.
type Session struct {
AccessToken string
CognitoSub string
UserID string
Email string
Name string
ExpiresAt int64
// Cookie is the cookie that produced this session, with any Set-Cookie
// rotation applied — persist it so the account keeps authenticating.
Cookie string
}
// GetSession exchanges the cookie for a fresh access token + account ids. Only a
// 401 or a 200 carrying no access token means the session is dead → ErrAuth;
// everything else (notably the 403/429 人机校验 page) is a temporary error.
func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) {
cookie = strings.TrimSpace(cookie)
if cookie == "" {
return nil, ErrAuth
}
// Re-use a cached, still-valid access token (keep a 60s safety margin) instead
// of hitting the heavily rate-limited get-session endpoint again.
c.mu.Lock()
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
c.mu.Unlock()
return cs, nil
}
c.mu.Unlock()
// Only one refresh per cookie at a time; the others wait and then re-use the
// token it minted.
gate := c.refreshGate(cookie)
gate.Lock()
defer gate.Unlock()
c.mu.Lock()
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
c.mu.Unlock()
return cs, nil
}
c.mu.Unlock()
// Use the freshest known value (an earlier response may have rotated the
// better-auth cookie cache) rather than the possibly stale stored cookie.
send := cookie
c.mu.Lock()
if fresh, ok := c.rotated[cookie]; ok && fresh != "" {
send = fresh
}
c.mu.Unlock()
var body []byte
var status int
var setCookies []string
var err error
var warmed bool
// 401 from cross-origin-cookie means Leonardo已经作废了这个 session(不是人机校验),
// 记下来好把日志写成"会话被吊销"而不是含糊的 get-session null。
warmStatus := 0
for attempt := 0; attempt < getSessionAttempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(getSessionRetryDelay):
}
}
// The local IP normally passes the checkpoint; only once it doesn't is the
// proxy worth its latency (and its own share of 429s).
useProxy := attempt > 0 && c.proxy != ""
client, cerr := c.newTLSClientP(useProxy)
if cerr != nil {
err = cerr
continue
}
// Warm up on the same connection: cross-origin-cookie hands back a fresh
// better-auth cookie cache (and CF_Access_Token). Without it get-session
// answers 200 null even for a perfectly healthy cookie.
warmed = false
warmCookies, wstatus, werr := c.warmSession(ctx, client, send)
warmStatus = wstatus
if werr == nil {
warmed = true
if merged := mergeCookies(send, warmCookies); merged != send && keepsSession(merged) {
send = merged
}
}
status, body, setCookies, err = c.fetchSession(ctx, client, send)
if err == nil && warmed && status != 429 && status != 403 {
if status == 200 && sessionAccessToken(body) == "" && attempt < getSessionNullAttempts-1 {
continue // retry a null session on another exit IP before giving up
}
break
}
}
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error())
}
// Only a real app answer may rotate the stored cookie. The 403/429 人机校验 页
// also sends Set-Cookie (often CLEARING better-auth cookies), and persisting
// that would strip the session_data cache — after which get-session answers
// 200 null and a perfectly healthy account looks dead.
if status == 200 {
if merged := mergeCookies(send, setCookies); keepsSession(merged) {
send = merged
}
if send != cookie {
c.mu.Lock()
c.rotated[cookie] = send
c.mu.Unlock()
}
}
if status == 401 {
return nil, fmt.Errorf("%w: get-session http 401: %s", ErrAuth, clip(body, 160))
}
if status != 200 {
// 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 —
// 当成临时错误,否则健康的号会被误判死。
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
}
var raw struct {
Session struct {
AccessToken string `json:"accessToken"`
CognitoSub string `json:"cognitoSub"`
UserID string `json:"userId"`
HasuraUserID string `json:"hasuraUserId"`
TokenExpiry int64 `json:"accessTokenExpiry"`
} `json:"session"`
User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
} `json:"user"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: get-session non-json", ErrTemporaryUpstream)
}
if strings.TrimSpace(raw.Session.AccessToken) == "" {
if warmStatus == 401 {
// Leonardo 明确拒了这份 session_token:服务端已把会话吊销,重新导入 cookie 才能恢复。
return nil, fmt.Errorf("%w: session revoked (cross-origin-cookie 401)", ErrAuth)
}
if !warmed {
// A cold get-session (the cookie cache was never refreshed) answers null
// for healthy accounts too — temporary, never a reason to kill the account.
return nil, fmt.Errorf("%w: get-session 200 without accessToken (no warmup): %s", ErrTemporaryUpstream, clip(body, 160))
}
// No bearer despite 200 → the cookie no longer authenticates. Carry the body
// so the log says WHICH shape it was (null session vs a session without a
// token) instead of a bare "auth failed".
return nil, fmt.Errorf("%w: get-session 200 without accessToken: %s", ErrAuth, clip(body, 160))
}
uid := raw.Session.UserID
if uid == "" {
uid = raw.Session.HasuraUserID
}
if uid == "" {
uid = raw.User.ID
}
sess := &Session{
AccessToken: raw.Session.AccessToken,
CognitoSub: raw.Session.CognitoSub,
UserID: uid,
Email: strings.TrimSpace(raw.User.Email),
Name: strings.TrimSpace(raw.User.Name),
ExpiresAt: raw.Session.TokenExpiry,
Cookie: send,
}
if sess.ExpiresAt > time.Now().Unix() {
c.mu.Lock()
c.sessions[cookie] = sess
c.mu.Unlock()
}
return sess, nil
}
// refreshGate returns the per-cookie lock that serialises get-session refreshes.
func (c *Client) refreshGate(cookie string) *sync.Mutex {
c.mu.Lock()
defer c.mu.Unlock()
gate, ok := c.refreshing[cookie]
if !ok {
gate = &sync.Mutex{}
c.refreshing[cookie] = gate
}
return gate
}
// warmSession calls cross-origin-cookie, whose response refreshes better-auth's
// cookie cache and CF_Access_Token. It returns the Set-Cookie headers; a
// checkpoint answer (403/429) is an error, since get-session would then be cold.
func (c *Client) warmSession(ctx context.Context, client tlsclient.HttpClient, cookie string) ([]string, int, error) {
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/cross-origin-cookie", nil)
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = sessionHeader(cookie)
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode == 403 || resp.StatusCode == 429 {
return nil, resp.StatusCode, fmt.Errorf("cross-origin-cookie http %d", resp.StatusCode)
}
return resp.Header["Set-Cookie"], resp.StatusCode, nil
}
// fetchSession performs one get-session call and returns its status, body and
// Set-Cookie headers.
func (c *Client) fetchSession(ctx context.Context, client tlsclient.HttpClient, cookie string) (int, []byte, []string, error) {
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/get-session", nil)
if err != nil {
return 0, nil, nil, err
}
req = req.WithContext(ctx)
req.Header = sessionHeader(cookie)
resp, err := client.Do(req)
if err != nil {
return 0, nil, nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, body, resp.Header["Set-Cookie"], nil
}
// sessionAccessToken pulls the bearer out of a get-session body; "" means the
// answer carried none (a null session, or a session without a token).
func sessionAccessToken(body []byte) string {
var raw struct {
Session struct {
AccessToken string `json:"accessToken"`
} `json:"session"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return ""
}
return strings.TrimSpace(raw.Session.AccessToken)
}
// sessionHeader is the auth endpoints' request shape, copied from a real
// browser's call (HAR): a same-origin GET carries NO origin header and DOES
// carry the ua client hints + priority — sending origin while omitting the
// hints is exactly the shape Vercel's checkpoint 429s.
func sessionHeader(cookie string) http.Header {
return http.Header{
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"cache-control": {"no-cache"},
"cookie": {cookie},
"pragma": {"no-cache"},
"priority": {"u=1, i"},
"referer": {appBase + "/"},
"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-origin"},
"user-agent": {userAgent},
http.HeaderOrderKey: {
"accept", "accept-language", "cache-control", "cookie", "pragma",
"priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile",
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode",
"sec-fetch-site", "user-agent",
},
}
}
// session returns the cookie's access token, optionally forcing a fresh mint
// (dropping the cache) — used when the upstream rejected the current bearer.
func (c *Client) session(ctx context.Context, cookie string, force bool) (*Session, error) {
if force {
c.mu.Lock()
delete(c.sessions, strings.TrimSpace(cookie))
c.mu.Unlock()
}
return c.GetSession(ctx, cookie)
}
// ProbeSession force-mints a session from the cookie, bypassing the cached
// bearer. Callers use it to double-check an auth failure before killing an
// account: a rejected bearer (rotation race / expired token) still yields a
// working cookie here, only a genuinely dead cookie returns ErrAuth.
func (c *Client) ProbeSession(ctx context.Context, cookie string) (*Session, error) {
return c.session(ctx, cookie, true)
}
// callGraphQL runs one GraphQL call for an account cookie. The bearer only lives
// ~1h, so a rejected token (401/403 or a JWTExpired GraphQL error) is re-minted
// from the cookie and the call retried once. Only a cookie that itself stops
// authenticating yields ErrAuth — an upstream bearer rejection stays temporary so
// the account is never killed for it.
func (c *Client) callGraphQL(ctx context.Context, cookie string, payload []byte, useProxy bool, label string) ([]byte, error) {
var lastStatus int
var lastBody []byte
for attempt := 0; attempt < 2; attempt++ {
sess, err := c.session(ctx, cookie, attempt > 0)
if err != nil {
return nil, err
}
body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, useProxy)
if err != nil {
return nil, fmt.Errorf("%w: %s: %s", ErrTemporaryUpstream, label, err.Error())
}
lastStatus, lastBody = status, body
stale := status == 401 || status == 403
var gqlErr error
if !stale && status == 200 {
gqlErr = graphqlError(body)
stale = errors.Is(gqlErr, ErrAuth)
}
if stale {
if attempt == 0 {
continue
}
break
}
if status != 200 {
return nil, fmt.Errorf("%w: %s http %d: %s", ErrTemporaryUpstream, label, status, clip(body, 160))
}
if gqlErr != nil {
return nil, gqlErr
}
return body, nil
}
return nil, fmt.Errorf("%w: %s rejected a freshly minted bearer (http %d): %s",
ErrTemporaryUpstream, label, lastStatus, clip(lastBody, 160))
}
const qGetTokens = `query GetUserTokensFromSub($sub: String) {
user_details(where: {cognitoId: {_eq: $sub}}) {
id
plan
subscriptionTokens
paidTokens
rolloverTokens
tokenRenewalDate
__typename
}
}`
// FetchCreditsBalance derives a JWT from the cookie then reads the account's image
// token balance. Returns a normalized map mirroring the other providers so the
// TokenService quota plumbing is uniform. remaining = subscription+paid+rollover
// (the spendable image tokens); available_until carries the daily renewal time so
// the maintenance sweep can auto-recover a 限额 account.
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
sess, err := c.GetSession(ctx, cookie)
if err != nil {
if errors.Is(err, ErrAuth) {
return nil, ErrAuth
}
return unknownBalance(err.Error()), nil
}
if sess.CognitoSub == "" {
return unknownBalance("no cognitoSub"), nil
}
payload, _ := json.Marshal(map[string]any{
"operationName": "GetUserTokensFromSub",
"variables": map[string]any{"sub": sess.CognitoSub},
"query": qGetTokens,
})
body, err := c.callGraphQL(ctx, cookie, payload, false, "credits")
if err != nil {
if errors.Is(err, ErrAuth) {
return nil, ErrAuth
}
return unknownBalance(err.Error()), nil
}
var result struct {
Data struct {
UserDetails []struct {
Plan string `json:"plan"`
SubscriptionTokens int `json:"subscriptionTokens"`
PaidTokens int `json:"paidTokens"`
RolloverTokens int `json:"rolloverTokens"`
TokenRenewalDate string `json:"tokenRenewalDate"`
} `json:"user_details"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return unknownBalance("non-json"), nil
}
if len(result.Data.UserDetails) == 0 {
return unknownBalance("no user_details"), nil
}
ud := result.Data.UserDetails[0]
remaining := ud.SubscriptionTokens + ud.PaidTokens + ud.RolloverTokens
return map[string]any{
"remaining": remaining,
"used": nil,
"total": nil,
"unknown": false,
"error": nil,
"plan": ud.Plan,
"available_until": strings.TrimSpace(ud.TokenRenewalDate),
"email": emptyStringNil(sess.Email),
"display_name": emptyStringNil(sess.Name),
"user_id": emptyStringNil(sess.UserID),
}, nil
}
// graphqlP runs a GraphQL call; callers pick the egress: only the generate submit
// uses the proxy; reference-image upload and polling run direct (local IP).
func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byte, useProxy bool) ([]byte, int, error) {
client, err := c.newTLSClientP(useProxy)
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(http.MethodPost, graphqlURL, bytes.NewReader(payload))
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"content-type": {"application/json"},
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"origin": {appBase},
"priority": {"u=1, i"},
"referer": {appBase + "/"},
"sec-ch-ua": {secChUA},
"sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {`"Windows"`},
"user-agent": {userAgent},
"authorization": {"Bearer " + accessToken},
"x-leo-schema-version": {schemaVersion},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-site"},
http.HeaderOrderKey: {
"content-type", "accept", "accept-language", "origin", "priority",
"referer", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
"user-agent", "authorization", "x-leo-schema-version",
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return body, resp.StatusCode, 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) {
// Match the fingerprint proven to work against Leonardo's Cloudflare edge:
// Chrome_120, fixed extension order. A randomized JA3 (Chrome_133 +
// WithRandomTLSExtensionOrder) gets flagged and 429'd at get-session.
options := []tlsclient.HttpClientOption{
tlsclient.WithTimeoutSeconds(60),
tlsclient.WithClientProfile(profiles.Chrome_120),
}
if useProxy && c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
}
// downloadImage fetches a generated image (cdn.leonardo.ai) and returns the bytes.
func (c *Client) downloadImage(ctx context.Context, imageURL string) ([]byte, error) {
if _, err := url.Parse(imageURL); err != nil {
return nil, err
}
client, err := c.newDirectTLSClient()
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, imageURL, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
"user-agent": {userAgent},
"referer": {appBase + "/"},
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
}
return body, nil
}
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
}
-401
View File
@@ -1,401 +0,0 @@
package leonardo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"strings"
"time"
http "github.com/bogdanfinn/fhttp"
)
// defaultStyleID is the "Dynamic" style applied when the caller doesn't specify
// one — Leonardo's Generate mutation expects a style_ids entry.
const defaultStyleID = "111dc692-d470-4eec-b791-3475abac4c46"
const mGenerate = `mutation Generate($request: CreateGenerationRequest!) {
generate(request: $request) {
apiCreditCost
generationId
__typename
}
}`
// qGenerationImages polls one generation's status AND its produced images in a
// single round-trip (where: id _in [genId]).
const qGenerationImages = `query GenerationImages($where: generations_bool_exp = {}) {
generations(where: $where) {
id
status
generated_images {
id
url
__typename
}
__typename
}
}`
const mUploadImage = `mutation UploadImage($uploadImageInput: UploadImageInput!) {
uploadImage(arg1: $uploadImageInput) {
uploadId
url
fields
__typename
}
}`
const mUploadInitImage = `mutation UploadInitImage($arg1: InitImageUploadInput!) {
uploadInitImage(arg1: $arg1) {
id
url
fields
__typename
}
}`
// initImageExtension narrows a sniffed extension to what uploadInitImage accepts
// (png / jpg / jpeg / webp); anything else is sent as png.
func initImageExtension(extension string) string {
switch strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".") {
case "jpg":
return "jpg"
case "jpeg":
return "jpeg"
case "webp":
return "webp"
default:
return "png"
}
}
// uploadInitImage uploads a reference image and returns the init image id to put
// in a Generate request's image_reference guidance. It has to go through
// uploadInitImage (permanent init-image bucket): the uploadImage mutation only
// hands out temporary-bucket ids, which the generation service can't resolve.
func (c *Client) uploadInitImage(ctx context.Context, cookie, extension string, img []byte) (string, error) {
extension = initImageExtension(extension)
payload, _ := json.Marshal(map[string]any{
"operationName": "UploadInitImage",
"query": mUploadInitImage,
"variables": map[string]any{"arg1": map[string]any{"extension": extension}},
})
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init-image")
if err != nil {
return "", err
}
var ur struct {
Data struct {
UploadInitImage struct {
ID string `json:"id"`
URL string `json:"url"`
Fields string `json:"fields"`
} `json:"uploadInitImage"`
} `json:"data"`
}
if err := json.Unmarshal(body, &ur); err != nil {
return "", fmt.Errorf("%w: upload-init-image non-json", ErrTemporaryUpstream)
}
up := ur.Data.UploadInitImage
if up.ID == "" || up.URL == "" {
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
}
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, img); err != nil {
return "", err
}
return up.ID, nil
}
// putPresigned performs the presigned S3 POST: all policy fields first, the file
// part LAST.
func (c *Client) putPresigned(ctx context.Context, url, fieldsJSON, filename string, asset []byte) error {
var fields map[string]string
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
return fmt.Errorf("%w: bad upload fields", ErrTemporaryUpstream)
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
_ = w.WriteField(k, v)
}
fw, err := w.CreateFormFile("file", filename)
if err != nil {
return err
}
if _, err := fw.Write(asset); err != nil {
return err
}
_ = w.Close()
client, err := c.newDirectTLSClient()
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, url, &buf)
if err != nil {
return err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"content-type": {w.FormDataContentType()},
"user-agent": {userAgent},
"origin": {appBase},
"referer": {appBase + "/"},
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("%w: s3 upload: %s", ErrTemporaryUpstream, err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != 204 && resp.StatusCode != 200 && resp.StatusCode != 201 {
return fmt.Errorf("%w: s3 upload http %d", ErrTemporaryUpstream, resp.StatusCode)
}
return nil
}
// uploadAsset uploads one reference asset (extension png / mp3 / mp4 …) through
// the same UploadImage presigned-S3 flow images use, and returns its upload id.
func (c *Client) uploadAsset(ctx context.Context, cookie, extension string, asset []byte) (string, error) {
extension = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
if extension == "" {
extension = "png"
}
payload, _ := json.Marshal(map[string]any{
"operationName": "UploadImage",
"query": mUploadImage,
// originalFilename is mandatory for audio uploads and harmless otherwise.
"variables": map[string]any{"uploadImageInput": map[string]any{
"uploadType": "INIT",
"extension": extension,
"originalFilename": "asset." + extension,
}},
})
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init")
if err != nil {
return "", err
}
var ur struct {
Data struct {
UploadImage struct {
UploadID string `json:"uploadId"`
URL string `json:"url"`
Fields string `json:"fields"`
} `json:"uploadImage"`
} `json:"data"`
}
if err := json.Unmarshal(body, &ur); err != nil {
return "", fmt.Errorf("%w: upload-init non-json", ErrTemporaryUpstream)
}
up := ur.Data.UploadImage
if up.UploadID == "" || up.URL == "" {
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
}
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, asset); err != nil {
return "", err
}
return up.UploadID, nil
}
// GenerateImage runs the full Leonardo image pipeline against one account cookie:
// mint a JWT, (for image-to-image) upload each reference image, submit the
// Generate mutation, poll until COMPLETE, then download the first produced image.
// Returns the image bytes, an info map, and a classified error.
func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string, width, height int, styleIDs []string, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
sess, err := c.GetSession(ctx, cookie)
if err != nil {
return nil, nil, err
}
if len(styleIDs) == 0 {
styleIDs = []string{defaultStyleID}
}
if strings.TrimSpace(model) == "" {
model = "seedream-4.5"
}
// Image-to-image: upload each reference and collect its guidance entry.
var imageRefs []map[string]any
for _, img := range refImages {
if len(img) == 0 {
continue
}
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
if upErr != nil {
return nil, nil, upErr
}
imageRefs = append(imageRefs, map[string]any{
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
"strength": "MID",
})
}
promptEnhance := "AUTO"
parameters := map[string]any{
"height": height,
"width": width,
"prompt_enhance": promptEnhance,
"quantity": 1,
"style_ids": styleIDs,
"prompt": prompt,
}
if len(imageRefs) > 0 {
// Preserve the reference when image-guided (matches the web app).
parameters["prompt_enhance"] = "OFF"
parameters["guidances"] = map[string]any{"image_reference": imageRefs}
}
// 1. submit
genReq := map[string]any{
"operationName": "Generate",
"query": mGenerate,
"variables": map[string]any{
"request": map[string]any{
"model": model,
"public": true,
"parameters": parameters,
},
},
}
payload, _ := json.Marshal(genReq)
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate")
if err != nil {
return nil, nil, err
}
var genResp struct {
Data struct {
Generate struct {
GenerationID string `json:"generationId"`
} `json:"generate"`
} `json:"data"`
}
if err := json.Unmarshal(body, &genResp); err != nil {
return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream)
}
genID := strings.TrimSpace(genResp.Data.Generate.GenerationID)
if genID == "" {
return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200))
}
// 2. poll until COMPLETE, then read the image url.
imageURL, err := c.pollImage(ctx, cookie, genID)
if err != nil {
return nil, nil, err
}
info := map[string]any{
"generation_id": genID,
"image_url": imageURL,
"user_id": sess.UserID,
}
if !downloadResult {
return nil, info, nil
}
// 3. download bytes
data, err := c.downloadImage(ctx, imageURL)
if err != nil {
return nil, nil, err
}
return data, info, nil
}
// pollImage polls one generation until it reports COMPLETE (returning the first
// image url) or FAILED (error). Honors ctx cancellation / deadline.
func (c *Client) pollImage(ctx context.Context, cookie, genID string) (string, error) {
payload, _ := json.Marshal(map[string]any{
"operationName": "GenerationImages",
"query": qGenerationImages,
"variables": map[string]any{
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
},
})
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
// Poll for the full generation budget (caller's genCtx), leaving headroom for
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
// ctx already bounds the wait, so a stuck job still can't hang indefinitely.
deadline := time.Now().Add(5 * time.Minute)
if dl, ok := ctx.Deadline(); ok {
deadline = dl.Add(-60 * time.Second)
}
for {
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll")
if errors.Is(err, ErrAuth) {
return "", err
}
// 其它错误(含上游临时抖动)不中断轮询,等 deadline 再判超时。
if err == nil {
var pr struct {
Data struct {
Generations []struct {
Status string `json:"status"`
GeneratedImages []struct {
URL string `json:"url"`
} `json:"generated_images"`
} `json:"generations"`
} `json:"data"`
}
if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 {
g := pr.Data.Generations[0]
switch strings.ToUpper(g.Status) {
case "COMPLETE":
for _, img := range g.GeneratedImages {
if u := strings.TrimSpace(img.URL); u != "" {
return u, nil
}
}
return "", fmt.Errorf("%w: complete but no image url", ErrTemporaryUpstream)
case "FAILED":
return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream)
}
}
}
if time.Now().After(deadline) {
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-ticker.C:
}
}
}
// graphqlError inspects a GraphQL response body for an "errors" array and maps the
// first message to a classified sentinel (auth / quota / temporary). Returns nil
// when there are no errors.
func graphqlError(body []byte) error {
var env struct {
Errors []struct {
Message string `json:"message"`
Extensions struct {
Code string `json:"code"`
Details struct {
Message string `json:"message"`
} `json:"details"`
} `json:"extensions"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &env); err != nil || len(env.Errors) == 0 {
return nil
}
msg := strings.TrimSpace(env.Errors[0].Message)
// The generic "An error occurred." hides the real reason in extensions.
if detail := strings.TrimSpace(env.Errors[0].Extensions.Details.Message); detail != "" && detail != msg {
msg = msg + " (" + detail + ")"
}
low := strings.ToLower(msg)
switch {
case strings.Contains(low, "unauthor") || strings.Contains(low, "jwt") || strings.Contains(low, "token is") || strings.Contains(low, "forbidden"):
return ErrAuth
case strings.Contains(low, "token") || strings.Contains(low, "credit") || strings.Contains(low, "quota") || strings.Contains(low, "insufficient") || strings.Contains(low, "not enough"):
return ErrQuotaExhausted
default:
return fmt.Errorf("leonardo: %s", clip([]byte(msg), 200))
}
}
-412
View File
@@ -1,412 +0,0 @@
package leonardo
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// qGenerationVideos polls one video generation's status AND its produced clip in
// a single round-trip (motionMP4URL carries the mp4).
const qGenerationVideos = `query GenerationVideos($where: generations_bool_exp = {}) {
generations(where: $where) {
id
status
generated_images {
id
motionMP4URL
__typename
}
__typename
}
}`
// VideoAssets are the decoded reference assets a video request can carry:
// image references (strength MID), one audio track and video references. The
// caller enforces the per-type caps; durations are derived from the bytes here
// because Leonardo requires them for audio/video guidances.
type VideoAssets struct {
Images [][]byte
Audios [][]byte
Videos [][]byte
}
// GenerateVideo runs the Leonardo video pipeline (seedance-2.0 / -fast, hailuo) against
// one account cookie: upload every reference asset, submit the Generate mutation
// as a PRIVATE generation, poll until COMPLETE, then optionally download the mp4.
func (c *Client) GenerateVideo(ctx context.Context, cookie, model, prompt string, width, height, durationSeconds int, refs VideoAssets, downloadResult bool) ([]byte, map[string]any, error) {
sess, err := c.GetSession(ctx, cookie)
if err != nil {
return nil, nil, err
}
if strings.TrimSpace(model) == "" {
model = "seedance-2.0-fast"
}
guidances := map[string]any{}
var imageRefs []map[string]any
for _, img := range refs.Images {
if len(img) == 0 {
continue
}
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
if upErr != nil {
return nil, nil, upErr
}
imageRefs = append(imageRefs, map[string]any{
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
"strength": "MID",
})
}
if len(imageRefs) > 0 {
guidances["image_reference"] = imageRefs
}
var videoRefs []map[string]any
for _, vid := range refs.Videos {
if len(vid) == 0 {
continue
}
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(vid, "mp4"), vid)
if upErr != nil {
return nil, nil, upErr
}
video := map[string]any{"id": uploadID, "type": "UPLOADED"}
if secs := MediaDurationSeconds(vid); secs > 0 {
video["duration"] = secs
}
videoRefs = append(videoRefs, map[string]any{"video": video})
}
if len(videoRefs) > 0 {
guidances["video_reference_base"] = videoRefs
}
// Leonardo rejects an audio reference that isn't paired with an image or
// video reference (audio_reference_only_compatible_with_image_or_video_reference).
var audioRefs []map[string]any
for _, aud := range refs.Audios {
if len(aud) == 0 {
continue
}
if len(imageRefs) == 0 && len(videoRefs) == 0 {
return nil, nil, errors.New("leonardo: audio reference requires an image or video reference")
}
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(aud, "mp3"), aud)
if upErr != nil {
return nil, nil, upErr
}
audio := map[string]any{"id": uploadID, "type": "UPLOADED"}
if secs := MediaDurationSeconds(aud); secs > 0 {
audio["duration"] = secs
}
audioRefs = append(audioRefs, map[string]any{"audio": audio})
}
if len(audioRefs) > 0 {
guidances["audio_reference"] = audioRefs
}
parameters := map[string]any{
"height": height,
"width": width,
"duration": durationSeconds,
"motion_has_audio": true,
"quantity": 1,
"prompt": prompt,
"guidances": guidances,
}
// seedance 走随机种子;hailuo 的生成页 seedEnabled=false,请求里不带 seed。
if !strings.HasPrefix(model, "hailuo") {
parameters["seed"] = -1
}
genReq := map[string]any{
"operationName": "Generate",
"query": mGenerate,
"variables": map[string]any{
"request": map[string]any{
"model": model,
// 私有生成:不进公开 feed。
"public": false,
"parameters": parameters,
},
},
}
payload, _ := json.Marshal(genReq)
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate-video")
if err != nil {
return nil, nil, err
}
var genResp struct {
Data struct {
Generate struct {
GenerationID string `json:"generationId"`
} `json:"generate"`
} `json:"data"`
}
if err := json.Unmarshal(body, &genResp); err != nil {
return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream)
}
genID := strings.TrimSpace(genResp.Data.Generate.GenerationID)
if genID == "" {
return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200))
}
videoURL, err := c.pollVideo(ctx, cookie, genID)
if err != nil {
return nil, nil, err
}
info := map[string]any{
"generation_id": genID,
"video_url": videoURL,
"user_id": sess.UserID,
}
if !downloadResult {
return nil, info, nil
}
data, err := c.downloadImage(ctx, videoURL)
if err != nil {
return nil, nil, err
}
return data, info, nil
}
// pollVideo polls one generation until COMPLETE (returning the mp4 url) or
// FAILED. Temporary upstream hiccups don't abort the wait — only ctx/deadline do.
func (c *Client) pollVideo(ctx context.Context, cookie, genID string) (string, error) {
payload, _ := json.Marshal(map[string]any{
"operationName": "GenerationVideos",
"query": qGenerationVideos,
"variables": map[string]any{
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
},
})
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
deadline := time.Now().Add(10 * time.Minute)
if dl, ok := ctx.Deadline(); ok {
deadline = dl.Add(-60 * time.Second)
}
for {
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll-video")
if errors.Is(err, ErrAuth) {
return "", err
}
if err == nil {
var pr struct {
Data struct {
Generations []struct {
Status string `json:"status"`
GeneratedImages []struct {
MotionMP4URL string `json:"motionMP4URL"`
} `json:"generated_images"`
} `json:"generations"`
} `json:"data"`
}
if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 {
g := pr.Data.Generations[0]
switch strings.ToUpper(g.Status) {
case "COMPLETE":
for _, img := range g.GeneratedImages {
if u := strings.TrimSpace(img.MotionMP4URL); u != "" {
return u, nil
}
}
return "", fmt.Errorf("%w: complete but no video url", ErrTemporaryUpstream)
case "FAILED":
return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream)
}
}
}
if time.Now().After(deadline) {
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-ticker.C:
}
}
}
// assetExtension sniffs the upload extension Leonardo expects for a reference
// asset; fallback is used when the bytes aren't recognized.
func assetExtension(data []byte, fallback string) string {
n := len(data)
switch {
case n >= 8 && string(data[1:4]) == "PNG":
return "png"
case n >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
return "jpg"
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
return "webp"
case n >= 12 && string(data[4:8]) == "ftyp":
if n >= 12 && strings.HasPrefix(string(data[8:12]), "qt") {
return "mov"
}
if n >= 12 && strings.HasPrefix(string(data[8:12]), "M4A") {
return "m4a"
}
return "mp4"
case n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3:
return "webm"
case n >= 3 && string(data[0:3]) == "ID3":
return "mp3"
case n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0:
return "mp3"
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WAVE":
return "wav"
}
return fallback
}
// MediaDurationSeconds returns a media asset's duration in seconds (0 when it
// can't be determined). Leonardo's audio_reference / video_reference_base
// guidances carry the duration, and the caller validates video lengths with it,
// so it's derived from the uploaded bytes: mp4/mov via the mvhd box, wav via the
// fmt byte rate, mp3 from the first frame's bitrate.
func MediaDurationSeconds(data []byte) float64 {
if secs := mp4Duration(data); secs > 0 {
return secs
}
if secs := wavDuration(data); secs > 0 {
return secs
}
return mp3Duration(data)
}
// mp4Duration walks the ISOBMFF box tree to moov/mvhd and reads timescale+duration.
func mp4Duration(data []byte) float64 {
if len(data) < 12 || string(data[4:8]) != "ftyp" {
return 0
}
moov := findBox(data, "moov")
if moov == nil {
return 0
}
mvhd := findBox(moov, "mvhd")
if len(mvhd) < 20 {
return 0
}
version := mvhd[0]
if version == 1 {
if len(mvhd) < 32 {
return 0
}
timescale := binary.BigEndian.Uint32(mvhd[20:24])
duration := binary.BigEndian.Uint64(mvhd[24:32])
if timescale == 0 {
return 0
}
return float64(duration) / float64(timescale)
}
timescale := binary.BigEndian.Uint32(mvhd[12:16])
duration := binary.BigEndian.Uint32(mvhd[16:20])
if timescale == 0 {
return 0
}
return float64(duration) / float64(timescale)
}
// findBox returns the payload of the first box named typ among data's boxes
// (callers descend one level at a time by passing a parent's payload back in).
func findBox(data []byte, typ string) []byte {
for off := 0; off+8 <= len(data); {
size := int(binary.BigEndian.Uint32(data[off : off+4]))
name := string(data[off+4 : off+8])
header := 8
if size == 1 { // 64-bit size
if off+16 > len(data) {
return nil
}
size = int(binary.BigEndian.Uint64(data[off+8 : off+16]))
header = 16
} else if size == 0 {
size = len(data) - off
}
if size < header || off+size > len(data) {
return nil
}
if name == typ {
return data[off+header : off+size]
}
off += size
}
return nil
}
// wavDuration reads the RIFF fmt chunk's byte rate and the data chunk size.
func wavDuration(data []byte) float64 {
if len(data) < 44 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
return 0
}
byteRate := 0
dataSize := 0
for off := 12; off+8 <= len(data); {
name := string(data[off : off+4])
size := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
body := off + 8
if size < 0 || body > len(data) {
break
}
switch name {
case "fmt ":
if body+16 <= len(data) {
byteRate = int(binary.LittleEndian.Uint32(data[body+8 : body+12]))
}
case "data":
dataSize = size
if body+size > len(data) {
dataSize = len(data) - body
}
}
off = body + size + size%2
}
if byteRate <= 0 || dataSize <= 0 {
return 0
}
return float64(dataSize) / float64(byteRate)
}
// mp3Bitrates are the Layer III bitrate tables (kbps) indexed by the frame
// header's bitrate index: 1 = MPEG1, 2 = MPEG2/2.5.
var mp3Bitrates = map[int][]int{
1: {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0},
2: {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0},
}
// mp3Duration estimates the duration from the first frame header's bitrate
// (constant-bitrate assumption — good enough for the guidance duration field).
func mp3Duration(data []byte) float64 {
off := 0
if len(data) >= 10 && string(data[0:3]) == "ID3" {
// syncsafe int: 7 bits per byte
tagSize := int(data[6]&0x7F)<<21 | int(data[7]&0x7F)<<14 | int(data[8]&0x7F)<<7 | int(data[9]&0x7F)
off = 10 + tagSize
}
for ; off+4 <= len(data); off++ {
if data[off] != 0xFF || data[off+1]&0xE0 != 0xE0 {
continue
}
versionBits := (data[off+1] >> 3) & 0x03
layerBits := (data[off+1] >> 1) & 0x03
if layerBits != 0x01 { // Layer III only
continue
}
table := 1
if versionBits != 0x03 { // MPEG2 / 2.5
table = 2
}
idx := int((data[off+2] >> 4) & 0x0F)
rates := mp3Bitrates[table]
if idx <= 0 || idx >= len(rates) || rates[idx] == 0 {
continue
}
kbps := rates[idx]
return float64(len(data)-off) * 8 / float64(kbps*1000)
}
return 0
}
-373
View File
@@ -1,373 +0,0 @@
// 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
}
-181
View File
@@ -1,181 +0,0 @@
package runway
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"strings"
"time"
tlsclient "github.com/bogdanfinn/tls-client"
)
// GenerateImage runs a Runway gemini 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. modelID selects the variant: "runway-nano-banana-2"
// → gemini_3_1_flash_image / gemini-3.1-flash-image-preview (with aspect_ratio),
// anything else → workflow_gemini_image / gemini-3-pro-image-preview. imageSize
// is the "1K"/"2K"/"4K" tier. teamID is the workspace id; if empty it's derived
// from the token. refs may be empty (pure text-to-image).
func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prompt, aspectRatio, imageSize string, refs [][]byte, downloadResult bool) ([]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(imageSize) == "" {
imageSize = "1K"
}
// Only the task-create (generate submit) egresses via the proxy; reference
// upload, polling and download run on the local IP.
submitClient, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
directClient, err := c.newDirectTLSClient()
if err != nil {
return nil, nil, err
}
var refImages []map[string]any
var refAssetIDs []string
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, directClient, 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,
})
refAssetIDs = append(refAssetIDs, assetID)
}
// 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, sessionID, assetGroupID, refImages)
if err != nil {
return nil, nil, err
}
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
if err != nil {
return nil, nil, err
}
meta := map[string]any{
"provider": "runway",
"task_id": taskID,
"team_id": teamID,
"image_url": artifactURL,
}
// downloadResult=false (API-key url-only mode): return the upstream artifact
// URL without downloading the PNG.
if !downloadResult {
return nil, meta, nil
}
data, err := c.download(ctx, directClient, artifactURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// 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 submits a gemini image task (workflow_gemini_image for Pro,
// gemini_3_1_flash_image for Nano Banana 2) INTO the pre-created session and
// 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"
opts := map[string]any{
"name": "Nano Banana Pro - " + prompt,
"text_prompt": prompt,
"aspect_ratio": aspectRatio,
"num_images": 1,
"image_size": imageSize,
"model": "gemini-3-pro-image-preview",
"exploreMode": false,
"creationSource": "tool-mode",
}
if modelID == "runway-nano-banana-2" {
taskType = "gemini_3_1_flash_image"
opts["name"] = "Nano Banana 2 - " + prompt
opts["model"] = "gemini-3.1-flash-image-preview"
}
if assetGroupID != "" {
opts["assetGroupId"] = assetGroupID
}
if len(refImages) > 0 {
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{
"taskType": taskType,
"options": opts,
"asTeamId": jsonNumberOrString(teamID),
"sessionId": sessionID,
})
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)
}
// Post-submit: generation record + session play (session already exists).
c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts)
return id, nil
}
-516
View File
@@ -1,516 +0,0 @@
package runway
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
mrand "math/rand/v2"
"strings"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
)
// ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size.
// These are the only dimensions gen4_turbo accepts; "2K" is a UI label over this
// native tier (see runway-video-gen-spec). Unknown ratios fall back to 16:9.
func ratioDimensions(aspectRatio string) (int, int) {
switch strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":")) {
case "16:9":
return 1280, 720
case "9:16":
return 720, 1280
case "1:1":
return 960, 960
case "4:3":
return 1104, 832
case "3:4":
return 832, 1104
case "21:9":
return 1584, 672
default:
return 1280, 720
}
}
// GenerateVideo runs the full i2v pipeline (gen_video.py): upload the first-frame
// image (preview + dataset), create a dataset, create a gen4_turbo task and poll
// it to completion, then download the rendered MP4. teamID is the workspace id
// (meta["team_id"]); if empty it's derived from the token. seconds must be 5 or
// 10; aspectRatio picks the native output size.
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
// With downloadResult=false it returns nil bytes and the upstream artifact URL in
// meta["video_url"] — used by the async /v1/videos job, which proxies that URL on
// /content instead of persisting the file.
func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspectRatio string, seconds int, frame []byte, downloadResult bool) ([]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 len(frame) == 0 {
return nil, nil, errors.New("runway: first-frame image required")
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(frame))
if err != nil {
return nil, nil, errors.New("runway: failed to decode first-frame image")
}
// Only the task-create (generate submit) egresses via the proxy; reference
// upload, dataset create, polling and download run on the local IP (matches
// the image pipeline).
submitClient, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
directClient, err := c.newDirectTLSClient()
if err != nil {
return nil, nil, err
}
filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png"
previewUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET_PREVIEW", frame)
if err != nil {
return nil, nil, err
}
datasetUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET", frame)
if err != nil {
return nil, nil, err
}
assetID, imageURL, err := c.createDataset(ctx, directClient, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
if err != nil {
return nil, nil, err
}
// 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, sessionID, aspectRatio, seconds)
if err != nil {
return nil, nil, err
}
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
if err != nil {
return nil, nil, err
}
meta := map[string]any{
"provider": "runway",
"task_id": taskID,
"team_id": teamID,
"video_url": artifactURL,
}
if !downloadResult {
return nil, meta, nil
}
data, err := c.download(ctx, directClient, artifactURL)
if err != nil {
return nil, nil, err
}
return data, meta, nil
}
// uploadFile mirrors gen_video.upload_file: register the upload, PUT the bytes to
// the returned S3 URL, then complete. Returns the upload id and final url.
func (c *Client) uploadFile(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, uploadType string, data []byte) (string, string, error) {
info, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads", map[string]any{
"filename": filename,
"numberOfParts": 1,
"type": uploadType,
})
if err != nil {
return "", "", err
}
uploadID := strings.TrimSpace(stringValue(info["id"]))
urls, _ := info["uploadUrls"].([]any)
if uploadID == "" || len(urls) == 0 {
return "", "", fmt.Errorf("%w: upload register missing fields", ErrTemporaryUpstream)
}
putURL := strings.TrimSpace(stringValue(urls[0]))
contentType := "application/octet-stream"
if hdrs, ok := info["uploadHeaders"].(map[string]any); ok {
if ct := strings.TrimSpace(stringValue(hdrs["Content-Type"])); ct != "" {
contentType = ct
}
}
etag, err := c.putBytes(ctx, client, putURL, contentType, data)
if err != nil {
return "", "", err
}
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads/"+uploadID+"/complete", map[string]any{
"parts": []map[string]any{{"PartNumber": 1, "ETag": etag}},
})
if err != nil {
return "", "", err
}
return uploadID, strings.TrimSpace(stringValue(res["url"])), nil
}
func (c *Client) createDataset(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, datasetUploadID, previewUploadID string, w, h int) (string, string, error) {
teamIDNum := jsonNumberOrString(teamID)
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/datasets", map[string]any{
"fileCount": 1,
"name": filename,
"uploadId": datasetUploadID,
"previewUploadIds": []string{previewUploadID},
"metadata": map[string]any{"size": map[string]any{"width": w, "height": h}},
"type": map[string]any{"name": "image", "type": "image", "isDirectory": false},
"asTeamId": teamIDNum,
"privateInTeam": true,
})
if err != nil {
return "", "", err
}
ds, _ := res["dataset"].(map[string]any)
id := strings.TrimSpace(stringValue(ds["id"]))
url := strings.TrimSpace(stringValue(ds["url"]))
if id == "" || url == "" {
return "", "", fmt.Errorf("%w: dataset missing fields", ErrTemporaryUpstream)
}
return id, url, nil
}
func (c *Client) assetGroupID(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) {
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet,
"/v1/asset_groups/by_name?name=Generations&asTeamId="+teamID+"&privateInTeam=true", nil)
if err != nil {
return "", err
}
ag, _ := res["assetGroup"].(map[string]any)
return strings.TrimSpace(stringValue(ag["id"])), nil
}
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)
opts := map[string]any{
"route": "i2v",
"name": "Gen-4 Turbo - " + prompt,
"text_prompt": prompt,
"seconds": seconds,
"width": w,
"height": h,
"init_image": imageURL,
"imageAssetId": assetID,
"exploreMode": false,
"creationSource": "tool-mode",
"seed": mrand.IntN(999999999) + 1,
"watermark": true,
}
if assetGroupID != "" {
opts["assetGroupId"] = assetGroupID
}
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
"taskType": "gen4_turbo",
"options": opts,
"asTeamId": jsonNumberOrString(teamID),
"sessionId": sessionID,
})
if err != nil {
return "", err
}
task, _ := res["task"].(map[string]any)
id := strings.TrimSpace(stringValue(task["id"]))
if id == "" {
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
}
func (c *Client) pollTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID string) (string, error) {
for {
if err := ctx.Err(); err != nil {
return "", err
}
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet, "/v1/tasks/"+taskID+"?asTeamId="+teamID, nil)
if err != nil {
// A transient blip shouldn't kill a render that may still succeed.
if errors.Is(err, ErrTemporaryUpstream) {
if sleepCtx(ctx, 5*time.Second) != nil {
return "", ctx.Err()
}
continue
}
return "", err
}
task, _ := res["task"].(map[string]any)
status := strings.ToUpper(strings.TrimSpace(stringValue(task["status"])))
switch status {
case "SUCCEEDED":
arts, _ := task["artifacts"].([]any)
for _, raw := range arts {
art, _ := raw.(map[string]any)
if url := strings.TrimSpace(stringValue(art["url"])); url != "" {
return url, nil
}
}
return "", errors.New("runway: task succeeded with no artifact url")
case "FAILED", "CANCELED":
reason := strings.TrimSpace(stringValue(task["error"]))
if isCreditError(reason) {
return "", fmt.Errorf("%w: %s", ErrQuotaExhausted, reason)
}
return "", fmt.Errorf("runway: task %s: %s", status, reason)
}
if sleepCtx(ctx, 5*time.Second) != nil {
return "", ctx.Err()
}
}
}
// 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
// surface as ErrTemporaryUpstream so the pool fails over to the NEXT account
// (换号重试) instead of retrying this one.
func (c *Client) submitTask(ctx context.Context, client tlsclient.HttpClient, token, teamID string, body map[string]any) (map[string]any, error) {
return c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/tasks", body)
}
// apiJSON performs an authed JSON request against the Runway API and returns the
// parsed body, mapping status codes to the shared provider error sentinels.
func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token, teamID, method, path string, body any) (map[string]any, error) {
var reader io.Reader
if body != nil {
raw, _ := json.Marshal(body)
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, apiBase+path, reader)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
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)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
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))
case resp.StatusCode >= 500:
return nil, fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, resp.StatusCode, clip(raw, 200))
case resp.StatusCode < 200 || resp.StatusCode >= 300:
if isCreditError(string(raw)) {
return nil, fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 200))
}
return nil, fmt.Errorf("runway: %s %d %s", path, resp.StatusCode, clip(raw, 200))
}
var out map[string]any
if len(raw) == 0 {
return map[string]any{}, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
}
return out, nil
}
// putBytes uploads raw bytes to a presigned S3 URL (no auth) and returns the
// ETag, mirroring the plain requests.Session().put in gen_video.py.
func (c *Client) putBytes(ctx context.Context, client tlsclient.HttpClient, url, contentType string, data []byte) (string, error) {
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
if err != nil {
return "", err
}
req = req.WithContext(ctx)
req.Header = browserAssetHeaders()
req.Header.Set("content-type", contentType)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%w: s3 put %d", ErrTemporaryUpstream, resp.StatusCode)
}
return strings.Trim(resp.Header.Get("ETag"), `"`), nil
}
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = browserAssetHeaders()
req.Header.Set("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
req.Header.Set("sec-fetch-dest", "image")
req.Header.Set("sec-fetch-mode", "no-cors")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, errors.New("runway: empty artifact download")
}
return data, nil
}
// jsonNumberOrString returns the team id as a JSON number when it's purely
// numeric (Runway's asTeamId is an integer in the reference payloads), else the
// raw string.
func jsonNumberOrString(teamID string) any {
return json.Number(strings.TrimSpace(teamID))
}
func isCreditError(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
}
// sleepCtx sleeps for d or until ctx is done; returns ctx.Err() if cancelled.
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}