Skip to content

Commit 40dae95

Browse files
localai-botmudler
andauthored
feat: interleaved thinking with tool calls (reasoning_content alias + Anthropic thinking blocks) (#10744)
* feat(schema): accept reasoning_content as inbound alias for reasoning Interleaved-thinking clients (cogito, vLLM/DeepSeek-style) emit reasoning_content on assistant turns. Accept it as an inbound alias so reasoning survives the tool-result loop; canonical reasoning wins when both are present. Emission is unchanged (still reasoning). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(schema): pin interleaved reasoning+tool_calls round-trip Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(openai): pin reachedTokenBudget truncation detection Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(anthropic): add thinking and signature fields to content blocks Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(anthropic): parse inbound thinking blocks into reasoning Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(anthropic): emit thinking blocks with synthetic signature on tool turns Extract buildAnthropicContentBlocks so non-streaming content assembly is unit-testable, and prepend a thinking block (with an opaque synthetic signature) before text/tool_use blocks when the request opts into thinking. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(anthropic): stream thinking_delta and signature_delta before tool_use Extract anthropicStreamSequence so the streaming block order is unit-testable, and emit content_block_start(thinking) -> thinking_delta -> signature_delta -> content_block_stop before the tool_use block sequence when thinking is enabled. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: add interleaved thinking with tool calls guide Add a features guide describing interleaved thinking: an assistant turn carrying reasoning and tool_calls together, the reasoning-round-trip contract (including the reasoning_content inbound alias and Anthropic thinking blocks with a synthetic signature), per-backend enablement (reasoning_format for llama.cpp, reasoning_parser/tool_call_parser for vLLM/SGLang plus the vLLM auto-config hook), a worked request/response example, and known limitations. Cross-link from model-configuration, text-generation, and openai-functions. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 8671c8a commit 40dae95

11 files changed

Lines changed: 591 additions & 72 deletions

File tree

core/http/endpoints/anthropic/messages.go

Lines changed: 204 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package anthropic
33
import (
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+
700823
func 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

Comments
 (0)