更新openai风控协议
This commit is contained in:
@@ -28,6 +28,10 @@ var (
|
|||||||
ErrAuth = errors.New("chatgpt auth failed")
|
ErrAuth = errors.New("chatgpt auth failed")
|
||||||
ErrQuotaExhausted = errors.New("chatgpt quota exhausted")
|
ErrQuotaExhausted = errors.New("chatgpt quota exhausted")
|
||||||
ErrTemporaryUpstream = errors.New("chatgpt upstream temporary error")
|
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 {
|
type Client struct {
|
||||||
@@ -94,7 +98,7 @@ func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -791,6 +795,7 @@ func (c *Client) startImageGeneration(ctx context.Context, session tlsclient.Htt
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
conversationID := ""
|
conversationID := ""
|
||||||
|
asyncStarted := false
|
||||||
var fileIDs, sedimentIDs []string
|
var fileIDs, sedimentIDs []string
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
scanner.Buffer(make([]byte, 0, 1024*1024), 8*1024*1024)
|
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)
|
newFiles, newSeds := scanForIDs(payload)
|
||||||
fileIDs = mergeStrings(fileIDs, newFiles)
|
fileIDs = mergeStrings(fileIDs, newFiles)
|
||||||
sedimentIDs = mergeStrings(sedimentIDs, newSeds)
|
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 == "" {
|
if conversationID == "" {
|
||||||
joined := strings.Join(chunks, "\n")
|
joined := strings.Join(chunks, "\n")
|
||||||
@@ -856,6 +873,31 @@ func (c *Client) getConversation(ctx context.Context, session tlsclient.HttpClie
|
|||||||
return payload, nil
|
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) {
|
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()
|
start := time.Now()
|
||||||
fileIDs := append([]string{}, initialFileIDs...)
|
fileIDs := append([]string{}, initialFileIDs...)
|
||||||
@@ -885,6 +927,12 @@ func (c *Client) pollForImage(ctx context.Context, session tlsclient.HttpClient,
|
|||||||
newFiles, newSeds := extractImageIDs(conv)
|
newFiles, newSeds := extractImageIDs(conv)
|
||||||
fileIDs = mergeStrings(fileIDs, newFiles)
|
fileIDs = mergeStrings(fileIDs, newFiles)
|
||||||
sedimentIDs = mergeStrings(sedimentIDs, newSeds)
|
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 {
|
if len(fileIDs) > 0 || len(sedimentIDs) > 0 {
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
conv, err = c.getConversation(ctx, session, accessToken, conversationID)
|
conv, err = c.getConversation(ctx, session, accessToken, conversationID)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
baseURL = "https://chatgpt.com"
|
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"
|
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"
|
defaultClientVersion = "prod-db390ebea64862bf1899c420a4c736e0cf639747"
|
||||||
defaultClientBuildNumber = "7624276"
|
defaultClientBuildNumber = "7904904"
|
||||||
defaultPOWScript = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
defaultPOWScript = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,8 +28,91 @@ var (
|
|||||||
scriptSrcRE = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
|
scriptSrcRE = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
|
||||||
dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`)
|
dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`)
|
||||||
htmlDataBuildRE = regexp.MustCompile(`<html[^>]*data-build="([^"]*)"`)
|
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"}
|
||||||
|
|
||||||
|
// 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 {
|
func stringValue(v any) string {
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
|||||||
Reference in New Issue
Block a user