@@ -3,6 +3,7 @@ package anthropic
33import (
44 "encoding/json"
55 "fmt"
6+ "strings"
67 "sync"
78 "time"
89
@@ -244,63 +245,59 @@ func handleAnthropicNonStream(c echo.Context, id string, input *schema.Anthropic
244245 }
245246 }
246247
247- // No MCP tools to execute, build and return response
248+ // No MCP tools to execute, build and return response.
249+ // Reasoning surfaces as a thinking block only when the client opted in,
250+ // matching Anthropic's extended-thinking gating.
251+ reasoning := functions .ReasoningFromChatDeltas (chatDeltas )
252+ thinkingEnabled := input .Thinking != nil && input .Thinking .Type == "enabled"
253+
248254 var contentBlocks []schema.AnthropicContentBlock
249255 var stopReason string
250256
251257 if shouldUseFn && len (toolCalls ) > 0 {
252258 stopReason = "tool_use"
253- for _ , tc := range toolCalls {
254- var inputArgs map [string ]any
255- if err := json .Unmarshal ([]byte (tc .Arguments ), & inputArgs ); err != nil {
256- xlog .Warn ("Failed to parse tool call arguments as JSON" , "error" , err , "args" , tc .Arguments )
257- inputArgs = map [string ]any {"raw" : tc .Arguments }
258- }
259- contentBlocks = append (contentBlocks , schema.AnthropicContentBlock {
260- Type : "tool_use" ,
261- ID : fmt .Sprintf ("toolu_%s_%d" , id , len (contentBlocks )),
262- Name : tc .Name ,
263- Input : inputArgs ,
264- })
265- }
266- textContent := functions .ParseTextContent (result , cfg .FunctionsConfig )
267- if textContent != "" {
268- contentBlocks = append ([]schema.AnthropicContentBlock {{Type : "text" , Text : textContent }}, contentBlocks ... )
269- }
259+ contentBlocks = buildAnthropicContentBlocks (buildParams {
260+ reasoning : reasoning ,
261+ thinkingEnabled : thinkingEnabled ,
262+ text : functions .ParseTextContent (result , cfg .FunctionsConfig ),
263+ toolCalls : funcResultsToToolCalls (toolCalls ),
264+ id : id ,
265+ })
270266 } else if ! shouldUseFn && cfg .FunctionsConfig .AutomaticToolParsingFallback && result != "" {
271267 // Automatic tool parsing fallback: no tools in request but model emitted tool call markup
272268 parsed := functions .ParseFunctionCall (result , cfg .FunctionsConfig )
273269 if len (parsed ) > 0 {
274270 stopReason = "tool_use"
275- stripped := functions .StripToolCallMarkup (result )
276- if stripped != "" {
277- contentBlocks = append (contentBlocks , schema.AnthropicContentBlock {Type : "text" , Text : stripped })
278- }
279- for i , fc := range parsed {
280- var inputArgs map [string ]any
281- if err := json .Unmarshal ([]byte (fc .Arguments ), & inputArgs ); err != nil {
282- inputArgs = map [string ]any {"raw" : fc .Arguments }
283- }
284- toolCallID := fc .ID
285- if toolCallID == "" {
286- toolCallID = fmt .Sprintf ("toolu_%s_%d" , id , i )
287- }
288- contentBlocks = append (contentBlocks , schema.AnthropicContentBlock {
289- Type : "tool_use" ,
290- ID : toolCallID ,
291- Name : fc .Name ,
292- Input : inputArgs ,
293- })
294- }
271+ contentBlocks = buildAnthropicContentBlocks (buildParams {
272+ reasoning : reasoning ,
273+ thinkingEnabled : thinkingEnabled ,
274+ text : functions .StripToolCallMarkup (result ),
275+ toolCalls : funcResultsToToolCalls (parsed ),
276+ id : id ,
277+ })
295278 } else {
296279 stopReason = "end_turn"
297- contentBlocks = []schema.AnthropicContentBlock {{Type : "text" , Text : result }}
280+ contentBlocks = buildAnthropicContentBlocks (buildParams {
281+ reasoning : reasoning ,
282+ thinkingEnabled : thinkingEnabled ,
283+ text : result ,
284+ id : id ,
285+ })
298286 }
299287 } else {
300288 stopReason = "end_turn"
301- contentBlocks = []schema.AnthropicContentBlock {
302- {Type : "text" , Text : result },
303- }
289+ contentBlocks = buildAnthropicContentBlocks (buildParams {
290+ reasoning : reasoning ,
291+ thinkingEnabled : thinkingEnabled ,
292+ text : result ,
293+ id : id ,
294+ })
295+ }
296+
297+ // Anthropic responses must carry at least one content block; keep the
298+ // empty-text fallback the pre-refactor assembly guaranteed.
299+ if len (contentBlocks ) == 0 {
300+ contentBlocks = []schema.AnthropicContentBlock {{Type : "text" , Text : result }}
304301 }
305302
306303 resp := & schema.AnthropicResponse {
@@ -356,6 +353,9 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
356353 }
357354 hasMCPTools := mcpExecutor != nil && mcpExecutor .HasTools ()
358355
356+ // Reasoning streams as a thinking block only when the client opted in.
357+ thinkingEnabled := input .Thinking != nil && input .Thinking .Type == "enabled"
358+
359359 for mcpIteration := 0 ; mcpIteration <= mcpMaxIterations ; mcpIteration ++ {
360360 // Re-template on MCP iterations
361361 if mcpIteration > 0 {
@@ -510,7 +510,9 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
510510 })
511511 }
512512
513- // Emit tool_use blocks from ChatDeltas
513+ // Emit tool_use blocks from ChatDeltas, preceded by a fully-closed
514+ // thinking block when reasoning is present (Anthropic ordering:
515+ // thinking is streamed and closed before any tool_use starts).
514516 if len (deltaToolCalls ) > 0 && len (collectedToolCalls ) == 0 {
515517 collectedToolCalls = deltaToolCalls
516518
@@ -522,35 +524,23 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
522524 currentBlockIndex ++
523525 inToolCall = true
524526 }
525- for i , tc := range deltaToolCalls {
526- toolCallID := tc .ID
527- if toolCallID == "" {
528- toolCallID = fmt .Sprintf ("toolu_%s_%d" , id , i )
527+
528+ seq := anthropicStreamSequence (streamInput {
529+ reasoningDeltas : []string {functions .ReasoningFromChatDeltas (chatDeltas )},
530+ thinkingEnabled : thinkingEnabled ,
531+ toolCalls : funcResultsToToolCalls (deltaToolCalls ),
532+ startIndex : currentBlockIndex ,
533+ id : id ,
534+ })
535+ for _ , ev := range seq {
536+ sendAnthropicSSE (c , ev )
537+ // Each content block ends with exactly one content_block_stop,
538+ // so advance the running index in lockstep with the sequencer.
539+ if ev .Type == "content_block_stop" {
540+ currentBlockIndex ++
529541 }
530- sendAnthropicSSE (c , schema.AnthropicStreamEvent {
531- Type : "content_block_start" ,
532- Index : intPtr (currentBlockIndex ),
533- ContentBlock : & schema.AnthropicContentBlock {
534- Type : "tool_use" ,
535- ID : toolCallID ,
536- Name : tc .Name ,
537- },
538- })
539- sendAnthropicSSE (c , schema.AnthropicStreamEvent {
540- Type : "content_block_delta" ,
541- Index : intPtr (currentBlockIndex ),
542- Delta : & schema.AnthropicStreamDelta {
543- Type : "input_json_delta" ,
544- PartialJSON : tc .Arguments ,
545- },
546- })
547- sendAnthropicSSE (c , schema.AnthropicStreamEvent {
548- Type : "content_block_stop" ,
549- Index : intPtr (currentBlockIndex ),
550- })
551- currentBlockIndex ++
552- toolCallsEmitted ++
553542 }
543+ toolCallsEmitted += len (deltaToolCalls )
554544 }
555545 }
556546
@@ -697,6 +687,139 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
697687 return nil
698688}
699689
690+ // buildParams carries the pieces of an assistant turn that become Anthropic
691+ // content blocks. It exists so the block-assembly logic is unit-testable in
692+ // isolation from the HTTP handler.
693+ type buildParams struct {
694+ reasoning string
695+ thinkingEnabled bool
696+ text string
697+ toolCalls []schema.ToolCall
698+ id string
699+ }
700+
701+ // syntheticThinkingSignature returns an opaque, non-cryptographic signature so
702+ // Anthropic SDK clients round-trip the thinking block. Local models have no
703+ // real signature; this marker is accepted-but-ignored on the way back in.
704+ func syntheticThinkingSignature () string { return "localai" }
705+
706+ // buildAnthropicContentBlocks assembles the content blocks for a non-streaming
707+ // assistant turn. When thinking is enabled and reasoning is present, a thinking
708+ // block is prepended before text and tool_use blocks (Anthropic ordering).
709+ func buildAnthropicContentBlocks (p buildParams ) []schema.AnthropicContentBlock {
710+ var blocks []schema.AnthropicContentBlock
711+ if p .thinkingEnabled && p .reasoning != "" {
712+ blocks = append (blocks , schema.AnthropicContentBlock {
713+ Type : "thinking" ,
714+ Thinking : p .reasoning ,
715+ Signature : syntheticThinkingSignature (),
716+ })
717+ }
718+ if p .text != "" {
719+ blocks = append (blocks , schema.AnthropicContentBlock {Type : "text" , Text : p .text })
720+ }
721+ for i , tc := range p .toolCalls {
722+ var inputArgs map [string ]any
723+ if err := json .Unmarshal ([]byte (tc .FunctionCall .Arguments ), & inputArgs ); err != nil {
724+ inputArgs = map [string ]any {"raw" : tc .FunctionCall .Arguments }
725+ }
726+ id := tc .ID
727+ if id == "" {
728+ id = fmt .Sprintf ("toolu_%s_%d" , p .id , i )
729+ }
730+ blocks = append (blocks , schema.AnthropicContentBlock {
731+ Type : "tool_use" , ID : id , Name : tc .FunctionCall .Name , Input : inputArgs ,
732+ })
733+ }
734+ return blocks
735+ }
736+
737+ // funcResultsToToolCalls adapts the parser's FuncCallResults into schema
738+ // ToolCalls so the pure block builders operate on one tool-call shape.
739+ func funcResultsToToolCalls (results []functions.FuncCallResults ) []schema.ToolCall {
740+ out := make ([]schema.ToolCall , len (results ))
741+ for i , r := range results {
742+ out [i ] = schema.ToolCall {
743+ ID : r .ID ,
744+ Type : "function" ,
745+ FunctionCall : schema.FunctionCall {Name : r .Name , Arguments : r .Arguments },
746+ }
747+ }
748+ return out
749+ }
750+
751+ // streamInput carries the reasoning deltas and tool calls for one streaming
752+ // assistant turn. startIndex and id let the pure sequencer be wired into the
753+ // live loop, which already owns a running block index.
754+ type streamInput struct {
755+ reasoningDeltas []string
756+ thinkingEnabled bool
757+ toolCalls []schema.ToolCall
758+ startIndex int
759+ id string
760+ }
761+
762+ // anthropicStreamSequence produces the ordered stream events for a thinking
763+ // block followed by tool_use blocks. Per the Anthropic spec the thinking block
764+ // is fully streamed and closed (content_block_stop) before any tool_use block
765+ // starts. Kept pure so ordering is unit-testable without an HTTP stream.
766+ func anthropicStreamSequence (in streamInput ) []schema.AnthropicStreamEvent {
767+ var events []schema.AnthropicStreamEvent
768+ idx := in .startIndex
769+
770+ if in .thinkingEnabled && strings .Join (in .reasoningDeltas , "" ) != "" {
771+ events = append (events , schema.AnthropicStreamEvent {
772+ Type : "content_block_start" ,
773+ Index : intPtr (idx ),
774+ ContentBlock : & schema.AnthropicContentBlock {Type : "thinking" },
775+ })
776+ for _ , d := range in .reasoningDeltas {
777+ if d == "" {
778+ continue
779+ }
780+ events = append (events , schema.AnthropicStreamEvent {
781+ Type : "content_block_delta" ,
782+ Index : intPtr (idx ),
783+ Delta : & schema.AnthropicStreamDelta {Type : "thinking_delta" , Thinking : d },
784+ })
785+ }
786+ events = append (events , schema.AnthropicStreamEvent {
787+ Type : "content_block_delta" ,
788+ Index : intPtr (idx ),
789+ Delta : & schema.AnthropicStreamDelta {Type : "signature_delta" , Signature : syntheticThinkingSignature ()},
790+ })
791+ events = append (events , schema.AnthropicStreamEvent {
792+ Type : "content_block_stop" ,
793+ Index : intPtr (idx ),
794+ })
795+ idx ++
796+ }
797+
798+ for i , tc := range in .toolCalls {
799+ toolID := tc .ID
800+ if toolID == "" {
801+ toolID = fmt .Sprintf ("toolu_%s_%d" , in .id , i )
802+ }
803+ events = append (events , schema.AnthropicStreamEvent {
804+ Type : "content_block_start" ,
805+ Index : intPtr (idx ),
806+ ContentBlock : & schema.AnthropicContentBlock {Type : "tool_use" , ID : toolID , Name : tc .FunctionCall .Name },
807+ })
808+ events = append (events , schema.AnthropicStreamEvent {
809+ Type : "content_block_delta" ,
810+ Index : intPtr (idx ),
811+ Delta : & schema.AnthropicStreamDelta {Type : "input_json_delta" , PartialJSON : tc .FunctionCall .Arguments },
812+ })
813+ events = append (events , schema.AnthropicStreamEvent {
814+ Type : "content_block_stop" ,
815+ Index : intPtr (idx ),
816+ })
817+ idx ++
818+ }
819+
820+ return events
821+ }
822+
700823func convertFuncsToOpenAITools (funcs functions.Functions ) []functions.Tool {
701824 tools := make ([]functions.Tool , len (funcs ))
702825 for i , f := range funcs {
@@ -767,6 +890,17 @@ func convertAnthropicToOpenAIMessages(input *schema.AnthropicRequest) []schema.M
767890 if text , ok := blockMap ["text" ].(string ); ok {
768891 textContent += text
769892 }
893+ case "thinking" :
894+ // Anthropic interleaved thinking: preserve the model's reasoning so it
895+ // survives the tool-result loop. Signature is Anthropic-cloud specific;
896+ // for local models we read but do not validate it.
897+ if thinking , ok := blockMap ["thinking" ].(string ); ok && thinking != "" {
898+ combined := thinking
899+ if openAIMsg .Reasoning != nil {
900+ combined = * openAIMsg .Reasoning + thinking
901+ }
902+ openAIMsg .Reasoning = & combined
903+ }
770904 case "image" :
771905 // Handle image content
772906 if source , ok := blockMap ["source" ].(map [string ]any ); ok {
0 commit comments