完善工具调用支持的关键修复: - 在 content_block_start 时捕获工具调用的 ID 和 Name - 修复之前只初始化 Index 而忽略 ID/Name 的问题 - 改进空参数处理(使用空对象而非 panic) - 增强错误日志,包含实际参数内容以便调试 技术细节: - block.ContentBlock.ID -> currentToolCall.ID - block.ContentBlock.Name -> currentToolCall.Name - 处理空 JSON 字符串的情况 - 错误日志格式:包含 args 字符串便于诊断 这确保了需要工具调用的场景能够正常工作。
478 lines
14 KiB
Go
478 lines
14 KiB
Go
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(¶ms, 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
|
||
}
|