From 39b149b2069adeee90581a3d51c7b00a4b5a8053 Mon Sep 17 00:00:00 2001 From: chiyi Date: Thu, 2 Jul 2026 13:57:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0openai=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/provider/chatgpt/client.go | 50 +++++++++++- backend/internal/provider/chatgpt/util.go | 87 ++++++++++++++++++++- 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/backend/internal/provider/chatgpt/client.go b/backend/internal/provider/chatgpt/client.go index 97961c2..d27386e 100644 --- a/backend/internal/provider/chatgpt/client.go +++ b/backend/internal/provider/chatgpt/client.go @@ -28,6 +28,10 @@ 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 { @@ -94,7 +98,7 @@ func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, if err != nil { return nil, nil, err } - fileIDs, sedimentIDs, err = c.pollForImage(ctx, session, accessToken, conversationID, fileIDs, sedimentIDs, 180*time.Second) + fileIDs, sedimentIDs, err = c.pollForImage(ctx, session, accessToken, conversationID, fileIDs, sedimentIDs, pollBudget(ctx)) if err != nil { return nil, nil, err } @@ -791,6 +795,7 @@ func (c *Client) startImageGeneration(ctx context.Context, session tlsclient.Htt 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) @@ -816,6 +821,18 @@ func (c *Client) startImageGeneration(ctx context.Context, session tlsclient.Htt newFiles, newSeds := scanForIDs(payload) fileIDs = mergeStrings(fileIDs, newFiles) sedimentIDs = mergeStrings(sedimentIDs, newSeds) + if !asyncStarted && containsAsyncMarker(payload) { + asyncStarted = true + } + // Async pipeline: ChatGPT no longer streams the image inline — it returns + // a placeholder tool turn (image_gen_async / image_gen_task_id) and + // delivers the asset later via conversation polling. Once we have the + // conversation id there is nothing more to read here, so stop instead of + // holding the SSE open until [DONE] (a stalled stream would otherwise burn + // the whole generation budget and surface as "context deadline exceeded"). + if asyncStarted && conversationID != "" { + break + } } if conversationID == "" { joined := strings.Join(chunks, "\n") @@ -856,6 +873,31 @@ func (c *Client) getConversation(ctx context.Context, session tlsclient.HttpClie 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, timeout time.Duration) ([]string, []string, error) { start := time.Now() fileIDs := append([]string{}, initialFileIDs...) @@ -885,6 +927,12 @@ func (c *Client) pollForImage(ctx context.Context, session tlsclient.HttpClient, newFiles, newSeds := extractImageIDs(conv) fileIDs = mergeStrings(fileIDs, newFiles) sedimentIDs = mergeStrings(sedimentIDs, newSeds) + // 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) { + return nil, nil, ErrContentPolicy + } if len(fileIDs) > 0 || len(sedimentIDs) > 0 { time.Sleep(2 * time.Second) conv, err = c.getConversation(ctx, session, accessToken, conversationID) diff --git a/backend/internal/provider/chatgpt/util.go b/backend/internal/provider/chatgpt/util.go index 6a4ef7d..f8711bf 100644 --- a/backend/internal/provider/chatgpt/util.go +++ b/backend/internal/provider/chatgpt/util.go @@ -15,8 +15,8 @@ import ( 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-ab8a6348980a3e1d771c463b9f4f3e4e584f2769" - defaultClientBuildNumber = "7624276" + defaultClientVersion = "prod-db390ebea64862bf1899c420a4c736e0cf639747" + defaultClientBuildNumber = "7904904" defaultPOWScript = "https://chatgpt.com/backend-api/sentinel/sdk.js" ) @@ -28,8 +28,91 @@ var ( scriptSrcRE = regexp.MustCompile(`]+src="([^"]+)"`) dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`) htmlDataBuildRE = regexp.MustCompile(`]*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"} + + // 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", + } ) +// 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 +} + func stringValue(v any) string { switch x := v.(type) { case string: