picoclaw/pkg/providers/anthropic/provider.go
ZaneTung 3332220940 refactor: complete tool call support in streaming response
完善工具调用支持的关键修复:
- 在 content_block_start 时捕获工具调用的 ID 和 Name
- 修复之前只初始化 Index 而忽略 ID/Name 的问题
- 改进空参数处理(使用空对象而非 panic)
- 增强错误日志,包含实际参数内容以便调试

技术细节:
- block.ContentBlock.ID -> currentToolCall.ID
- block.ContentBlock.Name -> currentToolCall.Name
- 处理空 JSON 字符串的情况
- 错误日志格式:包含 args 字符串便于诊断

这确保了需要工具调用的场景能够正常工作。
2026-03-06 14:00:10 +08:00

478 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package anthropicprovider
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/anthropics/anthropic-sdk-go/packages/ssestream"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
type (
ToolCall = protocoltypes.ToolCall
FunctionCall = protocoltypes.FunctionCall
LLMResponse = protocoltypes.LLMResponse
UsageInfo = protocoltypes.UsageInfo
Message = protocoltypes.Message
ToolDefinition = protocoltypes.ToolDefinition
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
)
const defaultBaseURL = "https://api.anthropic.com"
type Provider struct {
client *anthropic.Client
tokenSource func() (string, error)
baseURL string
}
// SupportsThinking implements providers.ThinkingCapable.
func (p *Provider) SupportsThinking() bool { return true }
func NewProvider(token string) *Provider {
return NewProviderWithBaseURL(token, "")
}
func NewProviderWithBaseURL(token, apiBase string) *Provider {
baseURL := normalizeBaseURL(apiBase)
client := anthropic.NewClient(
option.WithAuthToken(token),
option.WithBaseURL(baseURL),
)
return &Provider{
client: &client,
baseURL: baseURL,
}
}
func NewProviderWithClient(client *anthropic.Client) *Provider {
return &Provider{
client: client,
baseURL: defaultBaseURL,
}
}
func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider {
return NewProviderWithTokenSourceAndBaseURL(token, tokenSource, "")
}
func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider {
p := NewProviderWithBaseURL(token, apiBase)
p.tokenSource = tokenSource
return p
}
func (p *Provider) Chat(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (*LLMResponse, error) {
var opts []option.RequestOption
if p.tokenSource != nil {
tok, err := p.tokenSource()
if err != nil {
return nil, fmt.Errorf("refreshing token: %w", err)
}
opts = append(opts, option.WithAuthToken(tok))
}
params, err := buildParams(messages, tools, model, options)
if err != nil {
return nil, err
}
// 使用流式 API 以兼容要求流式请求的服务端
// 流式响应会被聚合为完整响应后返回
stream := p.client.Messages.NewStreaming(ctx, params, opts...)
defer stream.Close()
// 边流式边解析,避免存储所有事件(优化内存使用)
resp, err := parseStreamingResponse(stream)
if err != nil {
return nil, err
}
return resp, nil
}
func (p *Provider) GetDefaultModel() string {
return "claude-sonnet-4.6"
}
func (p *Provider) BaseURL() string {
return p.baseURL
}
func buildParams(
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (anthropic.MessageNewParams, error) {
var system []anthropic.TextBlockParam
var anthropicMessages []anthropic.MessageParam
for _, msg := range messages {
switch msg.Role {
case "system":
// Prefer structured SystemParts for per-block cache_control.
// This enables LLM-side KV cache reuse: the static block's prefix
// hash stays stable across requests while dynamic parts change freely.
if len(msg.SystemParts) > 0 {
for _, part := range msg.SystemParts {
block := anthropic.TextBlockParam{Text: part.Text}
if part.CacheControl != nil && part.CacheControl.Type == "ephemeral" {
block.CacheControl = anthropic.NewCacheControlEphemeralParam()
}
system = append(system, block)
}
} else {
system = append(system, anthropic.TextBlockParam{Text: msg.Content})
}
case "user":
if msg.ToolCallID != "" {
anthropicMessages = append(anthropicMessages,
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
)
} else {
anthropicMessages = append(anthropicMessages,
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
)
}
case "assistant":
if len(msg.ToolCalls) > 0 {
var blocks []anthropic.ContentBlockParamUnion
if msg.Content != "" {
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
}
for _, tc := range msg.ToolCalls {
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
}
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
} else {
anthropicMessages = append(anthropicMessages,
anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
)
}
case "tool":
anthropicMessages = append(anthropicMessages,
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
)
}
}
maxTokens := int64(4096)
if mt, ok := options["max_tokens"].(int); ok {
maxTokens = int64(mt)
}
params := anthropic.MessageNewParams{
Model: anthropic.Model(model),
Messages: anthropicMessages,
MaxTokens: maxTokens,
}
if len(system) > 0 {
params.System = system
}
if temp, ok := options["temperature"].(float64); ok {
params.Temperature = anthropic.Float(temp)
}
if len(tools) > 0 {
params.Tools = translateTools(tools)
}
// Extended Thinking / Adaptive Thinking
// The thinking_level value directly determines the API parameter format:
// "adaptive" → {thinking: {type: "adaptive"}} + output_config.effort
// "low/medium/high/xhigh" → {thinking: {type: "enabled", budget_tokens: N}}
if level, ok := options["thinking_level"].(string); ok && level != "" && level != "off" {
applyThinkingConfig(&params, level)
}
return params, nil
}
// applyThinkingConfig sets thinking parameters based on the level value.
// "adaptive" uses the adaptive thinking API (Claude 4.6+).
// All other levels use budget_tokens which is universally supported.
//
// Anthropic API constraint: temperature must not be set when thinking is enabled.
// budget_tokens must be strictly less than max_tokens.
func applyThinkingConfig(params *anthropic.MessageNewParams, level string) {
// Anthropic API rejects requests with temperature set alongside thinking.
// Reset to zero value (omitted from JSON serialization).
if params.Temperature.Valid() {
log.Printf("anthropic: temperature cleared because thinking is enabled (level=%s)", level)
}
params.Temperature = anthropic.MessageNewParams{}.Temperature
if level == "adaptive" {
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
params.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive}
params.OutputConfig = anthropic.OutputConfigParam{
Effort: anthropic.OutputConfigEffortHigh,
}
return
}
budget := int64(levelToBudget(level))
if budget <= 0 {
return
}
// budget_tokens must be < max_tokens; clamp to respect user's max_tokens setting.
if budget >= params.MaxTokens {
log.Printf("anthropic: budget_tokens (%d) clamped to %d (max_tokens-1)", budget, params.MaxTokens-1)
budget = params.MaxTokens - 1
} else if budget > params.MaxTokens*80/100 {
log.Printf("anthropic: thinking budget (%d) exceeds 80%% of max_tokens (%d), output may be truncated",
budget, params.MaxTokens)
}
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget)
}
// levelToBudget maps a thinking level to budget_tokens.
// Values are based on Anthropic's recommendations and community best practices:
//
// low = 4,096 — simple reasoning, quick debugging (Claude Code "think")
// medium = 16,384 — Anthropic recommended sweet spot for most tasks
// high = 32,000 — complex architecture, deep analysis (diminishing returns above this)
// xhigh = 64,000 — extreme reasoning, research problems, benchmarks
//
// Note: For Claude 4.6+, prefer adaptive thinking over manual budget_tokens.
func levelToBudget(level string) int {
switch level {
case "low":
return 4096
case "medium":
return 16384
case "high":
return 32000
case "xhigh":
return 64000
default:
return 0
}
}
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
result := make([]anthropic.ToolUnionParam, 0, len(tools))
for _, t := range tools {
tool := anthropic.ToolParam{
Name: t.Function.Name,
InputSchema: anthropic.ToolInputSchemaParam{
Properties: t.Function.Parameters["properties"],
},
}
if desc := t.Function.Description; desc != "" {
tool.Description = anthropic.String(desc)
}
if req, ok := t.Function.Parameters["required"].([]any); ok {
required := make([]string, 0, len(req))
for _, r := range req {
if s, ok := r.(string); ok {
required = append(required, s)
}
}
tool.InputSchema.Required = required
}
result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
}
return result
}
func parseResponse(resp *anthropic.Message) *LLMResponse {
var content strings.Builder
var reasoning strings.Builder
var toolCalls []ToolCall
for _, block := range resp.Content {
switch block.Type {
case "thinking":
tb := block.AsThinking()
reasoning.WriteString(tb.Thinking)
case "text":
tb := block.AsText()
content.WriteString(tb.Text)
case "tool_use":
tu := block.AsToolUse()
var args map[string]any
if err := json.Unmarshal(tu.Input, &args); err != nil {
log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err)
args = map[string]any{"raw": string(tu.Input)}
}
toolCalls = append(toolCalls, ToolCall{
ID: tu.ID,
Name: tu.Name,
Arguments: args,
})
}
}
finishReason := "stop"
switch resp.StopReason {
case anthropic.StopReasonToolUse:
finishReason = "tool_calls"
case anthropic.StopReasonMaxTokens:
finishReason = "length"
case anthropic.StopReasonEndTurn:
finishReason = "stop"
}
return &LLMResponse{
Content: content.String(),
Reasoning: reasoning.String(),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: &UsageInfo{
PromptTokens: int(resp.Usage.InputTokens),
CompletionTokens: int(resp.Usage.OutputTokens),
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
},
}
}
// parseStreamingResponse 从流式响应中提取完整的 Message 对象
// 边流式边解析,避免存储所有事件(优化内存使用)
func parseStreamingResponse(stream *ssestream.Stream[anthropic.MessageStreamEventUnion]) (*LLMResponse, error) {
var content strings.Builder
var reasoning strings.Builder
var toolCalls []ToolCall
var stopReason anthropic.StopReason
var usage anthropic.Usage
var currentToolCall *struct {
ID string
Name string
Args strings.Builder
Index int64
}
// 直接遍历流式事件,边流式边处理
for stream.Next() {
evt := stream.Current()
switch evt.Type {
case "message_start":
if msg := evt.AsMessageStart(); msg.Message.ID != "" {
usage = msg.Message.Usage
}
case "content_block_start":
block := evt.AsContentBlockStart()
if block.ContentBlock.Type == "tool_use" {
// 初始化工具调用,捕获 ID 和 Name
currentToolCall = &struct {
ID string
Name string
Args strings.Builder
Index int64
}{
ID: block.ContentBlock.ID,
Name: block.ContentBlock.Name,
Index: block.Index,
}
}
case "content_block_delta":
delta := evt.AsContentBlockDelta()
switch delta.Delta.Type {
case "thinking_delta":
reasoning.WriteString(delta.Delta.Thinking)
case "text_delta":
content.WriteString(delta.Delta.Text)
case "input_json_delta":
// 累积工具调用参数PartialJSON 字段是 string
if currentToolCall != nil {
currentToolCall.Args.WriteString(delta.Delta.PartialJSON)
}
}
case "content_block_stop":
// 完成当前工具调用,添加到列表
if currentToolCall != nil {
var args map[string]any
argsStr := currentToolCall.Args.String()
if argsStr == "" {
// 如果没有参数,使用空对象
args = make(map[string]any)
} else if err := json.Unmarshal([]byte(argsStr), &args); err != nil {
log.Printf("anthropic: failed to decode tool call input for %q: %v (args: %s)", currentToolCall.Name, err, argsStr)
args = map[string]any{"raw": argsStr}
}
toolCalls = append(toolCalls, ToolCall{
ID: currentToolCall.ID,
Name: currentToolCall.Name,
Arguments: args,
})
currentToolCall = nil
}
case "message_delta":
msgDelta := evt.AsMessageDelta()
stopReason = msgDelta.Delta.StopReason
// 更新最终的使用量
usage.OutputTokens = msgDelta.Usage.OutputTokens
case "message_stop":
// 消息完成
case "error":
return nil, fmt.Errorf("stream error: type=%s, error=%v", evt.Type, evt)
}
}
// 检查流式传输是否有错误
if err := stream.Err(); err != nil {
return nil, fmt.Errorf("stream processing error: %w", err)
}
// 转换结束原因
finishReason := "stop"
switch stopReason {
case anthropic.StopReasonToolUse:
finishReason = "tool_calls"
case anthropic.StopReasonMaxTokens:
finishReason = "length"
case anthropic.StopReasonEndTurn:
finishReason = "stop"
}
return &LLMResponse{
Content: content.String(),
Reasoning: reasoning.String(),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: &UsageInfo{
PromptTokens: int(usage.InputTokens),
CompletionTokens: int(usage.OutputTokens),
TotalTokens: int(usage.InputTokens + usage.OutputTokens),
},
}, nil
}
func normalizeBaseURL(apiBase string) string {
base := strings.TrimSpace(apiBase)
if base == "" {
return defaultBaseURL
}
base = strings.TrimRight(base, "/")
if before, ok := strings.CutSuffix(base, "/v1"); ok {
base = before
}
if base == "" {
return defaultBaseURL
}
return base
}