- Add new 'anthropic-messages' protocol prefix to support Anthropic's native Messages API format (/v1/messages endpoint) - Use anthropicprovider.NewProviderWithBaseURL() for native format requests - Modify anthropicprovider to use streaming API (Messages.NewStreaming) for compatibility with services requiring streaming - Aggregate streaming events into complete response for backward compatibility - Update config.example.json with usage documentation - Maintain full backward compatibility with existing 'anthropic' protocol (OpenAI-compatible /chat/completions format) This change enables picoclaw to work with Anthropic-compatible APIs that only support the native Messages format, such as Weibo's GLM-4.7 service. BREAKING-CHANGE: anthropicprovider now uses streaming API internally, but maintains the same interface (aggregates to complete response). This should not affect existing users.
434 lines
12 KiB
Go
434 lines
12 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/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()
|
|
|
|
// 收集所有流式事件以便后续处理
|
|
var events []anthropic.MessageStreamEventUnion
|
|
for stream.Next() {
|
|
events = append(events, stream.Current())
|
|
}
|
|
|
|
err = stream.Err()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claude API call: %w", err)
|
|
}
|
|
|
|
// 从收集的事件中提取完整的响应
|
|
return parseStreamingEvents(events), 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),
|
|
},
|
|
}
|
|
}
|
|
|
|
// parseStreamingEvents 从流式事件中提取完整的 Message 对象
|
|
func parseStreamingEvents(events []anthropic.MessageStreamEventUnion) *LLMResponse {
|
|
var content strings.Builder
|
|
var reasoning strings.Builder
|
|
var toolCalls []ToolCall
|
|
var stopReason anthropic.StopReason
|
|
var usage anthropic.Usage
|
|
|
|
for _, evt := range events {
|
|
switch evt.Type {
|
|
case "message_start":
|
|
if msg := evt.AsMessageStart(); msg.Message.ID != "" {
|
|
usage = msg.Message.Usage
|
|
}
|
|
case "content_block_start":
|
|
block := evt.AsContentBlockStart()
|
|
switch block.ContentBlock.Type {
|
|
case "tool_use":
|
|
// 工具调用开始,在 delta 中处理
|
|
}
|
|
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":
|
|
// 工具调用参数增量,需要累积
|
|
// TODO: 实现完整的工具调用支持
|
|
}
|
|
case "content_block_stop":
|
|
// 内容块结束
|
|
case "message_delta":
|
|
msgDelta := evt.AsMessageDelta()
|
|
stopReason = msgDelta.Delta.StopReason
|
|
// 更新 usage 字段
|
|
usage.OutputTokens = msgDelta.Usage.OutputTokens
|
|
case "message_stop":
|
|
// 消息完成
|
|
case "error":
|
|
log.Printf("anthropic: streaming error: %v", evt)
|
|
}
|
|
}
|
|
|
|
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),
|
|
},
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|