diff --git a/.env.example b/.env.example index d62e114603c0..94dc2ab03f31 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,17 @@ # ENABLE_PPROF=true # 启用调试模式 # DEBUG=true +# IMAGE_FAILURE_REQUEST_LOG_ENABLED=false +# Records one sanitized relay_trace JSON event for every proxied request. +# RELAY_TRACE_LOG_ENABLED=false +# Only emit relay_trace events for 4xx/5xx responses or relay errors. +# RELAY_TRACE_LOG_FAILURES_ONLY=false +# Maximum retained redacted body preview per direction. Binary media is never retained. +# RELAY_TRACE_LOG_MAX_BODY_KB=256 +# Store complete unredacted request and response bodies. Enable only for short troubleshooting windows. +# RELAY_TRACE_LOG_FULL_BODY_ENABLED=false +# Hard capture limit per request or response direction when complete body logging is enabled. +# RELAY_TRACE_LOG_FULL_BODY_MAX_MB=64 # Pyroscope 配置 # PYROSCOPE_URL=http://localhost:4040 # PYROSCOPE_APP_NAME=new-api diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7b1eb508ebef..e279cc50b02a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,28 +1,37 @@ -# ⚠️ 提交说明 / PR Notice +# 提交说明 > [!IMPORTANT] > -> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。 +> - PR 标题、变更说明、验证结果和风险说明统一使用中文。 +> - 可以使用 AI 辅助,但必须如实披露,并由提交人复核代码和验证结果。 -## 📝 变更描述 / Description -(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容) +## 变更描述 + -## 🚀 变更类型 / Type of change -- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug* -- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通* -- [ ] ⚡ 性能优化 / 重构 (Refactor) -- [ ] 📝 文档更新 (Documentation) +## 变更类型 +- [ ] Bug 修复:请关联对应 Issue,避免将设计取舍或预期差异直接归类为缺陷 +- [ ] 新功能:重大特性建议先通过 Issue 沟通 +- [ ] 性能优化或重构 +- [ ] 文档或流程更新 -## 🔗 关联任务 / Related Issue -- Closes # (如有) +## 关联任务 +- 关联 Issue:#(如无请填写“无”) + +## AI 辅助披露 +- [ ] 未使用 AI 辅助 +- [ ] 使用了 AI 辅助,工具及范围: +- [ ] 我已人工复核 AI 辅助产生的代码、文档和验证结果 ## ✅ 提交前检查项 / Checklist -- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。 -- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。 -- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。 -- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。 -- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。 -- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。 -- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。 +- [ ] **中文说明:** PR 标题和正文已使用中文,必要的技术标识除外。 +- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。 +- [ ] **缺陷说明:** 若标记为 Bug 修复,已说明复现条件、根因和回归验证。 +- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。 +- [ ] **范围聚焦:** 本 PR 未包含与当前任务无关的改动。 +- [ ] **本地验证:** 已运行相关测试或手动验证,并在下方记录结果。 +- [ ] **安全合规:** 代码和日志中没有凭据、令牌或其他敏感信息。 + +## 验证结果 + -## 📸 运行证明 / Proof of Work -(请在此粘贴截图、关键日志或测试报告,以证明变更生效) +## 风险与回滚 + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000000..ae1bb73c6091 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Metaapple 仓库协作规范 + +本仓库使用一个共享基线和两个品牌生产分支: + +| 分支 | 用途 | +| --- | --- | +| `main` | 共享代码基线 | +| `brand/nanoapple` | Nanoapple 生产代码 | +| `brand/macroapple` | Macroapple 生产代码 | + +## 分支与提交 + +1. 禁止直接向上述三个长期分支推送日常修改。 +2. 每项修改从目标分支创建独立工作分支,例如 + `fix/stale-chunk-recovery` 或 `docs/deployment-record`。 +3. 提交信息使用中文,保留必要的代码符号、路径和协议名。推荐格式: + `修复(web):处理部署后旧分包加载失败`。 +4. 一个提交只处理一个明确主题,不混入无关格式化或重构。 +5. 禁止强制推送、删除或重写长期分支历史。 + +## Pull Request + +1. 所有长期分支变更必须通过 Pull Request 合并。 +2. PR 标题、变更描述、验证结果、风险和回滚说明统一使用中文。 +3. 必须完整填写 `.github/PULL_REQUEST_TEMPLATE.md`,不得删除模板章节。 +4. 共享修复先提交到 `main`,验证后再通过独立 PR 同步到对应品牌分支。 +5. 品牌文案、默认值和品牌资源直接以对应品牌分支为目标创建 PR。 +6. 使用 AI 辅助时必须如实注明工具和范围;提交人仍需复核代码和测试结果。 +7. 合并前确认 CI 通过、讨论已解决,并记录实际执行的测试。 + +## 部署 + +PR 合并不等于自动授权生产部署。生产部署必须单独确认目标品牌,并在部署后检查: + +- 应用状态接口和容器健康状态; +- 公网 Cloudflare 入口; +- 实际运行镜像与合并提交; +- 另一个品牌的回归状态; +- 对应部署或测试记录。 + +Nanoapple 与 Macroapple 的数据库、Redis、密钥、日志和运行目录相互独立,禁止在 +一次品牌部署中顺带修改或重启另一个品牌。 diff --git a/README.en.md b/README.en.md index 68b1c18cf2f8..4e348ad53ae4 100644 --- a/README.en.md +++ b/README.en.md @@ -323,6 +323,12 @@ docker run --name new-api -d --restart always \ | `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Error log switch | `false` | +| `IMAGE_FAILURE_REQUEST_LOG_ENABLED` | Log sanitized failed image requests; keeps prompts and generation parameters while removing credentials, Base64, and file URIs | `false` | +| `RELAY_TRACE_LOG_ENABLED` | Log a sanitized end-to-end trace for every relay request: downstream request/response, each upstream attempt, status, headers, and body previews | `false` | +| `RELAY_TRACE_LOG_FAILURES_ONLY` | When relay tracing is enabled, only log 4xx/5xx downstream responses or relay errors | `false` | +| `RELAY_TRACE_LOG_MAX_BODY_KB` | Maximum retained redacted body preview for each trace direction. Binary media is represented by size and a sample hash only | `256` | +| `RELAY_TRACE_LOG_FULL_BODY_ENABLED` | Store complete, unredacted request and response bodies in relay traces. Enable only during short troubleshooting windows because logs can contain private data and grow quickly | `false` | +| `RELAY_TRACE_LOG_FULL_BODY_MAX_MB` | Hard capture limit per request or response direction while complete body logging is enabled | `64` | | `PYROSCOPE_URL` | Pyroscope server address | - | | `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - | diff --git a/README.zh_CN.md b/README.zh_CN.md index eb7cadd927b6..da7cdd7a534e 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -330,6 +330,12 @@ docker run --name new-api -d --restart always \ | `MAX_REQUEST_BODY_MB` | 请求体最大大小(MB,**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | 错误日志开关 | `false` | +| `IMAGE_FAILURE_REQUEST_LOG_ENABLED` | 记录脱敏后的生图失败请求;保留 Prompt/尺寸/生成参数,移除密钥、Base64 和文件 URI | `false` | +| `RELAY_TRACE_LOG_ENABLED` | 记录每个转发请求的脱敏全链路:下游请求/响应、每次上游尝试、状态、请求头与请求体预览 | `false` | +| `RELAY_TRACE_LOG_FAILURES_ONLY` | 启用全链路日志时,仅记录下游响应为 4xx/5xx 或转发错误的请求 | `false` | +| `RELAY_TRACE_LOG_MAX_BODY_KB` | 每个方向保留的最大脱敏请求体预览;二进制媒体仅记录大小和样本哈希 | `256` | +| `RELAY_TRACE_LOG_FULL_BODY_ENABLED` | 在转发追踪中记录完整、未脱敏的请求与响应正文。仅应在短时间排障时启用,因为日志可能含隐私数据且增长很快 | `false` | +| `RELAY_TRACE_LOG_FULL_BODY_MAX_MB` | 启用完整正文记录时,每个请求或响应方向的硬捕获上限 | `64` | | `PYROSCOPE_URL` | Pyroscope 服务地址 | - | | `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `new-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - | diff --git a/common/init.go b/common/init.go index 4d4c62b27cac..b395e526ee3b 100644 --- a/common/init.go +++ b/common/init.go @@ -194,6 +194,26 @@ func initConstantEnv() { constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false) // 是否启用错误日志 constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false) + // 是否记录脱敏后的生图失败请求,供渠道与参数诊断使用。 + constant.ImageFailureRequestLogEnabled = GetEnvOrDefaultBool("IMAGE_FAILURE_REQUEST_LOG_ENABLED", false) + // 记录下游、上游和最终响应的完整转发链路,仅用于服务端排障。 + constant.RelayTraceLogEnabled = GetEnvOrDefaultBool("RELAY_TRACE_LOG_ENABLED", false) + constant.RelayTraceLogFailureOnly = GetEnvOrDefaultBool("RELAY_TRACE_LOG_FAILURES_ONLY", false) + constant.RelayTraceLogMaxBodyKB = GetEnvOrDefault("RELAY_TRACE_LOG_MAX_BODY_KB", 256) + if constant.RelayTraceLogMaxBodyKB < 1 { + constant.RelayTraceLogMaxBodyKB = 1 + } + if constant.RelayTraceLogMaxBodyKB > 4096 { + constant.RelayTraceLogMaxBodyKB = 4096 + } + constant.RelayTraceLogFullBodyEnabled = GetEnvOrDefaultBool("RELAY_TRACE_LOG_FULL_BODY_ENABLED", false) + constant.RelayTraceLogFullBodyMaxMB = GetEnvOrDefault("RELAY_TRACE_LOG_FULL_BODY_MAX_MB", 64) + if constant.RelayTraceLogFullBodyMaxMB < 1 { + constant.RelayTraceLogFullBodyMaxMB = 1 + } + if constant.RelayTraceLogFullBodyMaxMB > 256 { + constant.RelayTraceLogFullBodyMaxMB = 256 + } // 任务轮询时查询的最大数量 constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000) // 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。 diff --git a/constant/env.go b/constant/env.go index 512bfc31126b..d305c3b9e601 100644 --- a/constant/env.go +++ b/constant/env.go @@ -16,6 +16,12 @@ var NotifyLimitCount int var NotificationLimitDurationMinute int var GenerateDefaultToken bool var ErrorLogEnabled bool +var ImageFailureRequestLogEnabled bool +var RelayTraceLogEnabled bool +var RelayTraceLogFailureOnly bool +var RelayTraceLogMaxBodyKB int +var RelayTraceLogFullBodyEnabled bool +var RelayTraceLogFullBodyMaxMB int var TaskQueryLimit int var TaskTimeoutMinutes int diff --git a/controller/channel.go b/controller/channel.go index af4c6eec34e1..906106756d22 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -1211,13 +1211,9 @@ func buildAdvancedCustomModelPreviewChannel(req fetchModelsRequest) (*model.Chan } channel = savedChannel } else { - key := strings.TrimSpace(req.Key) - if key != "" { - key = strings.Split(key, "\n")[0] - } channel = &model.Channel{ Type: req.Type, - Key: key, + Key: firstFetchModelsKey(req.Key, false), } } @@ -1245,12 +1241,28 @@ func buildAdvancedCustomModelPreviewChannel(req fetchModelsRequest) (*model.Chan } channel.SetOtherSettings(settings) + if err := applyFetchModelsRequestOverrides(channel, req); err != nil { + return nil, err + } + + if err := validateChannel(channel, false); err != nil { + return nil, err + } + return channel, nil +} + +// applyFetchModelsRequestOverrides applies form-preview header/proxy overrides. +// Saved ordinary channels intentionally ignore request overrides and keep DB values. +func applyFetchModelsRequestOverrides(channel *model.Channel, req fetchModelsRequest) error { + if channel == nil { + return fmt.Errorf("channel is required") + } if req.HeaderOverride != nil { rawHeaderOverride := strings.TrimSpace(*req.HeaderOverride) if rawHeaderOverride != "" { var headerOverride map[string]any if err := common.UnmarshalJsonStr(rawHeaderOverride, &headerOverride); err != nil { - return nil, fmt.Errorf("header_override must be a JSON object: %w", err) + return fmt.Errorf("header_override must be a JSON object: %w", err) } } channel.HeaderOverride = &rawHeaderOverride @@ -1260,8 +1272,52 @@ func buildAdvancedCustomModelPreviewChannel(req fetchModelsRequest) (*model.Chan channelSettings.Proxy = strings.TrimSpace(*req.Proxy) channel.SetSetting(channelSettings) } + return nil +} - if err := validateChannel(channel, false); err != nil { +func firstFetchModelsKey(raw string, keepMultiline bool) string { + key := strings.TrimSpace(raw) + if keepMultiline || key == "" { + return key + } + for _, line := range strings.Split(key, "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + return trimmed + } + } + return "" +} + +func buildFetchModelsChannel(req fetchModelsRequest) (*model.Channel, error) { + if req.ChannelID > 0 && req.Type != constant.ChannelTypeAdvancedCustom { + channel, err := model.GetChannelById(req.ChannelID, true) + if err != nil { + return nil, err + } + if channel.Type != req.Type { + return nil, fmt.Errorf("channel %d type does not match request", req.ChannelID) + } + return channel, nil + } + + if req.Type == constant.ChannelTypeAdvancedCustom { + return buildAdvancedCustomModelPreviewChannel(req) + } + + baseURL := "" + if req.BaseURL != nil { + baseURL = strings.TrimSpace(*req.BaseURL) + } + if baseURL == "" { + baseURL = constant.ChannelBaseURLs[req.Type] + } + + channel := &model.Channel{ + Type: req.Type, + Key: firstFetchModelsKey(req.Key, req.Type == constant.ChannelTypeCodex), + BaseURL: &baseURL, + } + if err := applyFetchModelsRequestOverrides(channel, req); err != nil { return nil, err } return channel, nil @@ -1278,35 +1334,13 @@ func FetchModels(c *gin.Context) { return } - var channel *model.Channel - if req.Type == constant.ChannelTypeAdvancedCustom || req.ChannelID > 0 { - var err error - channel, err = buildAdvancedCustomModelPreviewChannel(req) - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - } else { - baseURL := "" - if req.BaseURL != nil { - baseURL = strings.TrimSpace(*req.BaseURL) - } - if baseURL == "" { - baseURL = constant.ChannelBaseURLs[req.Type] - } - - key := strings.TrimSpace(req.Key) - if req.Type != constant.ChannelTypeCodex { - key = strings.Split(key, "\n")[0] - } - channel = &model.Channel{ - Type: req.Type, - Key: key, - BaseURL: &baseURL, - } + channel, err := buildFetchModelsChannel(req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return } models, err := fetchChannelUpstreamModelIDs(channel) diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index 19db4345b3d8..49be5272906f 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -1,6 +1,7 @@ package controller import ( + "bytes" "context" "errors" "fmt" @@ -267,6 +268,9 @@ func parseOpenAIModelIDs(body []byte) ([]string, error) { Data *[]OpenAIModel `json:"data"` } if err := common.Unmarshal(body, &result); err != nil { + if formatted := formatFetchModelsBodyError(body, err); formatted != err { + return nil, formatted + } return nil, fmt.Errorf("invalid OpenAI Models response: %w", err) } if result.Data == nil { @@ -304,6 +308,23 @@ func sanitizeFetchModelsError(err error, key string) error { return errors.New(message) } +// formatFetchModelsBodyError turns opaque JSON parse failures into actionable +// diagnostics when the upstream returned HTML or an empty body. +func formatFetchModelsBodyError(body []byte, err error) error { + if err == nil { + return nil + } + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return fmt.Errorf("invalid models response: empty body: %w", err) + } + lower := bytes.ToLower(trimmed) + if trimmed[0] == '<' || bytes.HasPrefix(lower, []byte("
missing override`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"create-preview-model"}]}`)) + })) + defer server.Close() + + baseURL := server.URL + headerOverride := `{"X-Preview":"from-form"}` + body, err := common.Marshal(map[string]any{ + "type": constant.ChannelTypeOpenAI, + "key": "create-preview-key", + "base_url": baseURL, + "header_override": headerOverride, + }) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + FetchModels(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `{"success":true,"message":"","data":["create-preview-model"]}`, recorder.Body.String()) + headers := <-received + require.Equal(t, "Bearer create-preview-key", headers.Get("Authorization")) + require.Equal(t, "from-form", headers.Get("X-Preview")) +} + +func TestFetchModelsOrdinaryCreatePreviewReportsHTMLBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`login`)) + })) + defer server.Close() + + baseURL := server.URL + body, err := common.Marshal(map[string]any{ + "type": constant.ChannelTypeOpenAI, + "key": "html-body-key", + "base_url": baseURL, + }) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + FetchModels(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Success bool `json:"success"` + Message string `json:"message"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.False(t, response.Success) + require.Contains(t, response.Message, "upstream returned HTML instead of JSON model list") + require.NotContains(t, response.Message, "invalid character") + require.NotContains(t, response.Message, "html-body-key") +} + +func TestFetchModelsSavedOrdinaryChannelUsesSavedCredentials(t *testing.T) { + db := setupModelListControllerTestDB(t) + receivedAuthorization := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuthorization <- r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"saved-channel-model"}]}`)) + })) + defer server.Close() + + baseURL := server.URL + savedChannel := &model.Channel{ + Name: "saved ordinary channel", + Type: constant.ChannelTypeOpenAI, + Key: "saved-key", + BaseURL: &baseURL, + Models: "old-model", + } + require.NoError(t, db.Create(savedChannel).Error) + + body, err := common.Marshal(map[string]any{ + "channel_id": savedChannel.Id, + "type": constant.ChannelTypeOpenAI, + "key": "request-key-must-be-ignored", + "base_url": "http://127.0.0.1:1", + }) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + FetchModels(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `{"success":true,"message":"","data":["saved-channel-model"]}`, recorder.Body.String()) + require.Equal(t, "Bearer saved-key", <-receivedAuthorization) + require.NotContains(t, recorder.Body.String(), "saved-key") + require.NotContains(t, recorder.Body.String(), "request-key-must-be-ignored") +} + +func TestFetchModelsSavedOrdinaryChannelRejectsTypeMismatch(t *testing.T) { + db := setupModelListControllerTestDB(t) + baseURL := "http://127.0.0.1:1" + savedChannel := &model.Channel{ + Name: "saved ordinary channel", + Type: constant.ChannelTypeOpenAI, + Key: "saved-key", + BaseURL: &baseURL, + Models: "old-model", + } + require.NoError(t, db.Create(savedChannel).Error) + + body, err := common.Marshal(map[string]any{ + "channel_id": savedChannel.Id, + "type": constant.ChannelTypeAnthropic, + }) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/fetch_models", bytes.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + FetchModels(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, fmt.Sprintf(`{"success":false,"message":"channel %d type does not match request"}`, savedChannel.Id), recorder.Body.String()) + require.NotContains(t, recorder.Body.String(), "saved-key") +} + func TestFetchModelsAdvancedCustomCreatePreview(t *testing.T) { receivedAuthorization := make(chan string, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/controller/relay.go b/controller/relay.go index 6e91ccb60506..88e508ecca19 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -66,6 +66,7 @@ func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewA } func Relay(c *gin.Context, relayFormat types.RelayFormat) { + helper.StartRelayTrace(c, string(relayFormat)) requestId := c.GetString(common.RequestIdKey) //group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) @@ -74,7 +75,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { var ( newAPIError *types.NewAPIError ws *websocket.Conn + request dto.Request + relayInfo *relaycommon.RelayInfo ) + defer func() { + helper.FinishRelayTrace(c, newAPIError) + }() if relayFormat == types.RelayFormatOpenAIRealtime { var err error @@ -88,6 +94,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { defer func() { if newAPIError != nil { + helper.LogImageFailureRequest(c, relayFormat, request, relayInfo, newAPIError) logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error()))) newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId)) switch relayFormat { @@ -106,7 +113,8 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { } }() - request, err := helper.GetAndValidateRequest(c, relayFormat) + var err error + request, err = helper.GetAndValidateRequest(c, relayFormat) if err != nil { // Map "request body too large" to 413 so clients can handle it correctly if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) { @@ -117,7 +125,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { return } - relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws) + relayInfo, err = relaycommon.GenRelayInfo(c, relayFormat, request, ws) if err != nil { newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed) return @@ -469,6 +477,16 @@ func RelayNotFound(c *gin.Context) { } func RelayTaskFetch(c *gin.Context) { + helper.StartRelayTrace(c, string(types.RelayFormatTask)) + var taskErr *dto.TaskError + defer func() { + if taskErr != nil { + helper.FinishRelayTrace(c, taskErr.Error) + return + } + helper.FinishRelayTrace(c, nil) + }() + relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) if err != nil { c.JSON(http.StatusInternalServerError, &dto.TaskError{ @@ -478,12 +496,22 @@ func RelayTaskFetch(c *gin.Context) { }) return } - if taskErr := relay.RelayTaskFetch(c, relayInfo.RelayMode); taskErr != nil { + if taskErr = relay.RelayTaskFetch(c, relayInfo.RelayMode); taskErr != nil { respondTaskError(c, taskErr) } } func RelayTask(c *gin.Context) { + helper.StartRelayTrace(c, string(types.RelayFormatTask)) + var taskErr *dto.TaskError + defer func() { + if taskErr != nil { + helper.FinishRelayTrace(c, taskErr.Error) + return + } + helper.FinishRelayTrace(c, nil) + }() + relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) if err != nil { c.JSON(http.StatusInternalServerError, &dto.TaskError{ @@ -494,13 +522,12 @@ func RelayTask(c *gin.Context) { return } - if taskErr := relay.ResolveOriginTask(c, relayInfo); taskErr != nil { + if taskErr = relay.ResolveOriginTask(c, relayInfo); taskErr != nil { respondTaskError(c, taskErr) return } var result *relay.TaskSubmitResult - var taskErr *dto.TaskError defer func() { if taskErr != nil && relayInfo.Billing != nil { relayInfo.Billing.Refund(c) diff --git a/docs/changes/2026-08-02-stale-chunk-recovery.md b/docs/changes/2026-08-02-stale-chunk-recovery.md new file mode 100644 index 000000000000..5020669cf953 --- /dev/null +++ b/docs/changes/2026-08-02-stale-chunk-recovery.md @@ -0,0 +1,44 @@ +# 前端旧分包加载故障修复记录 + +## 背景 + +2026-08-02,Macroapple 的旧浏览器标签页在应用部署后进入系统设置页面时出现 +500 错误。调查确认,旧页面引用的异步 JavaScript 分包已经被新版本替换,而源站 +会把不存在的 `/static/` 路径回退为首页 HTML,并以 HTTP 200 返回。Cloudflare +随后按四小时缓存该错误响应,使浏览器无法按 JavaScript 模块加载。 + +Nanoapple 当时没有出现用户可见故障,但只读对照测试确认其生产版本具有相同旧 +行为。是否出现故障取决于用户是否在部署前打开页面,并在部署后继续使用该旧标签页。 + +## 修复内容 + +- 不存在的 `/static/` 文件返回 HTTP 404,并设置 `Cache-Control: no-store`。 +- 前端识别 `ChunkLoadError` 和动态模块加载失败后自动刷新一次。 +- 使用会话存储限制自动刷新频率,60 秒内最多刷新一次,避免刷新循环。 +- 增加后端路由测试和前端错误识别、刷新限流测试。 + +## 提交与部署 + +- 共享修复提交:`181da5c59b02`。 +- Macroapple 同步提交:`4e38b4762f53`。 +- Macroapple 生产镜像:`metaapple/macroapple:4e38b4762f53`。 +- Nanoapple 生产版本保持 `nanoapple/new-api:919c4757a10a`,未部署或重启。 + +## 验证结果 + +- 前端回归测试通过:6 项。 +- `bun run typecheck`、Oxlint、格式检查和生产构建通过。 +- `go test ./router -count=1` 通过。 +- Macroapple 公网缺失静态文件返回 `404 + Cache-Control: no-store`,Cloudflare + 状态为 `BYPASS`。 +- 已登录浏览器访问 `/system-settings/site/system-info` 正常, + `GET /api/option/` 返回 HTTP 200。 +- Macroapple 与 Nanoapple 容器均保持健康。 + +## 流程补救 + +上述两个修复提交最初直接推送到长期分支,没有经过 Pull Request,且提交信息使用 +英文,不符合当前仓库要求。本记录不改写已经部署的 Git 历史,而是如实保留该偏差; +后续通过中文 PR 模板、协作规范和分支保护禁止再次直接推送长期分支。 + +Nanoapple 同步本修复时必须另行创建中文 PR,并在获得明确部署授权后再更新生产环境。 diff --git a/model/channel_cache.go b/model/channel_cache.go index 81923017d79c..78e165fb9e1a 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -5,7 +5,6 @@ import ( "fmt" "math/rand" "sort" - "strings" "sync" "time" @@ -50,20 +49,21 @@ func InitChannelCache() { for group := range groups { newGroup2model2channels[group] = make(map[string][]int) } - for _, channel := range channels { - if channel.Status != common.ChannelStatusEnabled { - continue // skip disabled channels + // Abilities are the source of truth for routing. Channel.Group and + // Channel.Models are only defaults used when abilities are generated; + // using them here exposes manually scoped model abilities to other groups. + for _, ability := range abilities { + channel, ok := newChannelId2channel[ability.ChannelId] + if !ok || channel.Status != common.ChannelStatusEnabled || !ability.Enabled { + continue } - groups := strings.Split(channel.Group, ",") - for _, group := range groups { - models := strings.Split(channel.Models, ",") - for _, model := range models { - if _, ok := newGroup2model2channels[group][model]; !ok { - newGroup2model2channels[group][model] = make([]int, 0) - } - newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel.Id) - } + if _, ok := newGroup2model2channels[ability.Group]; !ok { + newGroup2model2channels[ability.Group] = make(map[string][]int) } + newGroup2model2channels[ability.Group][ability.Model] = append( + newGroup2model2channels[ability.Group][ability.Model], + ability.ChannelId, + ) } // sort by priority diff --git a/model/option.go b/model/option.go index 89a233ec57c3..575e1f09aba5 100644 --- a/model/option.go +++ b/model/option.go @@ -1,11 +1,13 @@ package model import ( + "fmt" "strconv" "strings" "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/config" "github.com/QuantumNous/new-api/setting/operation_setting" @@ -48,6 +50,8 @@ 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["RelayTraceLogMode"] = relayTraceLogMode() + common.OptionMap["RelayTraceLogFullBodyEnabled"] = strconv.FormatBool(constant.RelayTraceLogFullBodyEnabled) common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled) common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled) common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled) @@ -145,6 +149,7 @@ func InitOptionMap() { common.OptionMap["CreateCacheRatio"] = ratio_setting.CreateCacheRatio2JSONString() common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString() + common.OptionMap["GroupSpecialUsableGroup"] = ratio_setting.GroupSpecialUsableGroup2JSONString() common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString() common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString() common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString() @@ -205,6 +210,9 @@ func SyncOptions(frequency int) { } func UpdateOption(key string, value string) error { + if err := validateOptionValue(key, value); err != nil { + return err + } // Save to database first option := Option{ Key: key, @@ -229,6 +237,11 @@ func UpdateOptionsBulk(values map[string]string) error { if len(values) == 0 { return nil } + for key, value := range values { + if err := validateOptionValue(key, value); err != nil { + return err + } + } err := DB.Transaction(func(tx *gorm.DB) error { for k, v := range values { option := Option{Key: k} @@ -254,6 +267,9 @@ func UpdateOptionsBulk(values map[string]string) error { } func updateOptionMap(key string, value string) (err error) { + if err := validateOptionValue(key, value); err != nil { + return err + } if key == retiredThemeOptionKey { common.OptionMapRWMutex.Lock() delete(common.OptionMap, key) @@ -370,9 +386,23 @@ func updateOptionMap(key string, value string) (err error) { setting.DefaultUseAutoGroup = boolValue case "ExposeRatioEnabled": ratio_setting.SetExposeRatioEnabled(boolValue) + case "RelayTraceLogFullBodyEnabled": + constant.RelayTraceLogFullBodyEnabled = boolValue } } switch key { + case "RelayTraceLogMode": + switch value { + case "off": + constant.RelayTraceLogEnabled = false + constant.RelayTraceLogFailureOnly = false + case "failure": + constant.RelayTraceLogEnabled = true + constant.RelayTraceLogFailureOnly = true + case "all": + constant.RelayTraceLogEnabled = true + constant.RelayTraceLogFailureOnly = false + } case "EmailDomainWhitelist": common.EmailDomainWhitelist = strings.Split(value, ",") case "SMTPServer": @@ -536,6 +566,8 @@ func updateOptionMap(key string, value string) (err error) { err = ratio_setting.UpdateGroupRatioByJSONString(value) case "GroupGroupRatio": err = ratio_setting.UpdateGroupGroupRatioByJSONString(value) + case "GroupSpecialUsableGroup": + err = ratio_setting.UpdateGroupSpecialUsableGroupByJSONString(value) case "UserUsableGroups": err = setting.UpdateUserUsableGroupsByJSONString(value) case "CompletionRatio": @@ -582,6 +614,33 @@ func updateOptionMap(key string, value string) (err error) { return err } +func relayTraceLogMode() string { + if !constant.RelayTraceLogEnabled { + return "off" + } + if constant.RelayTraceLogFailureOnly { + return "failure" + } + return "all" +} + +func validateOptionValue(key string, value string) error { + switch key { + case "RelayTraceLogMode": + switch value { + case "off", "failure", "all": + return nil + default: + return fmt.Errorf("invalid relay trace log mode: %s", value) + } + case "RelayTraceLogFullBodyEnabled": + if _, err := strconv.ParseBool(value); err != nil { + return fmt.Errorf("invalid relay trace full body setting: %s", value) + } + } + return nil +} + // handleConfigUpdate 处理分层配置更新,返回是否已处理 func handleConfigUpdate(key, value string) bool { parts := strings.SplitN(key, ".", 2) diff --git a/model/pricing_endpoint_test.go b/model/pricing_endpoint_test.go index eeca35e6516f..e5a8e385a791 100644 --- a/model/pricing_endpoint_test.go +++ b/model/pricing_endpoint_test.go @@ -190,6 +190,36 @@ func TestPricingNativeChannelEndpointTypesUnchanged(t *testing.T) { assert.Equal(t, []constant.EndpointType{constant.EndpointTypeAnthropic, constant.EndpointTypeOpenAI}, byModel["claude-3-5-sonnet"]) } +func TestInitChannelCacheRoutesByExplicitAbilities(t *testing.T) { + resetPricingEndpointTestTables(t) + + channel := &Channel{ + Id: 204, + Type: constant.ChannelTypeGemini, + Key: "special-ability-key", + Status: common.ChannelStatusEnabled, + Name: "special-ability-channel", + Group: "public-image", + Models: "public-model,private-model", + } + require.NoError(t, DB.Create(channel).Error) + require.NoError(t, DB.Create(&[]Ability{ + {Group: "public-image", Model: "public-model", ChannelId: channel.Id, Enabled: true}, + {Group: "private-image", Model: "private-model", ChannelId: channel.Id, Enabled: true}, + }).Error) + + InitChannelCache() + + privateChannel, err := GetRandomSatisfiedChannel("private-image", "private-model", 0, "") + require.NoError(t, err) + require.NotNil(t, privateChannel) + assert.Equal(t, channel.Id, privateChannel.Id) + + publicPrivateChannel, err := GetRandomSatisfiedChannel("public-image", "private-model", 0, "") + require.NoError(t, err) + assert.Nil(t, publicPrivateChannel) +} + func TestInitChannelCacheInvalidatesPricingCache(t *testing.T) { resetPricingEndpointTestTables(t) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 62de6a32026b..67ad28d6f6d1 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -475,6 +475,7 @@ func DoRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http return doRequest(c, req, info) } func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { + helper.CaptureUpstreamRequest(c, req, info) var client *http.Client var err error if info.ChannelSetting.Proxy != "" { @@ -508,12 +509,14 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http resp, err := client.Do(req) if err != nil { + helper.CaptureUpstreamError(c, err) logger.LogError(c, "do request failed: "+err.Error()) return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed")) } if resp == nil { return nil, errors.New("resp is nil") } + helper.CaptureUpstreamResponse(c, resp) if upID := resp.Header.Get(common2.RequestIdKey); upID != "" { c.Set(common2.UpstreamRequestIdKey, upID) diff --git a/relay/channel/gemini/markdown_image.go b/relay/channel/gemini/markdown_image.go new file mode 100644 index 000000000000..aea2a916de60 --- /dev/null +++ b/relay/channel/gemini/markdown_image.go @@ -0,0 +1,195 @@ +package gemini + +import ( + "encoding/base64" + "io" + "mime" + "strings" + + "github.com/QuantumNous/new-api/common" +) + +const markdownDataImagePrefix = "data:image/" + +type geminiMarkdownSegment struct { + text string + mimeType string + data string +} + +// normalizeGeminiMarkdownImages converts upstream Markdown data images into +// Gemini inlineData parts while preserving unrelated and unknown JSON fields. +func normalizeGeminiMarkdownImages(data []byte) ([]byte, bool, error) { + var response map[string]any + if err := common.Unmarshal(data, &response); err != nil { + return nil, false, err + } + + candidates, ok := response["candidates"].([]any) + if !ok { + return data, false, nil + } + + changed := false + for _, candidateValue := range candidates { + candidate, ok := candidateValue.(map[string]any) + if !ok { + continue + } + content, ok := candidate["content"].(map[string]any) + if !ok { + continue + } + parts, ok := content["parts"].([]any) + if !ok { + continue + } + + contentChanged := false + normalizedParts := make([]any, 0, len(parts)) + for _, partValue := range parts { + part, ok := partValue.(map[string]any) + if !ok { + normalizedParts = append(normalizedParts, partValue) + continue + } + text, ok := part["text"].(string) + if !ok { + normalizedParts = append(normalizedParts, partValue) + continue + } + + segments, found := splitGeminiMarkdownImages(text) + if !found { + normalizedParts = append(normalizedParts, partValue) + continue + } + + changed = true + contentChanged = true + for _, segment := range segments { + normalizedPart := cloneGeminiPart(part) + if segment.mimeType == "" { + normalizedPart["text"] = segment.text + delete(normalizedPart, "inlineData") + delete(normalizedPart, "inline_data") + } else { + delete(normalizedPart, "text") + delete(normalizedPart, "inline_data") + normalizedPart["inlineData"] = map[string]any{ + "mimeType": segment.mimeType, + "data": segment.data, + } + } + normalizedParts = append(normalizedParts, normalizedPart) + } + } + if contentChanged { + content["parts"] = normalizedParts + } + } + + if !changed { + return data, false, nil + } + normalized, err := common.Marshal(response) + if err != nil { + return nil, false, err + } + return normalized, true, nil +} + +func splitGeminiMarkdownImages(text string) ([]geminiMarkdownSegment, bool) { + segments := make([]geminiMarkdownSegment, 0, 3) + last := 0 + searchFrom := 0 + found := false + + for searchFrom < len(text) { + startOffset := strings.Index(text[searchFrom:], "![") + if startOffset < 0 { + break + } + start := searchFrom + startOffset + labelEndOffset := strings.Index(text[start+2:], "](") + if labelEndOffset < 0 { + break + } + uriStart := start + 2 + labelEndOffset + 2 + if !strings.HasPrefix(text[uriStart:], markdownDataImagePrefix) { + searchFrom = start + 2 + continue + } + + commaOffset := strings.IndexByte(text[uriStart:], ',') + if commaOffset < 0 { + break + } + comma := uriStart + commaOffset + metadata := text[uriStart+len("data:") : comma] + encodingSeparator := strings.LastIndexByte(metadata, ';') + if encodingSeparator < 0 || !strings.EqualFold(metadata[encodingSeparator+1:], "base64") { + searchFrom = start + 2 + continue + } + + mediaType, _, err := mime.ParseMediaType(metadata[:encodingSeparator]) + if err != nil || !strings.HasPrefix(strings.ToLower(mediaType), "image/") { + searchFrom = start + 2 + continue + } + + dataStart := comma + 1 + closeOffset := strings.IndexByte(text[dataStart:], ')') + if closeOffset < 0 { + break + } + close := dataStart + closeOffset + imageData := text[dataStart:close] + if imageData == "" || !validGeminiImageBase64(imageData) { + searchFrom = start + 2 + continue + } + + if start > last { + segments = append(segments, geminiMarkdownSegment{text: text[last:start]}) + } + segments = append(segments, geminiMarkdownSegment{ + mimeType: mediaType, + data: imageData, + }) + found = true + last = close + 1 + searchFrom = last + } + + if !found { + return nil, false + } + if last < len(text) { + segments = append(segments, geminiMarkdownSegment{text: text[last:]}) + } + return segments, true +} + +func validGeminiImageBase64(data string) bool { + decoder := base64.NewDecoder(base64.StdEncoding.Strict(), strings.NewReader(data)) + _, err := io.Copy(io.Discard, decoder) + if err == nil { + return true + } + if len(data)%4 == 0 { + return false + } + decoder = base64.NewDecoder(base64.RawStdEncoding.Strict(), strings.NewReader(data)) + _, err = io.Copy(io.Discard, decoder) + return err == nil +} + +func cloneGeminiPart(part map[string]any) map[string]any { + clone := make(map[string]any, len(part)+1) + for key, value := range part { + clone[key] = value + } + return clone +} diff --git a/relay/channel/gemini/markdown_image_test.go b/relay/channel/gemini/markdown_image_test.go new file mode 100644 index 000000000000..bcefcff9f376 --- /dev/null +++ b/relay/channel/gemini/markdown_image_test.go @@ -0,0 +1,171 @@ +package gemini + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + +func TestNormalizeGeminiMarkdownImagesPreservesResponseFields(t *testing.T) { + t.Parallel() + + payload := []byte(`{ + "candidates":[{ + "content":{"role":"model","parts":[{"text":""}]}, + "finishReason":"STOP", + "groundingMetadata":{"custom":"preserved"} + }], + "usageMetadata":{"promptTokenCount":7,"totalTokenCount":1407}, + "modelVersion":"gemini-test", + "responseId":"response-test", + "vendorExtension":{"enabled":true} + }`) + + normalized, changed, err := normalizeGeminiMarkdownImages(payload) + require.NoError(t, err) + require.True(t, changed) + + var got map[string]any + require.NoError(t, common.Unmarshal(normalized, &got)) + assert.Equal(t, "gemini-test", got["modelVersion"]) + assert.Equal(t, "response-test", got["responseId"]) + assert.Equal(t, true, got["vendorExtension"].(map[string]any)["enabled"]) + + candidate := got["candidates"].([]any)[0].(map[string]any) + assert.Equal(t, "preserved", candidate["groundingMetadata"].(map[string]any)["custom"]) + parts := candidate["content"].(map[string]any)["parts"].([]any) + require.Len(t, parts, 1) + part := parts[0].(map[string]any) + assert.NotContains(t, part, "text") + assert.Equal(t, map[string]any{ + "mimeType": "image/png", + "data": testPNGBase64, + }, part["inlineData"]) +} + +func TestNormalizeGeminiMarkdownImagesSplitsTextAndMultipleImages(t *testing.T) { + t.Parallel() + + payload := []byte(`{"candidates":[{"content":{"parts":[{"text":"before  middle  after"}]}}]}`) + normalized, changed, err := normalizeGeminiMarkdownImages(payload) + require.NoError(t, err) + require.True(t, changed) + + var response dto.GeminiChatResponse + require.NoError(t, common.Unmarshal(normalized, &response)) + parts := response.Candidates[0].Content.Parts + require.Len(t, parts, 5) + assert.Equal(t, "before ", parts[0].Text) + assert.Equal(t, "image/png", parts[1].InlineData.MimeType) + assert.Equal(t, " middle ", parts[2].Text) + assert.Equal(t, "image/jpeg", parts[3].InlineData.MimeType) + assert.Equal(t, " after", parts[4].Text) +} + +func TestNormalizeGeminiMarkdownImagesLeavesInvalidDataUnchanged(t *testing.T) { + t.Parallel() + + payload := []byte(`{"candidates":[{"content":{"parts":[{"text":""},{"text":""}]}}]}`) + normalized, changed, err := normalizeGeminiMarkdownImages(payload) + require.NoError(t, err) + assert.False(t, changed) + assert.Equal(t, payload, normalized) +} + +func TestNormalizeGeminiMarkdownImagesLeavesOfficialInlineDataUnchanged(t *testing.T) { + t.Parallel() + + payload := []byte(`{ + "candidates":[{ + "content":{"role":"model","parts":[ + {"text":"Here is the generated image."}, + {"inlineData":{"mimeType":"image/png","data":"` + testPNGBase64 + `"}} + ]}, + "finishReason":"STOP" + }], + "usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":1400,"totalTokenCount":1407} + }`) + + normalized, changed, err := normalizeGeminiMarkdownImages(payload) + require.NoError(t, err) + assert.False(t, changed) + assert.Equal(t, payload, normalized) +} + +func TestGeminiTextGenerationHandlerReturnsOfficialInlineData(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-test:generateContent", nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "gemini-test", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gemini-test", + }, + } + payload := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":1400,"totalTokenCount":1407},"responseId":"keep-me"}`) + resp := &http.Response{Body: io.NopCloser(bytes.NewReader(payload))} + + usage, newAPIError := GeminiTextGenerationHandler(c, info, resp) + require.Nil(t, newAPIError) + require.NotNil(t, usage) + assert.Equal(t, 1400, usage.CompletionTokens) + + var got map[string]any + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &got)) + assert.Equal(t, "keep-me", got["responseId"]) + parts := got["candidates"].([]any)[0].(map[string]any)["content"].(map[string]any)["parts"].([]any) + part := parts[0].(map[string]any) + assert.NotContains(t, part, "text") + assert.Equal(t, "image/png", part["inlineData"].(map[string]any)["mimeType"]) + assert.Equal(t, testPNGBase64, part["inlineData"].(map[string]any)["data"]) +} + +func TestGeminiStreamHandlerNormalizesChunkBeforeCallback(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-test:streamGenerateContent", nil) + + oldStreamingTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 300 + t.Cleanup(func() { + constant.StreamingTimeout = oldStreamingTimeout + }) + + info := &relaycommon.RelayInfo{ + OriginModelName: "gemini-test", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gemini-test", + }, + } + chunk := `{"candidates":[{"content":{"role":"model","parts":[{"text":""}]}}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":1400,"totalTokenCount":1407}}` + resp := &http.Response{Body: io.NopCloser(bytes.NewBufferString("data: " + chunk + "\n\ndata: [DONE]\n\n"))} + + callbackCount := 0 + usage, newAPIError := geminiStreamHandler(c, info, resp, func(data string, response *dto.GeminiChatResponse) bool { + callbackCount++ + require.NotContains(t, data, "![image]") + require.Len(t, response.Candidates[0].Content.Parts, 1) + assert.Equal(t, "image/png", response.Candidates[0].Content.Parts[0].InlineData.MimeType) + assert.Equal(t, testPNGBase64, response.Candidates[0].Content.Parts[0].InlineData.Data) + return true + }) + require.Nil(t, newAPIError) + require.NotNil(t, usage) + assert.Equal(t, 1, callbackCount) + assert.Equal(t, 1400, usage.CompletionTokens) +} diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index f3bd79440c5f..1231d0daf530 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -27,6 +27,10 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re } logger.LogDebug(c, "Gemini native response body: %s", responseBody) + responseBody, _, err = normalizeGeminiMarkdownImages(responseBody) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } // 解析为 Gemini 原生响应格式 var geminiResponse dto.GeminiChatResponse diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 556d7dc27eed..1c76bb9ed6c2 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -139,6 +139,13 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http responseText := strings.Builder{} helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { + normalizedData, _, err := normalizeGeminiMarkdownImages([]byte(data)) + if err != nil { + sr.Stop(fmt.Errorf("normalize Gemini Markdown images: %w", err)) + return + } + data = string(normalizedData) + var geminiResponse dto.GeminiChatResponse if err := common.UnmarshalJsonStr(data, &geminiResponse); err != nil { sr.Stop(fmt.Errorf("unmarshal: %w", err)) @@ -303,6 +310,10 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R } service.CloseResponseBodyGracefully(resp) logger.LogDebug(c, "Gemini response body: %s", responseBody) + responseBody, _, err = normalizeGeminiMarkdownImages(responseBody) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } var geminiResponse dto.GeminiChatResponse err = common.Unmarshal(responseBody, &geminiResponse) if err != nil { diff --git a/relay/channel/gemini/relay_responses.go b/relay/channel/gemini/relay_responses.go index 08f96ab400e8..45e0066fc0a3 100644 --- a/relay/channel/gemini/relay_responses.go +++ b/relay/channel/gemini/relay_responses.go @@ -26,6 +26,10 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } logger.LogDebug(c, "Gemini responses response body: %s", responseBody) + responseBody, _, err = normalizeGeminiMarkdownImages(responseBody) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } var geminiResponse dto.GeminiChatResponse if err := common.Unmarshal(responseBody, &geminiResponse); err != nil { diff --git a/relay/helper/image_failure_audit.go b/relay/helper/image_failure_audit.go new file mode 100644 index 000000000000..7262f37aad48 --- /dev/null +++ b/relay/helper/image_failure_audit.go @@ -0,0 +1,283 @@ +package helper + +import ( + "fmt" + "net/url" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +const ( + imageAuditTextLimit = 4096 + imageAuditRawLimit = 16384 +) + +// LogImageFailureRequest records enough client request detail to diagnose image +// failures without retaining credentials or binary image payloads. +func LogImageFailureRequest(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, info *relaycommon.RelayInfo, relayErr *types.NewAPIError) { + if !constant.ImageFailureRequestLogEnabled || c == nil || relayErr == nil || !isImageRequest(relayFormat, c, info) { + return + } + + payload := map[string]any{ + "event": "image_failure_request", + "request_id": c.GetString(common.RequestIdKey), + "user_id": common.GetContextKeyInt(c, constant.ContextKeyUserId), + "username": common.GetContextKeyString(c, constant.ContextKeyUserName), + "group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + "token_id": common.GetContextKeyInt(c, constant.ContextKeyTokenId), + "channel_id": common.GetContextKeyInt(c, constant.ContextKeyChannelId), + "channel_name": common.GetContextKeyString(c, constant.ContextKeyChannelName), + "method": c.Request.Method, + "path": c.Request.URL.Path, + "relay_format": relayFormat, + "status_code": relayErr.StatusCode, + "error_code": relayErr.GetErrorCode(), + "error_type": relayErr.GetErrorType(), + "error": truncateImageAuditText(relayErr.MaskSensitiveError()), + "request": summarizeImageRequest(request), + } + if info != nil { + payload["origin_model"] = info.OriginModelName + payload["upstream_model"] = info.UpstreamModelName + payload["used_channels"] = c.GetStringSlice("use_channel") + } + + data, err := common.Marshal(payload) + if err != nil { + logger.LogError(c, fmt.Sprintf("image failure request audit marshal failed: %s", err.Error())) + return + } + if len(data) > imageAuditRawLimit { + data = append(data[:imageAuditRawLimit], []byte("... [audit truncated]")...) + } + logger.LogError(c, "image_failure_request_audit="+string(data)) +} + +func isImageRequest(relayFormat types.RelayFormat, c *gin.Context, info *relaycommon.RelayInfo) bool { + if relayFormat == types.RelayFormatOpenAIImage { + return true + } + if relayFormat != types.RelayFormatGemini { + return false + } + model := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + if info != nil && info.OriginModelName != "" { + model = info.OriginModelName + } + return strings.Contains(strings.ToLower(model), "image") || strings.Contains(strings.ToLower(c.Request.URL.Path), "image") +} + +func summarizeImageRequest(request dto.Request) any { + switch req := request.(type) { + case *dto.ImageRequest: + if req == nil { + return map[string]any{"parsed": false, "type": "openai_image"} + } + return summarizeOpenAIImageRequest(req) + case *dto.GeminiChatRequest: + if req == nil { + return map[string]any{"parsed": false, "type": "gemini"} + } + return summarizeGeminiImageRequest(req) + case nil: + return map[string]any{"parsed": false} + default: + return map[string]any{"parsed": true, "type": fmt.Sprintf("%T", request)} + } +} + +func summarizeOpenAIImageRequest(req *dto.ImageRequest) map[string]any { + result := map[string]any{ + "model": req.Model, + "prompt": truncateImageAuditText(req.Prompt), + "n": req.N, + "size": req.Size, + "quality": req.Quality, + "response_format": req.ResponseFormat, + "stream": req.Stream, + "watermark": req.Watermark, + } + addSafeRawJSON(result, "style", req.Style) + addSafeRawJSON(result, "background", req.Background) + addSafeRawJSON(result, "output_format", req.OutputFormat) + addSafeRawJSON(result, "output_compression", req.OutputCompression) + addSafeRawJSON(result, "partial_images", req.PartialImages) + addSafeRawJSON(result, "input_fidelity", req.InputFidelity) + result["binary_inputs"] = map[string]any{ + "images": rawPayloadMetadata(req.Images), + "image": rawPayloadMetadata(req.Image), + "mask": rawPayloadMetadata(req.Mask), + } + if len(req.Extra) > 0 { + extra := make(map[string]any, len(req.Extra)) + for key, value := range req.Extra { + if isSensitiveAuditKey(key) { + extra[key] = "[redacted]" + continue + } + extra[key] = safeRawJSON(value) + } + result["extra"] = extra + } + return result +} + +func summarizeGeminiImageRequest(req *dto.GeminiChatRequest) map[string]any { + result := map[string]any{ + "contents": summarizeGeminiContents(req.Contents), + "generation_config": map[string]any{ + "temperature": req.GenerationConfig.Temperature, + "top_p": req.GenerationConfig.TopP, + "top_k": req.GenerationConfig.TopK, + "candidate_count": req.GenerationConfig.CandidateCount, + "response_mime_type": req.GenerationConfig.ResponseMimeType, + "response_modalities": req.GenerationConfig.ResponseModalities, + "media_resolution": req.GenerationConfig.MediaResolution, + "seed": req.GenerationConfig.Seed, + "image_config": safeRawJSON(req.GenerationConfig.ImageConfig), + "thinking_config": req.GenerationConfig.ThinkingConfig, + "max_output_tokens": req.GenerationConfig.MaxOutputTokens, + }, + "safety_settings": req.SafetySettings, + "cached_content_set": req.CachedContent != "", + "tools_present": len(req.Tools) > 0, + "batch_request_count": len(req.Requests), + } + if req.SystemInstructions != nil { + result["system_instruction"] = summarizeGeminiContents([]dto.GeminiChatContent{*req.SystemInstructions}) + } + return result +} + +func summarizeGeminiContents(contents []dto.GeminiChatContent) []any { + const maxContents = 32 + const maxPartsPerContent = 64 + if len(contents) > maxContents { + contents = contents[:maxContents] + } + result := make([]any, 0, len(contents)) + for _, content := range contents { + if len(content.Parts) > maxPartsPerContent { + content.Parts = content.Parts[:maxPartsPerContent] + } + parts := make([]any, 0, len(content.Parts)) + for _, part := range content.Parts { + summary := map[string]any{} + if part.Text != "" { + summary["text"] = truncateImageAuditText(part.Text) + } + if part.InlineData != nil { + summary["inline_data"] = map[string]any{ + "mime_type": part.InlineData.MimeType, + "data_bytes": len(part.InlineData.Data), + "data": "[redacted]", + } + } + if part.FileData != nil { + summary["file_data"] = map[string]any{ + "mime_type": part.FileData.MimeType, + "file_uri": sanitizeFileURI(part.FileData.FileUri), + } + } + if part.FunctionCall != nil { + summary["function_call_present"] = true + } + if part.FunctionResponse != nil { + summary["function_response_present"] = true + } + parts = append(parts, summary) + } + result = append(result, map[string]any{"role": content.Role, "parts": parts}) + } + return result +} + +func addSafeRawJSON(target map[string]any, key string, raw []byte) { + if len(raw) > 0 { + target[key] = safeRawJSON(raw) + } +} + +func safeRawJSON(raw []byte) any { + if len(raw) == 0 { + return nil + } + if len(raw) > imageAuditTextLimit { + return map[string]any{"present": true, "bytes": len(raw), "value": "[redacted oversized value]"} + } + var value any + if err := common.Unmarshal(raw, &value); err != nil { + return map[string]any{"present": true, "bytes": len(raw), "value": "[invalid JSON redacted]"} + } + return sanitizeAuditValue(value) +} + +func sanitizeAuditValue(value any) any { + switch typed := value.(type) { + case map[string]any: + result := make(map[string]any, len(typed)) + for key, child := range typed { + if isSensitiveAuditKey(key) { + result[key] = "[redacted]" + continue + } + result[key] = sanitizeAuditValue(child) + } + return result + case []any: + result := make([]any, 0, len(typed)) + for _, child := range typed { + result = append(result, sanitizeAuditValue(child)) + } + return result + case string: + lower := strings.ToLower(typed) + if strings.HasPrefix(lower, "data:image/") || strings.Contains(lower, ";base64,") { + return fmt.Sprintf("[redacted binary string, bytes=%d]", len(typed)) + } + return truncateImageAuditText(typed) + default: + return typed + } +} + +func isSensitiveAuditKey(key string) bool { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + switch normalized { + case "authorization", "apikey", "key", "token", "password", "secret", "b64json", "base64", "data", "inlinedata", "filedata", "image", "images", "mask", "inputimage", "referenceimage", "referenceimages": + return true + default: + return false + } +} + +func rawPayloadMetadata(raw []byte) map[string]any { + return map[string]any{"present": len(raw) > 0, "bytes": len(raw), "value": "[redacted]"} +} + +func sanitizeFileURI(raw string) string { + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "[redacted file URI]" + } + return parsed.Scheme + "://" + parsed.Host + "/[redacted]" +} + +func truncateImageAuditText(value string) string { + if len(value) <= imageAuditTextLimit { + return value + } + return fmt.Sprintf("%s... [truncated, original_length=%d]", value[:imageAuditTextLimit], len(value)) +} diff --git a/relay/helper/image_failure_audit_test.go b/relay/helper/image_failure_audit_test.go new file mode 100644 index 000000000000..b09cec0a4c1d --- /dev/null +++ b/relay/helper/image_failure_audit_test.go @@ -0,0 +1,86 @@ +package helper + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSummarizeGeminiImageRequestRedactsBinaryData(t *testing.T) { + request := &dto.GeminiChatRequest{ + Contents: []dto.GeminiChatContent{{ + Role: "user", + Parts: []dto.GeminiPart{ + {Text: "生成一张红色苹果的 4K 产品图"}, + {InlineData: &dto.GeminiInlineData{MimeType: "image/png", Data: "sensitive-base64-payload"}}, + {FileData: &dto.GeminiFileData{MimeType: "image/jpeg", FileUri: "https://example.com/private/object?token=secret"}}, + }, + }}, + GenerationConfig: dto.GeminiChatGenerationConfig{ + ResponseModalities: []string{"IMAGE"}, + ImageConfig: []byte(`{"imageSize":"4K","aspectRatio":"1:1"}`), + }, + } + + data, err := common.Marshal(summarizeGeminiImageRequest(request)) + require.NoError(t, err) + text := string(data) + + assert.Contains(t, text, "生成一张红色苹果的 4K 产品图") + assert.Contains(t, text, `"imageSize":"4K"`) + assert.Contains(t, text, `"data_bytes":24`) + assert.Contains(t, text, `https://example.com/[redacted]`) + assert.NotContains(t, text, "sensitive-base64-payload") + assert.NotContains(t, text, "token=secret") +} + +func TestSummarizeOpenAIImageRequestRedactsSecretsAndMedia(t *testing.T) { + request := &dto.ImageRequest{ + Model: "gpt-image-2", + Prompt: "生成 2K 海报", + Size: "2048x2048", + Images: []byte(`"data:image/png;base64,sensitive-image"`), + Extra: map[string]json.RawMessage{ + "aspect_ratio": []byte(`"16:9"`), + "api_key": []byte(`"secret-key"`), + }, + } + + data, err := common.Marshal(summarizeOpenAIImageRequest(request)) + require.NoError(t, err) + text := string(data) + + assert.Contains(t, text, "生成 2K 海报") + assert.Contains(t, text, "2048x2048") + assert.Contains(t, text, "16:9") + assert.Contains(t, text, `"api_key":"[redacted]"`) + assert.NotContains(t, text, "sensitive-image") + assert.NotContains(t, text, "secret-key") +} + +func TestTruncateImageAuditTextBoundsPrompt(t *testing.T) { + value := strings.Repeat("a", imageAuditTextLimit+100) + result := truncateImageAuditText(value) + + assert.Contains(t, result, "original_length=4196") + assert.Less(t, len(result), len(value)) +} + +func TestSummarizeImageRequestHandlesTypedNil(t *testing.T) { + var geminiRequest *dto.GeminiChatRequest + var openAIRequest *dto.ImageRequest + + geminiData, err := common.Marshal(summarizeImageRequest(geminiRequest)) + require.NoError(t, err) + openAIData, err := common.Marshal(summarizeImageRequest(openAIRequest)) + require.NoError(t, err) + + assert.JSONEq(t, `{"parsed":false,"type":"gemini"}`, string(geminiData)) + assert.JSONEq(t, `{"parsed":false,"type":"openai_image"}`, string(openAIData)) +} diff --git a/relay/helper/relay_trace.go b/relay/helper/relay_trace.go new file mode 100644 index 000000000000..26391db41c6f --- /dev/null +++ b/relay/helper/relay_trace.go @@ -0,0 +1,530 @@ +package helper + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + + "github.com/gin-gonic/gin" +) + +const relayTraceContextKey = "relay_trace" + +type relayTrace struct { + startedAt time.Time + format string + writer *relayTraceResponseWriter + + mu sync.Mutex + attempts []*relayTraceAttempt +} + +type relayTraceAttempt struct { + startedAt time.Time + method string + url string + headers map[string]any + channel map[string]any + request *relayTraceCapture + response *relayTraceResponse + err string +} + +type relayTraceResponse struct { + status int + headers map[string]any + body *relayTraceCapture +} + +type relayTraceCapture struct { + limit int + + mu sync.Mutex + data bytes.Buffer + total int64 +} + +type relayTraceResponseWriter struct { + gin.ResponseWriter + capture *relayTraceCapture +} + +func (w *relayTraceResponseWriter) Write(data []byte) (int, error) { + n, err := w.ResponseWriter.Write(data) + w.capture.add(data[:n]) + return n, err +} + +func (w *relayTraceResponseWriter) WriteString(value string) (int, error) { + n, err := w.ResponseWriter.WriteString(value) + w.capture.add([]byte(value[:n])) + return n, err +} + +func (c *relayTraceCapture) add(data []byte) { + if len(data) == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.total += int64(len(data)) + remaining := c.limit - c.data.Len() + if remaining <= 0 { + return + } + if len(data) > remaining { + data = data[:remaining] + } + _, _ = c.data.Write(data) +} + +func (c *relayTraceCapture) snapshot(contentType string) map[string]any { + c.mu.Lock() + data := append([]byte(nil), c.data.Bytes()...) + total := c.total + limit := c.limit + c.mu.Unlock() + return summarizeRelayTraceBody(data, total, limit, contentType, constant.RelayTraceLogFullBodyEnabled) +} + +type relayTraceReadCloser struct { + io.ReadCloser + capture *relayTraceCapture +} + +func (r *relayTraceReadCloser) Read(data []byte) (int, error) { + n, err := r.ReadCloser.Read(data) + r.capture.add(data[:n]) + return n, err +} + +func StartRelayTrace(c *gin.Context, format string) { + if !constant.RelayTraceLogEnabled || c == nil { + return + } + writer := &relayTraceResponseWriter{ + ResponseWriter: c.Writer, + capture: newRelayTraceCapture(), + } + trace := &relayTrace{startedAt: time.Now(), format: format, writer: writer} + c.Writer = writer + c.Set(relayTraceContextKey, trace) +} + +func CaptureUpstreamRequest(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) { + trace := getRelayTrace(c) + if trace == nil || req == nil { + return + } + attempt := &relayTraceAttempt{ + startedAt: time.Now(), + method: req.Method, + url: sanitizeRelayTraceURL(req.URL), + headers: sanitizeRelayTraceHeaders(req.Header), + channel: relayTraceChannelInfo(c, info), + } + if req.Body != nil { + attempt.request = newRelayTraceCapture() + req.Body = &relayTraceReadCloser{ReadCloser: req.Body, capture: attempt.request} + } + trace.mu.Lock() + trace.attempts = append(trace.attempts, attempt) + trace.mu.Unlock() +} + +func CaptureUpstreamResponse(c *gin.Context, resp *http.Response) { + trace := getRelayTrace(c) + if trace == nil || resp == nil { + return + } + trace.mu.Lock() + defer trace.mu.Unlock() + if len(trace.attempts) == 0 { + return + } + attempt := trace.attempts[len(trace.attempts)-1] + attempt.response = &relayTraceResponse{ + status: resp.StatusCode, + headers: sanitizeRelayTraceHeaders(resp.Header), + } + if resp.Body != nil { + capture := newRelayTraceCapture() + attempt.response.body = capture + resp.Body = &relayTraceReadCloser{ReadCloser: resp.Body, capture: capture} + } +} + +func CaptureUpstreamError(c *gin.Context, err error) { + trace := getRelayTrace(c) + if trace == nil || err == nil { + return + } + trace.mu.Lock() + defer trace.mu.Unlock() + if len(trace.attempts) > 0 { + trace.attempts[len(trace.attempts)-1].err = sanitizeRelayTraceString(err.Error()) + } +} + +func FinishRelayTrace(c *gin.Context, err error) { + trace := getRelayTrace(c) + if trace == nil || c == nil { + return + } + if !shouldLogRelayTrace(c.Writer.Status(), err) { + return + } + + payload := map[string]any{ + "event": "relay_trace", + "request_id": c.GetString(common.RequestIdKey), + "started_at": trace.startedAt.Format(time.RFC3339Nano), + "duration_ms": time.Since(trace.startedAt).Milliseconds(), + "relay_format": trace.format, + "downstream_request": map[string]any{ + "method": c.Request.Method, + "url": sanitizeRelayTraceURL(c.Request.URL), + "protocol": c.Request.Proto, + "client_ip": c.ClientIP(), + "remote_addr": c.Request.RemoteAddr, + "user_agent": c.Request.UserAgent(), + "identity": map[string]any{ + "user_id": common.GetContextKeyInt(c, constant.ContextKeyUserId), + "username": common.GetContextKeyString(c, constant.ContextKeyUserName), + "token_id": common.GetContextKeyInt(c, constant.ContextKeyTokenId), + "using_group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + "original_model": common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + }, + "headers": sanitizeRelayTraceHeaders(c.Request.Header), + "body": readIncomingRelayTraceBody(c), + }, + "upstream_attempts": trace.snapshotAttempts(), + "downstream_response": map[string]any{ + "status": c.Writer.Status(), + "headers": sanitizeRelayTraceHeaders(c.Writer.Header()), + "body": trace.writer.capture.snapshot(c.Writer.Header().Get("Content-Type")), + }, + } + if hasRelayTraceError(err) { + payload["error"] = sanitizeRelayTraceString(err.Error()) + } + data, marshalErr := common.Marshal(payload) + if marshalErr != nil { + logger.LogError(c, fmt.Sprintf("relay trace marshal failed: %s", marshalErr.Error())) + return + } + logger.LogInfo(c, "relay_trace="+string(data)) +} + +func shouldLogRelayTrace(status int, err error) bool { + return !constant.RelayTraceLogFailureOnly || hasRelayTraceError(err) || status >= http.StatusBadRequest +} + +func hasRelayTraceError(err error) bool { + if err == nil { + return false + } + value := reflect.ValueOf(err) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return !value.IsNil() + default: + return true + } +} + +func (t *relayTrace) snapshotAttempts() []any { + t.mu.Lock() + defer t.mu.Unlock() + result := make([]any, 0, len(t.attempts)) + for _, attempt := range t.attempts { + item := map[string]any{ + "method": attempt.method, + "url": attempt.url, + "duration_ms": time.Since(attempt.startedAt).Milliseconds(), + "headers": attempt.headers, + "channel": attempt.channel, + } + if attempt.request != nil { + item["request_body"] = attempt.request.snapshot(headerContentType(attempt.headers)) + } + if attempt.response != nil { + response := map[string]any{ + "status": attempt.response.status, + "headers": attempt.response.headers, + } + if attempt.response.body != nil { + response["body"] = attempt.response.body.snapshot(headerContentType(attempt.response.headers)) + } + item["response"] = response + } + if attempt.err != "" { + item["error"] = attempt.err + } + result = append(result, item) + } + return result +} + +func getRelayTrace(c *gin.Context) *relayTrace { + if !constant.RelayTraceLogEnabled || c == nil { + return nil + } + value, ok := c.Get(relayTraceContextKey) + if !ok { + return nil + } + trace, _ := value.(*relayTrace) + return trace +} + +func newRelayTraceCapture() *relayTraceCapture { + maxKB := constant.RelayTraceLogMaxBodyKB + if constant.RelayTraceLogFullBodyEnabled { + maxKB = constant.RelayTraceLogFullBodyMaxMB * 1024 + } + if maxKB < 1 { + maxKB = 256 + } + return &relayTraceCapture{limit: maxKB * 1024} +} + +func readIncomingRelayTraceBody(c *gin.Context) map[string]any { + if c.Request.ContentLength == 0 { + return summarizeRelayTraceBody(nil, 0, newRelayTraceCapture().limit, c.Request.Header.Get("Content-Type"), constant.RelayTraceLogFullBodyEnabled) + } + storage, err := common.GetBodyStorage(c) + if err != nil { + return map[string]any{"available": false, "error": sanitizeRelayTraceString(err.Error())} + } + if _, err = storage.Seek(0, io.SeekStart); err != nil { + return map[string]any{"available": false, "error": sanitizeRelayTraceString(err.Error())} + } + capture := newRelayTraceCapture() + _, _ = io.Copy(capture, io.LimitReader(storage, int64(capture.limit)+1)) + capture.mu.Lock() + capture.total = storage.Size() + capture.mu.Unlock() + _, _ = storage.Seek(0, io.SeekStart) + result := capture.snapshot(c.Request.Header.Get("Content-Type")) + if c.Request.MultipartForm != nil { + fields := make(map[string]any, len(c.Request.MultipartForm.Value)) + for key, values := range c.Request.MultipartForm.Value { + if isRelayTraceSensitiveKey(key) { + fields[key] = "[redacted]" + continue + } + sanitized := make([]string, 0, len(values)) + for _, value := range values { + sanitized = append(sanitized, sanitizeRelayTraceString(value)) + } + fields[key] = sanitized + } + files := make(map[string]any, len(c.Request.MultipartForm.File)) + for key, values := range c.Request.MultipartForm.File { + items := make([]any, 0, len(values)) + for _, file := range values { + items = append(items, map[string]any{ + "filename": file.Filename, + "content_type": file.Header.Get("Content-Type"), + "size": file.Size, + }) + } + files[key] = items + } + result["form_fields"] = fields + result["files"] = files + } + return result +} + +func (c *relayTraceCapture) Write(data []byte) (int, error) { + c.add(data) + return len(data), nil +} + +func summarizeRelayTraceBody(data []byte, total int64, limit int, contentType string, fullBody bool) map[string]any { + result := map[string]any{ + "available": true, + "total_bytes": total, + "captured_bytes": len(data), + "truncated": total > int64(len(data)), + "sample_sha256": fmt.Sprintf("%x", sha256.Sum256(data)), + "content_type": contentType, + } + if total == 0 { + return result + } + if fullBody { + if isBinaryRelayTraceContent(contentType) { + result["body_encoding"] = "base64" + result["body"] = base64.StdEncoding.EncodeToString(data) + } else { + result["body"] = string(data) + } + return result + } + if isBinaryRelayTraceContent(contentType) { + result["body"] = "[binary body omitted]" + return result + } + if total > int64(limit) { + result["body"] = "[body preview truncated]" + return result + } + if isJSONRelayTraceContent(contentType) { + var value any + if err := common.Unmarshal(data, &value); err == nil { + result["body"] = sanitizeRelayTraceValue(value, "") + return result + } + } + result["body"] = sanitizeRelayTraceString(string(data)) + return result +} + +func sanitizeRelayTraceHeaders(headers http.Header) map[string]any { + result := make(map[string]any, len(headers)) + for key, values := range headers { + if isRelayTraceSensitiveKey(key) { + result[key] = "[redacted]" + continue + } + sanitized := make([]string, 0, len(values)) + for _, value := range values { + sanitized = append(sanitized, sanitizeRelayTraceString(value)) + } + result[key] = sanitized + } + return result +} + +func relayTraceChannelInfo(c *gin.Context, info *relaycommon.RelayInfo) map[string]any { + result := map[string]any{ + "channel_id": common.GetContextKeyInt(c, constant.ContextKeyChannelId), + "channel_name": common.GetContextKeyString(c, constant.ContextKeyChannelName), + "used_channels": c.GetStringSlice("use_channel"), + } + if info != nil { + result["channel_type"] = info.ChannelType + result["origin_model"] = info.OriginModelName + result["upstream_model"] = info.UpstreamModelName + result["retry_index"] = info.RetryIndex + } + return result +} + +func sanitizeRelayTraceValue(value any, key string) any { + if isRelayTraceSensitiveKey(key) { + return "[redacted]" + } + switch typed := value.(type) { + case map[string]any: + result := make(map[string]any, len(typed)) + for childKey, child := range typed { + result[childKey] = sanitizeRelayTraceValue(child, childKey) + } + return result + case []any: + result := make([]any, 0, len(typed)) + for _, child := range typed { + result = append(result, sanitizeRelayTraceValue(child, key)) + } + return result + case string: + return sanitizeRelayTraceString(typed) + default: + return typed + } +} + +func sanitizeRelayTraceString(value string) string { + lower := strings.ToLower(value) + if strings.HasPrefix(lower, "data:") || strings.Contains(lower, ";base64,") { + return fmt.Sprintf("[binary data omitted, bytes=%d]", len(value)) + } + parsed, err := url.Parse(value) + if err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") { + for key := range parsed.Query() { + if isRelayTraceSensitiveKey(key) { + query := parsed.Query() + query.Set(key, "[redacted]") + parsed.RawQuery = query.Encode() + } + } + return parsed.String() + } + return value +} + +func sanitizeRelayTraceURL(value *url.URL) string { + if value == nil { + return "" + } + copy := *value + for key := range copy.Query() { + if isRelayTraceSensitiveKey(key) { + query := copy.Query() + query.Set(key, "[redacted]") + copy.RawQuery = query.Encode() + } + } + return copy.String() +} + +func isRelayTraceSensitiveKey(key string) bool { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", ".", "").Replace(key)) + if strings.Contains(normalized, "authorization") || + strings.Contains(normalized, "apikey") || + strings.Contains(normalized, "secret") || + strings.Contains(normalized, "password") || + strings.Contains(normalized, "credential") || + strings.Contains(normalized, "signature") || + strings.Contains(normalized, "cookie") || + strings.Contains(normalized, "privatekey") || + strings.Contains(normalized, "session") || + (normalized != "tokens" && strings.HasSuffix(normalized, "token")) { + return true + } + switch normalized { + case "key", "b64json", "base64", "data", "inlinedata", "filedata", "image", "images", "mask", "inputimage", "referenceimage", "referenceimages": + return true + default: + return false + } +} + +func isBinaryRelayTraceContent(contentType string) bool { + contentType = strings.ToLower(contentType) + return strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") || strings.HasPrefix(contentType, "multipart/") || strings.Contains(contentType, "octet-stream") +} + +func isJSONRelayTraceContent(contentType string) bool { + contentType = strings.ToLower(contentType) + return strings.Contains(contentType, "application/json") || strings.Contains(contentType, "+json") +} + +func headerContentType(headers map[string]any) string { + for key, value := range headers { + if !strings.EqualFold(key, "Content-Type") { + continue + } + if values, ok := value.([]string); ok && len(values) > 0 { + return values[0] + } + } + return "" +} diff --git a/relay/helper/relay_trace_test.go b/relay/helper/relay_trace_test.go new file mode 100644 index 000000000000..bb56e26a230c --- /dev/null +++ b/relay/helper/relay_trace_test.go @@ -0,0 +1,131 @@ +package helper + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/constant" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSummarizeRelayTraceBodyRedactsCredentialsAndBinaryData(t *testing.T) { + result := summarizeRelayTraceBody( + []byte(`{"prompt":"draw a red apple","api_key":"secret","image":"data:image/png;base64,hidden"}`), + 79, + 1024, + "application/json", + false, + ) + + body, ok := result["body"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "draw a red apple", body["prompt"]) + assert.Equal(t, "[redacted]", body["api_key"]) + assert.Equal(t, "[redacted]", body["image"]) +} + +func TestSummarizeRelayTraceBodyOmitsBinaryAndLargeBodies(t *testing.T) { + binary := summarizeRelayTraceBody([]byte("image-bytes"), 11, 1024, "image/png", false) + assert.Equal(t, "[binary body omitted]", binary["body"]) + + large := summarizeRelayTraceBody([]byte("preview"), 1024, 7, "application/json", false) + assert.Equal(t, true, large["truncated"]) + assert.Equal(t, "[body preview truncated]", large["body"]) +} + +func TestSummarizeRelayTraceBodyKeepsFullBodyWhenEnabled(t *testing.T) { + jsonBody := summarizeRelayTraceBody( + []byte(`{"api_key":"secret","prompt":"apple"}`), + 37, + 1024, + "application/json", + true, + ) + assert.Equal(t, `{"api_key":"secret","prompt":"apple"}`, jsonBody["body"]) + + binaryBody := summarizeRelayTraceBody([]byte("image-bytes"), 11, 1024, "image/png", true) + assert.Equal(t, "base64", binaryBody["body_encoding"]) + assert.Equal(t, "aW1hZ2UtYnl0ZXM=", binaryBody["body"]) +} + +func TestRelayTraceCapturesUpstreamAndDownstreamBodies(t *testing.T) { + oldEnabled := constant.RelayTraceLogEnabled + oldLimit := constant.RelayTraceLogMaxBodyKB + constant.RelayTraceLogEnabled = true + constant.RelayTraceLogMaxBodyKB = 4 + t.Cleanup(func() { + constant.RelayTraceLogEnabled = oldEnabled + constant.RelayTraceLogMaxBodyKB = oldLimit + }) + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewBufferString(`{"prompt":"apple"}`)) + StartRelayTrace(c, "openai_image") + + upstreamReq := httptest.NewRequest(http.MethodPost, "https://upstream.example/v1/images?key=secret", bytes.NewBufferString(`{"prompt":"apple","api_key":"secret"}`)) + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("Authorization", "Bearer upstream-secret") + CaptureUpstreamRequest(c, upstreamReq, &relaycommon.RelayInfo{ + OriginModelName: "gpt-image-1", + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "provider-image"}, + }) + _, err := io.ReadAll(upstreamReq.Body) + require.NoError(t, err) + + upstreamResp := &http.Response{ + StatusCode: http.StatusBadGateway, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"provider unavailable"}`)), + } + CaptureUpstreamResponse(c, upstreamResp) + _, err = io.ReadAll(upstreamResp.Body) + require.NoError(t, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "provider unavailable"}) + + trace := getRelayTrace(c) + require.NotNil(t, trace) + attempts := trace.snapshotAttempts() + require.Len(t, attempts, 1) + attempt := attempts[0].(map[string]any) + assert.Equal(t, "https://upstream.example/v1/images?key=%5Bredacted%5D", attempt["url"]) + assert.Equal(t, "[redacted]", attempt["headers"].(map[string]any)["Authorization"]) + assert.Equal(t, "[redacted]", sanitizeRelayTraceHeaders(http.Header{"X-Goog-Api-Key": []string{"provider-secret"}})["X-Goog-Api-Key"]) + requestBody := attempt["request_body"].(map[string]any)["body"].(map[string]any) + assert.Equal(t, "[redacted]", requestBody["api_key"]) + response := attempt["response"].(map[string]any) + assert.Equal(t, http.StatusBadGateway, response["status"]) + assert.Equal(t, "provider unavailable", response["body"].(map[string]any)["body"].(map[string]any)["error"]) +} + +func TestShouldLogRelayTraceInFailureOnlyMode(t *testing.T) { + oldFailureOnly := constant.RelayTraceLogFailureOnly + constant.RelayTraceLogFailureOnly = true + t.Cleanup(func() { + constant.RelayTraceLogFailureOnly = oldFailureOnly + }) + + assert.False(t, shouldLogRelayTrace(http.StatusOK, nil)) + var typedNilError *types.NewAPIError + assert.False(t, shouldLogRelayTrace(http.StatusOK, typedNilError)) + assert.True(t, shouldLogRelayTrace(http.StatusBadRequest, nil)) + assert.True(t, shouldLogRelayTrace(http.StatusOK, assert.AnError)) +} + +func TestShouldLogRelayTraceInAllMode(t *testing.T) { + oldFailureOnly := constant.RelayTraceLogFailureOnly + constant.RelayTraceLogFailureOnly = false + t.Cleanup(func() { + constant.RelayTraceLogFailureOnly = oldFailureOnly + }) + + assert.True(t, shouldLogRelayTrace(http.StatusOK, nil)) +} diff --git a/router/web-router.go b/router/web-router.go index 83c91d8fe155..eab7c0f33f0a 100644 --- a/router/web-router.go +++ b/router/web-router.go @@ -27,12 +27,22 @@ func SetWebRouter(router *gin.Engine, assets WebAssets) { router.Use(middleware.Cache()) router.Use(static.Serve("/", frontendFS)) router.NoRoute(func(c *gin.Context) { - c.Set(middleware.RouteTagKey, "web") - if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") { - controller.RelayNotFound(c) - return - } - c.Header("Cache-Control", "no-cache") - c.Data(http.StatusOK, "text/html; charset=utf-8", assets.IndexPage) + handleWebFallback(c, assets.IndexPage) }) } + +func handleWebFallback(c *gin.Context, indexPage []byte) { + c.Set(middleware.RouteTagKey, "web") + requestPath := c.Request.URL.Path + if strings.HasPrefix(requestPath, "/v1") || strings.HasPrefix(requestPath, "/api") || strings.HasPrefix(requestPath, "/assets") { + controller.RelayNotFound(c) + return + } + if requestPath == "/static" || strings.HasPrefix(requestPath, "/static/") { + c.Header("Cache-Control", "no-store") + c.AbortWithStatus(http.StatusNotFound) + return + } + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage) +} diff --git a/router/web_router_test.go b/router/web_router_test.go new file mode 100644 index 000000000000..ac97800b3938 --- /dev/null +++ b/router/web_router_test.go @@ -0,0 +1,37 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestHandleWebFallbackRejectsMissingStaticAssets(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest(http.MethodGet, "/static/js/async/stale.js", nil) + + handleWebFallback(context, []byte("current app")) + + assert.Equal(t, http.StatusNotFound, recorder.Code) + assert.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) + assert.Empty(t, recorder.Body.String()) +} + +func TestHandleWebFallbackServesIndexForClientRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest(http.MethodGet, "/system-settings/site/system-info", nil) + + handleWebFallback(context, []byte("current app")) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, "no-cache", recorder.Header().Get("Cache-Control")) + assert.Equal(t, "text/html; charset=utf-8", recorder.Header().Get("Content-Type")) + assert.Equal(t, "current app", recorder.Body.String()) +} diff --git a/setting/ratio_setting/group_ratio.go b/setting/ratio_setting/group_ratio.go index 7d16d9283932..1230a1652303 100644 --- a/setting/ratio_setting/group_ratio.go +++ b/setting/ratio_setting/group_ratio.go @@ -105,6 +105,17 @@ func UpdateGroupGroupRatioByJSONString(jsonStr string) error { return types.LoadFromJsonString(groupGroupRatioMap, jsonStr) } +func GroupSpecialUsableGroup2JSONString() string { + return GetGroupRatioSetting().GroupSpecialUsableGroup.MarshalJSONString() +} + +func UpdateGroupSpecialUsableGroupByJSONString(jsonStr string) error { + return types.LoadFromJsonString( + GetGroupRatioSetting().GroupSpecialUsableGroup, + jsonStr, + ) +} + func CheckGroupRatio(jsonStr string) error { checkGroupRatio := make(map[string]float64) err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio) diff --git a/web/index.html b/web/index.html index 7f280b401afd..969579b8db0e 100644 --- a/web/index.html +++ b/web/index.html @@ -1,18 +1,27 @@ - + - + + -