Skip to content

Commit b636514

Browse files
fix: preserve responses reasoning metadata
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b36e7a3 commit b636514

4 files changed

Lines changed: 117 additions & 7 deletions

File tree

internal/inference/openai.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,13 @@ func sanitizeOpenAIRequestBody(modelName string, reqBody map[string]interface{})
255255
}
256256
out["top_p"] = 0.95
257257
}
258+
}
259+
if openAIModelRequiresToolCallReasoningContent(modelName) {
258260
messages := reqBody["messages"]
259261
if out != nil {
260262
messages = out["messages"]
261263
}
262-
if normalized, changed := normalizeKimiToolCallMessages(messages); changed {
264+
if normalized, changed := normalizeToolCallReasoningContentMessages(messages); changed {
263265
if out == nil {
264266
out = cloneOpenAIRequestBody(reqBody)
265267
}
@@ -290,13 +292,20 @@ func openAIModelRequiresTemperatureOne(modelName string) bool {
290292
return strings.HasPrefix(modelName, "kimi-") || strings.HasPrefix(modelName, "moonshot-")
291293
}
292294

293-
func normalizeKimiToolCallMessages(messages interface{}) (interface{}, bool) {
295+
func openAIModelRequiresToolCallReasoningContent(modelName string) bool {
296+
modelName = strings.TrimSpace(strings.ToLower(modelName))
297+
return strings.HasPrefix(modelName, "kimi-") ||
298+
strings.HasPrefix(modelName, "moonshot-") ||
299+
strings.Contains(modelName, "deepseek-v4")
300+
}
301+
302+
func normalizeToolCallReasoningContentMessages(messages interface{}) (interface{}, bool) {
294303
switch v := messages.(type) {
295304
case []map[string]interface{}:
296305
out := make([]map[string]interface{}, len(v))
297306
changed := false
298307
for i, msg := range v {
299-
next, msgChanged := normalizeKimiToolCallMessageMap(msg)
308+
next, msgChanged := normalizeToolCallReasoningContentMessageMap(msg)
300309
out[i] = next
301310
changed = changed || msgChanged
302311
}
@@ -312,7 +321,7 @@ func normalizeKimiToolCallMessages(messages interface{}) (interface{}, bool) {
312321
out[i] = item
313322
continue
314323
}
315-
next, msgChanged := normalizeKimiToolCallMessageMap(msg)
324+
next, msgChanged := normalizeToolCallReasoningContentMessageMap(msg)
316325
out[i] = next
317326
changed = changed || msgChanged
318327
}
@@ -339,7 +348,7 @@ func normalizeKimiToolCallMessages(messages interface{}) (interface{}, bool) {
339348
if msg.ReasoningContent != "" {
340349
next["reasoning_content"] = msg.ReasoningContent
341350
}
342-
normalized, msgChanged := normalizeKimiToolCallMessageMap(next)
351+
normalized, msgChanged := normalizeToolCallReasoningContentMessageMap(next)
343352
out[i] = normalized
344353
changed = changed || msgChanged
345354
}
@@ -350,7 +359,7 @@ func normalizeKimiToolCallMessages(messages interface{}) (interface{}, bool) {
350359
return messages, false
351360
}
352361

353-
func normalizeKimiToolCallMessageMap(msg map[string]interface{}) (map[string]interface{}, bool) {
362+
func normalizeToolCallReasoningContentMessageMap(msg map[string]interface{}) (map[string]interface{}, bool) {
354363
if strings.TrimSpace(fmt.Sprint(msg["role"])) != "assistant" {
355364
return msg, false
356365
}

internal/inference/openai_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,53 @@ func TestOpenAIEngineChatCompletionAddsKimiReasoningContentToToolCalls(t *testin
255255
t.Fatalf("top_p = %v, want 0.95", got["top_p"])
256256
}
257257
}
258+
259+
func TestOpenAIEngineChatCompletionAddsDeepSeekV4ReasoningContentToToolCalls(t *testing.T) {
260+
var got map[string]interface{}
261+
262+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
263+
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
264+
t.Fatalf("decode request: %v", err)
265+
}
266+
w.Header().Set("Content-Type", "application/json")
267+
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"}}]}`)
268+
}))
269+
defer ts.Close()
270+
271+
eng := NewOpenAIEngine(ts.URL, "deepseek-v4-pro", "test-token")
272+
resp, err := eng.(ChatCompletionProxier).ChatCompletion(context.Background(), map[string]interface{}{
273+
"model": "deepseek-v4-pro",
274+
"messages": []map[string]interface{}{
275+
{"role": "user", "content": "use a tool"},
276+
{
277+
"role": "assistant",
278+
"content": nil,
279+
"tool_calls": []map[string]interface{}{{
280+
"id": "call_1",
281+
"type": "function",
282+
"function": map[string]interface{}{
283+
"name": "lookup",
284+
"arguments": "{}",
285+
},
286+
}},
287+
},
288+
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
289+
},
290+
})
291+
if err != nil {
292+
t.Fatalf("ChatCompletion returned error: %v", err)
293+
}
294+
resp.Body.Close()
295+
296+
messages, ok := got["messages"].([]interface{})
297+
if !ok || len(messages) != 3 {
298+
t.Fatalf("messages = %#v", got["messages"])
299+
}
300+
assistant, ok := messages[1].(map[string]interface{})
301+
if !ok {
302+
t.Fatalf("assistant message = %#v", messages[1])
303+
}
304+
if value, ok := assistant["reasoning_content"]; !ok || value != "" {
305+
t.Fatalf("reasoning_content = %#v, want empty string", assistant["reasoning_content"])
306+
}
307+
}

internal/server/handlers_responses.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1154,7 +1154,7 @@ func buildResponsesOutputItem(itemID, text, status string) map[string]interface{
11541154
}
11551155

11561156
func buildResponsesReasoningItem(itemID, text, status string) map[string]interface{} {
1157-
return map[string]interface{}{
1157+
item := map[string]interface{}{
11581158
"id": itemID,
11591159
"type": "reasoning",
11601160
"status": status,
@@ -1164,6 +1164,14 @@ func buildResponsesReasoningItem(itemID, text, status string) map[string]interfa
11641164
"text": text,
11651165
}},
11661166
}
1167+
if text != "" {
1168+
item["summary"] = []map[string]interface{}{{
1169+
"type": "summary_text",
1170+
"text": text,
1171+
}}
1172+
item["encrypted_content"] = text
1173+
}
1174+
return item
11671175
}
11681176

11691177
func buildResponsesResponse(id, itemID, modelID, text string, created int64, status string, inputTokens int) map[string]interface{} {

internal/server/handlers_responses_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,42 @@ func TestResponsesInputReasoningAttachedToToolCallAssistant(t *testing.T) {
157157
}
158158
}
159159

160+
func TestResponsesInputEncryptedReasoningAttachedToToolCallAssistant(t *testing.T) {
161+
input := []interface{}{
162+
map[string]interface{}{
163+
"type": "reasoning",
164+
"encrypted_content": "opaque reasoning replay",
165+
},
166+
map[string]interface{}{
167+
"type": "function_call",
168+
"call_id": "call_1",
169+
"name": "list_dir",
170+
"arguments": `{}`,
171+
},
172+
map[string]interface{}{
173+
"type": "function_call_output",
174+
"name": "list_dir",
175+
"call_id": "call_1",
176+
"output": "ok",
177+
},
178+
}
179+
180+
messages, err := responsesInputToOpenAIMessages(input)
181+
if err != nil {
182+
t.Fatalf("responsesInputToOpenAIMessages() error = %v", err)
183+
}
184+
if len(messages) != 2 {
185+
t.Fatalf("len(messages) = %d, want 2", len(messages))
186+
}
187+
toolAssistant := messages[0]
188+
if len(toolAssistant.ToolCalls) != 1 || toolAssistant.ToolCalls[0].Function.Name != "list_dir" {
189+
t.Fatalf("tool assistant = %#v", toolAssistant)
190+
}
191+
if toolAssistant.ReasoningContent != "opaque reasoning replay" {
192+
t.Fatalf("ReasoningContent = %q, want opaque reasoning replay", toolAssistant.ReasoningContent)
193+
}
194+
}
195+
160196
func TestResponsesOutputFromOpenAIChatResponseIncludesReasoningItem(t *testing.T) {
161197
resp := api.OpenAIChatResponse{
162198
Choices: []api.OpenAIChoice{{
@@ -182,4 +218,11 @@ func TestResponsesOutputFromOpenAIChatResponseIncludesReasoningItem(t *testing.T
182218
if reasoning["type"] != "reasoning" || responsesReasoningItemText(reasoning) != "hidden reasoning" {
183219
t.Fatalf("reasoning item = %#v, want hidden reasoning", reasoning)
184220
}
221+
if reasoning["encrypted_content"] != "hidden reasoning" {
222+
t.Fatalf("encrypted_content = %#v, want hidden reasoning", reasoning["encrypted_content"])
223+
}
224+
summary, ok := reasoning["summary"].([]map[string]interface{})
225+
if !ok || len(summary) != 1 || summary[0]["text"] != "hidden reasoning" {
226+
t.Fatalf("summary = %#v, want hidden reasoning summary", reasoning["summary"])
227+
}
185228
}

0 commit comments

Comments
 (0)