mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:18:18 +08:00
Merge pull request #6200 from xuhaihan/fix/openai-reasoning-replay
fix(openai): normalize cross-provider reasoning replay
This commit is contained in:
@@ -182,6 +182,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
|
||||
}
|
||||
if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
|
||||
normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body)
|
||||
if reasoningErr != nil {
|
||||
return nil, fmt.Errorf("normalize OpenAI Responses reasoning content replay: %w", reasoningErr)
|
||||
}
|
||||
if reasoningChanged {
|
||||
body = normalizedReasoningBody
|
||||
originalBody = normalizedReasoningBody
|
||||
requestView = newOpenAIRequestView(normalizedReasoningBody)
|
||||
reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
||||
originalModel = reqModel
|
||||
}
|
||||
sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
|
||||
if sanitizeErr != nil {
|
||||
return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
|
||||
|
||||
@@ -458,6 +458,67 @@ func openAIRequestBodyHasTools(body []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeOpenAIResponsesReasoningContentReplay removes non-portable
|
||||
// reasoning.content arrays before history is sent to a real OpenAI Responses
|
||||
// endpoint. Compatible providers may return visible reasoning blocks there,
|
||||
// while OpenAI accepts only an empty array when the item is replayed.
|
||||
//
|
||||
// Keep the reasoning item and its portable fields (summary, encrypted_content,
|
||||
// ids, and opaque extensions). Callers scope this normalization to OpenAI
|
||||
// destinations; compatible providers may still consume their own content.
|
||||
func normalizeOpenAIResponsesReasoningContentReplay(body []byte) ([]byte, bool, error) {
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.IsArray() {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
needsNormalization := false
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
|
||||
return true
|
||||
}
|
||||
content := item.Get("content")
|
||||
if content.IsArray() && len(content.Array()) > 0 {
|
||||
needsNormalization = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !needsNormalization {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
|
||||
return body, false, fmt.Errorf("normalize OpenAI reasoning content replay: %w", err)
|
||||
}
|
||||
items, ok := reqBody["input"].([]any)
|
||||
if !ok {
|
||||
return body, false, nil
|
||||
}
|
||||
changed := false
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "reasoning" {
|
||||
continue
|
||||
}
|
||||
content, ok := item["content"].([]any)
|
||||
if !ok || len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
delete(item, "content")
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return body, false, nil
|
||||
}
|
||||
normalized, err := marshalOpenAIUpstreamJSON(reqBody)
|
||||
if err != nil {
|
||||
return body, false, fmt.Errorf("serialize normalized OpenAI reasoning content replay: %w", err)
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
|
||||
if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
|
||||
return body, false, nil
|
||||
@@ -1018,6 +1079,12 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
|
||||
return body, false, err
|
||||
}
|
||||
}
|
||||
if next, normalizedReasoningContent, err := normalizeOpenAIResponsesReasoningContentReplay(normalized); err != nil {
|
||||
return body, false, err
|
||||
} else if normalizedReasoningContent {
|
||||
normalized = next
|
||||
changed = true
|
||||
}
|
||||
if account.IsOpenAIApiKey() {
|
||||
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil {
|
||||
return body, false, err
|
||||
|
||||
@@ -353,3 +353,60 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAddition
|
||||
require.False(t, changed)
|
||||
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesReasoningContentReplayStripsCrossProviderArray(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.6-sol","input":[` +
|
||||
`{"type":"message","role":"user","content":"one"},` +
|
||||
`{"type":"message","role":"assistant","content":"two"},` +
|
||||
`{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},` +
|
||||
`{"type":"function_call_output","call_id":"call_1","output":"ok"},` +
|
||||
`{"type":"message","role":"user","content":"five"},` +
|
||||
`{"type":"reasoning","id":"rs_provider","summary":[{"type":"summary_text","text":"portable"}],"content":[{"type":"reasoning_text","text":"visible reasoning"}],"opaque":9007199254740993},` +
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}` +
|
||||
`]}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesReasoningContentReplay(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "reasoning", gjson.GetBytes(normalized, "input.5.type").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "input.5.content").Exists())
|
||||
require.Equal(t, "portable", gjson.GetBytes(normalized, "input.5.summary.0.text").String())
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.5.opaque").Raw)
|
||||
require.Equal(t, "answer", gjson.GetBytes(normalized, "input.6.content.0.text").String())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesReasoningContentReplayKeepsPortableShapes(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"input":[{"type":"reasoning","summary":[]}]}`,
|
||||
`{"input":[{"type":"reasoning","content":[],"summary":[]}]}`,
|
||||
`{"input":[{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`,
|
||||
} {
|
||||
normalized, changed, err := normalizeOpenAIResponsesReasoningContentReplay([]byte(body))
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.JSONEq(t, body, string(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyStripsReasoningContentOnlyForOpenAI(t *testing.T) {
|
||||
body := []byte(`{"type":"response.create","model":"gpt-5.6-sol","store":true,"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"content":[{"type":"reasoning_text","text":"remove"}]}]}`)
|
||||
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: accountType,
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "input.0.content").Exists())
|
||||
require.Equal(t, "keep", gjson.GetBytes(normalized, "input.0.summary.0.text").String())
|
||||
}
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformZhipu,
|
||||
Type: AccountTypeAPIKey,
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.JSONEq(t, string(body), string(normalized))
|
||||
}
|
||||
|
||||
@@ -538,6 +538,34 @@ func TestOpenAIGatewayService_APIKeyStripsAllIndexedNamespacesBeforeFirstForward
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceProactivelyStripsCrossProviderReasoningContent(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"store":true,"input":[` +
|
||||
`{"type":"message","role":"user","content":"one"},` +
|
||||
`{"type":"message","role":"assistant","content":"two"},` +
|
||||
`{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},` +
|
||||
`{"type":"function_call_output","call_id":"call_1","output":"ok"},` +
|
||||
`{"type":"message","role":"user","content":"five"},` +
|
||||
`{"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"content":[{"type":"reasoning_text","text":"remove"}]}` +
|
||||
`]}`)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
|
||||
}}
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(),
|
||||
newOpenAIRejectedFieldTestContext(body),
|
||||
newOpenAIRejectedFieldTestAccount(),
|
||||
body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1, "reasoning content should be normalized before the first upstream request")
|
||||
require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[0], "input.5.type").String())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.5.content").Exists())
|
||||
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[0], "input.5.summary.0.text").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OpenAIHTTPStripsInputNamespacesBeforeFirstForward(t *testing.T) {
|
||||
accounts := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user