Skip to content

Commit 1723d0a

Browse files
feat: preserve Anthropic reasoning content
Forward Anthropic thinking blocks through OpenAI-compatible proxy requests and stream reasoning content back in Anthropic responses. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b8ad532 commit 1723d0a

2 files changed

Lines changed: 372 additions & 6 deletions

File tree

internal/server/handlers_anthropic.go

Lines changed: 148 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
5151
s.handleAnthropicMessagesWithTools(w, r, req, eng, opts, inputTokens, id)
5252
return
5353
}
54+
if proxy, ok := eng.(inference.ChatCompletionProxier); ok {
55+
s.handleAnthropicMessagesProxy(w, r, req, proxy, opts, inputTokens, id)
56+
return
57+
}
5458

5559
messages := anthropicMessagesToInference(req)
5660

@@ -117,6 +121,76 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
117121
writeJSON(w, http.StatusOK, buildAnthropicMessageResponse(id, req.Model, response, inputTokens))
118122
}
119123

124+
func (s *Server) handleAnthropicMessagesProxy(
125+
w http.ResponseWriter,
126+
r *http.Request,
127+
req api.AnthropicMessageRequest,
128+
proxy inference.ChatCompletionProxier,
129+
opts inference.Options,
130+
inputTokens int,
131+
id string,
132+
) {
133+
reqBody, err := anthropicRequestToProxyBody(req, opts, false)
134+
if err != nil {
135+
writeAnthropicError(w, http.StatusBadRequest, err.Error())
136+
return
137+
}
138+
139+
resp, err := proxy.ChatCompletion(r.Context(), reqBody)
140+
if err != nil {
141+
if req.Stream {
142+
w.Header().Set("Content-Type", "text/event-stream")
143+
w.Header().Set("Cache-Control", "no-cache")
144+
w.Header().Set("Connection", "keep-alive")
145+
writeAnthropicSSE(w, "error", anthropicErrorPayloadFromInferenceError(err))
146+
return
147+
}
148+
writeAnthropicInferenceError(w, err)
149+
return
150+
}
151+
defer resp.Body.Close()
152+
153+
var openAIResp api.OpenAIChatResponse
154+
if err := json.NewDecoder(resp.Body).Decode(&openAIResp); err != nil {
155+
message := "decoding response: " + err.Error()
156+
if req.Stream {
157+
w.Header().Set("Content-Type", "text/event-stream")
158+
w.Header().Set("Cache-Control", "no-cache")
159+
w.Header().Set("Connection", "keep-alive")
160+
writeAnthropicSSE(w, "error", anthropicErrorPayloadWithType("api_error", message))
161+
return
162+
}
163+
writeAnthropicErrorWithType(w, http.StatusInternalServerError, "api_error", message)
164+
return
165+
}
166+
if openAIResp.Model == "" {
167+
openAIResp.Model = req.Model
168+
}
169+
170+
anthropicResp, err := anthropicMessageResponseFromOpenAI(id, req.Model, openAIResp, inputTokens)
171+
if err != nil {
172+
if req.Stream {
173+
w.Header().Set("Content-Type", "text/event-stream")
174+
w.Header().Set("Cache-Control", "no-cache")
175+
w.Header().Set("Connection", "keep-alive")
176+
writeAnthropicSSE(w, "error", anthropicErrorPayloadWithType("api_error", err.Error()))
177+
return
178+
}
179+
writeAnthropicErrorWithType(w, http.StatusInternalServerError, "api_error", err.Error())
180+
return
181+
}
182+
183+
if !req.Stream {
184+
writeJSON(w, http.StatusOK, anthropicResp)
185+
return
186+
}
187+
188+
w.Header().Set("Content-Type", "text/event-stream")
189+
w.Header().Set("Cache-Control", "no-cache")
190+
w.Header().Set("Connection", "keep-alive")
191+
writeAnthropicStreamedMessage(w, anthropicResp)
192+
}
193+
120194
func (s *Server) handleAnthropicMessagesWithTools(
121195
w http.ResponseWriter,
122196
r *http.Request,
@@ -293,10 +367,11 @@ func anthropicMessagesToInference(req api.AnthropicMessageRequest) []inference.M
293367
messages = append(messages, inference.Message{Role: "system", Content: system})
294368
}
295369
for _, item := range req.Messages {
296-
text := anthropicContentText(item.Content)
370+
text, reasoning := anthropicMessageTextAndReasoning(item.Role, item.Content)
297371
messages = append(messages, inference.Message{
298-
Role: item.Role,
299-
Content: text,
372+
Role: item.Role,
373+
Content: text,
374+
ReasoningContent: reasoning,
300375
})
301376
}
302377
return messages
@@ -378,6 +453,7 @@ func anthropicMessageToOpenAIEntries(message api.AnthropicMessage, messageIndex
378453
func anthropicContentBlockEntriesToOpenAI(role string, blocks []interface{}, messageIndex int, pendingToolNames map[string]string) ([]map[string]interface{}, error) {
379454
entries := make([]map[string]interface{}, 0, len(blocks))
380455
textParts := make([]string, 0, len(blocks))
456+
reasoningParts := make([]string, 0)
381457
assistantToolCalls := make([]map[string]interface{}, 0)
382458
assistantToolIndex := 0
383459

@@ -406,7 +482,11 @@ func anthropicContentBlockEntriesToOpenAI(role string, blocks []interface{}, mes
406482
}
407483
case "thinking":
408484
if thinking, _ := block["thinking"].(string); strings.TrimSpace(thinking) != "" {
409-
textParts = append(textParts, thinking)
485+
if role == "assistant" {
486+
reasoningParts = append(reasoningParts, thinking)
487+
} else {
488+
textParts = append(textParts, thinking)
489+
}
410490
}
411491
case "tool_use":
412492
if role != "assistant" {
@@ -460,7 +540,7 @@ func anthropicContentBlockEntriesToOpenAI(role string, blocks []interface{}, mes
460540
}
461541

462542
if role == "assistant" {
463-
if len(textParts) == 0 && len(assistantToolCalls) == 0 {
543+
if len(textParts) == 0 && len(reasoningParts) == 0 && len(assistantToolCalls) == 0 {
464544
return entries, nil
465545
}
466546
assistant := map[string]interface{}{"role": "assistant"}
@@ -472,6 +552,9 @@ func anthropicContentBlockEntriesToOpenAI(role string, blocks []interface{}, mes
472552
if len(assistantToolCalls) > 0 {
473553
assistant["tool_calls"] = assistantToolCalls
474554
}
555+
if len(reasoningParts) > 0 {
556+
assistant["reasoning_content"] = strings.Join(reasoningParts, "\n")
557+
}
475558
entries = append(entries, assistant)
476559
return entries, nil
477560
}
@@ -605,7 +688,13 @@ func anthropicContentText(content interface{}) string {
605688
}
606689

607690
func anthropicContentBlocksFromOpenAIMessage(msg *api.Message) []api.AnthropicContentBlock {
608-
blocks := make([]api.AnthropicContentBlock, 0, 1+len(msg.ToolCalls))
691+
blocks := make([]api.AnthropicContentBlock, 0, 2+len(msg.ToolCalls))
692+
if reasoning := strings.TrimSpace(msg.ReasoningContent); reasoning != "" {
693+
blocks = append(blocks, api.AnthropicContentBlock{
694+
Type: "thinking",
695+
Thinking: reasoning,
696+
})
697+
}
609698
if text := contentAsString(msg.Content); strings.TrimSpace(text) != "" {
610699
blocks = append(blocks, api.AnthropicContentBlock{
611700
Type: "text",
@@ -631,6 +720,48 @@ func anthropicContentBlocksFromOpenAIMessage(msg *api.Message) []api.AnthropicCo
631720
return blocks
632721
}
633722

723+
func anthropicMessageTextAndReasoning(role string, content interface{}) (string, string) {
724+
if role != "assistant" {
725+
return anthropicContentText(content), ""
726+
}
727+
switch value := content.(type) {
728+
case nil:
729+
return "", ""
730+
case string:
731+
return value, ""
732+
case []interface{}:
733+
textParts := make([]string, 0, len(value))
734+
reasoningParts := make([]string, 0)
735+
for _, raw := range value {
736+
text, reasoning := anthropicContentPartTextAndReasoning(raw)
737+
if text != "" {
738+
textParts = append(textParts, text)
739+
}
740+
if reasoning != "" {
741+
reasoningParts = append(reasoningParts, reasoning)
742+
}
743+
}
744+
return strings.Join(textParts, "\n"), strings.Join(reasoningParts, "\n")
745+
case map[string]interface{}:
746+
return anthropicContentPartTextAndReasoning(value)
747+
default:
748+
return anthropicContentText(value), ""
749+
}
750+
}
751+
752+
func anthropicContentPartTextAndReasoning(content interface{}) (string, string) {
753+
switch value := content.(type) {
754+
case map[string]interface{}:
755+
if stringValue(value["type"]) == "thinking" {
756+
thinking, _ := value["thinking"].(string)
757+
return "", thinking
758+
}
759+
return anthropicContentText(value), ""
760+
default:
761+
return anthropicContentText(value), ""
762+
}
763+
}
764+
634765
func anthropicContentBlocksText(blocks []api.AnthropicContentBlock) string {
635766
parts := make([]string, 0, len(blocks))
636767
for _, block := range blocks {
@@ -731,6 +862,17 @@ func writeAnthropicStreamedMessage(w http.ResponseWriter, resp api.AnthropicMess
731862
},
732863
})
733864
}
865+
case "thinking":
866+
if strings.TrimSpace(block.Thinking) != "" {
867+
writeAnthropicSSE(w, "content_block_delta", map[string]interface{}{
868+
"type": "content_block_delta",
869+
"index": i,
870+
"delta": map[string]interface{}{
871+
"type": "thinking_delta",
872+
"thinking": block.Thinking,
873+
},
874+
})
875+
}
734876
}
735877
writeAnthropicSSE(w, "content_block_stop", map[string]interface{}{
736878
"type": "content_block_stop",

0 commit comments

Comments
 (0)