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:
co-authored by
Claude Opus 4.6
parent
1ef3782dd4
commit
b7edc3ed82
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user