mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
Merge pull request #3026 from Zbl1007/openmeta/2956-deepseek
Surface DeepSeek reasoning-only responses
This commit is contained in:
@@ -525,6 +525,9 @@ func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput {
|
||||
}
|
||||
|
||||
text := chatMessageContentText(message.Content)
|
||||
if text == "" && strings.TrimSpace(message.ReasoningContent) != "" && len(message.ToolCalls) == 0 {
|
||||
text = message.ReasoningContent
|
||||
}
|
||||
if text != "" || len(message.ToolCalls) == 0 {
|
||||
outputs = append(outputs, ResponsesOutput{
|
||||
Type: "message",
|
||||
@@ -787,6 +790,7 @@ func FinalizeChatCompletionsResponsesStream(state *ChatCompletionsToResponsesStr
|
||||
// Close a reasoning item that never transitioned to content (reasoning-only
|
||||
// or empty completion).
|
||||
events = append(events, closeChatReasoningItem(state)...)
|
||||
events = append(events, synthesizeChatReasoningFallbackMessage(state)...)
|
||||
|
||||
if state.MessageItemID != "" {
|
||||
if state.TextPartOpen {
|
||||
@@ -917,6 +921,33 @@ func closeChatReasoningItem(state *ChatCompletionsToResponsesStreamState) []Resp
|
||||
}
|
||||
}
|
||||
|
||||
func synthesizeChatReasoningFallbackMessage(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
|
||||
if state == nil ||
|
||||
state.MessageItemID != "" ||
|
||||
state.Text.Len() > 0 ||
|
||||
state.Reasoning.Len() == 0 ||
|
||||
len(state.ToolCalls) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
text := state.Reasoning.String()
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var events []ResponsesStreamEvent
|
||||
events = append(events, ensureChatToResponsesMessageItem(state)...)
|
||||
events = append(events, ensureChatToResponsesTextPart(state)...)
|
||||
_, _ = state.Text.WriteString(text)
|
||||
events = append(events, chatToResponsesEvent(state, "response.output_text.delta", &ResponsesStreamEvent{
|
||||
OutputIndex: state.MessageIndex,
|
||||
ContentIndex: 0,
|
||||
Delta: text,
|
||||
ItemID: state.MessageItemID,
|
||||
}))
|
||||
return events
|
||||
}
|
||||
|
||||
func ensureChatToResponsesMessageItem(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent {
|
||||
if state.MessageItemID != "" {
|
||||
return nil
|
||||
|
||||
@@ -45,6 +45,106 @@ func TestStream_ReasoningOpensItemBeforeDelta(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStream_ReasoningOnlySynthesizesVisibleText(t *testing.T) {
|
||||
events := collectStreamEvents(t, []string{
|
||||
`{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`,
|
||||
`{"choices":[{"index":0,"delta":{"reasoning_content":"thinking before final"}}]}`,
|
||||
`{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
|
||||
})
|
||||
|
||||
open := map[int]string{}
|
||||
var sawTextDelta, sawTextDone, sawMessageDone bool
|
||||
for _, e := range events {
|
||||
switch e.Type {
|
||||
case "response.output_item.added":
|
||||
require.NotNil(t, e.Item)
|
||||
open[e.OutputIndex] = e.Item.Type
|
||||
case "response.output_text.delta":
|
||||
sawTextDelta = true
|
||||
require.Equalf(t, "message", open[e.OutputIndex], "fallback text delta before its item was opened")
|
||||
require.Equal(t, "thinking before final", e.Delta)
|
||||
case "response.output_text.done":
|
||||
sawTextDone = true
|
||||
require.Equal(t, "thinking before final", e.Text)
|
||||
case "response.output_item.done":
|
||||
if e.Item != nil && e.Item.Type == "message" {
|
||||
sawMessageDone = true
|
||||
require.Equal(t, "thinking before final", e.Item.Content[0].Text)
|
||||
}
|
||||
case "response.completed":
|
||||
require.NotNil(t, e.Response)
|
||||
require.Equal(t, "incomplete", e.Response.Status)
|
||||
require.NotNil(t, e.Response.IncompleteDetails)
|
||||
require.Equal(t, "max_output_tokens", e.Response.IncompleteDetails.Reason)
|
||||
require.Len(t, e.Response.Output, 2)
|
||||
require.Equal(t, "reasoning", e.Response.Output[0].Type)
|
||||
require.Equal(t, "message", e.Response.Output[1].Type)
|
||||
require.Equal(t, "thinking before final", e.Response.Output[1].Content[0].Text)
|
||||
}
|
||||
}
|
||||
require.True(t, sawTextDelta, "reasoning-only stream must produce visible text delta")
|
||||
require.True(t, sawTextDone, "reasoning-only stream must close visible text part")
|
||||
require.True(t, sawMessageDone, "reasoning-only stream must close synthesized message item")
|
||||
}
|
||||
|
||||
func TestStream_ReasoningOnlyBlankDoesNotSynthesizeVisibleText(t *testing.T) {
|
||||
events := collectStreamEvents(t, []string{
|
||||
`{"choices":[{"index":0,"delta":{"reasoning_content":" "}}]}`,
|
||||
`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
||||
})
|
||||
|
||||
for _, e := range events {
|
||||
require.NotEqual(t, "response.output_text.delta", e.Type)
|
||||
if e.Type == "response.completed" {
|
||||
require.NotNil(t, e.Response)
|
||||
require.Len(t, e.Response.Output, 2)
|
||||
require.Equal(t, "reasoning", e.Response.Output[0].Type)
|
||||
require.Equal(t, "message", e.Response.Output[1].Type)
|
||||
require.Equal(t, "", e.Response.Output[1].Content[0].Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStream_ReasoningThenContentDoesNotDuplicateFallbackText(t *testing.T) {
|
||||
events := collectStreamEvents(t, []string{
|
||||
`{"choices":[{"index":0,"delta":{"reasoning_content":"private plan"}}]}`,
|
||||
`{"choices":[{"index":0,"delta":{"content":"final answer"}}]}`,
|
||||
`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
||||
})
|
||||
|
||||
var textDeltas []string
|
||||
for _, e := range events {
|
||||
switch e.Type {
|
||||
case "response.output_text.delta":
|
||||
textDeltas = append(textDeltas, e.Delta)
|
||||
case "response.completed":
|
||||
require.NotNil(t, e.Response)
|
||||
require.Len(t, e.Response.Output, 2)
|
||||
require.Equal(t, "private plan", e.Response.Output[0].Summary[0].Text)
|
||||
require.Equal(t, "final answer", e.Response.Output[1].Content[0].Text)
|
||||
}
|
||||
}
|
||||
require.Equal(t, []string{"final answer"}, textDeltas)
|
||||
}
|
||||
|
||||
func TestStream_ReasoningThenToolCallDoesNotSynthesizeVisibleText(t *testing.T) {
|
||||
events := collectStreamEvents(t, []string{
|
||||
`{"choices":[{"index":0,"delta":{"reasoning_content":"call a tool"}}]}`,
|
||||
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`,
|
||||
`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
})
|
||||
|
||||
for _, e := range events {
|
||||
require.NotEqual(t, "response.output_text.delta", e.Type)
|
||||
if e.Type == "response.completed" {
|
||||
require.NotNil(t, e.Response)
|
||||
require.Len(t, e.Response.Output, 2)
|
||||
require.Equal(t, "reasoning", e.Response.Output[0].Type)
|
||||
require.Equal(t, "function_call", e.Response.Output[1].Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStream_ToolCallLifecycleComplete guards that a tool call is fully closed
|
||||
// (function_call_arguments.done + output_item.done with full arguments), which
|
||||
// codex needs to execute the call.
|
||||
|
||||
@@ -260,6 +260,65 @@ func TestChatCompletionsToResponses_EmptyContentNeverNull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMessageText(t *testing.T) {
|
||||
content := json.RawMessage(`""`)
|
||||
resp := &ChatCompletionsResponse{
|
||||
ID: "chatcmpl_deepseek_reasoning_only",
|
||||
Object: "chat.completion",
|
||||
Model: "deepseek-reasoner",
|
||||
Choices: []ChatChoice{{
|
||||
Index: 0,
|
||||
Message: ChatMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
ReasoningContent: "reasoning-only answer",
|
||||
},
|
||||
FinishReason: "stop",
|
||||
}},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner")
|
||||
|
||||
require.Len(t, out.Output, 2)
|
||||
require.Equal(t, "reasoning", out.Output[0].Type)
|
||||
require.Equal(t, "message", out.Output[1].Type)
|
||||
require.Len(t, out.Output[1].Content, 1)
|
||||
assert.Equal(t, "reasoning-only answer", out.Output[1].Content[0].Text)
|
||||
}
|
||||
|
||||
func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFallbackToMessageText(t *testing.T) {
|
||||
content := json.RawMessage(`""`)
|
||||
resp := &ChatCompletionsResponse{
|
||||
ID: "chatcmpl_deepseek_reasoning_tool",
|
||||
Object: "chat.completion",
|
||||
Model: "deepseek-reasoner",
|
||||
Choices: []ChatChoice{{
|
||||
Index: 0,
|
||||
Message: ChatMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
ReasoningContent: "call a tool",
|
||||
ToolCalls: []ChatToolCall{{
|
||||
ID: "call_a",
|
||||
Type: "function",
|
||||
Function: ChatFunctionCall{
|
||||
Name: "exec",
|
||||
Arguments: `{}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
FinishReason: "tool_calls",
|
||||
}},
|
||||
}
|
||||
|
||||
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner")
|
||||
|
||||
require.Len(t, out.Output, 2)
|
||||
require.Equal(t, "reasoning", out.Output[0].Type)
|
||||
require.Equal(t, "function_call", out.Output[1].Type)
|
||||
assert.Equal(t, "exec", out.Output[1].Name)
|
||||
}
|
||||
|
||||
func TestChatCompletionsToResponses_SystemArrayContent(t *testing.T) {
|
||||
req := &ChatCompletionsRequest{
|
||||
Model: "gpt-4o",
|
||||
|
||||
@@ -102,6 +102,45 @@ func TestForwardResponses_ForceChatCompletionsRoutesStreamingToChatCompletions(t
|
||||
require.NotNil(t, result.FirstTokenMs)
|
||||
}
|
||||
|
||||
func TestForwardResponses_DeepSeekReasoningOnlyStreamProducesVisibleText(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstreamBody := strings.Join([]string{
|
||||
`data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""},"finish_reason":null}]}`,
|
||||
"",
|
||||
`data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"visible fallback"},"finish_reason":null}]}`,
|
||||
"",
|
||||
`data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_deepseek_reasoning_responses_stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: rawChatCompletionsTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
require.Contains(t, rec.Body.String(), "event: response.output_text.delta")
|
||||
require.Contains(t, rec.Body.String(), `"delta":"visible fallback"`)
|
||||
require.Contains(t, rec.Body.String(), `"status":"incomplete"`)
|
||||
require.Contains(t, rec.Body.String(), "data: [DONE]")
|
||||
}
|
||||
|
||||
func TestForwardResponses_AutoSupportedAccountStillUsesResponsesEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user