From b7edc3ed82006c28daa9c33b95cd6562c3f7f169 Mon Sep 17 00:00:00 2001 From: shuanbao0 Date: Sat, 11 Apr 2026 20:22:18 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(gateway):=20=E5=85=BC=E5=AE=B9=20Cursor?= =?UTF-8?q?=20/v1/chat/completions=20=E7=9A=84=20Responses=20API=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor 云端 (User-Agent: Go-http-client/2.0) 发往 /v1/chat/completions 的 body 使用 Responses API 格式: {"model":"gpt-5.4","input":[{"role":"system","content":"..."}],"stream":true} 原代码用 ChatCompletionsRequest 反序列化,该结构体没有 Input 字段, Cursor 的 input 数组被静默丢弃,ChatCompletionsToResponses 转换后产出 input: null,Codex 上游以 "Invalid type for 'input': expected a string, but got an object" 拒绝请求(上游 typeof null === 'object')。 修复:在 ForwardAsChatCompletions 里用 gjson 检测 body shape,当 input 存在且 messages 缺失时,跳过 Chat→Responses 转换,用 sjson 仅改写 model 字段后原样透传 body。billing 所需的 ServiceTier 和 Reasoning.Effort 通过 gjson 从 raw body 提取,下游 codex OAuth transform 路径保持不变。 测试:新增 openai_cursor_warmup_pipeline_test.go,覆盖 5 个 shape 检测 用例(正向/标准请求不误伤/两字段共存/空 body/JSON 回读)。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../openai_cursor_warmup_pipeline_test.go | 155 ++++++++++++++++++ .../openai_gateway_chat_completions.go | 60 +++++-- 2 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/openai_cursor_warmup_pipeline_test.go diff --git a/backend/internal/service/openai_cursor_warmup_pipeline_test.go b/backend/internal/service/openai_cursor_warmup_pipeline_test.go new file mode 100644 index 00000000..8ade9dbb --- /dev/null +++ b/backend/internal/service/openai_cursor_warmup_pipeline_test.go @@ -0,0 +1,155 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// TestCursorMixedShapeDetection covers the core invariant of the Cursor +// compatibility fix in ForwardAsChatCompletions: when a client POSTs a +// Responses-shaped body (has `input`, no `messages`) to /v1/chat/completions, +// the request must be forwarded as-is with only the `model` field rewritten. +// The raw `input` array (including Cursor's 80KB system prompt) must not be +// discarded or reshaped. +// +// Context: +// +// Before the fix, the handler unmarshaled the body into ChatCompletionsRequest, +// which has no Input field, silently dropping Cursor's input. The subsequent +// conversion produced `input: null`, which Codex upstreams reject with +// "Invalid type for 'input': expected a string, but got an object". +func TestCursorMixedShapeDetection(t *testing.T) { + // Representative Cursor cloud body — shape is what matters, content is + // abridged. Notice: `input` is a Responses-API array, there is no + // `messages` field at all, and `user`/`stream` are at the top level. + cursorBody := []byte(`{ + "user": "85df22e7463ab6c2", + "model": "gpt-5.4", + "stream": true, + "input": [ + {"role":"system","content":"You are GPT-5.4 running as a coding agent."}, + {"role":"user","content":"hello"} + ], + "service_tier": "auto", + "reasoning": {"effort": "high"} + }`) + + // --- Step 1: Shape detection (mirrors ForwardAsChatCompletions) --- + hasMessages := gjson.GetBytes(cursorBody, "messages").Exists() + hasInput := gjson.GetBytes(cursorBody, "input").Exists() + isResponsesShape := !hasMessages && hasInput + + require.True(t, isResponsesShape, + "Cursor body must be detected as Responses-shape (has input, no messages)") + + // --- Step 2: Model rewrite (mirrors the sjson.SetBytes branch) --- + const upstreamModel = "gpt-5.1-codex" + rewritten, err := sjson.SetBytes(cursorBody, "model", upstreamModel) + require.NoError(t, err) + + // --- Step 3: Invariants of the rewritten body --- + + // 3a. model must be rewritten to the upstream target. + assert.Equal(t, upstreamModel, gjson.GetBytes(rewritten, "model").String()) + + // 3b. input array must be preserved verbatim — no reshaping, no nulling. + inputResult := gjson.GetBytes(rewritten, "input") + require.True(t, inputResult.Exists(), "input field must still exist after rewrite") + require.True(t, inputResult.IsArray(), "input must still be an array (not null, not object)") + + items := inputResult.Array() + require.Len(t, items, 2, "both input items must be preserved") + assert.Equal(t, "system", items[0].Get("role").String()) + assert.Equal(t, "You are GPT-5.4 running as a coding agent.", + items[0].Get("content").String()) + assert.Equal(t, "user", items[1].Get("role").String()) + assert.Equal(t, "hello", items[1].Get("content").String()) + + // 3c. ALL other top-level fields must survive intact. + assert.Equal(t, "85df22e7463ab6c2", gjson.GetBytes(rewritten, "user").String()) + assert.Equal(t, true, gjson.GetBytes(rewritten, "stream").Bool()) + assert.Equal(t, "auto", gjson.GetBytes(rewritten, "service_tier").String()) + assert.Equal(t, "high", gjson.GetBytes(rewritten, "reasoning.effort").String()) + + // 3d. Final upstream body must NOT contain the old "input":null pattern. + assert.NotContains(t, string(rewritten), `"input":null`, + "rewritten body must not collapse input to null") +} + +// TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected guards that +// the shape detection does NOT misfire on a standard Chat Completions request +// (one that has a `messages` array). Such requests must fall through to the +// existing ChatCompletionsToResponses conversion path. +func TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected(t *testing.T) { + body := []byte(`{ + "model": "gpt-4o", + "messages": [{"role":"user","content":"hi"}], + "stream": true + }`) + + hasMessages := gjson.GetBytes(body, "messages").Exists() + hasInput := gjson.GetBytes(body, "input").Exists() + isResponsesShape := !hasMessages && hasInput + + assert.False(t, isResponsesShape, + "standard Chat Completions body must NOT be detected as Responses-shape") +} + +// TestCursorMixedShapeDetection_BothFieldsPrefersMessages guards the +// ambiguous case where a client sends both `messages` and `input`. We fall +// through to the normal conversion path (messages wins), since mixing the +// two is almost certainly a client bug and messages is the documented +// Chat Completions contract. +func TestCursorMixedShapeDetection_BothFieldsPrefersMessages(t *testing.T) { + body := []byte(`{ + "model": "gpt-4o", + "messages": [{"role":"user","content":"hi"}], + "input": [{"role":"user","content":"other"}] + }`) + + hasMessages := gjson.GetBytes(body, "messages").Exists() + hasInput := gjson.GetBytes(body, "input").Exists() + isResponsesShape := !hasMessages && hasInput + + assert.False(t, isResponsesShape, + "when both messages and input are present, must not take the Cursor shortcut") +} + +// TestCursorMixedShapeDetection_EmptyBody ensures a body with neither +// messages nor input is NOT taken as Cursor-shape (would hit the normal +// conversion and fail on its own with a clearer error). +func TestCursorMixedShapeDetection_EmptyBody(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","stream":true}`) + + hasMessages := gjson.GetBytes(body, "messages").Exists() + hasInput := gjson.GetBytes(body, "input").Exists() + isResponsesShape := !hasMessages && hasInput + + assert.False(t, isResponsesShape, + "body with neither messages nor input must not be taken as Cursor shape") +} + +// TestCursorMixedShape_JSONRoundtrip ensures the rewritten body is still +// valid JSON and parseable back into a map without surprises — catches +// any encoding drift from sjson. +func TestCursorMixedShape_JSONRoundtrip(t *testing.T) { + cursorBody := []byte(`{"model":"gpt-5.4","stream":true,"input":[{"role":"user","content":"hi"}]}`) + + rewritten, err := sjson.SetBytes(cursorBody, "model", "gpt-5.1-codex") + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(rewritten, &parsed)) + + assert.Equal(t, "gpt-5.1-codex", parsed["model"]) + assert.Equal(t, true, parsed["stream"]) + + inputArr, ok := parsed["input"].([]any) + require.True(t, ok, "input must decode to a Go []any after round-trip") + require.Len(t, inputArr, 1) +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 9b3f69bc..c827fd7b 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -16,6 +16,8 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" "go.uber.org/zap" ) @@ -55,13 +57,52 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( compatPromptCacheInjected = promptCacheKey != "" } - // 3. Convert to Responses and forward - // ChatCompletionsToResponses always sets Stream=true (upstream always streams). - responsesReq, err := apicompat.ChatCompletionsToResponses(&chatReq) - if err != nil { - return nil, fmt.Errorf("convert chat completions to responses: %w", err) + // 3. Build the upstream (Responses API) body. + // + // Cursor compatibility: some clients (notably Cursor cloud) send Responses + // API shaped bodies — `input: [...]` with no `messages` field — to the + // /v1/chat/completions URL. Running those through ChatCompletionsToResponses + // would silently drop Cursor's `input` array (the struct has no Input field) + // and produce `input: null`, which Codex upstreams reject with + // "Invalid type for 'input': expected a string, but got an object". + // + // Detect that shape and forward the raw body as-is, only rewriting `model` + // to the resolved upstream model. The downstream codex OAuth transform will + // still normalize store/stream/instructions/etc. + isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() + + var ( + responsesReq *apicompat.ResponsesRequest + responsesBody []byte + err error + ) + if isResponsesShape { + responsesBody, err = sjson.SetBytes(body, "model", upstreamModel) + if err != nil { + return nil, fmt.Errorf("rewrite model in responses-shape body: %w", err) + } + // Minimal stub populated from the raw body so downstream billing + // propagation (ServiceTier, ReasoningEffort) keeps working. + responsesReq = &apicompat.ResponsesRequest{ + Model: upstreamModel, + ServiceTier: gjson.GetBytes(responsesBody, "service_tier").String(), + } + if effort := gjson.GetBytes(responsesBody, "reasoning.effort").String(); effort != "" { + responsesReq.Reasoning = &apicompat.ResponsesReasoning{Effort: effort} + } + } else { + // Normal path: convert Chat Completions → Responses. + // ChatCompletionsToResponses always sets Stream=true (upstream always streams). + responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq) + if err != nil { + return nil, fmt.Errorf("convert chat completions to responses: %w", err) + } + responsesReq.Model = upstreamModel + responsesBody, err = json.Marshal(responsesReq) + if err != nil { + return nil, fmt.Errorf("marshal responses request: %w", err) + } } - responsesReq.Model = upstreamModel logFields := []zap.Field{ zap.Int64("account_id", account.ID), @@ -69,6 +110,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( zap.String("billing_model", billingModel), zap.String("upstream_model", upstreamModel), zap.Bool("stream", clientStream), + zap.Bool("responses_shape", isResponsesShape), } if compatPromptCacheInjected { logFields = append(logFields, @@ -78,12 +120,6 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } logger.L().Debug("openai chat_completions: model mapping applied", logFields...) - // 4. Marshal Responses request body, then apply OAuth codex transform - responsesBody, err := json.Marshal(responsesReq) - if err != nil { - return nil, fmt.Errorf("marshal responses request: %w", err) - } - if account.Type == AccountTypeOAuth { var reqBody map[string]any if err := json.Unmarshal(responsesBody, &reqBody); err != nil { From 422e25c99f2a3a6d16198a2fbeb2eb64cbc912d1 Mon Sep 17 00:00:00 2001 From: shuanbao0 Date: Sat, 11 Apr 2026 22:48:45 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(gateway):=20=E5=89=A5=E7=A6=BB=20Cursor?= =?UTF-8?q?=20raw=20body=20=E9=80=8F=E4=BC=A0=E8=B7=AF=E5=BE=84=E4=B8=AD?= =?UTF-8?q?=20Codex=20=E4=B8=8D=E6=94=AF=E6=8C=81=E7=9A=84=20Responses=20A?= =?UTF-8?q?PI=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在前一个 commit 的 isResponsesShape 短路路径基础上,补充对 Cursor 云端 带过来的、Codex 上游统一不支持的顶层 Responses API 参数的剥离: - prompt_cache_retention - safety_identifier - metadata - stream_options 根因补充:这条 raw-body 透传路径为了保留 Cursor 的 input 数组整体结构, 不再经过 ChatCompletionsRequest 的反序列化过滤,所以这些 Go 结构体里 没有对应字段的参数会被原样发到上游,上游返回: Unsupported parameter: 常规 Chat Completions 转换路径天然通过 ChatCompletionsRequest 丢弃未知字段, 不受影响;此处仅在 isResponsesShape 分支内用 sjson.DeleteBytes 显式过滤, 作用域最小。剥离列表与 openai_gateway_service.go:2034 的 unsupportedFields 语义对齐。 另外在 applyCodexOAuthTransform 的 OAuth 兜底 strip 列表里同步追加 prompt_cache_retention,作为对该函数所有其他 OAuth 调用点的 defense in depth(当前只有 Cursor 路径的短路已在前面剥过,但保留这一层更稳)。 测试: - TestCursorMixedShape_StripsUnsupportedFields — 验证所有 4 个字段都被剥 - TestApplyCodexOAuthTransform_StripsPromptCacheRetention — OAuth 兜底路径 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../service/openai_codex_transform.go | 8 ++++ .../service/openai_codex_transform_test.go | 20 +++++++++ .../openai_cursor_warmup_pipeline_test.go | 44 +++++++++++++++++++ .../openai_gateway_chat_completions.go | 26 +++++++++++ 4 files changed, 98 insertions(+) diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 4ec038e0..a266d6a0 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -124,6 +124,14 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact "top_p", "frequency_penalty", "presence_penalty", + // prompt_cache_retention is a newer Responses API parameter (cache TTL). + // The ChatGPT internal Codex endpoint rejects it with + // "Unsupported parameter: prompt_cache_retention". Defense-in-depth + // for any OAuth path that reaches this transform — the Cursor + // Responses-shape short-circuit in ForwardAsChatCompletions strips + // it earlier too, but we keep this line so other OAuth callers are + // equally protected. + "prompt_cache_retention", } { if _, ok := reqBody[key]; ok { delete(reqBody, key) diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 889ac615..993ade07 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -481,6 +481,26 @@ func TestExtractSystemMessagesFromInput(t *testing.T) { }) } +// TestApplyCodexOAuthTransform_StripsPromptCacheRetention is a regression +// test: some clients (e.g. Cursor cloud via the Responses-shape compat path) +// send prompt_cache_retention, but the ChatGPT internal Codex endpoint +// rejects it with "Unsupported parameter: prompt_cache_retention". +func TestApplyCodexOAuthTransform_StripsPromptCacheRetention(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.1", + "prompt_cache_retention": "24h", + "input": []any{ + map[string]any{"role": "user", "content": "hi"}, + }, + } + + applyCodexOAuthTransform(reqBody, false, false) + + _, stillThere := reqBody["prompt_cache_retention"] + require.False(t, stillThere, + "prompt_cache_retention must be stripped before forwarding to Codex upstream") +} + func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.1", diff --git a/backend/internal/service/openai_cursor_warmup_pipeline_test.go b/backend/internal/service/openai_cursor_warmup_pipeline_test.go index 8ade9dbb..19bb13d6 100644 --- a/backend/internal/service/openai_cursor_warmup_pipeline_test.go +++ b/backend/internal/service/openai_cursor_warmup_pipeline_test.go @@ -153,3 +153,47 @@ func TestCursorMixedShape_JSONRoundtrip(t *testing.T) { require.True(t, ok, "input must decode to a Go []any after round-trip") require.Len(t, inputArr, 1) } + +// TestCursorMixedShape_StripsUnsupportedFields mirrors the strip loop in +// ForwardAsChatCompletions (isResponsesShape branch). Cursor cloud sends +// prompt_cache_retention, safety_identifier, metadata and stream_options +// as top-level Responses API parameters, which Codex upstreams reject with +// "Unsupported parameter: ...". The fix must remove them from the raw body +// before it is forwarded, for BOTH OAuth and API Key account types. +func TestCursorMixedShape_StripsUnsupportedFields(t *testing.T) { + cursorBody := []byte(`{ + "model": "gpt-5.4", + "stream": true, + "prompt_cache_retention": "24h", + "safety_identifier": "cursor-user-xyz", + "metadata": {"trace_id":"abc","caller":"cursor"}, + "stream_options": {"include_usage": true}, + "input": [{"role":"user","content":"hi"}] + }`) + + // Sanity: the test fixture contains every field the production code strips. + for _, field := range cursorResponsesUnsupportedFields { + require.True(t, gjson.GetBytes(cursorBody, field).Exists(), + "test fixture must contain %s", field) + } + + // Run the exact same loop as the production code. + result := cursorBody + for _, field := range cursorResponsesUnsupportedFields { + if stripped, err := sjson.DeleteBytes(result, field); err == nil { + result = stripped + } + } + + // All unsupported fields must be gone. + for _, field := range cursorResponsesUnsupportedFields { + assert.False(t, gjson.GetBytes(result, field).Exists(), + "%s must be stripped", field) + } + + // Everything else must survive intact. + assert.Equal(t, "gpt-5.4", gjson.GetBytes(result, "model").String()) + assert.Equal(t, true, gjson.GetBytes(result, "stream").Bool()) + assert.True(t, gjson.GetBytes(result, "input").IsArray()) + assert.Equal(t, "user", gjson.GetBytes(result, "input.0.role").String()) +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index c827fd7b..ac7d28a7 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -21,6 +21,22 @@ import ( "go.uber.org/zap" ) +// cursorResponsesUnsupportedFields are top-level Responses API parameters that +// Codex upstreams reject with "Unsupported parameter: ...". They must be +// stripped when forwarding a raw client body through the Responses-shape +// short-circuit in ForwardAsChatCompletions (see isResponsesShape branch). +// The normal Chat Completions → Responses conversion path is unaffected +// because ChatCompletionsRequest has no fields for these parameters — unknown +// fields are dropped naturally by json.Unmarshal. Kept semantically in sync +// with the list in openai_gateway_service.go:2034 used by the /v1/responses +// passthrough path. +var cursorResponsesUnsupportedFields = []string{ + "prompt_cache_retention", + "safety_identifier", + "metadata", + "stream_options", +} + // ForwardAsChatCompletions accepts a Chat Completions request body, converts it // to OpenAI Responses API format, forwards to the OpenAI upstream, and converts // the response back to Chat Completions format. All account types (OAuth and API @@ -81,6 +97,16 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if err != nil { return nil, fmt.Errorf("rewrite model in responses-shape body: %w", err) } + // Strip Responses API parameters that no Codex upstream accepts. + // Because this branch forwards the raw body (the normal path rebuilds + // it from ChatCompletionsRequest and drops unknown fields naturally), + // we must filter these fields explicitly here — otherwise the upstream + // rejects the request with "Unsupported parameter: ...". + for _, field := range cursorResponsesUnsupportedFields { + if stripped, derr := sjson.DeleteBytes(responsesBody, field); derr == nil { + responsesBody = stripped + } + } // Minimal stub populated from the raw body so downstream billing // propagation (ServiceTier, ReasoningEffort) keeps working. responsesReq = &apicompat.ResponsesRequest{