fix(gateway): 兼容 Cursor /v1/chat/completions 的 Responses API body

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) <noreply@anthropic.com>
This commit is contained in:
shuanbao0
2026-04-11 20:22:18 +08:00
parent 1ef3782dd4
commit b7edc3ed82
2 changed files with 203 additions and 12 deletions

View File

@@ -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)
}

View File

@@ -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 {