Files
sub2api/backend/internal/service/sora_client.go
yangjianbo 5d2219d299 fix(sora): 修复令牌刷新请求格式与流式错误转义
- 将 refresh_token 恢复请求改为表单编码并匹配 OAuth 约定
- 流式错误改为 JSON 序列化,避免消息含引号或换行导致 SSE 非法
- 补充 Sora token 恢复与 failover 流式错误透传回归测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:23:00 +08:00

1467 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"path"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
openaioauth "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"golang.org/x/crypto/sha3"
)
const (
soraChatGPTBaseURL = "https://chatgpt.com"
soraSentinelFlow = "sora_2_create_task"
soraDefaultUserAgent = "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
)
var (
soraSessionAuthURL = "https://sora.chatgpt.com/api/auth/session"
soraOAuthTokenURL = "https://auth.openai.com/oauth/token"
)
const (
soraPowMaxIteration = 500000
)
var soraPowCores = []int{8, 16, 24, 32}
var soraPowScripts = []string{
"https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3",
}
var soraPowDPL = []string{
"prod-f501fe933b3edf57aea882da888e1a544df99840",
}
var soraPowNavigatorKeys = []string{
"registerProtocolHandlerfunction registerProtocolHandler() { [native code] }",
"storage[object StorageManager]",
"locks[object LockManager]",
"appCodeNameMozilla",
"permissions[object Permissions]",
"webdriverfalse",
"vendorGoogle Inc.",
"mediaDevices[object MediaDevices]",
"cookieEnabledtrue",
"productGecko",
"productSub20030107",
"hardwareConcurrency32",
"onLinetrue",
}
var soraPowDocumentKeys = []string{
"_reactListeningo743lnnpvdg",
"location",
}
var soraPowWindowKeys = []string{
"0", "window", "self", "document", "name", "location",
"navigator", "screen", "innerWidth", "innerHeight",
"localStorage", "sessionStorage", "crypto", "performance",
"fetch", "setTimeout", "setInterval", "console",
}
var soraDesktopUserAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
}
var soraRand = rand.New(rand.NewSource(time.Now().UnixNano()))
var soraRandMu sync.Mutex
var soraPerfStart = time.Now()
// SoraClient 定义直连 Sora 的任务操作接口。
type SoraClient interface {
Enabled() bool
UploadImage(ctx context.Context, account *Account, data []byte, filename string) (string, error)
CreateImageTask(ctx context.Context, account *Account, req SoraImageRequest) (string, error)
CreateVideoTask(ctx context.Context, account *Account, req SoraVideoRequest) (string, error)
EnhancePrompt(ctx context.Context, account *Account, prompt, expansionLevel string, durationS int) (string, error)
GetImageTask(ctx context.Context, account *Account, taskID string) (*SoraImageTaskStatus, error)
GetVideoTask(ctx context.Context, account *Account, taskID string) (*SoraVideoTaskStatus, error)
}
// SoraImageRequest 图片生成请求参数
type SoraImageRequest struct {
Prompt string
Width int
Height int
MediaID string
}
// SoraVideoRequest 视频生成请求参数
type SoraVideoRequest struct {
Prompt string
Orientation string
Frames int
Model string
Size string
MediaID string
RemixTargetID string
}
// SoraImageTaskStatus 图片任务状态
type SoraImageTaskStatus struct {
ID string
Status string
ProgressPct float64
URLs []string
ErrorMsg string
}
// SoraVideoTaskStatus 视频任务状态
type SoraVideoTaskStatus struct {
ID string
Status string
ProgressPct int
URLs []string
ErrorMsg string
}
// SoraUpstreamError 上游错误
type SoraUpstreamError struct {
StatusCode int
Message string
Headers http.Header
Body []byte
}
func (e *SoraUpstreamError) Error() string {
if e == nil {
return "sora upstream error"
}
if e.Message != "" {
return fmt.Sprintf("sora upstream error: %d %s", e.StatusCode, e.Message)
}
return fmt.Sprintf("sora upstream error: %d", e.StatusCode)
}
// SoraDirectClient 直连 Sora 实现
type SoraDirectClient struct {
cfg *config.Config
httpUpstream HTTPUpstream
tokenProvider *OpenAITokenProvider
accountRepo AccountRepository
soraAccountRepo SoraAccountRepository
baseURL string
}
// NewSoraDirectClient 创建 Sora 直连客户端
func NewSoraDirectClient(cfg *config.Config, httpUpstream HTTPUpstream, tokenProvider *OpenAITokenProvider) *SoraDirectClient {
baseURL := ""
if cfg != nil {
rawBaseURL := strings.TrimRight(strings.TrimSpace(cfg.Sora.Client.BaseURL), "/")
baseURL = normalizeSoraBaseURL(rawBaseURL)
if rawBaseURL != "" && baseURL != rawBaseURL {
log.Printf("[SoraClient] normalized base_url from %s to %s", sanitizeSoraLogURL(rawBaseURL), sanitizeSoraLogURL(baseURL))
}
}
return &SoraDirectClient{
cfg: cfg,
httpUpstream: httpUpstream,
tokenProvider: tokenProvider,
baseURL: baseURL,
}
}
func (c *SoraDirectClient) SetAccountRepositories(accountRepo AccountRepository, soraAccountRepo SoraAccountRepository) {
if c == nil {
return
}
c.accountRepo = accountRepo
c.soraAccountRepo = soraAccountRepo
}
// Enabled 判断是否启用 Sora 直连
func (c *SoraDirectClient) Enabled() bool {
if c == nil {
return false
}
if strings.TrimSpace(c.baseURL) != "" {
return true
}
if c.cfg == nil {
return false
}
return strings.TrimSpace(normalizeSoraBaseURL(c.cfg.Sora.Client.BaseURL)) != ""
}
// PreflightCheck 在创建任务前执行账号能力预检。
// 当前仅对视频模型执行 /nf/check 预检,用于提前识别额度耗尽或能力缺失。
func (c *SoraDirectClient) PreflightCheck(ctx context.Context, account *Account, requestedModel string, modelCfg SoraModelConfig) error {
if modelCfg.Type != "video" {
return nil
}
token, err := c.getAccessToken(ctx, account)
if err != nil {
return err
}
headers := c.buildBaseHeaders(token, c.defaultUserAgent())
headers.Set("Accept", "application/json")
body, _, err := c.doRequest(ctx, account, http.MethodGet, c.buildURL("/nf/check"), headers, nil, false)
if err != nil {
var upstreamErr *SoraUpstreamError
if errors.As(err, &upstreamErr) && upstreamErr.StatusCode == http.StatusNotFound {
return &SoraUpstreamError{
StatusCode: http.StatusForbidden,
Message: "当前账号未开通 Sora2 能力或无可用配额",
Headers: upstreamErr.Headers,
Body: upstreamErr.Body,
}
}
return err
}
rateLimitReached := gjson.GetBytes(body, "rate_limit_and_credit_balance.rate_limit_reached").Bool()
remaining := gjson.GetBytes(body, "rate_limit_and_credit_balance.estimated_num_videos_remaining")
if rateLimitReached || (remaining.Exists() && remaining.Int() <= 0) {
msg := "当前账号 Sora2 可用配额不足"
if requestedModel != "" {
msg = fmt.Sprintf("当前账号 %s 可用配额不足", requestedModel)
}
return &SoraUpstreamError{
StatusCode: http.StatusTooManyRequests,
Message: msg,
Headers: http.Header{},
}
}
return nil
}
func (c *SoraDirectClient) UploadImage(ctx context.Context, account *Account, data []byte, filename string) (string, error) {
if len(data) == 0 {
return "", errors.New("empty image data")
}
token, err := c.getAccessToken(ctx, account)
if err != nil {
return "", err
}
if filename == "" {
filename = "image.png"
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
contentType := mime.TypeByExtension(path.Ext(filename))
if contentType == "" {
contentType = "application/octet-stream"
}
partHeader := make(textproto.MIMEHeader)
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
partHeader.Set("Content-Type", contentType)
part, err := writer.CreatePart(partHeader)
if err != nil {
return "", err
}
if _, err := part.Write(data); err != nil {
return "", err
}
if err := writer.WriteField("file_name", filename); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
headers := c.buildBaseHeaders(token, c.defaultUserAgent())
headers.Set("Content-Type", writer.FormDataContentType())
respBody, _, err := c.doRequest(ctx, account, http.MethodPost, c.buildURL("/uploads"), headers, &body, false)
if err != nil {
return "", err
}
id := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
if id == "" {
return "", errors.New("upload response missing id")
}
return id, nil
}
func (c *SoraDirectClient) CreateImageTask(ctx context.Context, account *Account, req SoraImageRequest) (string, error) {
token, err := c.getAccessToken(ctx, account)
if err != nil {
return "", err
}
operation := "simple_compose"
inpaintItems := []map[string]any{}
if strings.TrimSpace(req.MediaID) != "" {
operation = "remix"
inpaintItems = append(inpaintItems, map[string]any{
"type": "image",
"frame_index": 0,
"upload_media_id": req.MediaID,
})
}
payload := map[string]any{
"type": "image_gen",
"operation": operation,
"prompt": req.Prompt,
"width": req.Width,
"height": req.Height,
"n_variants": 1,
"n_frames": 1,
"inpaint_items": inpaintItems,
}
headers := c.buildBaseHeaders(token, c.defaultUserAgent())
headers.Set("Content-Type", "application/json")
headers.Set("Origin", "https://sora.chatgpt.com")
headers.Set("Referer", "https://sora.chatgpt.com/")
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
sentinel, err := c.generateSentinelToken(ctx, account, token)
if err != nil {
return "", err
}
headers.Set("openai-sentinel-token", sentinel)
respBody, _, err := c.doRequest(ctx, account, http.MethodPost, c.buildURL("/video_gen"), headers, bytes.NewReader(body), true)
if err != nil {
return "", err
}
taskID := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
if taskID == "" {
return "", errors.New("image task response missing id")
}
return taskID, nil
}
func (c *SoraDirectClient) CreateVideoTask(ctx context.Context, account *Account, req SoraVideoRequest) (string, error) {
token, err := c.getAccessToken(ctx, account)
if err != nil {
return "", err
}
orientation := req.Orientation
if orientation == "" {
orientation = "landscape"
}
nFrames := req.Frames
if nFrames <= 0 {
nFrames = 450
}
model := req.Model
if model == "" {
model = "sy_8"
}
size := req.Size
if size == "" {
size = "small"
}
inpaintItems := []map[string]any{}
if strings.TrimSpace(req.MediaID) != "" {
inpaintItems = append(inpaintItems, map[string]any{
"kind": "upload",
"upload_id": req.MediaID,
})
}
payload := map[string]any{
"kind": "video",
"prompt": req.Prompt,
"orientation": orientation,
"size": size,
"n_frames": nFrames,
"model": model,
"inpaint_items": inpaintItems,
}
if strings.TrimSpace(req.RemixTargetID) != "" {
payload["remix_target_id"] = req.RemixTargetID
payload["cameo_ids"] = []string{}
payload["cameo_replacements"] = map[string]any{}
}
headers := c.buildBaseHeaders(token, c.defaultUserAgent())
headers.Set("Content-Type", "application/json")
headers.Set("Origin", "https://sora.chatgpt.com")
headers.Set("Referer", "https://sora.chatgpt.com/")
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
sentinel, err := c.generateSentinelToken(ctx, account, token)
if err != nil {
return "", err
}
headers.Set("openai-sentinel-token", sentinel)
respBody, _, err := c.doRequest(ctx, account, http.MethodPost, c.buildURL("/nf/create"), headers, bytes.NewReader(body), true)
if err != nil {
return "", err
}
taskID := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
if taskID == "" {
return "", errors.New("video task response missing id")
}
return taskID, nil
}
func (c *SoraDirectClient) EnhancePrompt(ctx context.Context, account *Account, prompt, expansionLevel string, durationS int) (string, error) {
token, err := c.getAccessToken(ctx, account)
if err != nil {
return "", err
}
if strings.TrimSpace(expansionLevel) == "" {
expansionLevel = "medium"
}
if durationS <= 0 {
durationS = 10
}
payload := map[string]any{
"prompt": prompt,
"expansion_level": expansionLevel,
"duration_s": durationS,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
headers := c.buildBaseHeaders(token, c.defaultUserAgent())
headers.Set("Content-Type", "application/json")
headers.Set("Accept", "application/json")
headers.Set("Origin", "https://sora.chatgpt.com")
headers.Set("Referer", "https://sora.chatgpt.com/")
respBody, _, err := c.doRequest(ctx, account, http.MethodPost, c.buildURL("/editor/enhance_prompt"), headers, bytes.NewReader(body), false)
if err != nil {
return "", err
}
enhancedPrompt := strings.TrimSpace(gjson.GetBytes(respBody, "enhanced_prompt").String())
if enhancedPrompt == "" {
return "", errors.New("enhance_prompt response missing enhanced_prompt")
}
return enhancedPrompt, nil
}
func (c *SoraDirectClient) GetImageTask(ctx context.Context, account *Account, taskID string) (*SoraImageTaskStatus, error) {
status, found, err := c.fetchRecentImageTask(ctx, account, taskID, c.recentTaskLimit())
if err != nil {
return nil, err
}
if found {
return status, nil
}
maxLimit := c.recentTaskLimitMax()
if maxLimit > 0 && maxLimit != c.recentTaskLimit() {
status, found, err = c.fetchRecentImageTask(ctx, account, taskID, maxLimit)
if err != nil {
return nil, err
}
if found {
return status, nil
}
}
return &SoraImageTaskStatus{ID: taskID, Status: "processing"}, nil
}
func (c *SoraDirectClient) fetchRecentImageTask(ctx context.Context, account *Account, taskID string, limit int) (*SoraImageTaskStatus, bool, error) {
token, err := c.getAccessToken(ctx, account)
if err != nil {
return nil, false, err
}
headers := c.buildBaseHeaders(token, c.defaultUserAgent())
if limit <= 0 {
limit = 20
}
endpoint := fmt.Sprintf("/v2/recent_tasks?limit=%d", limit)
respBody, _, err := c.doRequest(ctx, account, http.MethodGet, c.buildURL(endpoint), headers, nil, false)
if err != nil {
return nil, false, err
}
var found *SoraImageTaskStatus
gjson.GetBytes(respBody, "task_responses").ForEach(func(_, item gjson.Result) bool {
if item.Get("id").String() != taskID {
return true // continue
}
status := strings.TrimSpace(item.Get("status").String())
progress := item.Get("progress_pct").Float()
var urls []string
item.Get("generations").ForEach(func(_, gen gjson.Result) bool {
if u := strings.TrimSpace(gen.Get("url").String()); u != "" {
urls = append(urls, u)
}
return true
})
found = &SoraImageTaskStatus{
ID: taskID,
Status: status,
ProgressPct: progress,
URLs: urls,
}
return false // break
})
if found != nil {
return found, true, nil
}
return &SoraImageTaskStatus{ID: taskID, Status: "processing"}, false, nil
}
func (c *SoraDirectClient) recentTaskLimit() int {
if c == nil || c.cfg == nil {
return 20
}
if c.cfg.Sora.Client.RecentTaskLimit > 0 {
return c.cfg.Sora.Client.RecentTaskLimit
}
return 20
}
func (c *SoraDirectClient) recentTaskLimitMax() int {
if c == nil || c.cfg == nil {
return 0
}
if c.cfg.Sora.Client.RecentTaskLimitMax > 0 {
return c.cfg.Sora.Client.RecentTaskLimitMax
}
return 0
}
func (c *SoraDirectClient) GetVideoTask(ctx context.Context, account *Account, taskID string) (*SoraVideoTaskStatus, error) {
token, err := c.getAccessToken(ctx, account)
if err != nil {
return nil, err
}
headers := c.buildBaseHeaders(token, c.defaultUserAgent())
respBody, _, err := c.doRequest(ctx, account, http.MethodGet, c.buildURL("/nf/pending/v2"), headers, nil, false)
if err != nil {
return nil, err
}
// 搜索 pending 列表JSON 数组)
pendingResult := gjson.ParseBytes(respBody)
if pendingResult.IsArray() {
var pendingFound *SoraVideoTaskStatus
pendingResult.ForEach(func(_, task gjson.Result) bool {
if task.Get("id").String() != taskID {
return true
}
progress := 0
if v := task.Get("progress_pct"); v.Exists() {
progress = int(v.Float() * 100)
}
status := strings.TrimSpace(task.Get("status").String())
pendingFound = &SoraVideoTaskStatus{
ID: taskID,
Status: status,
ProgressPct: progress,
}
return false
})
if pendingFound != nil {
return pendingFound, nil
}
}
respBody, _, err = c.doRequest(ctx, account, http.MethodGet, c.buildURL("/project_y/profile/drafts?limit=15"), headers, nil, false)
if err != nil {
return nil, err
}
var draftFound *SoraVideoTaskStatus
gjson.GetBytes(respBody, "items").ForEach(func(_, draft gjson.Result) bool {
if draft.Get("task_id").String() != taskID {
return true
}
kind := strings.TrimSpace(draft.Get("kind").String())
reason := strings.TrimSpace(draft.Get("reason_str").String())
if reason == "" {
reason = strings.TrimSpace(draft.Get("markdown_reason_str").String())
}
urlStr := strings.TrimSpace(draft.Get("downloadable_url").String())
if urlStr == "" {
urlStr = strings.TrimSpace(draft.Get("url").String())
}
if kind == "sora_content_violation" || reason != "" || urlStr == "" {
msg := reason
if msg == "" {
msg = "Content violates guardrails"
}
draftFound = &SoraVideoTaskStatus{
ID: taskID,
Status: "failed",
ErrorMsg: msg,
}
} else {
draftFound = &SoraVideoTaskStatus{
ID: taskID,
Status: "completed",
URLs: []string{urlStr},
}
}
return false
})
if draftFound != nil {
return draftFound, nil
}
return &SoraVideoTaskStatus{ID: taskID, Status: "processing"}, nil
}
func (c *SoraDirectClient) buildURL(endpoint string) string {
base := strings.TrimRight(strings.TrimSpace(c.baseURL), "/")
if base == "" && c != nil && c.cfg != nil {
base = normalizeSoraBaseURL(c.cfg.Sora.Client.BaseURL)
c.baseURL = base
}
if base == "" {
return endpoint
}
if strings.HasPrefix(endpoint, "/") {
return base + endpoint
}
return base + "/" + endpoint
}
func (c *SoraDirectClient) defaultUserAgent() string {
if c == nil || c.cfg == nil {
return soraDefaultUserAgent
}
ua := strings.TrimSpace(c.cfg.Sora.Client.UserAgent)
if ua == "" {
return soraDefaultUserAgent
}
return ua
}
func (c *SoraDirectClient) getAccessToken(ctx context.Context, account *Account) (string, error) {
if account == nil {
return "", errors.New("account is nil")
}
allowProvider := c.allowOpenAITokenProvider(account)
var providerErr error
if allowProvider && c.tokenProvider != nil {
token, err := c.tokenProvider.GetAccessToken(ctx, account)
if err == nil && strings.TrimSpace(token) != "" {
c.logTokenSource(account, "openai_token_provider")
return token, nil
}
providerErr = err
if err != nil && c.debugEnabled() {
c.debugLogf(
"token_provider_failed account_id=%d platform=%s err=%s",
account.ID,
account.Platform,
logredact.RedactText(err.Error()),
)
}
}
token := strings.TrimSpace(account.GetCredential("access_token"))
if token != "" {
expiresAt := account.GetCredentialAsTime("expires_at")
if expiresAt != nil && time.Until(*expiresAt) <= 2*time.Minute {
refreshed, refreshErr := c.recoverAccessToken(ctx, account, "access_token_expiring")
if refreshErr == nil && strings.TrimSpace(refreshed) != "" {
c.logTokenSource(account, "refresh_token_recovered")
return refreshed, nil
}
if refreshErr != nil && c.debugEnabled() {
c.debugLogf("token_refresh_before_use_failed account_id=%d err=%s", account.ID, logredact.RedactText(refreshErr.Error()))
}
}
c.logTokenSource(account, "account_credentials")
return token, nil
}
recovered, recoverErr := c.recoverAccessToken(ctx, account, "access_token_missing")
if recoverErr == nil && strings.TrimSpace(recovered) != "" {
c.logTokenSource(account, "session_or_refresh_recovered")
return recovered, nil
}
if recoverErr != nil && c.debugEnabled() {
c.debugLogf("token_recover_failed account_id=%d platform=%s err=%s", account.ID, account.Platform, logredact.RedactText(recoverErr.Error()))
}
if providerErr != nil {
return "", providerErr
}
if c.tokenProvider != nil && !allowProvider {
c.logTokenSource(account, "account_credentials(provider_disabled)")
}
return "", errors.New("access_token not found")
}
func (c *SoraDirectClient) recoverAccessToken(ctx context.Context, account *Account, reason string) (string, error) {
if account == nil {
return "", errors.New("account is nil")
}
if sessionToken := strings.TrimSpace(account.GetCredential("session_token")); sessionToken != "" {
accessToken, expiresAt, err := c.exchangeSessionToken(ctx, account, sessionToken)
if err == nil && strings.TrimSpace(accessToken) != "" {
c.applyRecoveredToken(ctx, account, accessToken, "", expiresAt, sessionToken)
c.logTokenRecover(account, "session_token", reason, true, nil)
return accessToken, nil
}
c.logTokenRecover(account, "session_token", reason, false, err)
}
refreshToken := strings.TrimSpace(account.GetCredential("refresh_token"))
if refreshToken == "" {
return "", errors.New("session_token/refresh_token not found")
}
accessToken, newRefreshToken, expiresAt, err := c.exchangeRefreshToken(ctx, account, refreshToken)
if err != nil {
c.logTokenRecover(account, "refresh_token", reason, false, err)
return "", err
}
if strings.TrimSpace(accessToken) == "" {
return "", errors.New("refreshed access_token is empty")
}
c.applyRecoveredToken(ctx, account, accessToken, newRefreshToken, expiresAt, "")
c.logTokenRecover(account, "refresh_token", reason, true, nil)
return accessToken, nil
}
func (c *SoraDirectClient) exchangeSessionToken(ctx context.Context, account *Account, sessionToken string) (string, string, error) {
headers := http.Header{}
headers.Set("Cookie", "__Secure-next-auth.session-token="+sessionToken)
headers.Set("Accept", "application/json")
headers.Set("Origin", "https://sora.chatgpt.com")
headers.Set("Referer", "https://sora.chatgpt.com/")
headers.Set("User-Agent", c.defaultUserAgent())
body, _, err := c.doRequest(ctx, account, http.MethodGet, soraSessionAuthURL, headers, nil, false)
if err != nil {
return "", "", err
}
accessToken := strings.TrimSpace(gjson.GetBytes(body, "accessToken").String())
if accessToken == "" {
return "", "", errors.New("session exchange missing accessToken")
}
expiresAt := strings.TrimSpace(gjson.GetBytes(body, "expires").String())
return accessToken, expiresAt, nil
}
func (c *SoraDirectClient) exchangeRefreshToken(ctx context.Context, account *Account, refreshToken string) (string, string, string, error) {
clientIDs := []string{
strings.TrimSpace(account.GetCredential("client_id")),
openaioauth.SoraClientID,
openaioauth.ClientID,
}
tried := make(map[string]struct{}, len(clientIDs))
var lastErr error
for _, clientID := range clientIDs {
if clientID == "" {
continue
}
if _, ok := tried[clientID]; ok {
continue
}
tried[clientID] = struct{}{}
formData := url.Values{}
formData.Set("client_id", clientID)
formData.Set("grant_type", "refresh_token")
formData.Set("refresh_token", refreshToken)
formData.Set("redirect_uri", "com.openai.chat://auth0.openai.com/ios/com.openai.chat/callback")
headers := http.Header{}
headers.Set("Accept", "application/json")
headers.Set("Content-Type", "application/x-www-form-urlencoded")
headers.Set("User-Agent", c.defaultUserAgent())
respBody, _, err := c.doRequest(ctx, account, http.MethodPost, soraOAuthTokenURL, headers, strings.NewReader(formData.Encode()), false)
if err != nil {
lastErr = err
if c.debugEnabled() {
c.debugLogf("refresh_token_exchange_failed account_id=%d client_id=%s err=%s", account.ID, clientID, logredact.RedactText(err.Error()))
}
continue
}
accessToken := strings.TrimSpace(gjson.GetBytes(respBody, "access_token").String())
if accessToken == "" {
lastErr = errors.New("oauth refresh response missing access_token")
continue
}
newRefreshToken := strings.TrimSpace(gjson.GetBytes(respBody, "refresh_token").String())
expiresIn := gjson.GetBytes(respBody, "expires_in").Int()
expiresAt := ""
if expiresIn > 0 {
expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second).Format(time.RFC3339)
}
return accessToken, newRefreshToken, expiresAt, nil
}
if lastErr != nil {
return "", "", "", lastErr
}
return "", "", "", errors.New("no available client_id for refresh_token exchange")
}
func (c *SoraDirectClient) applyRecoveredToken(ctx context.Context, account *Account, accessToken, refreshToken, expiresAt, sessionToken string) {
if account == nil {
return
}
if account.Credentials == nil {
account.Credentials = make(map[string]any)
}
if strings.TrimSpace(accessToken) != "" {
account.Credentials["access_token"] = accessToken
}
if strings.TrimSpace(refreshToken) != "" {
account.Credentials["refresh_token"] = refreshToken
}
if strings.TrimSpace(expiresAt) != "" {
account.Credentials["expires_at"] = expiresAt
}
if strings.TrimSpace(sessionToken) != "" {
account.Credentials["session_token"] = sessionToken
}
if c.accountRepo != nil {
if err := c.accountRepo.Update(ctx, account); err != nil {
if c.debugEnabled() {
c.debugLogf("persist_recovered_token_failed account_id=%d err=%s", account.ID, logredact.RedactText(err.Error()))
}
}
}
c.updateSoraAccountExtension(ctx, account, accessToken, refreshToken, sessionToken)
}
func (c *SoraDirectClient) updateSoraAccountExtension(ctx context.Context, account *Account, accessToken, refreshToken, sessionToken string) {
if c == nil || c.soraAccountRepo == nil || account == nil || account.ID <= 0 {
return
}
updates := make(map[string]any)
if strings.TrimSpace(accessToken) != "" && strings.TrimSpace(refreshToken) != "" {
updates["access_token"] = accessToken
updates["refresh_token"] = refreshToken
}
if strings.TrimSpace(sessionToken) != "" {
updates["session_token"] = sessionToken
}
if len(updates) == 0 {
return
}
if err := c.soraAccountRepo.Upsert(ctx, account.ID, updates); err != nil && c.debugEnabled() {
c.debugLogf("persist_sora_extension_failed account_id=%d err=%s", account.ID, logredact.RedactText(err.Error()))
}
}
func (c *SoraDirectClient) logTokenRecover(account *Account, source, reason string, success bool, err error) {
if !c.debugEnabled() || account == nil {
return
}
if success {
c.debugLogf("token_recover_success account_id=%d platform=%s source=%s reason=%s", account.ID, account.Platform, source, reason)
return
}
if err == nil {
c.debugLogf("token_recover_failed account_id=%d platform=%s source=%s reason=%s", account.ID, account.Platform, source, reason)
return
}
c.debugLogf("token_recover_failed account_id=%d platform=%s source=%s reason=%s err=%s", account.ID, account.Platform, source, reason, logredact.RedactText(err.Error()))
}
func (c *SoraDirectClient) allowOpenAITokenProvider(account *Account) bool {
if c == nil || c.tokenProvider == nil {
return false
}
if account != nil && account.Platform == PlatformSora {
return c.cfg != nil && c.cfg.Sora.Client.UseOpenAITokenProvider
}
return true
}
func (c *SoraDirectClient) logTokenSource(account *Account, source string) {
if !c.debugEnabled() || account == nil {
return
}
c.debugLogf(
"token_selected account_id=%d platform=%s account_type=%s source=%s",
account.ID,
account.Platform,
account.Type,
source,
)
}
func (c *SoraDirectClient) buildBaseHeaders(token, userAgent string) http.Header {
headers := http.Header{}
if token != "" {
headers.Set("Authorization", "Bearer "+token)
}
if userAgent != "" {
headers.Set("User-Agent", userAgent)
}
if c != nil && c.cfg != nil {
for key, value := range c.cfg.Sora.Client.Headers {
if strings.EqualFold(key, "authorization") || strings.EqualFold(key, "openai-sentinel-token") {
continue
}
headers.Set(key, value)
}
}
return headers
}
func (c *SoraDirectClient) doRequest(ctx context.Context, account *Account, method, urlStr string, headers http.Header, body io.Reader, allowRetry bool) ([]byte, http.Header, error) {
if strings.TrimSpace(urlStr) == "" {
return nil, nil, errors.New("empty upstream url")
}
timeout := 0
if c != nil && c.cfg != nil {
timeout = c.cfg.Sora.Client.TimeoutSeconds
}
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
}
maxRetries := 0
if allowRetry && c != nil && c.cfg != nil {
maxRetries = c.cfg.Sora.Client.MaxRetries
}
if maxRetries < 0 {
maxRetries = 0
}
var bodyBytes []byte
if body != nil {
b, err := io.ReadAll(body)
if err != nil {
return nil, nil, err
}
bodyBytes = b
}
attempts := maxRetries + 1
authRecovered := false
authRecoverExtraAttemptGranted := false
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if c.debugEnabled() {
c.debugLogf(
"request_start method=%s url=%s attempt=%d/%d timeout_s=%d body_bytes=%d proxy_bound=%t headers=%s",
method,
sanitizeSoraLogURL(urlStr),
attempt,
attempts,
timeout,
len(bodyBytes),
account != nil && account.ProxyID != nil && account.Proxy != nil,
formatSoraHeaders(headers),
)
}
var reader io.Reader
if bodyBytes != nil {
reader = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequestWithContext(ctx, method, urlStr, reader)
if err != nil {
return nil, nil, err
}
req.Header = headers.Clone()
start := time.Now()
proxyURL := ""
if account != nil && account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := c.doHTTP(req, proxyURL, account)
if err != nil {
lastErr = err
if c.debugEnabled() {
c.debugLogf(
"request_transport_error method=%s url=%s attempt=%d/%d err=%s",
method,
sanitizeSoraLogURL(urlStr),
attempt,
attempts,
logredact.RedactText(err.Error()),
)
}
if attempt < attempts && allowRetry {
if c.debugEnabled() {
c.debugLogf("request_retry_scheduled method=%s url=%s reason=transport_error next_attempt=%d/%d", method, sanitizeSoraLogURL(urlStr), attempt+1, attempts)
}
c.sleepRetry(attempt)
continue
}
return nil, nil, err
}
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
_ = resp.Body.Close()
if readErr != nil {
return nil, resp.Header, readErr
}
if c.cfg != nil && c.cfg.Sora.Client.Debug {
c.debugLogf(
"response_received method=%s url=%s attempt=%d/%d status=%d cost=%s resp_bytes=%d resp_headers=%s",
method,
sanitizeSoraLogURL(urlStr),
attempt,
attempts,
resp.StatusCode,
time.Since(start),
len(respBody),
formatSoraHeaders(resp.Header),
)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
if !authRecovered && shouldAttemptSoraTokenRecover(resp.StatusCode, urlStr) && account != nil {
if recovered, recoverErr := c.recoverAccessToken(ctx, account, fmt.Sprintf("upstream_status_%d", resp.StatusCode)); recoverErr == nil && strings.TrimSpace(recovered) != "" {
headers.Set("Authorization", "Bearer "+recovered)
authRecovered = true
if attempt == attempts && !authRecoverExtraAttemptGranted {
attempts++
authRecoverExtraAttemptGranted = true
}
if c.debugEnabled() {
c.debugLogf("request_retry_with_recovered_token method=%s url=%s status=%d", method, sanitizeSoraLogURL(urlStr), resp.StatusCode)
}
continue
} else if recoverErr != nil && c.debugEnabled() {
c.debugLogf("request_recover_token_failed method=%s url=%s status=%d err=%s", method, sanitizeSoraLogURL(urlStr), resp.StatusCode, logredact.RedactText(recoverErr.Error()))
}
}
if c.debugEnabled() {
c.debugLogf(
"response_non_success method=%s url=%s attempt=%d/%d status=%d body=%s",
method,
sanitizeSoraLogURL(urlStr),
attempt,
attempts,
resp.StatusCode,
summarizeSoraResponseBody(respBody, 512),
)
}
upstreamErr := c.buildUpstreamError(resp.StatusCode, resp.Header, respBody, urlStr)
lastErr = upstreamErr
if allowRetry && attempt < attempts && (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500) {
if c.debugEnabled() {
c.debugLogf("request_retry_scheduled method=%s url=%s reason=status_%d next_attempt=%d/%d", method, sanitizeSoraLogURL(urlStr), resp.StatusCode, attempt+1, attempts)
}
c.sleepRetry(attempt)
continue
}
return nil, resp.Header, upstreamErr
}
return respBody, resp.Header, nil
}
if lastErr != nil {
return nil, nil, lastErr
}
return nil, nil, errors.New("upstream retries exhausted")
}
func shouldAttemptSoraTokenRecover(statusCode int, rawURL string) bool {
switch statusCode {
case http.StatusUnauthorized, http.StatusForbidden:
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return false
}
host := strings.ToLower(parsed.Hostname())
if host != "sora.chatgpt.com" && host != "chatgpt.com" {
return false
}
// 避免在 ST->AT 转换接口上递归触发 token 恢复导致死循环。
path := strings.ToLower(strings.TrimSpace(parsed.Path))
if path == "/api/auth/session" {
return false
}
return true
default:
return false
}
}
func (c *SoraDirectClient) doHTTP(req *http.Request, proxyURL string, account *Account) (*http.Response, error) {
enableTLS := c != nil && c.cfg != nil && c.cfg.Gateway.TLSFingerprint.Enabled && !c.cfg.Sora.Client.DisableTLSFingerprint
if c.httpUpstream != nil {
accountID := int64(0)
accountConcurrency := 0
if account != nil {
accountID = account.ID
accountConcurrency = account.Concurrency
}
return c.httpUpstream.DoWithTLS(req, proxyURL, accountID, accountConcurrency, enableTLS)
}
return http.DefaultClient.Do(req)
}
func (c *SoraDirectClient) sleepRetry(attempt int) {
backoff := time.Duration(attempt*attempt) * time.Second
if backoff > 10*time.Second {
backoff = 10 * time.Second
}
time.Sleep(backoff)
}
func (c *SoraDirectClient) buildUpstreamError(status int, headers http.Header, body []byte, requestURL string) error {
msg := strings.TrimSpace(extractUpstreamErrorMessage(body))
msg = sanitizeUpstreamErrorMessage(msg)
if status == http.StatusNotFound && strings.Contains(strings.ToLower(msg), "not found") {
if hint := soraBaseURLNotFoundHint(requestURL); hint != "" {
msg = strings.TrimSpace(msg + " " + hint)
}
}
if msg == "" {
msg = truncateForLog(body, 256)
}
return &SoraUpstreamError{
StatusCode: status,
Message: msg,
Headers: headers,
Body: body,
}
}
func normalizeSoraBaseURL(raw string) string {
trimmed := strings.TrimRight(strings.TrimSpace(raw), "/")
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return trimmed
}
host := strings.ToLower(parsed.Hostname())
if host != "sora.chatgpt.com" && host != "chatgpt.com" {
return trimmed
}
pathVal := strings.TrimRight(strings.TrimSpace(parsed.Path), "/")
switch pathVal {
case "", "/":
parsed.Path = "/backend"
case "/backend-api":
parsed.Path = "/backend"
}
return strings.TrimRight(parsed.String(), "/")
}
func soraBaseURLNotFoundHint(requestURL string) string {
parsed, err := url.Parse(strings.TrimSpace(requestURL))
if err != nil || parsed.Host == "" {
return ""
}
host := strings.ToLower(parsed.Hostname())
if host != "sora.chatgpt.com" && host != "chatgpt.com" {
return ""
}
pathVal := strings.TrimSpace(parsed.Path)
if strings.HasPrefix(pathVal, "/backend/") || pathVal == "/backend" {
return ""
}
return "(请检查 sora.client.base_url建议配置为 https://sora.chatgpt.com/backend)"
}
func (c *SoraDirectClient) generateSentinelToken(ctx context.Context, account *Account, accessToken string) (string, error) {
reqID := uuid.NewString()
userAgent := soraRandChoice(soraDesktopUserAgents)
powToken := soraGetPowToken(userAgent)
payload := map[string]any{
"p": powToken,
"flow": soraSentinelFlow,
"id": reqID,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
headers := http.Header{}
headers.Set("Accept", "application/json, text/plain, */*")
headers.Set("Content-Type", "application/json")
headers.Set("Origin", "https://sora.chatgpt.com")
headers.Set("Referer", "https://sora.chatgpt.com/")
headers.Set("User-Agent", userAgent)
if accessToken != "" {
headers.Set("Authorization", "Bearer "+accessToken)
}
urlStr := soraChatGPTBaseURL + "/backend-api/sentinel/req"
respBody, _, err := c.doRequest(ctx, account, http.MethodPost, urlStr, headers, bytes.NewReader(body), true)
if err != nil {
return "", err
}
var resp map[string]any
if err := json.Unmarshal(respBody, &resp); err != nil {
return "", err
}
sentinel := soraBuildSentinelToken(soraSentinelFlow, reqID, powToken, resp, userAgent)
if sentinel == "" {
return "", errors.New("failed to build sentinel token")
}
return sentinel, nil
}
func soraRandChoice(items []string) string {
if len(items) == 0 {
return ""
}
soraRandMu.Lock()
idx := soraRand.Intn(len(items))
soraRandMu.Unlock()
return items[idx]
}
func soraGetPowToken(userAgent string) string {
configList := soraBuildPowConfig(userAgent)
seed := strconv.FormatFloat(soraRandFloat(), 'f', -1, 64)
difficulty := "0fffff"
solution, _ := soraSolvePow(seed, difficulty, configList)
return "gAAAAAC" + solution
}
func soraRandFloat() float64 {
soraRandMu.Lock()
defer soraRandMu.Unlock()
return soraRand.Float64()
}
func soraBuildPowConfig(userAgent string) []any {
screen := soraRandChoice([]string{
strconv.Itoa(1920 + 1080),
strconv.Itoa(2560 + 1440),
strconv.Itoa(1920 + 1200),
strconv.Itoa(2560 + 1600),
})
screenVal, _ := strconv.Atoi(screen)
perfMs := float64(time.Since(soraPerfStart).Milliseconds())
wallMs := float64(time.Now().UnixNano()) / 1e6
diff := wallMs - perfMs
return []any{
screenVal,
soraPowParseTime(),
4294705152,
0,
userAgent,
soraRandChoice(soraPowScripts),
soraRandChoice(soraPowDPL),
"en-US",
"en-US,es-US,en,es",
0,
soraRandChoice(soraPowNavigatorKeys),
soraRandChoice(soraPowDocumentKeys),
soraRandChoice(soraPowWindowKeys),
perfMs,
uuid.NewString(),
"",
soraRandChoiceInt(soraPowCores),
diff,
}
}
func soraRandChoiceInt(items []int) int {
if len(items) == 0 {
return 0
}
soraRandMu.Lock()
idx := soraRand.Intn(len(items))
soraRandMu.Unlock()
return items[idx]
}
func soraPowParseTime() string {
loc := time.FixedZone("EST", -5*3600)
return time.Now().In(loc).Format("Mon Jan 02 2006 15:04:05 GMT-0700 (Eastern Standard Time)")
}
func soraSolvePow(seed, difficulty string, configList []any) (string, bool) {
diffLen := len(difficulty) / 2
target, err := hexDecodeString(difficulty)
if err != nil {
return "", false
}
seedBytes := []byte(seed)
part1 := mustMarshalJSON(configList[:3])
part2 := mustMarshalJSON(configList[4:9])
part3 := mustMarshalJSON(configList[10:])
staticPart1 := append(part1[:len(part1)-1], ',')
staticPart2 := append([]byte(","), append(part2[1:len(part2)-1], ',')...)
staticPart3 := append([]byte(","), part3[1:]...)
for i := 0; i < soraPowMaxIteration; i++ {
dynamicI := []byte(strconv.Itoa(i))
dynamicJ := []byte(strconv.Itoa(i >> 1))
finalJSON := make([]byte, 0, len(staticPart1)+len(dynamicI)+len(staticPart2)+len(dynamicJ)+len(staticPart3))
finalJSON = append(finalJSON, staticPart1...)
finalJSON = append(finalJSON, dynamicI...)
finalJSON = append(finalJSON, staticPart2...)
finalJSON = append(finalJSON, dynamicJ...)
finalJSON = append(finalJSON, staticPart3...)
b64 := base64.StdEncoding.EncodeToString(finalJSON)
hash := sha3.Sum512(append(seedBytes, []byte(b64)...))
if bytes.Compare(hash[:diffLen], target[:diffLen]) <= 0 {
return b64, true
}
}
errorToken := "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\"%s\"", seed)))
return errorToken, false
}
func soraBuildSentinelToken(flow, reqID, powToken string, resp map[string]any, userAgent string) string {
finalPow := powToken
proof, _ := resp["proofofwork"].(map[string]any)
if required, _ := proof["required"].(bool); required {
seed, _ := proof["seed"].(string)
difficulty, _ := proof["difficulty"].(string)
if seed != "" && difficulty != "" {
configList := soraBuildPowConfig(userAgent)
solution, _ := soraSolvePow(seed, difficulty, configList)
finalPow = "gAAAAAB" + solution
}
}
if !strings.HasSuffix(finalPow, "~S") {
finalPow += "~S"
}
turnstile, _ := resp["turnstile"].(map[string]any)
tokenPayload := map[string]any{
"p": finalPow,
"t": safeMapString(turnstile, "dx"),
"c": safeString(resp["token"]),
"id": reqID,
"flow": flow,
}
encoded, _ := json.Marshal(tokenPayload)
return string(encoded)
}
func safeMapString(m map[string]any, key string) string {
if m == nil {
return ""
}
if v, ok := m[key]; ok {
return safeString(v)
}
return ""
}
func safeString(v any) string {
switch val := v.(type) {
case string:
return val
default:
return fmt.Sprintf("%v", val)
}
}
func mustMarshalJSON(v any) []byte {
b, _ := json.Marshal(v)
return b
}
func hexDecodeString(s string) ([]byte, error) {
dst := make([]byte, len(s)/2)
_, err := hex.Decode(dst, []byte(s))
return dst, err
}
func sanitizeSoraLogURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
q := parsed.Query()
q.Del("sig")
q.Del("expires")
parsed.RawQuery = q.Encode()
return parsed.String()
}
func (c *SoraDirectClient) debugEnabled() bool {
return c != nil && c.cfg != nil && c.cfg.Sora.Client.Debug
}
func (c *SoraDirectClient) debugLogf(format string, args ...any) {
if !c.debugEnabled() {
return
}
log.Printf("[SoraClient] "+format, args...)
}
func formatSoraHeaders(headers http.Header) string {
if len(headers) == 0 {
return "{}"
}
keys := make([]string, 0, len(headers))
for key := range headers {
keys = append(keys, key)
}
sort.Strings(keys)
out := make(map[string]string, len(keys))
for _, key := range keys {
values := headers.Values(key)
if len(values) == 0 {
continue
}
val := strings.Join(values, ",")
if isSensitiveHeader(key) {
out[key] = "***"
continue
}
out[key] = truncateForLog([]byte(logredact.RedactText(val)), 160)
}
encoded, err := json.Marshal(out)
if err != nil {
return "{}"
}
return string(encoded)
}
func isSensitiveHeader(key string) bool {
k := strings.ToLower(strings.TrimSpace(key))
switch k {
case "authorization", "openai-sentinel-token", "cookie", "set-cookie", "x-api-key":
return true
default:
return false
}
}
func summarizeSoraResponseBody(body []byte, maxLen int) string {
if len(body) == 0 {
return ""
}
var text string
if json.Valid(body) {
text = logredact.RedactJSON(body)
} else {
text = logredact.RedactText(string(body))
}
text = strings.TrimSpace(text)
if maxLen <= 0 || len(text) <= maxLen {
return text
}
return text[:maxLen] + "...(truncated)"
}