1397 lines
46 KiB
Go
1397 lines
46 KiB
Go
package chatgpt
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
stdhttp "net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
http "github.com/bogdanfinn/fhttp"
|
|
tlsclient "github.com/bogdanfinn/tls-client"
|
|
"github.com/bogdanfinn/tls-client/profiles"
|
|
)
|
|
|
|
var (
|
|
ErrAuth = errors.New("chatgpt auth failed")
|
|
ErrQuotaExhausted = errors.New("chatgpt quota exhausted")
|
|
ErrTemporaryUpstream = errors.New("chatgpt upstream temporary error")
|
|
// ErrContentPolicy marks a prompt rejected by ChatGPT's content audit. It is
|
|
// terminal and NOT retryable: the same prompt fails on every account, so the
|
|
// caller must fail fast rather than poll or fail over.
|
|
ErrContentPolicy = errors.New("chatgpt content policy rejection")
|
|
)
|
|
|
|
type Client struct {
|
|
proxy string
|
|
deviceID string
|
|
sessionID string
|
|
}
|
|
|
|
type fileEntry struct {
|
|
FileID string
|
|
UploadURL string
|
|
}
|
|
|
|
type uploadedReference struct {
|
|
FileID string
|
|
LibraryFileID string
|
|
FileName string
|
|
MimeType string
|
|
SizeBytes int
|
|
Width int
|
|
Height int
|
|
}
|
|
|
|
func NewClient(proxy string) *Client {
|
|
return &Client{
|
|
proxy: strings.TrimSpace(proxy),
|
|
deviceID: newUUID(),
|
|
sessionID: newUUID(),
|
|
}
|
|
}
|
|
|
|
func (c *Client) SetProxy(proxy string) {
|
|
c.proxy = strings.TrimSpace(proxy)
|
|
}
|
|
|
|
func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, aspectRatio, resolution string, refs [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
|
// Everything except the generation submit egresses on the local IP. Only
|
|
// startImageGeneration (the /backend-api/f/conversation POST) goes through
|
|
// the proxy; the bootstrap / chat-requirements / reference upload / prepare
|
|
// handshake and the poll / resolve / download run direct.
|
|
session, err := c.newDirectSession(accessToken)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Fail over immediately when the account's image_gen allowance is spent:
|
|
// submitting anyway just burns the whole poll budget and surfaces as
|
|
// "image poll timeout". Unknown quota (init failed) proceeds as before.
|
|
if quota, qErr := c.fetchImageQuota(ctx, session, accessToken); qErr == nil && quota["unknown"] == false {
|
|
if remaining, ok := quota["remaining"].(int); ok && remaining <= 0 {
|
|
return nil, nil, fmt.Errorf("%w: image_gen remaining 0 (resets %s)", ErrQuotaExhausted, stringValue(quota["reset_after"]))
|
|
}
|
|
}
|
|
|
|
scriptSources, dataBuild, err := c.bootstrap(ctx, session)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
reqs, err := c.getChatRequirements(ctx, session, accessToken, scriptSources, dataBuild)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
effectivePrompt := injectSizeHint(prompt, aspectRatio, resolution)
|
|
uploadedRefs, err := c.uploadReferenceImages(ctx, session, accessToken, refs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
conduitToken, err := c.prepareImageConversation(ctx, session, accessToken, effectivePrompt, reqs, model, uploadedRefs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// The generation submit is the only request that egresses via the proxy. A
|
|
// bare connection error here (EOF / connection reset) is almost always the
|
|
// proxy hop flaking rather than the account, so retry the submit on a fresh
|
|
// connection a few times before surfacing it — otherwise a momentary network
|
|
// blip fails over accounts (burning their quota) or fails the request.
|
|
var conversationID string
|
|
var fileIDs, sedimentIDs []string
|
|
for attempt := 0; ; attempt++ {
|
|
submitSession, sErr := c.newSession(accessToken)
|
|
if sErr != nil {
|
|
return nil, nil, sErr
|
|
}
|
|
conversationID, fileIDs, sedimentIDs, err = c.startImageGeneration(ctx, submitSession, accessToken, effectivePrompt, reqs, conduitToken, model, uploadedRefs)
|
|
if err == nil || attempt >= submitConnectRetries || ctx.Err() != nil || !isConnResetErr(err) {
|
|
break
|
|
}
|
|
time.Sleep(submitRetryBackoff)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// The SSE stream and conversation JSON echo the user's uploaded reference
|
|
// assets; treating those ids as "the generated image" would return the
|
|
// reference itself. Drop them from every id set we collect.
|
|
refIDs := uploadedRefIDSet(uploadedRefs)
|
|
fileIDs = dropIDs(fileIDs, refIDs)
|
|
sedimentIDs = dropIDs(sedimentIDs, refIDs)
|
|
fileIDs, sedimentIDs, err = c.pollForImage(ctx, session, accessToken, conversationID, fileIDs, sedimentIDs, refIDs, pollBudget(ctx))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
urls, err := c.resolveImageURLs(ctx, session, accessToken, conversationID, fileIDs, sedimentIDs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(urls) == 0 {
|
|
return nil, nil, errors.New("no image urls resolved")
|
|
}
|
|
meta := map[string]any{
|
|
"provider": "chatgpt",
|
|
"model": model,
|
|
"conversation_id": conversationID,
|
|
"image_url": urls[0], // auth-gated (files.oaiusercontent.com) — needs the account token to fetch
|
|
}
|
|
// downloadResult=false: skip the (auth-gated) download and return just the URL;
|
|
// the caller proxies it via OpenAsset with the account token.
|
|
if !downloadResult {
|
|
return nil, meta, nil
|
|
}
|
|
images, err := c.downloadBytes(ctx, session, accessToken, urls)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(images) == 0 {
|
|
return nil, nil, errors.New("download produced no bytes")
|
|
}
|
|
return images[0], meta, nil
|
|
}
|
|
|
|
// OpenAsset streams an auth-gated ChatGPT image URL (files.oaiusercontent.com)
|
|
// using the generating account's token — a plain GET 403s. Mirrors downloadBytes
|
|
// but returns a live stream instead of buffering.
|
|
func (c *Client) OpenAsset(ctx context.Context, accessToken, rawURL string) (io.ReadCloser, string, error) {
|
|
session, err := c.newDirectSession(accessToken)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
req.Header = c.baseHeaders(accessToken)
|
|
req.Header.Set("accept", "*/*")
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
resp.Body.Close()
|
|
return nil, "", fmt.Errorf("%w: chatgpt asset status %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
}
|
|
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
|
if ct == "" {
|
|
ct = "image/png"
|
|
}
|
|
return resp.Body, ct, nil
|
|
}
|
|
|
|
func ExtractAccountInfo(token string) map[string]any {
|
|
claims := decodeJWTPayload(token)
|
|
profile, _ := claims["https://api.openai.com/profile"].(map[string]any)
|
|
auth, _ := claims["https://api.openai.com/auth"].(map[string]any)
|
|
return map[string]any{
|
|
"email": emptyStringNil(strings.TrimSpace(stringValue(profile["email"]))),
|
|
"email_verified": profile["email_verified"] == true,
|
|
"plan_type": emptyStringNil(strings.TrimSpace(stringValue(auth["chatgpt_plan_type"]))),
|
|
"user_id": emptyStringNil(strings.TrimSpace(stringValue(auth["chatgpt_user_id"]))),
|
|
"issued_at": claims["iat"],
|
|
"expires_at": claims["exp"],
|
|
}
|
|
}
|
|
|
|
func (c *Client) FetchImageQuota(ctx context.Context, accessToken string) (map[string]any, error) {
|
|
session, err := c.newDirectSession(accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.fetchImageQuota(ctx, session, accessToken)
|
|
}
|
|
|
|
func (c *Client) fetchImageQuota(ctx context.Context, session tlsclient.HttpClient, accessToken string) (map[string]any, error) {
|
|
path := "/backend-api/conversation/init"
|
|
body, _ := json.Marshal(map[string]any{
|
|
"gizmo_id": nil,
|
|
"requested_default_model": nil,
|
|
"conversation_id": nil,
|
|
})
|
|
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.headers(accessToken, path, map[string]string{
|
|
"accept": "application/json",
|
|
"content-type": "application/json",
|
|
})
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": "network: " + err.Error()}, nil
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode == 401 {
|
|
return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": "token invalid", "auth_failed": true}, nil
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": fmt.Sprintf("http %d: %s", resp.StatusCode, clip(respBody, 160))}, nil
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(respBody, &payload); err != nil {
|
|
return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": "non-json response"}, nil
|
|
}
|
|
limits, _ := payload["limits_progress"].([]any)
|
|
for _, raw := range limits {
|
|
item, _ := raw.(map[string]any)
|
|
if strings.TrimSpace(stringValue(item["feature_name"])) != "image_gen" {
|
|
continue
|
|
}
|
|
return map[string]any{
|
|
"remaining": intOrNil(item["remaining"]),
|
|
"reset_after": emptyStringNil(strings.TrimSpace(stringValue(item["reset_after"]))),
|
|
"unknown": false,
|
|
"error": nil,
|
|
}, nil
|
|
}
|
|
return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": nil}, nil
|
|
}
|
|
|
|
type chatRequirements struct {
|
|
Token string
|
|
ProofToken string
|
|
TurnstileToken string
|
|
}
|
|
|
|
func (c *Client) newSession(accessToken string) (tlsclient.HttpClient, error) {
|
|
return c.newSessionP(accessToken, true)
|
|
}
|
|
|
|
// newDirectSession egresses on the local IP (never the proxy). Used for
|
|
// everything except the generation submit (the /backend-api/f/conversation
|
|
// POST), which is the only request that uses the proxy.
|
|
func (c *Client) newDirectSession(accessToken string) (tlsclient.HttpClient, error) {
|
|
return c.newSessionP(accessToken, false)
|
|
}
|
|
|
|
func (c *Client) newSessionP(accessToken string, useProxy bool) (tlsclient.HttpClient, error) {
|
|
options := []tlsclient.HttpClientOption{
|
|
tlsclient.WithTimeoutSeconds(600),
|
|
// Match the Python reference (curl_cffi impersonate="chrome110"): the
|
|
// Chrome_133 JA3/JA4 was tripping Cloudflare on the bootstrap GET (403).
|
|
tlsclient.WithClientProfile(profiles.Chrome_110),
|
|
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
|
|
}
|
|
client.SetCookies(&url.URL{Scheme: "https", Host: "chatgpt.com"}, nil)
|
|
return client, nil
|
|
}
|
|
|
|
func (c *Client) baseHeaders(accessToken string) http.Header {
|
|
return http.Header{
|
|
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
|
"oai-client-build-number": {defaultClientBuildNumber},
|
|
"oai-client-version": {defaultClientVersion},
|
|
"oai-device-id": {c.deviceID},
|
|
"oai-language": {"zh-CN"},
|
|
"oai-session-id": {c.sessionID},
|
|
"origin": {baseURL},
|
|
"priority": {"u=1, i"},
|
|
"referer": {baseURL + "/"},
|
|
"sec-ch-ua": {`"Microsoft Edge";v="149", "Chromium";v="149", "Not)A;Brand";v="24"`},
|
|
"sec-ch-ua-arch": {`"x86"`},
|
|
"sec-ch-ua-bitness": {`"64"`},
|
|
"sec-ch-ua-full-version": {`"149.0.4022.69"`},
|
|
"sec-ch-ua-full-version-list": {`"Microsoft Edge";v="149.0.4022.69", "Chromium";v="149.0.7827.115", "Not)A;Brand";v="24.0.0.0"`},
|
|
"sec-ch-ua-mobile": {"?0"},
|
|
"sec-ch-ua-model": {`""`},
|
|
"sec-ch-ua-platform": {`"Windows"`},
|
|
"sec-ch-ua-platform-version": {`"19.0.0"`},
|
|
"sec-fetch-dest": {"empty"},
|
|
"sec-fetch-mode": {"cors"},
|
|
"sec-fetch-site": {"same-origin"},
|
|
"user-agent": {defaultUserAgent},
|
|
"authorization": {"Bearer " + strings.TrimSpace(accessToken)},
|
|
}
|
|
}
|
|
|
|
func (c *Client) headers(accessToken, path string, extra map[string]string) http.Header {
|
|
h := http.Header{
|
|
"x-openai-target-path": {path},
|
|
"x-openai-target-route": {path},
|
|
}
|
|
for k, values := range c.baseHeaders(accessToken) {
|
|
h[k] = append([]string{}, values...)
|
|
}
|
|
for k, v := range extra {
|
|
h.Set(k, v)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (c *Client) bootstrap(ctx context.Context, session tlsclient.HttpClient) ([]string, string, error) {
|
|
req, err := http.NewRequest(http.MethodGet, baseURL+"/", nil)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"user-agent": {defaultUserAgent},
|
|
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
|
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8"},
|
|
"sec-ch-ua": {`"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"`},
|
|
"sec-ch-ua-mobile": {"?0"},
|
|
"sec-ch-ua-platform": {`"Windows"`},
|
|
"sec-fetch-dest": {"document"},
|
|
"sec-fetch-mode": {"navigate"},
|
|
"sec-fetch-site": {"none"},
|
|
"upgrade-insecure-requests": {"1"},
|
|
}
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if err := ensureOK(resp.StatusCode, body, "bootstrap"); err != nil {
|
|
return nil, "", err
|
|
}
|
|
sources, dataBuild := parsePOWResources(string(body))
|
|
return sources, dataBuild, nil
|
|
}
|
|
|
|
func (c *Client) getChatRequirements(ctx context.Context, session tlsclient.HttpClient, accessToken string, scriptSources []string, dataBuild string) (*chatRequirements, error) {
|
|
pToken := buildLegacyRequirementsToken(defaultUserAgent, scriptSources, dataBuild)
|
|
path := "/backend-api/sentinel/chat-requirements/prepare"
|
|
reqBody, _ := json.Marshal(map[string]any{"p": pToken})
|
|
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.headers(accessToken, path, map[string]string{
|
|
"content-type": "application/json",
|
|
})
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureOK(resp.StatusCode, body, "chat_requirements_prepare"); err != nil {
|
|
return nil, err
|
|
}
|
|
var prepare map[string]any
|
|
if err := json.Unmarshal(body, &prepare); err != nil {
|
|
return nil, err
|
|
}
|
|
if arkose, _ := prepare["arkose"].(map[string]any); arkose["required"] == true {
|
|
return nil, errors.New("chat-requirements requires arkose token")
|
|
}
|
|
proofToken := ""
|
|
if powInfo, _ := prepare["proofofwork"].(map[string]any); powInfo["required"] == true {
|
|
proofToken, err = buildProofToken(strings.TrimSpace(stringValue(powInfo["seed"])), strings.TrimSpace(stringValue(powInfo["difficulty"])), defaultUserAgent, scriptSources, dataBuild)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
turnstileToken := ""
|
|
if tsInfo, _ := prepare["turnstile"].(map[string]any); tsInfo["required"] == true {
|
|
turnstileToken = solveTurnstileToken(strings.TrimSpace(stringValue(tsInfo["dx"])), pToken)
|
|
}
|
|
|
|
path = "/backend-api/sentinel/chat-requirements/finalize"
|
|
finalizeBody, _ := json.Marshal(map[string]any{
|
|
"prepare_token": stringValue(prepare["prepare_token"]),
|
|
"proof_token": proofToken,
|
|
"turnstile_token": turnstileToken,
|
|
})
|
|
req, err = http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(finalizeBody))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.headers(accessToken, path, map[string]string{
|
|
"content-type": "application/json",
|
|
})
|
|
resp, err = session.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
body, err = io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureOK(resp.StatusCode, body, "chat_requirements_finalize"); err != nil {
|
|
return nil, err
|
|
}
|
|
var data map[string]any
|
|
if err := json.Unmarshal(body, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
token := strings.TrimSpace(stringValue(data["token"]))
|
|
if token == "" {
|
|
return nil, errors.New("chat-requirements missing token")
|
|
}
|
|
return &chatRequirements{Token: token, ProofToken: proofToken, TurnstileToken: turnstileToken}, nil
|
|
}
|
|
|
|
func (c *Client) imageHeaders(accessToken, path string, reqs *chatRequirements, conduitToken, accept string) http.Header {
|
|
h := c.headers(accessToken, path, map[string]string{
|
|
"content-type": "application/json",
|
|
"accept": accept,
|
|
"openai-sentinel-chat-requirements-token": reqs.Token,
|
|
})
|
|
if reqs.ProofToken != "" {
|
|
h.Set("openai-sentinel-proof-token", reqs.ProofToken)
|
|
}
|
|
// The real browser also sends the turnstile token on the conversation call
|
|
// (HAR confirms openai-sentinel-turnstile-token). We already compute it in
|
|
// chat-requirements/finalize but were dropping it here — send it so the
|
|
// request matches the browser and isn't extra-challenged by sentinel.
|
|
if reqs.TurnstileToken != "" {
|
|
h.Set("openai-sentinel-turnstile-token", reqs.TurnstileToken)
|
|
}
|
|
if conduitToken != "" {
|
|
h.Set("x-conduit-token", conduitToken)
|
|
}
|
|
if accept == "text/event-stream" {
|
|
h.Set("x-oai-turn-trace-id", newUUID())
|
|
h.Set("oai-telemetry", "[1,null]")
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (c *Client) uploadReferenceImages(ctx context.Context, session tlsclient.HttpClient, accessToken string, refs [][]byte) ([]uploadedReference, error) {
|
|
if len(refs) == 0 {
|
|
return nil, nil
|
|
}
|
|
out := make([]uploadedReference, 0, len(refs))
|
|
for i, ref := range refs {
|
|
meta, err := inspectReferenceImage(ref, i)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entry, err := c.createFileEntry(ctx, session, accessToken, meta)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.uploadRawFile(ctx, session, entry.UploadURL, meta.MimeType, ref); err != nil {
|
|
return nil, err
|
|
}
|
|
libraryFileID, err := c.processUploadStream(ctx, session, accessToken, entry.FileID, meta.FileName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta.FileID = entry.FileID
|
|
meta.LibraryFileID = libraryFileID
|
|
out = append(out, meta)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func imageModelSlug(model string) string {
|
|
if strings.EqualFold(strings.TrimSpace(model), "gpt-image-2") {
|
|
return "gpt-5-5-thinking"
|
|
}
|
|
return "auto"
|
|
}
|
|
|
|
func inspectReferenceImage(data []byte, index int) (uploadedReference, error) {
|
|
if len(data) == 0 {
|
|
return uploadedReference{}, errors.New("empty reference image")
|
|
}
|
|
mimeType := normalizeImageMime(stdhttp.DetectContentType(data))
|
|
if mimeType == "" {
|
|
return uploadedReference{}, errors.New("unsupported reference image type")
|
|
}
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
|
if err != nil {
|
|
return uploadedReference{}, errors.New("failed to decode reference image")
|
|
}
|
|
ext := extensionForMime(mimeType)
|
|
fileName := fmt.Sprintf("reference_%s_%02d%s", time.Now().UTC().Format("20060102_150405"), index+1, ext)
|
|
return uploadedReference{
|
|
FileName: fileName,
|
|
MimeType: mimeType,
|
|
SizeBytes: len(data),
|
|
Width: cfg.Width,
|
|
Height: cfg.Height,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeImageMime(v string) string {
|
|
v = strings.ToLower(strings.TrimSpace(strings.Split(v, ";")[0]))
|
|
switch v {
|
|
case "image/jpeg", "image/jpg":
|
|
return "image/jpeg"
|
|
case "image/png":
|
|
return "image/png"
|
|
case "image/gif":
|
|
return "image/gif"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func extensionForMime(mimeType string) string {
|
|
switch strings.ToLower(strings.TrimSpace(mimeType)) {
|
|
case "image/jpeg":
|
|
return ".jpg"
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/gif":
|
|
return ".gif"
|
|
default:
|
|
return filepath.Ext(mimeType)
|
|
}
|
|
}
|
|
|
|
func (c *Client) createFileEntry(ctx context.Context, session tlsclient.HttpClient, accessToken string, meta uploadedReference) (*fileEntry, error) {
|
|
path := "/backend-api/files"
|
|
payload := map[string]any{
|
|
"file_name": meta.FileName,
|
|
"file_size": meta.SizeBytes,
|
|
"use_case": "multimodal",
|
|
"timezone_offset_min": -480,
|
|
"reset_rate_limits": false,
|
|
"mime_type": meta.MimeType,
|
|
"entry_surface": "chat_composer",
|
|
"selection_method": "file_picker",
|
|
"client_resolved_mime_type": meta.MimeType,
|
|
"mime_resolution_source": "filename_extension",
|
|
"store_in_library": true,
|
|
"library_persistence_mode": "opportunistic",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.headers(accessToken, path, map[string]string{
|
|
"accept": "application/json",
|
|
"content-type": "application/json",
|
|
})
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureOK(resp.StatusCode, respBody, "file_create"); err != nil {
|
|
return nil, err
|
|
}
|
|
var data map[string]any
|
|
if err := json.Unmarshal(respBody, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
entry := &fileEntry{
|
|
FileID: strings.TrimSpace(stringValue(data["file_id"])),
|
|
UploadURL: strings.TrimSpace(stringValue(data["upload_url"])),
|
|
}
|
|
if entry.FileID == "" || entry.UploadURL == "" {
|
|
return nil, errors.New("file_create missing upload payload")
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func (c *Client) uploadRawFile(ctx context.Context, session tlsclient.HttpClient, uploadURL, mimeType string, data []byte) error {
|
|
req, err := http.NewRequest(http.MethodPut, uploadURL, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"accept": {"application/json, text/plain, */*"},
|
|
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8"},
|
|
"content-type": {mimeType},
|
|
"origin": {baseURL},
|
|
"referer": {baseURL + "/"},
|
|
"sec-ch-ua": {`"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"`},
|
|
"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": {defaultUserAgent},
|
|
"x-ms-blob-type": {"BlockBlob"},
|
|
"x-ms-version": {"2020-04-08"},
|
|
}
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
if err := ensureOK(resp.StatusCode, body, "file_upload"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) processUploadStream(ctx context.Context, session tlsclient.HttpClient, accessToken, fileID, fileName string) (string, error) {
|
|
path := "/backend-api/files/process_upload_stream"
|
|
payload := map[string]any{
|
|
"file_id": fileID,
|
|
"use_case": "multimodal",
|
|
"index_for_retrieval": false,
|
|
"file_name": fileName,
|
|
"library_persistence_mode": "opportunistic",
|
|
"entry_surface": "chat_composer",
|
|
"metadata": map[string]any{
|
|
"store_in_library": true,
|
|
"is_temporary_chat": false,
|
|
"library_eligibility_reason": "eligible",
|
|
"is_project_thread": false,
|
|
},
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.headers(accessToken, path, map[string]string{
|
|
"accept": "text/event-stream",
|
|
"content-type": "application/json",
|
|
})
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err := ensureOK(resp.StatusCode, respBody, "file_process_upload"); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
defer resp.Body.Close()
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 0, 1024*32), 1024*1024)
|
|
libraryFileID := ""
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var item map[string]any
|
|
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
|
continue
|
|
}
|
|
if extra, _ := item["extra"].(map[string]any); extra != nil {
|
|
if v := strings.TrimSpace(stringValue(extra["metadata_object_id"])); v != "" {
|
|
libraryFileID = v
|
|
}
|
|
}
|
|
if strings.TrimSpace(stringValue(item["event"])) == "file.processing.completed" {
|
|
break
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
return libraryFileID, nil
|
|
}
|
|
|
|
func attachmentMimeTypes(refs []uploadedReference) []string {
|
|
if len(refs) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(refs))
|
|
for _, ref := range refs {
|
|
if ref.MimeType != "" && !containsString(out, ref.MimeType) {
|
|
out = append(out, ref.MimeType)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildAttachmentMetadata(refs []uploadedReference) []map[string]any {
|
|
out := make([]map[string]any, 0, len(refs))
|
|
for _, ref := range refs {
|
|
out = append(out, map[string]any{
|
|
"id": ref.FileID,
|
|
"size": ref.SizeBytes,
|
|
"name": ref.FileName,
|
|
"mime_type": ref.MimeType,
|
|
"width": ref.Width,
|
|
"height": ref.Height,
|
|
"source": "local",
|
|
"library_file_id": emptyStringNil(ref.LibraryFileID),
|
|
"is_big_paste": false,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildMultimodalParts(refs []uploadedReference, prompt string) []any {
|
|
out := make([]any, 0, len(refs)+1)
|
|
for _, ref := range refs {
|
|
out = append(out, map[string]any{
|
|
"content_type": "image_asset_pointer",
|
|
"asset_pointer": "sediment://" + ref.FileID,
|
|
"size_bytes": ref.SizeBytes,
|
|
"width": ref.Width,
|
|
"height": ref.Height,
|
|
})
|
|
}
|
|
out = append(out, prompt)
|
|
return out
|
|
}
|
|
|
|
func (c *Client) prepareImageConversation(ctx context.Context, session tlsclient.HttpClient, accessToken, prompt string, reqs *chatRequirements, model string, refs []uploadedReference) (string, error) {
|
|
path := "/backend-api/f/conversation/prepare"
|
|
payload := map[string]any{
|
|
"action": "next",
|
|
"parent_message_id": "client-created-root",
|
|
"model": imageModelSlug(model),
|
|
"timezone_offset_min": -480,
|
|
"timezone": "Asia/Shanghai",
|
|
"conversation_mode": map[string]any{"kind": "primary_assistant"},
|
|
"system_hints": []string{"picture_v2"},
|
|
"supports_buffering": true,
|
|
"supported_encodings": []string{"v1"},
|
|
"client_contextual_info": map[string]any{"app_name": "chatgpt.com"},
|
|
}
|
|
if len(refs) > 0 {
|
|
payload["client_prepare_state"] = "none"
|
|
payload["attachment_mime_types"] = attachmentMimeTypes(refs)
|
|
} else {
|
|
payload["client_prepare_state"] = "success"
|
|
payload["partial_query"] = map[string]any{
|
|
"id": newUUID(),
|
|
"author": map[string]any{"role": "user"},
|
|
"content": map[string]any{"content_type": "text", "parts": []string{prompt}},
|
|
}
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.imageHeaders(accessToken, path, reqs, "no-token", "application/json")
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := ensureOK(resp.StatusCode, respBody, "image_prepare"); err != nil {
|
|
return "", err
|
|
}
|
|
var data map[string]any
|
|
if err := json.Unmarshal(respBody, &data); err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(stringValue(data["conduit_token"])), nil
|
|
}
|
|
|
|
func (c *Client) startImageGeneration(ctx context.Context, session tlsclient.HttpClient, accessToken, prompt string, reqs *chatRequirements, conduitToken, model string, refs []uploadedReference) (string, []string, []string, error) {
|
|
path := "/backend-api/f/conversation"
|
|
// Match the web app: a picture_v2 prompt is sent as the "@创建图片" ecosystem
|
|
// mention followed by the prompt, with the mention marked via a
|
|
// custom_symbol_offset so the server routes the turn to image generation.
|
|
content := map[string]any{"content_type": "text", "parts": []string{pictureV2Command + " " + prompt}}
|
|
metadata := map[string]any{
|
|
"system_hints": []string{"picture_v2"},
|
|
"serialization_metadata": map[string]any{
|
|
"custom_symbol_offsets": []any{
|
|
map[string]any{
|
|
"id": "picture_v2",
|
|
"symbol": "ecosystemMention",
|
|
"startIndex": 0,
|
|
"endIndex": pictureV2MentionEnd,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if len(refs) > 0 {
|
|
// Reference-image turns carry image parts and no leading mention, so keep
|
|
// the multimodal shape and drop the ecosystemMention offset.
|
|
content = map[string]any{
|
|
"content_type": "multimodal_text",
|
|
"parts": buildMultimodalParts(refs, prompt),
|
|
}
|
|
metadata["attachments"] = buildAttachmentMetadata(refs)
|
|
metadata["serialization_metadata"] = map[string]any{"custom_symbol_offsets": []any{}}
|
|
}
|
|
payload := map[string]any{
|
|
"action": "next",
|
|
"messages": []map[string]any{{
|
|
"id": newUUID(),
|
|
"author": map[string]any{"role": "user"},
|
|
"create_time": float64(timeMillis()) / 1000.0,
|
|
"content": content,
|
|
"metadata": metadata,
|
|
}},
|
|
"parent_message_id": "client-created-root",
|
|
"model": imageModelSlug(model),
|
|
"client_prepare_state": "success",
|
|
"timezone_offset_min": -480,
|
|
"timezone": "Asia/Shanghai",
|
|
"conversation_mode": map[string]any{"kind": "primary_assistant"},
|
|
"enable_message_followups": true,
|
|
"system_hints": []string{"picture_v2"},
|
|
"supports_buffering": true,
|
|
"supported_encodings": []string{"v1"},
|
|
"client_contextual_info": map[string]any{"is_dark_mode": false, "time_since_loaded": 1200, "page_height": 1072, "page_width": 1724, "pixel_ratio": 1.2, "screen_height": 1440, "screen_width": 2560, "app_name": "chatgpt.com", "has_web_push_capabilities": true, "web_push_notification_permission": "default"},
|
|
"paragen_cot_summary_display_override": "allow",
|
|
"force_parallel_switch": "auto",
|
|
"local_function_names": []string{"local.continue_in_work"},
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.imageHeaders(accessToken, path, reqs, conduitToken, "text/event-stream")
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return "", nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err := ensureOK(resp.StatusCode, respBody, "image_start"); err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
conversationID := ""
|
|
asyncStarted := false
|
|
var fileIDs, sedimentIDs []string
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), 8*1024*1024)
|
|
var chunks []string
|
|
refIDs := uploadedRefIDSet(refs)
|
|
// Watchdog: scanner.Scan() blocks on a read while the SSE is silent, which
|
|
// would hold the whole ctx budget. ChatGPT's inline image pipeline keeps the
|
|
// connection open with periodic ": ping" keepalives (~15s apart) while the
|
|
// image renders, then streams the asset (a role:"tool" multimodal_text turn
|
|
// carrying image_asset_pointer / sediment://) in the same SSE. So this is an
|
|
// *idle* timeout reset on every received line — a fixed grace from the
|
|
// conversation id would abort mid-render before the asset arrives. It only
|
|
// closes the body (unblocking the read) once the stream is genuinely silent.
|
|
activity := make(chan struct{}, 1)
|
|
watchdogDone := make(chan struct{})
|
|
defer close(watchdogDone)
|
|
go func() {
|
|
timer := time.NewTimer(sseIdleGrace)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-activity:
|
|
if !timer.Stop() {
|
|
<-timer.C
|
|
}
|
|
timer.Reset(sseIdleGrace)
|
|
case <-timer.C:
|
|
resp.Body.Close()
|
|
return
|
|
case <-watchdogDone:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
for scanner.Scan() {
|
|
select {
|
|
case activity <- struct{}{}:
|
|
default:
|
|
}
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, "data:") {
|
|
continue
|
|
}
|
|
payload := strings.TrimSpace(line[5:])
|
|
if payload == "" {
|
|
continue
|
|
}
|
|
if payload == "[DONE]" {
|
|
break
|
|
}
|
|
chunks = append(chunks, payload)
|
|
if conversationID == "" {
|
|
if match := conversationIDRE.FindStringSubmatch(payload); len(match) >= 2 {
|
|
conversationID = match[1]
|
|
}
|
|
}
|
|
newFiles, newSeds := scanForIDs(payload)
|
|
fileIDs = mergeStrings(fileIDs, newFiles)
|
|
sedimentIDs = mergeStrings(sedimentIDs, newSeds)
|
|
if !asyncStarted && containsAsyncMarker(payload) {
|
|
asyncStarted = true
|
|
}
|
|
// Stop once the image pipeline is confirmed engaged (async/tool marker) or
|
|
// the generated asset id has streamed inline. The stream echoes back the
|
|
// user's uploaded reference images too, so exclude those ids before deciding
|
|
// — otherwise an edit request would stop on its own upload. The idle watchdog
|
|
// above bounds a silent stream; pollForImage backstops asset retrieval when
|
|
// nothing streamed inline.
|
|
haveAsset := len(dropIDs(append([]string(nil), fileIDs...), refIDs)) > 0 ||
|
|
len(dropIDs(append([]string(nil), sedimentIDs...), refIDs)) > 0
|
|
if conversationID != "" && (asyncStarted || haveAsset) {
|
|
break
|
|
}
|
|
}
|
|
if conversationID == "" {
|
|
joined := strings.Join(chunks, "\n")
|
|
if match := conversationIDRE.FindStringSubmatch(joined); len(match) >= 2 {
|
|
conversationID = match[1]
|
|
}
|
|
}
|
|
if conversationID == "" {
|
|
return "", nil, nil, errors.New("chatgpt SSE closed without conversation_id")
|
|
}
|
|
// The stream closed with a conversation id but no sign the image pipeline
|
|
// engaged — no async/tool marker and no asset streamed inline. Polling such a
|
|
// conversation only burns the whole budget and surfaces as the non-retryable
|
|
// "image poll timeout", so treat the attempt as a transient upstream failure.
|
|
// That is retryable: a fresh submission reliably engages the pipeline, so the
|
|
// pool retries the same account a few times and then fails over to another
|
|
// account (换号重试) instead of failing the request.
|
|
if !asyncStarted && len(fileIDs) == 0 && len(sedimentIDs) == 0 {
|
|
return "", nil, nil, fmt.Errorf("%w: image generation did not start (no async marker)", ErrTemporaryUpstream)
|
|
}
|
|
return conversationID, fileIDs, sedimentIDs, nil
|
|
}
|
|
|
|
func (c *Client) getConversation(ctx context.Context, session tlsclient.HttpClient, accessToken, conversationID string) (map[string]any, error) {
|
|
path := "/backend-api/conversation/" + conversationID
|
|
req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.headers(accessToken, path, map[string]string{"accept": "application/json"})
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureOK(resp.StatusCode, body, "conversation_get"); err != nil {
|
|
return nil, err
|
|
}
|
|
if dir := os.Getenv("CHATGPT_DEBUG_DUMP"); dir != "" {
|
|
_ = os.WriteFile(filepath.Join(dir, fmt.Sprintf("conv-%s-%d.json", conversationID, time.Now().UnixMilli())), body, 0o644)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
// pollBudget derives how long to poll for the async image from the caller's
|
|
// remaining context budget, leaving headroom to resolve+download the asset
|
|
// before the outer deadline (genCtx, 8min) fires. Async image generation under
|
|
// load routinely exceeds the old hard-coded 180s, which surfaced as
|
|
// "image poll timeout"; tying the budget to the deadline lets slow gens finish
|
|
// while the context still backstops a truly stuck request.
|
|
func pollBudget(ctx context.Context) time.Duration {
|
|
const (
|
|
maxBudget = 6 * time.Minute
|
|
headroom = 25 * time.Second
|
|
)
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
return 3 * time.Minute
|
|
}
|
|
budget := time.Until(deadline) - headroom
|
|
if budget < 0 {
|
|
budget = 0
|
|
}
|
|
if budget > maxBudget {
|
|
budget = maxBudget
|
|
}
|
|
return budget
|
|
}
|
|
|
|
func (c *Client) pollForImage(ctx context.Context, session tlsclient.HttpClient, accessToken, conversationID string, initialFileIDs, initialSedimentIDs []string, refIDs map[string]bool, timeout time.Duration) ([]string, []string, error) {
|
|
start := time.Now()
|
|
fileIDs := dropIDs(append([]string{}, initialFileIDs...), refIDs)
|
|
sedimentIDs := dropIDs(append([]string{}, initialSedimentIDs...), refIDs)
|
|
if len(fileIDs) == 0 {
|
|
time.Sleep(8 * time.Second)
|
|
} else {
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
attempt := 0
|
|
for time.Since(start) < timeout {
|
|
// Bail out immediately if the caller's context is already done — without
|
|
// this, a cancelled request spins here re-issuing doomed upstream calls
|
|
// (each fails instantly with "operation was canceled") until `timeout`.
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
attempt++
|
|
conv, err := c.getConversation(ctx, session, accessToken, conversationID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrTemporaryUpstream) {
|
|
time.Sleep(time.Duration(minInt(1<<minInt(attempt, 4), 8)) * time.Second)
|
|
continue
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
newFiles, newSeds := extractImageIDs(conv)
|
|
fileIDs = mergeStrings(fileIDs, dropIDs(newFiles, refIDs))
|
|
sedimentIDs = mergeStrings(sedimentIDs, dropIDs(newSeds, refIDs))
|
|
// Fail fast on a content-audit refusal: the assistant turn carries the
|
|
// rejection text and no image will ever land, so polling to timeout only
|
|
// wastes the whole budget. Only bail while we have no asset yet.
|
|
if len(fileIDs) == 0 && len(sedimentIDs) == 0 && (conversationRejected(conv) || conversationEndedWithoutImage(conv)) {
|
|
return nil, nil, ErrContentPolicy
|
|
}
|
|
if len(fileIDs) > 0 || len(sedimentIDs) > 0 {
|
|
time.Sleep(2 * time.Second)
|
|
conv, err = c.getConversation(ctx, session, accessToken, conversationID)
|
|
if err == nil {
|
|
finalFiles, finalSeds := extractImageIDs(conv)
|
|
fileIDs = mergeStrings(fileIDs, dropIDs(finalFiles, refIDs))
|
|
sedimentIDs = mergeStrings(sedimentIDs, dropIDs(finalSeds, refIDs))
|
|
}
|
|
return fileIDs, sedimentIDs, nil
|
|
}
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
return nil, nil, errors.New("image poll timeout")
|
|
}
|
|
|
|
// uploadedRefIDSet collects every id belonging to the user's uploaded
|
|
// reference images so they can be excluded from generated-asset extraction.
|
|
func uploadedRefIDSet(refs []uploadedReference) map[string]bool {
|
|
ids := make(map[string]bool, len(refs)*2)
|
|
for _, ref := range refs {
|
|
if ref.FileID != "" {
|
|
ids[ref.FileID] = true
|
|
}
|
|
if ref.LibraryFileID != "" {
|
|
ids[ref.LibraryFileID] = true
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func dropIDs(ids []string, exclude map[string]bool) []string {
|
|
if len(exclude) == 0 || len(ids) == 0 {
|
|
return ids
|
|
}
|
|
out := ids[:0]
|
|
for _, id := range ids {
|
|
if !exclude[id] {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *Client) getFileDownloadURL(ctx context.Context, session tlsclient.HttpClient, accessToken, conversationID, fileID string, inline bool) (string, error) {
|
|
// Current web client form: GET /backend-api/files/download/{id}
|
|
// ?conversation_id=...&inline=false → {"status":"success","download_url":...}.
|
|
// Falls back to the legacy /files/{id}/download form if the new one fails.
|
|
paths := []string{
|
|
"/backend-api/files/download/" + fileID + "?conversation_id=" + conversationID + "&inline=" + strconv.FormatBool(inline),
|
|
"/backend-api/files/" + fileID + "/download",
|
|
}
|
|
var lastErr error
|
|
for _, path := range paths {
|
|
rawURL, err := c.fetchDownloadURL(ctx, session, accessToken, path)
|
|
if err == nil && rawURL != "" {
|
|
return rawURL, nil
|
|
}
|
|
if err != nil {
|
|
lastErr = err
|
|
}
|
|
}
|
|
return "", lastErr
|
|
}
|
|
|
|
func (c *Client) fetchDownloadURL(ctx context.Context, session tlsclient.HttpClient, accessToken, path string) (string, error) {
|
|
req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.headers(accessToken, path, map[string]string{"accept": "application/json"})
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := ensureOK(resp.StatusCode, body, "file_download_url"); err != nil {
|
|
return "", err
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return "", err
|
|
}
|
|
rawURL := strings.TrimSpace(stringValue(payload["download_url"]))
|
|
if rawURL == "" {
|
|
rawURL = strings.TrimSpace(stringValue(payload["url"]))
|
|
}
|
|
return rawURL, nil
|
|
}
|
|
|
|
func (c *Client) resolveImageURLs(ctx context.Context, session tlsclient.HttpClient, accessToken, conversationID string, fileIDs, sedimentIDs []string) ([]string, error) {
|
|
var urls []string
|
|
for _, fileID := range fileIDs {
|
|
if fileID == "file_upload" {
|
|
continue
|
|
}
|
|
rawURL, err := c.getFileDownloadURL(ctx, session, accessToken, conversationID, fileID, false)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if rawURL != "" && !containsString(urls, rawURL) {
|
|
urls = append(urls, rawURL)
|
|
}
|
|
}
|
|
for _, sedimentID := range sedimentIDs {
|
|
path := "/backend-api/conversation/" + conversationID + "/attachment/" + sedimentID + "/download"
|
|
req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = c.headers(accessToken, path, map[string]string{"accept": "application/json"})
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if readErr != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
continue
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
continue
|
|
}
|
|
rawURL := strings.TrimSpace(stringValue(payload["download_url"]))
|
|
if rawURL == "" {
|
|
rawURL = strings.TrimSpace(stringValue(payload["url"]))
|
|
}
|
|
if rawURL != "" && !containsString(urls, rawURL) {
|
|
urls = append(urls, rawURL)
|
|
}
|
|
}
|
|
return urls, nil
|
|
}
|
|
|
|
func (c *Client) downloadBytes(ctx context.Context, session tlsclient.HttpClient, accessToken string, urls []string) ([][]byte, error) {
|
|
out := make([][]byte, 0, len(urls))
|
|
for _, rawURL := range urls {
|
|
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
// Mirror Python's session.get(url): the resolved download_url is a
|
|
// backend-api stream that requires the same default headers as every
|
|
// other call — crucially Authorization. Without it the fetch 403s with
|
|
// {"detail":"File stream access denied."}.
|
|
req.Header = c.baseHeaders(accessToken)
|
|
req.Header.Set("accept", "*/*")
|
|
resp, err := session.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
if err := ensureOK(resp.StatusCode, body, "image_download"); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > 0 {
|
|
out = append(out, body)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func ensureOK(statusCode int, body []byte, context string) error {
|
|
if statusCode >= 200 && statusCode < 300 {
|
|
return nil
|
|
}
|
|
switch statusCode {
|
|
case 401, 403:
|
|
return fmt.Errorf("%w: %s %d %s", ErrAuth, context, statusCode, clip(body, 400))
|
|
case 429:
|
|
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, context, clip(body, 400))
|
|
case 500, 502, 503, 504:
|
|
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, context, statusCode, clip(body, 400))
|
|
default:
|
|
return fmt.Errorf("%s: %d %s", context, statusCode, clip(body, 400))
|
|
}
|
|
}
|
|
|
|
func injectSizeHint(prompt, aspectRatio, resolution string) string {
|
|
_ = resolution
|
|
ratio := strings.TrimSpace(aspectRatio)
|
|
if ratio == "" || strings.EqualFold(ratio, "auto") {
|
|
return strings.TrimSpace(prompt)
|
|
}
|
|
return strings.TrimSpace(prompt) + "\n\n将宽高比设为 " + ratio
|
|
}
|
|
|
|
func scanForIDs(text string) ([]string, []string) {
|
|
var fileIDs []string
|
|
for _, id := range fileServiceIDPattern.FindAllStringSubmatch(text, -1) {
|
|
if len(id) >= 2 && id[1] != "file_upload" && !containsString(fileIDs, id[1]) {
|
|
fileIDs = append(fileIDs, id[1])
|
|
}
|
|
}
|
|
for _, id := range realImageIDPattern.FindAllString(text, -1) {
|
|
if !containsString(fileIDs, id) {
|
|
fileIDs = append(fileIDs, id)
|
|
}
|
|
}
|
|
var sedimentIDs []string
|
|
for _, id := range sedimentIDPattern.FindAllStringSubmatch(text, -1) {
|
|
if len(id) >= 2 && !containsString(sedimentIDs, id[1]) {
|
|
sedimentIDs = append(sedimentIDs, id[1])
|
|
}
|
|
}
|
|
return fileIDs, sedimentIDs
|
|
}
|
|
|
|
func extractImageIDs(conversation map[string]any) ([]string, []string) {
|
|
var fileIDs, sedimentIDs []string
|
|
mapping, _ := conversation["mapping"].(map[string]any)
|
|
for _, rawNode := range mapping {
|
|
node, _ := rawNode.(map[string]any)
|
|
message, _ := node["message"].(map[string]any)
|
|
author, _ := message["author"].(map[string]any)
|
|
role := strings.ToLower(strings.TrimSpace(stringValue(author["role"])))
|
|
if role != "tool" && role != "assistant" {
|
|
continue
|
|
}
|
|
walkForIDs(message["content"], &fileIDs, &sedimentIDs)
|
|
walkForIDs(message["metadata"], &fileIDs, &sedimentIDs)
|
|
}
|
|
return fileIDs, sedimentIDs
|
|
}
|
|
|
|
func walkForIDs(value any, fileIDs, sedimentIDs *[]string) {
|
|
switch x := value.(type) {
|
|
case string:
|
|
newFiles, newSeds := scanForIDs(x)
|
|
*fileIDs = mergeStrings(*fileIDs, newFiles)
|
|
*sedimentIDs = mergeStrings(*sedimentIDs, newSeds)
|
|
case map[string]any:
|
|
for _, item := range x {
|
|
walkForIDs(item, fileIDs, sedimentIDs)
|
|
}
|
|
case []any:
|
|
for _, item := range x {
|
|
walkForIDs(item, fileIDs, sedimentIDs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mergeStrings(dst, src []string) []string {
|
|
for _, item := range src {
|
|
if !containsString(dst, item) {
|
|
dst = append(dst, item)
|
|
}
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func containsString(items []string, value string) bool {
|
|
for _, item := range items {
|
|
if item == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|