diff --git a/docs/superpowers/plans/2026-07-04-minimax-openai-response-sanitize.md b/docs/superpowers/plans/2026-07-04-minimax-openai-response-sanitize.md new file mode 100644 index 000000000000..f400179ed722 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-minimax-openai-response-sanitize.md @@ -0,0 +1,87 @@ +# MiniMax OpenAI Response Sanitization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix MiniMax OpenAI-format text responses so provider-specific fields do not leak into strict OpenAI clients. + +**Architecture:** Keep the fix local to the MiniMax adaptor. MiniMax already delegates text response handling to the OpenAI adaptor; the implementation will force the existing OpenAI DTO formatting path before delegation. + +**Tech Stack:** Go 1.22+, Gin test context, `github.com/stretchr/testify/require` and `assert`. + +--- + +## Task 1: Non-Streaming Regression Test + +**Files:** +- Modify: `relay/channel/minimax/adaptor_test.go` + +- [ ] **Step 1: Write the failing test** + +Add a test that sends a MiniMax-style non-streaming response with extra top-level and message fields through `Adaptor.DoResponse`. Assert the recorded response keeps OpenAI fields and omits MiniMax-only fields. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./relay/channel/minimax -run TestDoResponseForOpenAITextStripsMiniMaxFields -count=1` + +Expected before implementation: FAIL because `input_sensitive`, `base_resp`, `name`, or `audio_content` appears in the response body. + +## Task 2: Streaming Regression Test + +**Files:** +- Modify: `relay/channel/minimax/adaptor_test.go` + +- [ ] **Step 1: Write the failing test** + +Add a streaming test that sends SSE chunks containing MiniMax-only `delta.name`, `delta.audio_content`, and a final chunk containing `choices[].message`. Assert the downstream SSE omits those fields and still emits `[DONE]`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./relay/channel/minimax -run TestDoResponseForOpenAIStreamStripsMiniMaxFieldsAndMessage -count=1` + +Expected before implementation: FAIL because raw SSE chunks are forwarded with `name`, `audio_content`, or `message`. + +## Task 3: Minimal Implementation + +**Files:** +- Modify: `relay/channel/minimax/adaptor.go` +- Modify: `relay/channel/openai/relay-openai.go` + +- [ ] **Step 1: Force formatting for MiniMax OpenAI text responses** + +In `Adaptor.DoResponse`, before delegating to `openai.Adaptor`, set `info.ChannelSetting.ForceFormat = true` when: + +- `info.RelayFormat` is `types.RelayFormatOpenAI` +- `info.RelayMode` is `constant.RelayModeChatCompletions` + +- [ ] **Step 2: Run targeted tests** + +In `OpenaiHandler`, when `forceFormat` is true, clear `simpleResponse.Choices[i].Message.Name` before marshalling the response. + +- [ ] **Step 3: Run targeted tests** + +Run: `go test ./relay/channel/minimax -count=1` + +Expected after implementation: PASS. + +## Task 4: Related Verification + +**Files:** +- No production edits expected. + +- [ ] **Step 1: Run delegated OpenAI handler tests** + +Run: `go test ./relay/channel/openai -count=1` + +Expected: PASS. + +- [ ] **Step 2: Run touched relay package tests** + +Run: `go test ./relay/channel/minimax ./relay/channel/openai -count=1` + +Expected: PASS. + +- [ ] **Step 3: Inspect diff** + +Run: `git diff -- relay/channel/minimax/adaptor.go relay/channel/minimax/adaptor_test.go docs/superpowers/specs/2026-07-04-minimax-openai-response-sanitize-design.md docs/superpowers/plans/2026-07-04-minimax-openai-response-sanitize.md` + +Expected: Only the planned MiniMax adaptor, tests, and docs changed. diff --git a/docs/superpowers/reports/minimax-openai-response-sanitize-review.html b/docs/superpowers/reports/minimax-openai-response-sanitize-review.html new file mode 100644 index 000000000000..05737337c8fb --- /dev/null +++ b/docs/superpowers/reports/minimax-openai-response-sanitize-review.html @@ -0,0 +1,477 @@ + + + + + + MiniMax OpenAI 响应清洗改动审查报告 + + + +
+
+

MiniMax OpenAI 响应清洗改动审查报告

+

用于判断本次改动是否只修复 #5833 / #5834 描述的问题,是否存在不应该修改的范围。

+
+
+ +
+
+

结论概览

+
+
+ 2 + 目标 issue:#5833、#5834 +
+
+ 2 + 生产代码文件有改动 +
+
+ 1 + 测试文件新增回归测试 +
+
+ 0 + 数据库/前端/API 路由改动 +
+
+

+ 本次改动的核心是:MiniMax 文本聊天走 OpenAI Chat Completions 输出时,强制使用项目已有的 OpenAI DTO 格式化路径,过滤 MiniMax 上游的私有字段。 +

+
+ +
+

对应 Issue

+ + + + + + + + + + + + + + + + + + + + +
Issue问题本次覆盖方式
#5833MiniMax 流式响应最后一个 chunk 多出 message 块,严格客户端可能重复追加完整内容。流式 E2E 和单测确认输出只保留 deltafinish_reason,不再输出 message
#5834MiniMax 响应泄漏非 OpenAI 字段,例如 input_sensitiveservice_tierbase_respnameaudio_content非流式和流式测试都扫描确认这些字段不再出现在客户端响应中。
+
+ +
+

改动文件

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
文件类型改动内容为什么需要
relay/channel/minimax/adaptor.go生产代码MiniMax + OpenAI Chat Completions 响应时设置 info.ChannelSetting.ForceFormat = trueMiniMax 上游可能返回厂商私有字段;强制走已有 OpenAI DTO marshal 路径,相当于白名单输出。
relay/channel/openai/relay-openai.go生产代码forceFormat 非流式响应 marshal 前清掉 choices[].message.name项目复用了请求侧 dto.Message,请求里 name 合法,但 MiniMax 响应里的 name 是供应商元数据,不应出现在 OpenAI 响应。
relay/channel/minimax/adaptor_test.go测试代码新增非流式和流式回归测试,构造污染响应并断言输出已清洗。锁定 #5833 / #5834 的真实问题形态,防止以后改回原样透传。
+
+ +
+

前后对比

+
+
+

修复前可能返回

+
{
+  "message": {
+    "role": "assistant",
+    "content": "hello",
+    "name": "MiniMax AI",
+    "audio_content": ""
+  },
+  "input_sensitive": false,
+  "service_tier": "standard",
+  "base_resp": {"status_code": 0}
+}
+
+
+

修复后实际返回

+
{
+  "message": {
+    "role": "assistant",
+    "content": "hello"
+  },
+  "usage": {
+    "prompt_tokens": 1,
+    "completion_tokens": 2,
+    "total_tokens": 3
+  }
+}
+
+
+ +
+
+

修复前流式尾块

+
data: {
+  "choices": [{
+    "delta": {},
+    "message": {
+      "role": "assistant",
+      "content": "hello",
+      "name": "MiniMax AI"
+    },
+    "finish_reason": "stop"
+  }]
+}
+
+
+

修复后流式尾块

+
data: {
+  "choices": [{
+    "delta": {},
+    "finish_reason": "stop"
+  }]
+}
+
+data: [DONE]
+
+
+
+ +
+

为什么这样改

+ +
+ +
+

没有修改的范围

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
范围是否修改说明
数据库 schema / migration没有不涉及表结构、字段迁移或兼容性变更。
前端页面没有没有改 web/default 或 web/classic。
路由和鉴权没有没有改 router、middleware、token 验证逻辑。
其他 provider仅间接受影响 forceFormatrelay-openai.go 中清理 message.name 只在 forceFormat 为 true 时生效。默认普通 OpenAI 透传路径不受影响。
MiniMax 图片/TTS/Claude 格式没有改动条件限定为 RelayModeChatCompletionsRelayFormatOpenAI
+
+ +
+

你需要重点判断的点

+
+ 唯一值得审查的边界是:是否允许在 forceFormat 非流式响应中去掉 message.name。我的判断是应该去掉,因为这里处理的是响应输出,MiniMax 的 name 是厂商元数据;但如果项目明确希望保留 OpenAI 响应里的 assistant name,这部分需要调整为仅 MiniMax 生效。 +
+ +
+ +
+

测试证据

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
测试结果验证内容
go test ./relay/channel/minimax ./relay/channel/openai -count=1通过目标 MiniMax 和委托 OpenAI handler 包通过。
go test ./relay/channel/... -count=1通过相邻渠道包没有被破坏。
go test ./...通过后端全量 Go 测试通过。
HTTP E2E:非流式HTTP 200,通过真实 new-api + 临时 SQLite + mock MiniMax upstream;响应不含污染字段。
HTTP E2E:流式 SSEHTTP 200,通过真实 SSE 输出含 deltafinish_reason[DONE],不含 message/name/audio_content
+
+ +
+

本次改动的最小代码片段

+

MiniMax adaptor:只对 OpenAI 文本聊天强制格式化

+
if info.RelayFormat == types.RelayFormatOpenAI &&
+   info.RelayMode == constant.RelayModeChatCompletions {
+    info.ChannelSetting.ForceFormat = true
+}
+ +

OpenAI handler:forceFormat 响应清理 message.name

+
if forceFormat {
+    for i := range simpleResponse.Choices {
+        simpleResponse.Choices[i].Message.Name = nil
+    }
+    responseBody, err = common.Marshal(simpleResponse)
+}
+
+
+ + + + diff --git a/docs/superpowers/specs/2026-07-04-minimax-openai-response-sanitize-design.md b/docs/superpowers/specs/2026-07-04-minimax-openai-response-sanitize-design.md new file mode 100644 index 000000000000..1b93e31a824a --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-minimax-openai-response-sanitize-design.md @@ -0,0 +1,76 @@ +# MiniMax OpenAI Response Sanitization Design + +## Context + +On 2026-07-04, `main` was synchronized from upstream `QuantumNous/new-api` to fork `zhukangfeng/new-api` at commit `722d0366b727b82fced878af902e48363626b2fb`. + +I reviewed open issues and excluded items that already had an open PR, assignee, insufficient reproduction, or a larger feature scope. The selected issues are: + +- [#5833](https://github.com/QuantumNous/new-api/issues/5833): MiniMax streaming responses can emit a final chunk containing a non-streaming `message` object. +- [#5834](https://github.com/QuantumNous/new-api/issues/5834): MiniMax OpenAI-compatible responses leak non-OpenAI fields such as `name`, `audio_content`, `input_sensitive`, `service_tier`, and `base_resp`. + +These issues are urgent because strict OpenAI-compatible clients can reject the response schema or duplicate assistant output. They are unassigned and have no obvious open PR in the current open PR list. + +## Root Cause + +MiniMax text chat uses the MiniMax adaptor for request URL/header selection, but response handling delegates to the generic OpenAI adaptor. + +The generic OpenAI handler has two behaviors: + +- Without `force_format`, it forwards upstream JSON/SSE chunks nearly as received. +- With `force_format`, it unmarshals into project OpenAI DTOs and marshals back, which keeps only supported OpenAI response fields. + +MiniMax upstream chat responses can include provider-specific fields. Because the MiniMax adaptor does not force formatting for text responses, those provider-specific fields pass through to OpenAI clients. + +## Design + +For MiniMax text responses in OpenAI relay format, force OpenAI DTO formatting before delegating to the OpenAI response handler. + +This keeps the change local to MiniMax and avoids changing behavior for generic OpenAI-compatible channels that intentionally rely on raw pass-through behavior. + +The response path will be: + +1. MiniMax text request reaches `relay/channel/minimax.Adaptor.DoResponse`. +2. If `RelayFormat` is OpenAI and the relay mode is text chat, set `info.ChannelSetting.ForceFormat = true`. +3. Delegate to `openai.Adaptor.DoResponse` as today. +4. Existing OpenAI handler strips unsupported fields by unmarshalling into `dto.OpenAITextResponse` or `dto.ChatCompletionsStreamResponse`. +5. In forced-format non-streaming responses, clear `choices[].message.name` before marshalling because the shared request/response `dto.Message` type accepts request-side `name`, but the MiniMax value is provider metadata rather than OpenAI response content. + +## Behavioral Contract + +Non-streaming MiniMax text responses returned through `/v1/chat/completions` must: + +- Preserve standard OpenAI fields: `id`, `object`, `created`, `model`, `choices`, `usage`. +- Preserve `choices[].message.role`, `choices[].message.content`, and supported reasoning/tool fields. +- Omit MiniMax-only top-level fields: `input_sensitive`, `output_sensitive`, `input_sensitive_type`, `output_sensitive_type`, `output_sensitive_int`, `service_tier`, `base_resp`. +- Omit MiniMax-only message fields: `name`, `audio_content`. + +Streaming MiniMax text responses returned through `/v1/chat/completions` must: + +- Preserve standard OpenAI SSE chunks. +- Preserve `delta.role`, `delta.content`, `delta.reasoning_content`, `delta.reasoning`, and `delta.tool_calls`. +- Omit MiniMax-only delta fields such as `name` and `audio_content`. +- Omit any stream-only `choices[].message` object from chunks; stream chunks should use `delta`. + +## Files + +- Modify `relay/channel/minimax/adaptor.go` to force response formatting for OpenAI-format text responses. +- Modify `relay/channel/openai/relay-openai.go` to omit response message `name` in the forced-format path. +- Add tests in `relay/channel/minimax/adaptor_test.go` covering non-streaming and streaming response sanitization. + +## Testing + +Use test-driven development: + +1. Add a failing non-streaming test with MiniMax-only top-level and message fields. +2. Add a failing streaming test with MiniMax-only delta fields and a final `message` field. +3. Implement the minimal MiniMax adaptor change. +4. Run targeted Go tests for `relay/channel/minimax`. +5. Run related OpenAI channel tests to ensure the delegated handler behavior still passes. +6. Run a broader Go test command for touched packages. + +## Risks + +The change intentionally affects only MiniMax text chat responses in OpenAI relay format. MiniMax image, TTS, and Claude-format response paths are left unchanged. + +If an operator depended on MiniMax-specific fields leaking through the OpenAI endpoint, those fields will no longer be present. This is acceptable because the endpoint contract is OpenAI-compatible output. diff --git a/relay/channel/minimax/adaptor_test.go b/relay/channel/minimax/adaptor_test.go index 46d57c11ff72..4f98adf9c52f 100644 --- a/relay/channel/minimax/adaptor_test.go +++ b/relay/channel/minimax/adaptor_test.go @@ -13,6 +13,8 @@ import ( relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetRequestURLForImageGeneration(t *testing.T) { @@ -36,6 +38,23 @@ func TestGetRequestURLForImageGeneration(t *testing.T) { } } +func TestGetRequestURLForChatCompletionsUsesOpenAICompatibleEndpoint(t *testing.T) { + t.Parallel() + + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeChatCompletions, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelBaseUrl: "https://api.minimax.chat", + }, + } + + got, err := GetRequestURL(info) + require.NoError(t, err, "GetRequestURL returned error") + + want := "https://api.minimax.chat/v1/chat/completions" + assert.Equal(t, want, got) +} + func TestConvertImageRequest(t *testing.T) { t.Parallel() diff --git a/relay/channel/minimax/relay-minimax.go b/relay/channel/minimax/relay-minimax.go index a1a05150c00f..3441f5d74691 100644 --- a/relay/channel/minimax/relay-minimax.go +++ b/relay/channel/minimax/relay-minimax.go @@ -20,7 +20,7 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) { default: switch info.RelayMode { case constant.RelayModeChatCompletions: - return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil + return fmt.Sprintf("%s/v1/chat/completions", baseUrl), nil case constant.RelayModeImagesGenerations: return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil case constant.RelayModeAudioSpeech: