mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
9fed4ec136
* feat: add anthropic-messages protocol support Add native Anthropic Messages API format support to enable compatibility with custom endpoints that only support Anthropic's native message format (not OpenAI-compatible format). Changes: - Add new pkg/providers/anthropic_messages package with HTTP-based provider - Implement Anthropic Messages API request/response format conversion - Add anthropic-messages protocol support in factory_provider.go - Include comprehensive unit tests (64.2% coverage) Features: - Support for system, user, assistant, and tool messages - Support for tool calls (tool_use blocks) - Proper header handling (x-api-key, anthropic-version) - Configurable max_tokens and temperature - Automatic base URL normalization Configuration example: model: "anthropic-messages/claude-opus-4-6" api_base: "https://api.anthropic.com" api_key: "sk-..." Tested with actual API endpoint, verified compatibility with Anthropic Messages API specification. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: add anthropic-messages protocol examples to README and config Add configuration examples and documentation for the new anthropic-messages protocol: - config.example.json: Add claude-opus-4.6 example with anthropic-messages - README.md: Add "Anthropic Messages API (native format)" section - README.zh.md: Add Chinese version of the documentation This helps users understand when to use anthropic-messages vs anthropic protocol and fixes issue #269. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: format code with gofmt -s - Align constant definitions in provider.go - Align struct fields in test cases - Fix gofmt formatting issues reported in review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address linter errors - Fix HTTP header canonical form: "x-api-key" → "X-API-Key" - Fix HTTP header canonical form: "anthropic-version" → "Anthropic-Version" - Format imports with gci (standard, default, localmodule order) - Format code with golines (max line length 120) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve golangci-lint errors in anthropic-messages provider - add nolint comment for canonicalheader rule on X-API-Key header (Anthropic API requires exact casing) - fix golines formatting issues in provider_test.go (split long lines under 120 chars) - fix long comment line in factory_provider.go (split into two lines) Resolves CI linter failures for the anthropic-messages protocol implementation. * fix(providers): address review comments in anthropic-messages provider - fix normalizeBaseURL edge case that incorrectly appends /v1 to URLs already containing /v1 path (e.g., https://api.example.com/v1/proxy) - remove dead code for apiBase empty check as normalizeBaseURL() always provides a default value - update test to use proper constructor instead of direct struct initialization - add detailed comments explaining the URL normalization logic Resolves review comments on PR #1284 * fix(providers): remove hardcoded max_tokens in anthropic-messages provider - remove hardcoded max_tokens value (4096) from buildRequestBody - read max_tokens directly from options parameter - add error handling when max_tokens is missing from options - update test cases to include max_tokens in options This fix ensures the provider respects the config default value (32768) or system fallback (8192) instead of always using the hardcoded 4096. * fix(providers): improve error handling and add edge case tests - fix ToolCalls nil vs empty slice issue to ensure consistent JSON serialization - add detailed HTTP error handling for common status codes (401, 429, 400, 404, 500, 503) - add edge case tests for buildRequestBody and parseResponseBody - clarify anthropic vs anthropic-messages protocol differences in docs --------- Co-authored-by: Claude <noreply@anthropic.com>
416 lines
10 KiB
Go
416 lines
10 KiB
Go
// PicoClaw - Ultra-lightweight personal AI agent
|
|
// License: MIT
|
|
//
|
|
// Copyright (c) 2026 PicoClaw contributors
|
|
|
|
package anthropicmessages
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"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 (
|
|
defaultAPIVersion = "2023-06-01"
|
|
defaultBaseURL = "https://api.anthropic.com/v1"
|
|
defaultRequestTimeout = 120 * time.Second
|
|
)
|
|
|
|
// Provider implements Anthropic Messages API via HTTP (without SDK).
|
|
// It supports custom endpoints that use Anthropic's native message format.
|
|
type Provider struct {
|
|
apiKey string
|
|
apiBase string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewProvider creates a new Anthropic Messages API provider.
|
|
func NewProvider(apiKey, apiBase string) *Provider {
|
|
return NewProviderWithTimeout(apiKey, apiBase, 0)
|
|
}
|
|
|
|
// NewProviderWithTimeout creates a provider with custom request timeout.
|
|
func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider {
|
|
baseURL := normalizeBaseURL(apiBase)
|
|
timeout := defaultRequestTimeout
|
|
if timeoutSeconds > 0 {
|
|
timeout = time.Duration(timeoutSeconds) * time.Second
|
|
}
|
|
|
|
return &Provider{
|
|
apiKey: apiKey,
|
|
apiBase: baseURL,
|
|
httpClient: &http.Client{
|
|
Timeout: timeout,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Chat sends messages to the Anthropic Messages API and returns the response.
|
|
func (p *Provider) Chat(
|
|
ctx context.Context,
|
|
messages []Message,
|
|
tools []ToolDefinition,
|
|
model string,
|
|
options map[string]any,
|
|
) (*LLMResponse, error) {
|
|
if p.apiKey == "" {
|
|
return nil, fmt.Errorf("API key not configured")
|
|
}
|
|
|
|
// Build request body
|
|
requestBody, err := buildRequestBody(messages, tools, model, options)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building request body: %w", err)
|
|
}
|
|
|
|
// Serialize to JSON
|
|
jsonBody, err := json.Marshal(requestBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serializing request body: %w", err)
|
|
}
|
|
|
|
// Build request URL
|
|
endpointURL, err := url.JoinPath(p.apiBase, "messages")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building endpoint URL: %w", err)
|
|
}
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequestWithContext(ctx, "POST", endpointURL, bytes.NewReader(jsonBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating HTTP request: %w", err)
|
|
}
|
|
|
|
// Set headers
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
|
|
req.Header.Set("Anthropic-Version", defaultAPIVersion)
|
|
|
|
// Execute request
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("executing HTTP request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read response body
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading response body: %w", err)
|
|
}
|
|
|
|
// Check for HTTP errors with detailed messages
|
|
switch resp.StatusCode {
|
|
case http.StatusUnauthorized:
|
|
return nil, fmt.Errorf("authentication failed (401): check your API key")
|
|
case http.StatusTooManyRequests:
|
|
return nil, fmt.Errorf("rate limited (429): %s", string(body))
|
|
case http.StatusBadRequest:
|
|
return nil, fmt.Errorf("bad request (400): %s", string(body))
|
|
case http.StatusNotFound:
|
|
return nil, fmt.Errorf("endpoint not found (404): %s", string(body))
|
|
case http.StatusInternalServerError:
|
|
return nil, fmt.Errorf("internal server error (500): %s", string(body))
|
|
case http.StatusServiceUnavailable:
|
|
return nil, fmt.Errorf("service unavailable (503): %s", string(body))
|
|
default:
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
}
|
|
|
|
// Parse response
|
|
return parseResponseBody(body)
|
|
}
|
|
|
|
// GetDefaultModel returns the default model for this provider.
|
|
func (p *Provider) GetDefaultModel() string {
|
|
return "claude-sonnet-4.6"
|
|
}
|
|
|
|
// buildRequestBody converts internal message format to Anthropic Messages API format.
|
|
func buildRequestBody(
|
|
messages []Message,
|
|
tools []ToolDefinition,
|
|
model string,
|
|
options map[string]any,
|
|
) (map[string]any, error) {
|
|
// max_tokens is required and guaranteed by agent loop
|
|
maxTokens, ok := asInt(options["max_tokens"])
|
|
if !ok {
|
|
return nil, fmt.Errorf("max_tokens is required in options")
|
|
}
|
|
|
|
result := map[string]any{
|
|
"model": model,
|
|
"max_tokens": int64(maxTokens),
|
|
"messages": []any{},
|
|
}
|
|
|
|
// Set temperature from options
|
|
if temp, ok := asFloat(options["temperature"]); ok {
|
|
result["temperature"] = temp
|
|
}
|
|
|
|
// Process messages
|
|
var systemPrompt string
|
|
var apiMessages []any
|
|
|
|
for _, msg := range messages {
|
|
switch msg.Role {
|
|
case "system":
|
|
// Accumulate system messages
|
|
if systemPrompt != "" {
|
|
systemPrompt += "\n\n" + msg.Content
|
|
} else {
|
|
systemPrompt = msg.Content
|
|
}
|
|
|
|
case "user":
|
|
if msg.ToolCallID != "" {
|
|
// Tool result message
|
|
content := []map[string]any{
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": msg.ToolCallID,
|
|
"content": msg.Content,
|
|
},
|
|
}
|
|
apiMessages = append(apiMessages, map[string]any{
|
|
"role": "user",
|
|
"content": content,
|
|
})
|
|
} else {
|
|
// Regular user message
|
|
apiMessages = append(apiMessages, map[string]any{
|
|
"role": "user",
|
|
"content": msg.Content,
|
|
})
|
|
}
|
|
|
|
case "assistant":
|
|
content := []any{}
|
|
|
|
// Add text content if present
|
|
if msg.Content != "" {
|
|
content = append(content, map[string]any{
|
|
"type": "text",
|
|
"text": msg.Content,
|
|
})
|
|
}
|
|
|
|
// Add tool_use blocks
|
|
for _, tc := range msg.ToolCalls {
|
|
toolUse := map[string]any{
|
|
"type": "tool_use",
|
|
"id": tc.ID,
|
|
"name": tc.Name,
|
|
"input": tc.Arguments,
|
|
}
|
|
content = append(content, toolUse)
|
|
}
|
|
|
|
apiMessages = append(apiMessages, map[string]any{
|
|
"role": "assistant",
|
|
"content": content,
|
|
})
|
|
|
|
case "tool":
|
|
// Tool result (alternative format)
|
|
content := []map[string]any{
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": msg.ToolCallID,
|
|
"content": msg.Content,
|
|
},
|
|
}
|
|
apiMessages = append(apiMessages, map[string]any{
|
|
"role": "user",
|
|
"content": content,
|
|
})
|
|
}
|
|
}
|
|
|
|
result["messages"] = apiMessages
|
|
|
|
// Set system prompt if present
|
|
if systemPrompt != "" {
|
|
result["system"] = systemPrompt
|
|
}
|
|
|
|
// Add tools if present
|
|
if len(tools) > 0 {
|
|
result["tools"] = buildTools(tools)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// buildTools converts tool definitions to Anthropic format.
|
|
func buildTools(tools []ToolDefinition) []any {
|
|
result := make([]any, len(tools))
|
|
for i, tool := range tools {
|
|
toolDef := map[string]any{
|
|
"name": tool.Function.Name,
|
|
"description": tool.Function.Description,
|
|
"input_schema": tool.Function.Parameters,
|
|
}
|
|
result[i] = toolDef
|
|
}
|
|
return result
|
|
}
|
|
|
|
// parseResponseBody parses Anthropic Messages API response.
|
|
func parseResponseBody(body []byte) (*LLMResponse, error) {
|
|
var resp anthropicMessageResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("parsing JSON response: %w", err)
|
|
}
|
|
|
|
// Extract content and tool calls
|
|
var content strings.Builder
|
|
toolCalls := make([]ToolCall, 0) // Initialize as empty slice (not nil) for consistent JSON serialization
|
|
|
|
for _, block := range resp.Content {
|
|
switch block.Type {
|
|
case "text":
|
|
content.WriteString(block.Text)
|
|
case "tool_use":
|
|
argsJSON, _ := json.Marshal(block.Input)
|
|
toolCalls = append(toolCalls, ToolCall{
|
|
ID: block.ID,
|
|
Name: block.Name,
|
|
Arguments: block.Input,
|
|
Function: &FunctionCall{
|
|
Name: block.Name,
|
|
Arguments: string(argsJSON),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Map stop_reason
|
|
finishReason := "stop"
|
|
switch resp.StopReason {
|
|
case "tool_use":
|
|
finishReason = "tool_calls"
|
|
case "max_tokens":
|
|
finishReason = "length"
|
|
case "end_turn":
|
|
finishReason = "stop"
|
|
case "stop_sequence":
|
|
finishReason = "stop"
|
|
}
|
|
|
|
return &LLMResponse{
|
|
Content: content.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),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// normalizeBaseURL ensures the base URL is properly formatted.
|
|
// It removes /v1 suffix if present (to avoid duplication) and always appends /v1.
|
|
// This handles edge cases like "https://api.example.com/v1/proxy" correctly.
|
|
func normalizeBaseURL(apiBase string) string {
|
|
base := strings.TrimSpace(apiBase)
|
|
if base == "" {
|
|
return defaultBaseURL
|
|
}
|
|
|
|
// Remove trailing slashes
|
|
base = strings.TrimRight(base, "/")
|
|
|
|
// Remove /v1 suffix if present (will be re-added)
|
|
// This prevents duplication for URLs like "https://api.example.com/v1/proxy"
|
|
if before, ok := strings.CutSuffix(base, "/v1"); ok {
|
|
base = before
|
|
}
|
|
|
|
// Ensure we don't have an empty string after cutting
|
|
if base == "" {
|
|
return defaultBaseURL
|
|
}
|
|
|
|
// Add /v1 suffix (required by Anthropic Messages API)
|
|
return base + "/v1"
|
|
}
|
|
|
|
// Helper functions for type conversion
|
|
|
|
func asInt(v any) (int, bool) {
|
|
switch val := v.(type) {
|
|
case int:
|
|
return val, true
|
|
case float64:
|
|
return int(val), true
|
|
case int64:
|
|
return int(val), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func asFloat(v any) (float64, bool) {
|
|
switch val := v.(type) {
|
|
case float64:
|
|
return val, true
|
|
case int:
|
|
return float64(val), true
|
|
case int64:
|
|
return float64(val), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// Anthropic API response structures
|
|
|
|
type anthropicMessageResponse struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
Content []contentBlock `json:"content"`
|
|
StopReason string `json:"stop_reason"`
|
|
Model string `json:"model"`
|
|
Usage usageInfo `json:"usage"`
|
|
}
|
|
|
|
type contentBlock struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Input map[string]any `json:"input,omitempty"`
|
|
}
|
|
|
|
type usageInfo struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
}
|