diff --git a/common/api_type.go b/common/api_type.go index 39c1fe9a5406..2b5b803d90e6 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -75,6 +75,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeReplicate case constant.ChannelTypeCodex: apiType = constant.APITypeCodex + case constant.ChannelTypeChatGPTWeb: + apiType = constant.APITypeChatGPTWeb } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/common/constants.go b/common/constants.go index 5118da70628c..b74546b5ff8e 100644 --- a/common/constants.go +++ b/common/constants.go @@ -73,6 +73,7 @@ var DebugEnabled bool var MemoryCacheEnabled bool var LogConsumeEnabled = true +var LogRequestBodyEnabled = false var TLSInsecureSkipVerify bool var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true} diff --git a/constant/api_type.go b/constant/api_type.go index 536ebd2c7198..df8ac588fd04 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -36,5 +36,6 @@ const ( APITypeMiniMax APITypeReplicate APITypeCodex + APITypeChatGPTWeb APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index 48502bedc52c..5d2af47c62d6 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -55,6 +55,7 @@ const ( ChannelTypeSora = 55 ChannelTypeReplicate = 56 ChannelTypeCodex = 57 + ChannelTypeChatGPTWeb = 58 // ChatGPT 网页逆向(/backend-api/conversation,用订阅账号 OAuth token) ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -118,6 +119,7 @@ var ChannelBaseURLs = []string{ "https://api.openai.com", //55 "https://api.replicate.com", //56 "https://chatgpt.com", //57 + "https://chatgpt.com", //58 ChatGPTWeb } var ChannelTypeNames = map[int]string{ @@ -175,6 +177,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeSora: "Sora", ChannelTypeReplicate: "Replicate", ChannelTypeCodex: "Codex", + ChannelTypeChatGPTWeb: "ChatGPTWeb", } func GetChannelTypeName(channelType int) string { diff --git a/controller/relay.go b/controller/relay.go index 593b31b7ca25..c0a78b666a8b 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -67,9 +67,21 @@ func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewA func Relay(c *gin.Context, relayFormat types.RelayFormat) { requestId := c.GetString(common.RequestIdKey) + relayStartTime := time.Now() //group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) //originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + // Capture request body for request logging + var capturedRequestBody string + if common.LogRequestBodyEnabled { + if bodyStorage, bodyErr := common.GetBodyStorage(c); bodyErr == nil { + if bodyBytes, readErr := bodyStorage.Bytes(); readErr == nil { + capturedRequestBody = string(bodyBytes) + } + bodyStorage.Seek(0, 0) + } + } + var ( newAPIError *types.NewAPIError ws *websocket.Conn @@ -103,6 +115,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { }) } } + // Save request log after relay completes + if common.LogRequestBodyEnabled && capturedRequestBody != "" { + responseBody := "" + if rb := middleware.GetCapturedResponseBody(c); len(rb) > 0 { + responseBody = string(rb) + } + statusCode := c.Writer.Status() + service.SaveRequestLog(c, capturedRequestBody, responseBody, statusCode, relayStartTime) + } }() request, err := helper.GetAndValidateRequest(c, relayFormat) diff --git a/controller/request_log.go b/controller/request_log.go new file mode 100644 index 000000000000..e2e50c96a83c --- /dev/null +++ b/controller/request_log.go @@ -0,0 +1,60 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// GetRequestLogDetail returns the full request/response body for a given request_id (admin) +func GetRequestLogDetail(c *gin.Context) { + requestId := c.Query("request_id") + if requestId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "request_id is required", + }) + return + } + log, err := model.GetRequestLogByRequestId(requestId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "request log not found", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": log, + }) +} + +// GetUserRequestLogDetail returns the full request/response body for a given request_id (user's own) +func GetUserRequestLogDetail(c *gin.Context) { + requestId := c.Query("request_id") + if requestId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "request_id is required", + }) + return + } + userId := c.GetInt("id") + log, err := model.GetRequestLogByRequestIdAndUserId(requestId, userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "request log not found", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": log, + }) +} diff --git a/middleware/request_log.go b/middleware/request_log.go new file mode 100644 index 000000000000..8965ffe97e0b --- /dev/null +++ b/middleware/request_log.go @@ -0,0 +1,62 @@ +package middleware + +import ( + "bytes" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +const KeyResponseBodyWriter = "key_response_body_writer" +const maxResponseCaptureSize = 4 * 1024 * 1024 // 4MB + +// responseBodyWriter wraps gin.ResponseWriter to capture the response body +type responseBodyWriter struct { + gin.ResponseWriter + body *bytes.Buffer + capped bool +} + +func (w *responseBodyWriter) Write(b []byte) (int, error) { + if !w.capped { + remaining := maxResponseCaptureSize - w.body.Len() + if remaining > 0 { + if len(b) <= remaining { + w.body.Write(b) + } else { + w.body.Write(b[:remaining]) + w.body.WriteString("\n...[truncated]") + w.capped = true + } + } + } + return w.ResponseWriter.Write(b) +} + +// GetCapturedResponseBody returns the captured bytes from the writer stored in context. +func GetCapturedResponseBody(c *gin.Context) []byte { + if w, exists := c.Get(KeyResponseBodyWriter); exists { + if rbw, ok := w.(*responseBodyWriter); ok { + return rbw.body.Bytes() + } + } + return nil +} + +// RequestLogCapture captures the response body for request logging. +// Only active when LogRequestBodyEnabled is true. +func RequestLogCapture() gin.HandlerFunc { + return func(c *gin.Context) { + if !common.LogRequestBodyEnabled { + c.Next() + return + } + writer := &responseBodyWriter{ + ResponseWriter: c.Writer, + body: &bytes.Buffer{}, + } + c.Writer = writer + c.Set(KeyResponseBodyWriter, writer) + c.Next() + } +} diff --git a/model/main.go b/model/main.go index f37cb667cd43..65a3718476cb 100644 --- a/model/main.go +++ b/model/main.go @@ -213,6 +213,9 @@ func InitDB() (err error) { func InitLogDB() (err error) { if os.Getenv("LOG_SQL_DSN") == "" { LOG_DB = DB + if common.IsMasterNode { + err = migrateLOGDB() + } return } db, err := chooseDB("LOG_SQL_DSN", true) @@ -370,6 +373,14 @@ func migrateLOGDB() error { if err = LOG_DB.AutoMigrate(&Log{}); err != nil { return err } + if err = LOG_DB.AutoMigrate(&RequestLog{}); err != nil { + return err + } + // Migrate TEXT -> MEDIUMTEXT for MySQL (TEXT is 64KB, too small for large requests) + if common.UsingMySQL || common.LogSqlType == common.DatabaseTypeMySQL { + LOG_DB.Exec("ALTER TABLE `request_logs` MODIFY `request_body` MEDIUMTEXT") + LOG_DB.Exec("ALTER TABLE `request_logs` MODIFY `response_body` MEDIUMTEXT") + } return nil } diff --git a/model/option.go b/model/option.go index 967fa0aa6708..85f9e9416bf8 100644 --- a/model/option.go +++ b/model/option.go @@ -47,6 +47,7 @@ func InitOptionMap() { common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled) common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled) + common.OptionMap["LogRequestBodyEnabled"] = strconv.FormatBool(common.LogRequestBodyEnabled) common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled) common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled) common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled) @@ -264,6 +265,8 @@ func updateOptionMap(key string, value string) (err error) { common.AutomaticEnableChannelEnabled = boolValue case "LogConsumeEnabled": common.LogConsumeEnabled = boolValue + case "LogRequestBodyEnabled": + common.LogRequestBodyEnabled = boolValue case "DisplayInCurrencyEnabled": // 兼容旧字段:同步到新配置 general_setting.quota_display_type(运行时生效) // true -> USD, false -> TOKENS diff --git a/model/request_log.go b/model/request_log.go new file mode 100644 index 000000000000..ec8c5f11664e --- /dev/null +++ b/model/request_log.go @@ -0,0 +1,66 @@ +package model + +// RequestLog stores the full request and response body for API relay calls. +// Linked to the Log table via RequestId for detail lookups. +type RequestLog struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + UserId int `json:"user_id" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + RequestId string `json:"request_id" gorm:"type:varchar(64);index:idx_request_log_request_id;default:''"` + RequestBody string `json:"request_body" gorm:"type:mediumtext"` + ResponseBody string `json:"response_body" gorm:"type:mediumtext"` + ModelName string `json:"model_name" gorm:"type:varchar(255);index;default:''"` + TokenName string `json:"token_name" gorm:"type:varchar(255);default:''"` + ChannelId int `json:"channel_id" gorm:"default:0"` + Endpoint string `json:"endpoint" gorm:"type:varchar(512);default:''"` + StatusCode int `json:"status_code" gorm:"default:0"` + UseTime int `json:"use_time" gorm:"default:0"` + IsStream bool `json:"is_stream"` +} + +func CreateRequestLog(log *RequestLog) error { + return LOG_DB.Create(log).Error +} + +func GetRequestLogByRequestId(requestId string) (*RequestLog, error) { + var log RequestLog + err := LOG_DB.Where("request_id = ?", requestId).First(&log).Error + if err != nil { + return nil, err + } + return &log, nil +} + +func GetRequestLogByRequestIdAndUserId(requestId string, userId int) (*RequestLog, error) { + var log RequestLog + err := LOG_DB.Where("request_id = ? AND user_id = ?", requestId, userId).First(&log).Error + if err != nil { + return nil, err + } + return &log, nil +} + +func GetAllRequestLogs(startTimestamp int64, endTimestamp int64, modelName string, username string, startIdx int, num int) (logs []*RequestLog, total int64, err error) { + tx := LOG_DB.Model(&RequestLog{}) + + if modelName != "" { + tx = tx.Where("model_name like ?", modelName) + } + if startTimestamp != 0 { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("created_at <= ?", endTimestamp) + } + err = tx.Count(&total).Error + if err != nil { + return nil, 0, err + } + err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&logs).Error + return logs, total, err +} + +func DeleteOldRequestLog(targetTimestamp int64, limit int) (int64, error) { + result := LOG_DB.Where("created_at < ?", targetTimestamp).Limit(limit).Delete(&RequestLog{}) + return result.RowsAffected, result.Error +} diff --git a/relay/channel/chatgpt_web/adaptor.go b/relay/channel/chatgpt_web/adaptor.go new file mode 100644 index 000000000000..70f0a7fd59e8 --- /dev/null +++ b/relay/channel/chatgpt_web/adaptor.go @@ -0,0 +1,142 @@ +package chatgpt_web + +import ( + "errors" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// Adaptor 实现 ChatGPT 网页逆向渠道。 +// 流程:ConvertOpenAIRequest 造 conversation 体 -> SetupRequestHeader 里 sentinel+PoW 拿令牌 +// -> DoRequest 复用 channel.DoApiRequest 发 conversation -> DoResponse 解 v1 SSE 转 OpenAI。 +type Adaptor struct { + // promptTokens 在 ConvertOpenAIRequest 阶段算好,DoResponse 估算 usage 时复用。 + // 适配器实例是每请求 new 的,故可安全持有请求级状态。 + promptTokens int +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) {} + +func (a *Adaptor) GetChannelName() string { return ChannelName } + +func (a *Adaptor) GetModelList() []string { return ModelList } + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return strings.TrimRight(info.ChannelBaseUrl, "/") + "/backend-api/conversation", nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("chatgpt-web channel: request is nil") + } + a.promptTokens = countPromptTokens(request.Messages, info.UpstreamModelName) + return buildConversationRequest(request.Messages, info.UpstreamModelName), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error { + key, err := ParseWebKey(info.ApiKey) + if err != nil { + return err + } + + ua := defaultUA + base := map[string]string{ + "Authorization": "Bearer " + key.AccessToken, + "chatgpt-account-id": key.AccountID, + "OAI-Device-Id": key.DeviceID, + "OAI-Language": "en-US", + "User-Agent": ua, + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + } + for k, v := range base { + header.Set(k, v) + } + header.Set("Content-Type", "application/json") + header.Set("Accept", "text/event-stream") + + // 关键:发 conversation 之前,先 sentinel 换 token + 本地解 PoW。 + client, err := getHttpClient(info) + if err != nil { + return err + } + cr, err := fetchChatRequirements(client, info.ChannelBaseUrl, base) + if err != nil { + return err + } + header.Set("OpenAI-Sentinel-Chat-Requirements-Token", cr.Token) + if cr.Proofofwork.Required { + header.Set("OpenAI-Sentinel-Proof-Token", solveProofOfWork(cr.Proofofwork.Seed, cr.Proofofwork.Difficulty, ua)) + } + // 注:turnstile.required 实测可不带 turnstile token;若上游某天强制,这里会在 DoResponse 报错暴露。 + return nil +} + +func getHttpClient(info *relaycommon.RelayInfo) (*http.Client, error) { + if info.ChannelSetting.Proxy != "" { + return service.NewProxyHttpClient(info.ChannelSetting.Proxy) + } + return service.GetHttpClient(), nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + // Responses API(/v1/responses):把 conversation SSE 合成为 responses 事件 + if info.RelayMode == relayconstant.RelayModeResponses { + if info.IsStream { + return ResponsesStreamHandler(c, info, resp, a.promptTokens) + } + return ResponsesHandler(c, info, resp, a.promptTokens) + } + // Chat Completions(/v1/chat/completions) + if info.IsStream { + return StreamHandler(c, info, resp, a.promptTokens) + } + return Handler(c, info, resp, a.promptTokens) +} + +// ConvertOpenAIResponsesRequest 把 /v1/responses 请求转成 ChatGPT conversation 体。 +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + body, msgs := buildResponsesConversationRequest(request, info.UpstreamModelName) + a.promptTokens = countPromptTokens(msgs, info.UpstreamModelName) + return body, nil +} + +// ───── 不支持的端点 ───── + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("chatgpt-web channel: rerank not supported") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("chatgpt-web channel: embedding not supported") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("chatgpt-web channel: audio not supported") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("chatgpt-web channel: image not supported") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + return nil, errors.New("chatgpt-web channel: claude messages not supported") +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return nil, errors.New("chatgpt-web channel: gemini not supported") +} diff --git a/relay/channel/chatgpt_web/constants.go b/relay/channel/chatgpt_web/constants.go new file mode 100644 index 000000000000..fbc64c1d3f2a --- /dev/null +++ b/relay/channel/chatgpt_web/constants.go @@ -0,0 +1,28 @@ +package chatgpt_web + +// ChatGPT 网页逆向渠道(/backend-api/conversation)。 +// 设计背景与实测见记忆文件 chatgpt-web-reverse-feasible: +// 用 ChatGPT 订阅账号的 OAuth access_token 模拟网页前端,经 sentinel + PoW 调用 conversation, +// 把网页自有的 v1 delta SSE 转成 OpenAI chat/completions 格式。 + +const ChannelName = "chatgpt-web" + +// defaultUA 取自真实浏览器抓包。ChatGPT 后端对 UA 不强校验,但保持真实更稳, +// 且 PoW config 里也用同一个 UA。 +const defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" + +// ModelList 是该渠道对外暴露、可路由到此渠道的模型名。 +// 注意:网页端 model slug 随 OpenAI 更新频繁变动(2026-02 已下线 gpt-4o 等), +// 强烈推荐用 "auto" 让服务端自动路由;其余为常见别名,实际可用性以账号订阅为准。 +var ModelList = []string{ + "auto", + "gpt-5", + "gpt-5-thinking", + "gpt-5-pro", + "gpt-5-t-mini", + "gpt-4o", + "gpt-4-1", + "o3", + "o4-mini", + "chatgpt-4o-latest", +} diff --git a/relay/channel/chatgpt_web/convert.go b/relay/channel/chatgpt_web/convert.go new file mode 100644 index 000000000000..0c96613fa1d0 --- /dev/null +++ b/relay/channel/chatgpt_web/convert.go @@ -0,0 +1,364 @@ +package chatgpt_web + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ───────────────────────── 请求体(OpenAI -> ChatGPT 网页 conversation)───────────────────────── + +type convAuthor struct { + Role string `json:"role"` +} + +type convContent struct { + ContentType string `json:"content_type"` + Parts []string `json:"parts"` +} + +type convMessage struct { + ID string `json:"id"` + Author convAuthor `json:"author"` + CreateTime float64 `json:"create_time"` + Content convContent `json:"content"` + Metadata map[string]any `json:"metadata"` +} + +type conversationRequest struct { + Action string `json:"action"` + Messages []convMessage `json:"messages"` + ParentMessageID string `json:"parent_message_id"` + ConversationID *string `json:"conversation_id"` + Model string `json:"model"` + ConversationMode map[string]any `json:"conversation_mode"` + HistoryAndTrainingDisabled bool `json:"history_and_training_disabled"` + ForceUseSSE bool `json:"force_use_sse"` + SupportedEncodings []string `json:"supported_encodings"` + Timezone string `json:"timezone"` + TimezoneOffsetMin int `json:"timezone_offset_min"` +} + +func resolveModel(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "auto" + } + return model +} + +// mapRole 把 OpenAI 角色映射到 ChatGPT author.role。 +func mapRole(role string) string { + switch role { + case "assistant": + return "assistant" + case "system", "developer": + return "system" + case "tool", "function": + return "tool" + default: + return "user" + } +} + +// buildConversationRequest 把 OpenAI messages 转成 conversation 请求体。 +// 注:多模态(图片/音频)暂只取文本部分;history_and_training_disabled=true 不入库不训练。 +func buildConversationRequest(messages []dto.Message, model string) *conversationRequest { + now := float64(common.GetTimestamp()) + convMsgs := make([]convMessage, 0, len(messages)) + for _, m := range messages { + convMsgs = append(convMsgs, convMessage{ + ID: uuid.NewString(), + Author: convAuthor{Role: mapRole(m.Role)}, + CreateTime: now, + Content: convContent{ContentType: "text", Parts: []string{m.StringContent()}}, + Metadata: map[string]any{}, + }) + } + if len(convMsgs) == 0 { + convMsgs = append(convMsgs, convMessage{ + ID: uuid.NewString(), + Author: convAuthor{Role: "user"}, + CreateTime: now, + Content: convContent{ContentType: "text", Parts: []string{""}}, + Metadata: map[string]any{}, + }) + } + return &conversationRequest{ + Action: "next", + Messages: convMsgs, + ParentMessageID: uuid.NewString(), + ConversationID: nil, + Model: resolveModel(model), + ConversationMode: map[string]any{"kind": "primary_assistant"}, + HistoryAndTrainingDisabled: true, + ForceUseSSE: true, + SupportedEncodings: []string{"v1"}, + Timezone: "America/Los_Angeles", + TimezoneOffsetMin: 480, + } +} + +func countPromptTokens(messages []dto.Message, model string) int { + var sb strings.Builder + for _, m := range messages { + sb.WriteString(m.Role) + sb.WriteString(": ") + sb.WriteString(m.StringContent()) + sb.WriteString("\n") + } + return service.CountTextToken(sb.String(), model) +} + +// ───────────────────────── 响应(ChatGPT 网页 v1 delta SSE -> OpenAI)───────────────────────── + +// deltaState 解析 ChatGPT 的 v1 delta encoding,增量提取 assistant 的文本。 +// +// 事件形态(实测): +// +// event: delta_encoding data: "v1" -> 忽略 +// data: {"o":"add","p":"","v":{"message":{author,content,...}}} -> 初始化(确定当前消息是否为 assistant 文本) +// data: {"o":"append","p":"/message/content/parts/0","v":"文本"} -> 追加 +// data: {"v":"文本"} -> 裸续写,追加到当前 parts/0 +// data: {"o":"patch","v":[{...append...}]} -> 批量 +// data: {"type":"message_stream_complete",...} -> 结束 +// data: [DONE] -> 结束 +type deltaState struct { + activeIsText bool // 当前正在流式的消息是否为 assistant 的 text 内容 + curText string // 当前 parts/0 已累计文本(用于 replace 求增量) +} + +func (s *deltaState) apply(data string) (delta string, done bool) { + data = strings.TrimSpace(data) + if data == "" { + return "", false + } + if data == "[DONE]" { + return "", true + } + // delta_encoding 标记之类的纯 JSON 字符串(如 "v1") + if strings.HasPrefix(data, "\"") { + return "", false + } + var ev map[string]any + if err := common.UnmarshalJsonStr(data, &ev); err != nil { + return "", false + } + + if t, ok := ev["type"].(string); ok { + if t == "message_stream_complete" { + return "", true + } + return "", false + } + + o, _ := ev["o"].(string) + p, _ := ev["p"].(string) + + switch o { + case "add": + if p == "" { + return s.handleMessageSnapshot(ev["v"]), false + } + if strings.HasSuffix(p, "/parts/0") { + return s.appendIfText(ev["v"]), false + } + case "append": + if p == "" || strings.HasSuffix(p, "/parts/0") { + return s.appendIfText(ev["v"]), false + } + case "patch": + return s.handlePatch(ev["v"]), false + case "replace": + if strings.HasSuffix(p, "/parts/0") { + if str, ok := ev["v"].(string); ok && s.activeIsText { + d := diffSuffix(s.curText, str) + s.curText = str + return d, false + } + } + case "": + // 无 o:要么是文本续写 {"v":"..."},要么是新消息快照 {"v":{"message":{...}}}。 + // ChatGPT v1 下发新消息正是用裸对象 v(不带 o/p),必须当快照处理,否则会漏掉 assistant 消息。 + switch vv := ev["v"].(type) { + case string: + return s.appendIfText(vv), false + case map[string]any: + return s.handleMessageSnapshot(vv), false + } + } + return "", false +} + +func (s *deltaState) appendIfText(v any) string { + if !s.activeIsText { + return "" + } + str, ok := v.(string) + if !ok { + return "" + } + s.curText += str + return str +} + +// handleMessageSnapshot 处理"新消息快照"({"o":"add","p":"","v":{message}} 或裸 {"v":{message}})。 +// 据此确定当前流式消息是否为 assistant 的 text 内容,并取初始 parts[0]。 +func (s *deltaState) handleMessageSnapshot(v any) string { + m, ok := v.(map[string]any) + if !ok { + return "" + } + msg, ok := m["message"].(map[string]any) + if !ok { + return "" + } + role := "" + if author, ok := msg["author"].(map[string]any); ok { + role, _ = author["role"].(string) + } + contentType := "" + text := "" + if content, ok := msg["content"].(map[string]any); ok { + contentType, _ = content["content_type"].(string) + if parts, ok := content["parts"].([]any); ok && len(parts) > 0 { + text, _ = parts[0].(string) + } + } + if role == "assistant" && contentType == "text" { + s.activeIsText = true + s.curText = text + return text + } + // 切到非文本消息(如推理 thoughts),停止采集 + s.activeIsText = false + return "" +} + +func (s *deltaState) handlePatch(v any) string { + arr, ok := v.([]any) + if !ok { + return "" + } + var sb strings.Builder + for _, item := range arr { + op, ok := item.(map[string]any) + if !ok { + continue + } + oo, _ := op["o"].(string) + pp, _ := op["p"].(string) + if (oo == "append" || oo == "add") && strings.HasSuffix(pp, "/parts/0") { + sb.WriteString(s.appendIfText(op["v"])) + } + } + return sb.String() +} + +func diffSuffix(prev, next string) string { + if strings.HasPrefix(next, prev) { + return next[len(prev):] + } + return next +} + +// ───────────────────────── 流式 / 非流式 处理器 ───────────────────────── + +// StreamHandler 把 ChatGPT 网页 SSE 转成 OpenAI chat.completion.chunk 下发。 +func StreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, promptTokens int) (any, *types.NewAPIError) { + id := helper.GetResponseID(c) + created := common.GetTimestamp() + model := info.UpstreamModelName + var full strings.Builder + state := &deltaState{} + + _ = helper.ObjectData(c, helper.GenerateStartEmptyResponse(id, created, model, nil)) + + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { + delta, _ := state.apply(data) + if delta != "" { + full.WriteString(delta) + _ = helper.ObjectData(c, newTextChunk(id, created, model, delta)) + } + }) + + usage := service.ResponseText2Usage(c, full.String(), model, promptTokens) + _ = helper.ObjectData(c, helper.GenerateStopResponse(id, created, model, "stop")) + _ = helper.ObjectData(c, helper.GenerateFinalUsageResponse(id, created, model, *usage)) + helper.Done(c) + return usage, nil +} + +func newTextChunk(id string, created int64, model, content string) *dto.ChatCompletionsStreamResponse { + v := content + return &dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: created, + Model: model, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: &v}, + }, + }, + } +} + +// Handler 客户端非流式时:把上游 SSE 累计为完整文本,返回单个 chat.completion。 +func Handler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, promptTokens int) (any, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + if resp.StatusCode != http.StatusOK { + return nil, types.NewError(fmt.Errorf("chatgpt-web upstream status %d: %s", resp.StatusCode, truncate(string(body), 300)), types.ErrorCodeBadResponseBody) + } + + state := &deltaState{} + var full strings.Builder + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(line[len("data:"):]) + delta, done := state.apply(data) + full.WriteString(delta) + if done { + break + } + } + + model := info.UpstreamModelName + usage := service.ResponseText2Usage(c, full.String(), model, promptTokens) + respObj := dto.OpenAITextResponse{ + Id: helper.GetResponseID(c), + Model: model, + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: []dto.OpenAITextResponseChoice{ + { + Index: 0, + FinishReason: "stop", + }, + }, + Usage: *usage, + } + respObj.Choices[0].Message.Role = "assistant" + respObj.Choices[0].Message.SetStringContent(full.String()) + c.JSON(http.StatusOK, respObj) + return usage, nil +} diff --git a/relay/channel/chatgpt_web/live_test.go b/relay/channel/chatgpt_web/live_test.go new file mode 100644 index 000000000000..709098ce6c38 --- /dev/null +++ b/relay/channel/chatgpt_web/live_test.go @@ -0,0 +1,152 @@ +package chatgpt_web + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +// doLiveConversation 复用适配器真实逻辑跑一遍 sentinel+PoW+conversation,返回上游 SSE Response。 +// 仅用于 CGPT_LIVE=1 的人工集成测试;token 读自 /tmp/cgpt_tok.txt。 +func doLiveConversation(t *testing.T, body *conversationRequest) *http.Response { + t.Helper() + raw, err := os.ReadFile("/tmp/cgpt_tok.txt") + if err != nil { + t.Fatal(err) + } + key, err := ParseWebKey(strings.TrimSpace(string(raw))) + if err != nil { + t.Fatalf("ParseWebKey: %v", err) + } + client := &http.Client{} + headers := map[string]string{ + "Authorization": "Bearer " + key.AccessToken, + "chatgpt-account-id": key.AccountID, + "OAI-Device-Id": key.DeviceID, + "OAI-Language": "en-US", + "User-Agent": defaultUA, + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + } + cr, err := fetchChatRequirements(client, "https://chatgpt.com", headers) + if err != nil { + t.Fatalf("fetchChatRequirements: %v", err) + } + t.Logf("persona=%s turnstile.required=%v pow.required=%v diff=%s", + cr.Persona, cr.Turnstile.Required, cr.Proofofwork.Required, cr.Proofofwork.Difficulty) + proof := "" + if cr.Proofofwork.Required { + proof = solveProofOfWork(cr.Proofofwork.Seed, cr.Proofofwork.Difficulty, defaultUA) + } + rawBody, _ := common.Marshal(body) + req, _ := http.NewRequest(http.MethodPost, "https://chatgpt.com/backend-api/conversation", bytes.NewReader(rawBody)) + for k, v := range headers { + req.Header.Set(k, v) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("OpenAI-Sentinel-Chat-Requirements-Token", cr.Token) + if proof != "" { + req.Header.Set("OpenAI-Sentinel-Proof-Token", proof) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("conversation: %v", err) + } + t.Logf("conversation HTTP %d", resp.StatusCode) + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("status %d body=%s", resp.StatusCode, truncate(string(b), 500)) + } + return resp +} + +// scanAssistantText 用适配器的 deltaState 解析上游 SSE,返回拼出的 assistant 文本。 +func scanAssistantText(t *testing.T, resp *http.Response) string { + t.Helper() + defer resp.Body.Close() + state := &deltaState{} + var full strings.Builder + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 64*1024), 4*1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(line[len("data:"):]) + delta, done := state.apply(data) + if delta != "" { + full.WriteString(delta) + } + if done { + break + } + } + return full.String() +} + +// TestLiveChat 验证 chat/completions 路径:OpenAI messages -> conversation -> SSE -> 文本。 +func TestLiveChat(t *testing.T) { + if os.Getenv("CGPT_LIVE") != "1" { + t.Skip("set CGPT_LIVE=1 to run live test") + } + msg := dto.Message{Role: "user"} + msg.SetStringContent("3*7=? Reply with only the number.") + body := buildConversationRequest([]dto.Message{msg}, "auto") + resp := doLiveConversation(t, body) + answer := scanAssistantText(t, resp) + t.Logf("CHAT ANSWER: %q", answer) + if !strings.Contains(answer, "21") { + t.Fatalf("unexpected answer: %q", answer) + } +} + +// TestLiveResponses 验证 Responses 路径:responses Input -> conversation -> SSE -> 文本。 +func TestLiveResponses(t *testing.T) { + if os.Getenv("CGPT_LIVE") != "1" { + t.Skip("set CGPT_LIVE=1 to run live test") + } + req := dto.OpenAIResponsesRequest{ + Model: "auto", + Input: json.RawMessage(`"2+2=? Reply with only the number."`), + } + body, msgs := buildResponsesConversationRequest(req, "auto") + if len(msgs) == 0 { + t.Fatal("buildResponsesConversationRequest produced no messages") + } + resp := doLiveConversation(t, body) + answer := scanAssistantText(t, resp) + t.Logf("RESPONSES ANSWER: %q", answer) + if !strings.Contains(answer, "4") { + t.Fatalf("unexpected answer: %q", answer) + } +} + +// TestBuildResponsesRequest 纯单测:验证 responses Input(字符串/数组)解析。 +func TestBuildResponsesRequest(t *testing.T) { + // 字符串 input + req := dto.OpenAIResponsesRequest{Input: json.RawMessage(`"hello"`)} + _, msgs := buildResponsesConversationRequest(req, "auto") + if len(msgs) != 1 || msgs[0].Role != "user" || msgs[0].StringContent() != "hello" { + t.Fatalf("string input parse failed: %+v", msgs) + } + // 数组 input + instructions + req2 := dto.OpenAIResponsesRequest{ + Instructions: json.RawMessage(`"be brief"`), + Input: json.RawMessage(`[{"role":"user","content":[{"type":"input_text","text":"hi"}]}]`), + } + _, msgs2 := buildResponsesConversationRequest(req2, "auto") + if len(msgs2) != 2 || msgs2[0].Role != "system" || msgs2[1].StringContent() != "hi" { + t.Fatalf("array input parse failed: %+v", msgs2) + } +} diff --git a/relay/channel/chatgpt_web/pow.go b/relay/channel/chatgpt_web/pow.go new file mode 100644 index 000000000000..c1d0c61aba36 --- /dev/null +++ b/relay/channel/chatgpt_web/pow.go @@ -0,0 +1,133 @@ +package chatgpt_web + +import ( + "bytes" + "crypto/sha3" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/google/uuid" +) + +// chatRequirements 是 /backend-api/sentinel/chat-requirements 的返回。 +type chatRequirements struct { + Persona string `json:"persona"` + Token string `json:"token"` + Proofofwork struct { + Required bool `json:"required"` + Seed string `json:"seed"` + Difficulty string `json:"difficulty"` + } `json:"proofofwork"` + Turnstile struct { + Required bool `json:"required"` + } `json:"turnstile"` +} + +// fetchChatRequirements 在发 conversation 之前,先换取 sentinel token + PoW 种子。 +func fetchChatRequirements(client *http.Client, baseURL string, headers map[string]string) (*chatRequirements, error) { + url := strings.TrimRight(baseURL, "/") + "/backend-api/sentinel/chat-requirements" + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString("{}")) + if err != nil { + return nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("chatgpt-web: chat-requirements request failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("chatgpt-web: chat-requirements status %d: %s", resp.StatusCode, truncate(string(body), 256)) + } + var cr chatRequirements + if err := common.Unmarshal(body, &cr); err != nil { + return nil, fmt.Errorf("chatgpt-web: parse chat-requirements failed: %w", err) + } + if cr.Token == "" { + return nil, fmt.Errorf("chatgpt-web: empty chat-requirements token: %s", truncate(string(body), 256)) + } + return &cr, nil +} + +// solveProofOfWork 复刻网页端 sentinel PoW: +// +// hash = sha3_512(seed + base64(json(config))) +// 命中条件:hex(hash)[:len(difficulty)] <= difficulty(字典序) +// proof token = "gAAAAAB" + base64(json(config)) +// +// 实测(见记忆 chatgpt-web-reverse-feasible):难度约 6 位十六进制,十几次迭代即解; +// 服务端只校验哈希是否达标,不深校验 config 内容,因此 config 用合理占位即可。 +func solveProofOfWork(seed, difficulty, userAgent string) string { + if difficulty == "" { + difficulty = "000000" + } + config := []any{ + 3008, // 0 屏幕尺寸和 + parseTimeString(), // 1 本地时间字符串 + 4294705152, // 2 内存类常量 + 0, // 3 循环计数器占位(下面被覆盖) + userAgent, // 4 UA(与请求头一致) + "https://cdn.oaistatic.com/_next/static/chunks/main.js", // 5 缓存脚本 URL + "dpl=" + randomHex(16), // 6 部署标识 + "en-US", // 7 + "en-US,en", // 8 + 0, // 9 + "plugins", // 10 navigator key + "location", // 11 document key + "scrollX", // 12 window key + 0, // 13 performance.now() + uuid.NewString(), // 14 随机 UUID + "", // 15 + 8, // 16 hardwareConcurrency + 0, // 17 时间基准偏移 + } + + diffLen := len(difficulty) + for i := 0; i < 500000; i++ { + config[3] = i + raw, err := common.Marshal(config) + if err != nil { + break + } + b64 := base64.StdEncoding.EncodeToString(raw) + h := sha3.New512() + h.Write([]byte(seed + b64)) + hexStr := hex.EncodeToString(h.Sum(nil)) + if len(hexStr) >= diffLen && hexStr[:diffLen] <= difficulty { + return "gAAAAAB" + b64 + } + } + // 兜底(几乎不会触发):返回 seed 的 base64 + return "gAAAAAB" + base64.StdEncoding.EncodeToString([]byte(seed)) +} + +// parseTimeString 形如 "Tue Jun 02 2026 09:21:00 GMT+0000 (Coordinated Universal Time)"。 +func parseTimeString() string { + return time.Now().UTC().Format("Mon Jan 02 2006 15:04:05") + " GMT+0000 (Coordinated Universal Time)" +} + +func randomHex(n int) string { + s := strings.ReplaceAll(uuid.NewString(), "-", "") + for len(s) < n { + s += strings.ReplaceAll(uuid.NewString(), "-", "") + } + return s[:n] +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/relay/channel/chatgpt_web/responses.go b/relay/channel/chatgpt_web/responses.go new file mode 100644 index 000000000000..b430edaa5c91 --- /dev/null +++ b/relay/channel/chatgpt_web/responses.go @@ -0,0 +1,235 @@ +package chatgpt_web + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// ───────────────────────── Responses 请求 -> conversation ───────────────────────── + +// buildResponsesConversationRequest 把 /v1/responses 的 Input/Instructions 转成 conversation 体。 +// Input 可能是字符串,也可能是输入项数组(每项含 role + content)。 +func buildResponsesConversationRequest(req dto.OpenAIResponsesRequest, model string) (*conversationRequest, []dto.Message) { + msgs := make([]dto.Message, 0, 4) + + // instructions -> system + if len(req.Instructions) > 0 { + var instr string + if err := common.Unmarshal(req.Instructions, &instr); err == nil && strings.TrimSpace(instr) != "" { + m := dto.Message{Role: "system"} + m.SetStringContent(instr) + msgs = append(msgs, m) + } + } + + // input:先试字符串,再试数组 + var asString string + if err := common.Unmarshal(req.Input, &asString); err == nil { + m := dto.Message{Role: "user"} + m.SetStringContent(asString) + msgs = append(msgs, m) + } else { + var arr []map[string]any + if err := common.Unmarshal(req.Input, &arr); err == nil { + for _, item := range arr { + role, _ := item["role"].(string) + if role == "" { + // 非 message 类输入项(如 function_call_output)暂跳过 + if _, hasContent := item["content"]; !hasContent { + continue + } + role = "user" + } + text := contentToText(item["content"]) + m := dto.Message{Role: role} + m.SetStringContent(text) + msgs = append(msgs, m) + } + } + } + + return buildConversationRequest(msgs, model), msgs +} + +func contentToText(content any) string { + switch v := content.(type) { + case string: + return v + case []any: + var sb strings.Builder + for _, p := range v { + if pm, ok := p.(map[string]any); ok { + if t, _ := pm["text"].(string); t != "" { + sb.WriteString(t) + } + } + } + return sb.String() + } + return "" +} + +// ───────────────────────── conversation SSE -> Responses 事件 ───────────────────────── + +func respIDs(c *gin.Context) (respID, itemID string) { + logID := c.GetString(common.RequestIdKey) + return "resp_" + logID, "msg_" + logID +} + +func sendResponsesEvent(c *gin.Context, eventType string, payload map[string]any) { + payload["type"] = eventType + data, err := common.Marshal(payload) + if err != nil { + return + } + helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: eventType}, string(data)) +} + +func buildResponseObject(respID, model, status string, output []any, usage map[string]any, created int) map[string]any { + obj := map[string]any{ + "id": respID, + "object": "response", + "created_at": created, + "status": status, + "model": model, + "output": output, + "parallel_tool_calls": true, + "tools": []any{}, + } + if usage != nil { + obj["usage"] = usage + } + return obj +} + +func buildMessageItem(itemID, status, text string) map[string]any { + return map[string]any{ + "type": "message", + "id": itemID, + "status": status, + "role": "assistant", + "content": []any{ + map[string]any{"type": "output_text", "text": text, "annotations": []any{}}, + }, + } +} + +// ResponsesStreamHandler 把 ChatGPT 网页 SSE 合成为 OpenAI Responses 流式事件。 +func ResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, promptTokens int) (any, *types.NewAPIError) { + respID, itemID := respIDs(c) + model := info.UpstreamModelName + created := int(common.GetTimestamp()) + + // 起始事件 + sendResponsesEvent(c, "response.created", map[string]any{ + "response": buildResponseObject(respID, model, "in_progress", []any{}, nil, created), + }) + sendResponsesEvent(c, "response.output_item.added", map[string]any{ + "output_index": 0, + "item": buildMessageItem(itemID, "in_progress", ""), + }) + sendResponsesEvent(c, "response.content_part.added", map[string]any{ + "item_id": itemID, + "output_index": 0, + "content_index": 0, + "part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}}, + }) + + var full strings.Builder + state := &deltaState{} + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { + delta, _ := state.apply(data) + if delta != "" { + full.WriteString(delta) + sendResponsesEvent(c, "response.output_text.delta", map[string]any{ + "item_id": itemID, + "output_index": 0, + "content_index": 0, + "delta": delta, + }) + } + }) + + text := full.String() + usage := service.ResponseText2Usage(c, text, model, promptTokens) + usageObj := map[string]any{ + "input_tokens": usage.PromptTokens, + "output_tokens": usage.CompletionTokens, + "total_tokens": usage.TotalTokens, + } + + sendResponsesEvent(c, "response.output_text.done", map[string]any{ + "item_id": itemID, + "output_index": 0, + "content_index": 0, + "text": text, + }) + sendResponsesEvent(c, "response.content_part.done", map[string]any{ + "item_id": itemID, + "output_index": 0, + "content_index": 0, + "part": map[string]any{"type": "output_text", "text": text, "annotations": []any{}}, + }) + sendResponsesEvent(c, "response.output_item.done", map[string]any{ + "output_index": 0, + "item": buildMessageItem(itemID, "completed", text), + }) + sendResponsesEvent(c, "response.completed", map[string]any{ + "response": buildResponseObject(respID, model, "completed", + []any{buildMessageItem(itemID, "completed", text)}, usageObj, created), + }) + return usage, nil +} + +// ResponsesHandler 非流式:累计完整文本,返回单个 Responses 响应对象。 +func ResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, promptTokens int) (any, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + if resp.StatusCode != http.StatusOK { + return nil, types.NewError(fmt.Errorf("chatgpt-web upstream status %d: %s", resp.StatusCode, truncate(string(body), 300)), types.ErrorCodeBadResponseBody) + } + + state := &deltaState{} + var full strings.Builder + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(line[len("data:"):]) + delta, done := state.apply(data) + full.WriteString(delta) + if done { + break + } + } + + respID, itemID := respIDs(c) + model := info.UpstreamModelName + created := int(common.GetTimestamp()) + text := full.String() + usage := service.ResponseText2Usage(c, text, model, promptTokens) + usageObj := map[string]any{ + "input_tokens": usage.PromptTokens, + "output_tokens": usage.CompletionTokens, + "total_tokens": usage.TotalTokens, + } + out := buildResponseObject(respID, model, "completed", + []any{buildMessageItem(itemID, "completed", text)}, usageObj, created) + c.JSON(http.StatusOK, out) + return usage, nil +} diff --git a/relay/channel/chatgpt_web/web_key.go b/relay/channel/chatgpt_web/web_key.go new file mode 100644 index 000000000000..06bff5b55ce0 --- /dev/null +++ b/relay/channel/chatgpt_web/web_key.go @@ -0,0 +1,98 @@ +package chatgpt_web + +import ( + "crypto/md5" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" +) + +// WebKey 是 ChatGPT 网页逆向渠道的凭证。 +// +// 用户填法(两种都支持,越省事越好): +// 1. 直接粘贴原始 access_token(最常见)——account_id 自动从 JWT 解析,device_id 自动派生; +// 2. 填 JSON:{"access_token":"...","account_id":"...","device_id":"..."} 用于显式覆盖。 +// +// 关键约束(见记忆 chatgpt-web-reverse-feasible):该账号已被风控,token 无法刷新, +// 过期需用户手动从浏览器重新抠 access_token 更新本渠道 Key。 +type WebKey struct { + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + AccountID string `json:"account_id,omitempty"` + DeviceID string `json:"device_id,omitempty"` +} + +// JWT 自定义 claim 路径,chatgpt_account_id 藏在这里面。 +const jwtAuthClaimPath = "https://api.openai.com/auth" + +func ParseWebKey(raw string) (*WebKey, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("chatgpt-web channel: empty key") + } + key := &WebKey{} + if strings.HasPrefix(raw, "{") { + if err := common.Unmarshal([]byte(raw), key); err != nil { + return nil, errors.New("chatgpt-web channel: invalid key json") + } + } else { + key.AccessToken = raw + } + + key.AccessToken = strings.TrimSpace(strings.TrimPrefix(key.AccessToken, "Bearer ")) + if key.AccessToken == "" { + return nil, errors.New("chatgpt-web channel: access_token is required") + } + + if strings.TrimSpace(key.AccountID) == "" { + if id, ok := extractAccountIDFromJWT(key.AccessToken); ok { + key.AccountID = id + } + } + if strings.TrimSpace(key.AccountID) == "" { + return nil, errors.New("chatgpt-web channel: account_id missing and not found in access_token JWT") + } + + if strings.TrimSpace(key.DeviceID) == "" { + key.DeviceID = deriveDeviceID(key.AccountID) + } + return key, nil +} + +// extractAccountIDFromJWT 解码 access_token 的 payload,取 chatgpt_account_id。 +func extractAccountIDFromJWT(token string) (string, bool) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", false + } + var claims map[string]any + if err := common.Unmarshal(payload, &claims); err != nil { + return "", false + } + auth, ok := claims[jwtAuthClaimPath].(map[string]any) + if !ok { + return "", false + } + id, ok := auth["chatgpt_account_id"].(string) + if !ok { + return "", false + } + id = strings.TrimSpace(id) + return id, id != "" +} + +// deriveDeviceID 从 account_id 确定性派生一个 UUID 形态的稳定 device id。 +// 同一账号每次请求得到相同 device id(风控更友好,避免每次都是“新设备”)。 +func deriveDeviceID(accountID string) string { + sum := md5.Sum([]byte("newapi-chatgpt-web-device:" + accountID)) + h := hex.EncodeToString(sum[:]) + return fmt.Sprintf("%s-%s-%s-%s-%s", h[0:8], h[8:12], h[12:16], h[16:20], h[20:32]) +} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 3139c9a2dd4a..177b63613a2a 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -11,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/baidu_v2" "github.com/QuantumNous/new-api/relay/channel/claude" "github.com/QuantumNous/new-api/relay/channel/cloudflare" + "github.com/QuantumNous/new-api/relay/channel/chatgpt_web" "github.com/QuantumNous/new-api/relay/channel/codex" "github.com/QuantumNous/new-api/relay/channel/cohere" "github.com/QuantumNous/new-api/relay/channel/coze" @@ -120,6 +121,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &replicate.Adaptor{} case constant.APITypeCodex: return &codex.Adaptor{} + case constant.APITypeChatGPTWeb: + return &chatgpt_web.Adaptor{} } return nil } diff --git a/router/api-router.go b/router/api-router.go index bff158a819ce..8952570a507d 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -290,6 +290,8 @@ func SetApiRouter(router *gin.Engine) { logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) logRoute.GET("/self/search", middleware.UserAuth(), middleware.SearchRateLimit(), controller.SearchUserLogs) + logRoute.GET("/detail", middleware.AdminAuth(), controller.GetRequestLogDetail) + logRoute.GET("/self/detail", middleware.UserAuth(), controller.GetUserRequestLogDetail) dataRoute := apiRouter.Group("/data") dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates) diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad7fd6..dae6966ce463 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -68,6 +68,7 @@ func SetRelayRouter(router *gin.Engine) { } relayV1Router := router.Group("/v1") relayV1Router.Use(middleware.RouteTag("relay")) + relayV1Router.Use(middleware.RequestLogCapture()) relayV1Router.Use(middleware.SystemPerformanceCheck()) relayV1Router.Use(middleware.TokenAuth()) relayV1Router.Use(middleware.ModelRequestRateLimit()) diff --git a/service/request_log.go b/service/request_log.go new file mode 100644 index 000000000000..61cfe8beae2d --- /dev/null +++ b/service/request_log.go @@ -0,0 +1,63 @@ +package service + +import ( + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" +) + +const maxRequestLogBodySize = 4 * 1024 * 1024 // 4MB per field + +func truncateBody(body string) string { + if len(body) > maxRequestLogBodySize { + return body[:maxRequestLogBodySize] + "\n...[truncated]" + } + return body +} + +// SaveRequestLog saves the request and response bodies asynchronously +func SaveRequestLog(c *gin.Context, requestBody string, responseBody string, statusCode int, startTime time.Time) { + if !common.LogRequestBodyEnabled { + return + } + + userId := c.GetInt("id") + requestId := c.GetString(common.RequestIdKey) + modelName := c.GetString("original_model") + tokenName := c.GetString("token_name") + channelId := c.GetInt("channel_id") + isStream := c.GetBool("is_stream") + endpoint := "" + if c.Request != nil && c.Request.URL != nil { + endpoint = c.Request.URL.Path + } + useTime := int(time.Since(startTime).Seconds()) + + reqBody := truncateBody(requestBody) + respBody := truncateBody(responseBody) + + gopool.Go(func() { + log := &model.RequestLog{ + UserId: userId, + CreatedAt: common.GetTimestamp(), + RequestId: requestId, + RequestBody: reqBody, + ResponseBody: respBody, + ModelName: modelName, + TokenName: tokenName, + ChannelId: channelId, + Endpoint: endpoint, + StatusCode: statusCode, + UseTime: useTime, + IsStream: isStream, + } + err := model.CreateRequestLog(log) + if err != nil { + common.SysError("failed to save request log: " + err.Error()) + } + }) +} diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index d5243afc6ed9..4199531c08d1 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -35,7 +35,7 @@ import { renderModelPriceSimple, } from '../../../helpers'; import { IconHelpCircle } from '@douyinfe/semi-icons'; -import { CircleAlert, Route, Sparkles } from 'lucide-react'; +import { CircleAlert, Route, Sparkles, FileSearch } from 'lucide-react'; const colors = [ 'amber', @@ -511,6 +511,7 @@ export const getLogsColumns = ({ copyText, showUserInfoFunc, openChannelAffinityUsageCacheModal, + openRequestLogDetail, isAdminUser, billingDisplayMode = 'price', }) => { @@ -958,5 +959,39 @@ export const getLogsColumns = ({ return renderCompactDetailSummary(detailSummary.segments); }, }, + { + key: COLUMN_KEYS.REQUEST_DETAIL, + title: t('请求详情'), + dataIndex: 'request_id', + width: 90, + render: (text, record) => { + if ( + !record.request_id || + !(record.type === 2 || record.type === 5) + ) { + return <>; + } + return ( + + { + e.stopPropagation(); + openRequestLogDetail?.(record.request_id); + }} + > + + {t('预览')} + + + ); + }, + }, ]; }; diff --git a/web/src/components/table/usage-logs/UsageLogsTable.jsx b/web/src/components/table/usage-logs/UsageLogsTable.jsx index f99dbfcfe2ea..890836510483 100644 --- a/web/src/components/table/usage-logs/UsageLogsTable.jsx +++ b/web/src/components/table/usage-logs/UsageLogsTable.jsx @@ -41,6 +41,7 @@ const LogsTable = (logsData) => { copyText, showUserInfoFunc, openChannelAffinityUsageCacheModal, + openRequestLogDetail, hasExpandableRows, isAdminUser, billingDisplayMode, @@ -56,6 +57,7 @@ const LogsTable = (logsData) => { copyText, showUserInfoFunc, openChannelAffinityUsageCacheModal, + openRequestLogDetail, isAdminUser, billingDisplayMode, }); @@ -65,6 +67,7 @@ const LogsTable = (logsData) => { copyText, showUserInfoFunc, openChannelAffinityUsageCacheModal, + openRequestLogDetail, isAdminUser, billingDisplayMode, ]); diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx index ce5a17f859d8..4a01fbc3875e 100644 --- a/web/src/components/table/usage-logs/index.jsx +++ b/web/src/components/table/usage-logs/index.jsx @@ -26,6 +26,7 @@ import ColumnSelectorModal from './modals/ColumnSelectorModal'; import UserInfoModal from './modals/UserInfoModal'; import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal'; import ParamOverrideModal from './modals/ParamOverrideModal'; +import RequestLogDetailModal from './modals/RequestLogDetailModal'; import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { createCardProPagination } from '../../../helpers/utils'; @@ -41,6 +42,13 @@ const LogsPage = () => { + logsData.setShowRequestLogDetail(false)} + requestId={logsData.requestLogDetailRequestId} + isAdminUser={logsData.isAdminUser} + t={logsData.t} + /> {/* Main Content */} { + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'boolean') { + return {value ? 'true' : 'false'}; + } + if (typeof value === 'number') { + return {value}; + } + if (typeof value === 'string') { + // Truncate very long strings in display but keep them selectable + const display = value.length > 500 ? value.slice(0, 500) + '...' : value; + return ( + + "{display}" + + ); + } + return null; +}; + +const JsonNode = ({ keyName, value, depth = 0, defaultExpanded = true }) => { + const [expanded, setExpanded] = useState(depth < 2 ? defaultExpanded : false); + const indent = depth * 20; + + const isObject = value !== null && typeof value === 'object' && !Array.isArray(value); + const isArray = Array.isArray(value); + + if (!isObject && !isArray) { + return ( +
+ {keyName !== null && ( + + "{keyName}" + : + + )} + +
+ ); + } + + const entries = isArray ? value.map((v, i) => [i, v]) : Object.entries(value); + const bracketOpen = isArray ? '[' : '{'; + const bracketClose = isArray ? ']' : '}'; + const isEmpty = entries.length === 0; + const summary = isArray ? `${entries.length} items` : `${entries.length} keys`; + + return ( +
+
setExpanded(!expanded)} + > + + {!isEmpty && (expanded ? : )} + + + {keyName !== null && ( + + "{keyName}" + : + + )} + {bracketOpen} + {(!expanded || isEmpty) && ( + <> + {isEmpty ? ( + {bracketClose} + ) : ( + <> + + {summary} + + {bracketClose} + + )} + + )} + +
+ {expanded && !isEmpty && ( + <> + {entries.map(([k, v], i) => ( + + ))} +
+ {bracketClose} +
+ + )} +
+ ); +}; + +const JsonViewer = ({ data, t }) => { + if (!data) { + return N/A; + } + + let parsed = null; + let isJson = false; + try { + parsed = JSON.parse(data); + isJson = true; + } catch (e) { + // not JSON + } + + if (!isJson) { + return ( +
+        {data}
+      
+ ); + } + + return ( +
+ +
+ ); +}; + +const RequestLogDetailModal = ({ + visible, + onClose, + requestId, + isAdminUser, + t, +}) => { + const [loading, setLoading] = useState(false); + const [detail, setDetail] = useState(null); + + const fetchDetail = useCallback(async () => { + setLoading(true); + try { + const url = isAdminUser + ? `/api/log/detail?request_id=${encodeURIComponent(requestId)}` + : `/api/log/self/detail?request_id=${encodeURIComponent(requestId)}`; + const res = await API.get(url); + const { success, message, data } = res.data; + if (success) { + setDetail(data); + } else { + showError(message || t('请求日志未找到')); + } + } catch (e) { + showError(t('请求日志未找到')); + } + setLoading(false); + }, [requestId, isAdminUser, t]); + + useEffect(() => { + if (visible && requestId) { + fetchDetail(); + } else { + setDetail(null); + } + }, [visible, requestId, fetchDetail]); + + const metaData = detail + ? [ + { key: 'Request ID', value: detail.request_id }, + { key: t('模型'), value: detail.model_name }, + { key: t('请求路径'), value: detail.endpoint }, + { + key: t('状态码'), + value: ( + = 200 && detail.status_code < 300 ? 'green' : 'red'} + shape='circle' + > + {detail.status_code} + + ), + }, + { + key: t('用时'), + value: `${detail.use_time}s`, + }, + { + key: t('流'), + value: detail.is_stream ? ( + {t('流')} + ) : ( + {t('非流')} + ), + }, + ] + : []; + + return ( + + {loading ? ( +
+ +
+ ) : detail ? ( +
+ + + +
+ +
+
+ +
+ +
+
+
+
+ ) : ( + + )} +
+ ); +}; + +export default RequestLogDetailModal; diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 9fa78779de8f..f0aca52b9832 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -189,6 +189,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: 'Codex (OpenAI OAuth)', }, + { + value: 58, + color: 'teal', + label: 'ChatGPT 网页逆向 (订阅账号)', + }, ]; // Channel types that support upstream model list fetching in UI. diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index e406b2ab8f7b..2484eaf9eabe 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -61,6 +61,7 @@ export const useLogsData = () => { RETRY: 'retry', IP: 'ip', DETAILS: 'details', + REQUEST_DETAIL: 'request_detail', }; // Basic state @@ -124,6 +125,7 @@ export const useLogsData = () => { [COLUMN_KEYS.RETRY]: isAdminUser, [COLUMN_KEYS.IP]: true, [COLUMN_KEYS.DETAILS]: true, + [COLUMN_KEYS.REQUEST_DETAIL]: true, }; }; @@ -186,6 +188,10 @@ export const useLogsData = () => { const [showParamOverrideModal, setShowParamOverrideModal] = useState(false); const [paramOverrideTarget, setParamOverrideTarget] = useState(null); + // Request log detail modal state + const [showRequestLogDetail, setShowRequestLogDetail] = useState(false); + const [requestLogDetailRequestId, setRequestLogDetailRequestId] = useState(null); + // Initialize default column visibility const initDefaultColumns = () => { const defaults = getDefaultColumnVisibility(); @@ -349,6 +355,12 @@ export const useLogsData = () => { setShowChannelAffinityUsageCacheModal(true); }; + const openRequestLogDetail = (requestId) => { + if (!requestId) return; + setRequestLogDetailRequestId(requestId); + setShowRequestLogDetail(true); + }; + const openParamOverrideModal = (log, other) => { const lines = Array.isArray(other?.po) ? other.po.filter(Boolean) : []; if (lines.length === 0) { @@ -877,6 +889,12 @@ export const useLogsData = () => { setShowParamOverrideModal, paramOverrideTarget, + // Request log detail modal + showRequestLogDetail, + setShowRequestLogDetail, + requestLogDetailRequestId, + openRequestLogDetail, + // Functions loadLogs, handlePageChange, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index aade2fd39032..08278ae49574 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -886,6 +886,8 @@ "启用请求透传": "Enable request pass-through", "启用违规扣费": "Enable violation deduction", "启用额度消费日志记录": "Enable quota consumption logging", + "启用请求内容日志记录": "Enable request body logging", + "记录完整的请求和响应内容,会占用较多存储空间": "Record full request and response content. This will consume significant storage space.", "启用验证": "Enable Authentication", "周": "week", "命中判定:usage 中存在 cached tokens(例如 cached_tokens/prompt_cache_hit_tokens)即视为命中。": "Hit determination: Presence of cached tokens in usage (e.g. cached_tokens/prompt_cache_hit_tokens) is considered a hit.", @@ -2693,6 +2695,13 @@ "请求结束后多退少补": "Adjust after request completion", "请求超时,请刷新页面后重新发起 GitHub 登录": "Request timed out, please refresh and restart GitHub login", "请求路径": "Request path", + "请求详情": "Request Detail", + "查看请求和响应内容": "View request and response content", + "预览": "Preview", + "请求内容": "Request Body", + "响应内容": "Response Body", + "请求日志未找到": "Request log not found", + "状态码": "Status Code", "流状态": "Stream Status", "流错误详情": "Stream Error Details", "软错误": "soft errors", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 99a721ab81c9..b197501a52c8 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -695,6 +695,8 @@ "启用请求体透传功能": "启用请求体透传功能", "启用请求透传": "启用请求透传", "启用额度消费日志记录": "启用额度消费日志记录", + "启用请求内容日志记录": "启用请求内容日志记录", + "记录完整的请求和响应内容,会占用较多存储空间": "记录完整的请求和响应内容,会占用较多存储空间", "启用验证": "启用验证", "启用违规扣费": "启用违规扣费", "周": "周", @@ -2153,6 +2155,13 @@ "请求结束后多退少补": "请求结束后多退少补", "请求超时,请刷新页面后重新发起 GitHub 登录": "请求超时,请刷新页面后重新发起 GitHub 登录", "请求路径": "请求路径", + "请求详情": "请求详情", + "查看请求和响应内容": "查看请求和响应内容", + "预览": "预览", + "请求内容": "请求内容", + "响应内容": "响应内容", + "请求日志未找到": "请求日志未找到", + "状态码": "状态码", "请求转换": "请求转换", "原生格式": "原生格式", "转换": "转换", diff --git a/web/src/pages/Setting/Operation/SettingsLog.jsx b/web/src/pages/Setting/Operation/SettingsLog.jsx index d309a12b0606..320a25b5dc5d 100644 --- a/web/src/pages/Setting/Operation/SettingsLog.jsx +++ b/web/src/pages/Setting/Operation/SettingsLog.jsx @@ -46,6 +46,7 @@ export default function SettingsLog(props) { const [loadingCleanHistoryLog, setLoadingCleanHistoryLog] = useState(false); const [inputs, setInputs] = useState({ LogConsumeEnabled: false, + LogRequestBodyEnabled: false, historyTimestamp: dayjs().subtract(1, 'month').toDate(), }); const refForm = useRef(); @@ -216,6 +217,28 @@ export default function SettingsLog(props) { }} /> + + { + setInputs({ + ...inputs, + LogRequestBodyEnabled: value, + }); + }} + /> + + {t('记录完整的请求和响应内容,会占用较多存储空间')} + +