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>
This commit is contained in:
parent
457533b960
commit
c602550576
3 changed files with 864 additions and 1 deletions
392
pkg/providers/anthropic_messages/provider.go
Normal file
392
pkg/providers/anthropic_messages/provider.go
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
// 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.apiBase == "" {
|
||||
return nil, fmt.Errorf("API base not configured")
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
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) {
|
||||
result := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": int64(4096),
|
||||
"messages": []any{},
|
||||
}
|
||||
|
||||
// Set max_tokens from options
|
||||
if mt, ok := asInt(options["max_tokens"]); ok {
|
||||
result["max_tokens"] = int64(mt)
|
||||
}
|
||||
|
||||
// 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
|
||||
var toolCalls []ToolCall
|
||||
|
||||
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.
|
||||
func normalizeBaseURL(apiBase string) string {
|
||||
base := strings.TrimSpace(apiBase)
|
||||
if base == "" {
|
||||
return defaultBaseURL
|
||||
}
|
||||
|
||||
base = strings.TrimRight(base, "/")
|
||||
// Add /v1 if not present
|
||||
if !strings.HasSuffix(base, "/v1") {
|
||||
base = base + "/v1"
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
455
pkg/providers/anthropic_messages/provider_test.go
Normal file
455
pkg/providers/anthropic_messages/provider_test.go
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package anthropicmessages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildRequestBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []Message
|
||||
tools []ToolDefinition
|
||||
model string
|
||||
options map[string]any
|
||||
want map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic user message",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "Hello, world!"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(4096),
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "Hello, world!",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user and assistant messages",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "What is 2+2?"},
|
||||
{Role: "assistant", Content: "4"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(4096),
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "What is 2+2?",
|
||||
},
|
||||
map[string]any{
|
||||
"role": "assistant",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with system message",
|
||||
messages: []Message{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(4096),
|
||||
"system": "You are a helpful assistant.",
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "Hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with custom max_tokens and temperature",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "Test"},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.5,
|
||||
},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(2048),
|
||||
"temperature": 0.5,
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "Test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with tools",
|
||||
messages: []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
},
|
||||
tools: []ToolDefinition{
|
||||
{
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get current weather",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
model: "test-model",
|
||||
options: map[string]any{},
|
||||
want: map[string]any{
|
||||
"model": "test-model",
|
||||
"max_tokens": int64(4096),
|
||||
"messages": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": "What's the weather?",
|
||||
},
|
||||
},
|
||||
"tools": []any{
|
||||
map[string]any{
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather",
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(tt.want, "", " ")
|
||||
t.Errorf("buildRequestBody() mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponseBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
want *LLMResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic text response",
|
||||
body: []byte(`{
|
||||
"id": "msg-123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello, how can I help?"}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"model": "test-model",
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5
|
||||
}
|
||||
}`),
|
||||
want: &LLMResponse{
|
||||
Content: "Hello, how can I help?",
|
||||
ToolCalls: []ToolCall{},
|
||||
FinishReason: "stop",
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: 10,
|
||||
CompletionTokens: 5,
|
||||
TotalTokens: 15,
|
||||
},
|
||||
Reasoning: "",
|
||||
ReasoningDetails: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "response with tool use",
|
||||
body: []byte(`{
|
||||
"id": "msg-456",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll check the weather for you."},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu-123",
|
||||
"name": "get_weather",
|
||||
"input": {"location": "Tokyo"}
|
||||
}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"model": "test-model",
|
||||
"usage": {
|
||||
"input_tokens": 20,
|
||||
"output_tokens": 15
|
||||
}
|
||||
}`),
|
||||
want: &LLMResponse{
|
||||
Content: "I'll check the weather for you.",
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "toolu-123",
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{
|
||||
"location": "Tokyo",
|
||||
},
|
||||
Function: &FunctionCall{
|
||||
Name: "get_weather",
|
||||
Arguments: `{"location":"Tokyo"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
FinishReason: "tool_calls",
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: 20,
|
||||
CompletionTokens: 15,
|
||||
TotalTokens: 35,
|
||||
},
|
||||
Reasoning: "",
|
||||
ReasoningDetails: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
body: []byte(`invalid json`),
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "max_tokens stop reason",
|
||||
body: []byte(`{
|
||||
"id": "msg-789",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Partial response"}
|
||||
],
|
||||
"stop_reason": "max_tokens",
|
||||
"model": "test-model",
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 4096
|
||||
}
|
||||
}`),
|
||||
want: &LLMResponse{
|
||||
Content: "Partial response",
|
||||
ToolCalls: []ToolCall{},
|
||||
FinishReason: "length",
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 4096,
|
||||
TotalTokens: 4196,
|
||||
},
|
||||
Reasoning: "",
|
||||
ReasoningDetails: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseResponseBody(tt.body)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Compare individual fields
|
||||
if got.Content != tt.want.Content {
|
||||
t.Errorf("Content = %q, want %q", got.Content, tt.want.Content)
|
||||
}
|
||||
if got.FinishReason != tt.want.FinishReason {
|
||||
t.Errorf("FinishReason = %q, want %q", got.FinishReason, tt.want.FinishReason)
|
||||
}
|
||||
if got.Usage == nil && tt.want.Usage != nil {
|
||||
t.Errorf("Usage = nil, want non-nil")
|
||||
} else if got.Usage != nil && tt.want.Usage == nil {
|
||||
t.Errorf("Usage = non-nil, want nil")
|
||||
} else if got.Usage != nil && tt.want.Usage != nil {
|
||||
if got.Usage.PromptTokens != tt.want.Usage.PromptTokens {
|
||||
t.Errorf("Usage.PromptTokens = %d, want %d", got.Usage.PromptTokens, tt.want.Usage.PromptTokens)
|
||||
}
|
||||
if got.Usage.CompletionTokens != tt.want.Usage.CompletionTokens {
|
||||
t.Errorf("Usage.CompletionTokens = %d, want %d", got.Usage.CompletionTokens, tt.want.Usage.CompletionTokens)
|
||||
}
|
||||
if got.Usage.TotalTokens != tt.want.Usage.TotalTokens {
|
||||
t.Errorf("Usage.TotalTokens = %d, want %d", got.Usage.TotalTokens, tt.want.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
if len(got.ToolCalls) != len(tt.want.ToolCalls) {
|
||||
t.Errorf("ToolCalls length = %d, want %d", len(got.ToolCalls), len(tt.want.ToolCalls))
|
||||
} else {
|
||||
for i := range got.ToolCalls {
|
||||
if got.ToolCalls[i].ID != tt.want.ToolCalls[i].ID {
|
||||
t.Errorf("ToolCalls[%d].ID = %q, want %q", i, got.ToolCalls[i].ID, tt.want.ToolCalls[i].ID)
|
||||
}
|
||||
if got.ToolCalls[i].Name != tt.want.ToolCalls[i].Name {
|
||||
t.Errorf("ToolCalls[%d].Name = %q, want %q", i, got.ToolCalls[i].Name, tt.want.ToolCalls[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiBase string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty string defaults to official API",
|
||||
apiBase: "",
|
||||
expected: "https://api.anthropic.com/v1",
|
||||
},
|
||||
{
|
||||
name: "URL without /v1 gets it appended",
|
||||
apiBase: "https://api.example.com/anthropic",
|
||||
expected: "https://api.example.com/anthropic/v1",
|
||||
},
|
||||
{
|
||||
name: "URL with /v1 remains unchanged",
|
||||
apiBase: "https://api.example.com/v1",
|
||||
expected: "https://api.example.com/v1",
|
||||
},
|
||||
{
|
||||
name: "URL with trailing slash gets cleaned",
|
||||
apiBase: "https://api.example.com/anthropic/",
|
||||
expected: "https://api.example.com/anthropic/v1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := normalizeBaseURL(tt.apiBase)
|
||||
if got != tt.expected {
|
||||
t.Errorf("normalizeBaseURL(%q) = %q, want %q", tt.apiBase, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProvider(t *testing.T) {
|
||||
provider := NewProvider("test-key", "https://api.example.com")
|
||||
if provider == nil {
|
||||
t.Fatal("NewProvider() returned nil")
|
||||
}
|
||||
if provider.apiKey != "test-key" {
|
||||
t.Errorf("provider.apiKey = %q, want %q", provider.apiKey, "test-key")
|
||||
}
|
||||
if provider.apiBase != "https://api.example.com/v1" {
|
||||
t.Errorf("provider.apiBase = %q, want %q", provider.apiBase, "https://api.example.com/v1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultModel(t *testing.T) {
|
||||
provider := NewProvider("test-key", "")
|
||||
got := provider.GetDefaultModel()
|
||||
expected := "claude-sonnet-4.6"
|
||||
if got != expected {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Mock HTTP server test for integration testing
|
||||
func TestProviderChatErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiKey string
|
||||
apiBase string
|
||||
messages []Message
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "missing API base",
|
||||
apiKey: "test-key",
|
||||
apiBase: "",
|
||||
messages: []Message{{Role: "user", Content: "Test"}},
|
||||
wantErrMsg: "API base not configured",
|
||||
},
|
||||
{
|
||||
name: "missing API key",
|
||||
apiKey: "",
|
||||
apiBase: "https://api.example.com",
|
||||
messages: []Message{{Role: "user", Content: "Test"}},
|
||||
wantErrMsg: "API key not configured",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create provider with empty apiBase to trigger error
|
||||
provider := &Provider{
|
||||
apiKey: tt.apiKey,
|
||||
apiBase: tt.apiBase,
|
||||
}
|
||||
|
||||
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error, got nil")
|
||||
}
|
||||
if err.Error() != tt.wantErrMsg {
|
||||
t.Errorf("Chat() error = %q, want %q", err.Error(), tt.wantErrMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages"
|
||||
)
|
||||
|
||||
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||
|
|
@ -53,7 +54,7 @@ func ExtractProtocol(model string) (protocol, modelID string) {
|
|||
|
||||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||
// Supported protocols: openai, litellm, anthropic, antigravity, claude-cli, codex-cli, github-copilot
|
||||
// Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity, claude-cli, codex-cli, github-copilot
|
||||
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||
if cfg == nil {
|
||||
|
|
@ -137,6 +138,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
cfg.RequestTimeout,
|
||||
), modelID, nil
|
||||
|
||||
case "anthropic-messages":
|
||||
// Anthropic Messages API with native format (HTTP-based, no SDK)
|
||||
apiBase := cfg.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.anthropic.com/v1"
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model)
|
||||
}
|
||||
return anthropicmessages.NewProviderWithTimeout(
|
||||
cfg.APIKey,
|
||||
apiBase,
|
||||
cfg.RequestTimeout,
|
||||
), modelID, nil
|
||||
|
||||
case "antigravity":
|
||||
return NewAntigravityProvider(), modelID, nil
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue