Files
nofx/mcp/provider/claude.go
tinkle-community 8e294a5eed refactor: restructure project directories for better modularity
- Delete llm/ dead code (3 files, zero references)
- Split mcp/ into sub-packages: mcp/provider/ (8 providers) and
  mcp/payment/ (4 payment clients) with registry pattern
- Export Client internal fields and ClientHooks interface for
  sub-package access
- Split api/server.go (3892 lines) into 8 domain-specific handler files
- Split trader/auto_trader.go (2296 lines) into 5 focused files
- Reorganize web/src/components/ flat files into auth/, charts/,
  trader/, common/, modals/, backtest/ subdirectories
- Update all consumer imports to use registry-based provider creation
2026-03-11 23:58:13 +08:00

299 lines
10 KiB
Go

// Package provider — ClaudeClient implements the Anthropic Messages API.
//
// Wire-format differences from the OpenAI-compatible base Client:
//
// ┌─────────────────────┬───────────────────────────┬─────────────────────────────────┐
// │ Concept │ OpenAI format │ Anthropic format │
// ├─────────────────────┼───────────────────────────┼─────────────────────────────────┤
// │ Endpoint │ /v1/chat/completions │ /v1/messages │
// │ Auth header │ Authorization: Bearer xxx │ x-api-key: xxx │
// │ System prompt │ messages[0] role=system │ top-level "system" field │
// │ Tool definition │ type=function + parameters │ name + description + input_schema│
// │ Tool choice │ "auto" (string) │ {"type":"auto"} (object) │
// │ Assistant tool call │ tool_calls array │ content[{type:tool_use,...}] │
// │ Tool result │ role=tool + tool_call_id │ role=user content[tool_result] │
// │ Max tokens │ max_tokens │ max_tokens (same) │
// └─────────────────────┴───────────────────────────┴─────────────────────────────────┘
package provider
import (
"encoding/json"
"fmt"
"net/http"
"nofx/mcp"
)
const (
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
DefaultClaudeModel = "claude-opus-4-6"
)
func init() {
mcp.RegisterProvider(mcp.ProviderClaude, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewClaudeClientWithOptions(opts...)
})
}
// ClaudeClient wraps the base Client and overrides the methods that differ
// for the Anthropic Messages API. All other behaviour (retry, timeout,
// logging) is inherited unchanged.
type ClaudeClient struct {
*mcp.Client
}
func (c *ClaudeClient) BaseClient() *mcp.Client { return c.Client }
// NewClaudeClient creates a ClaudeClient with default settings.
func NewClaudeClient() mcp.AIClient {
return NewClaudeClientWithOptions()
}
// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides.
func NewClaudeClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
baseClient := mcp.NewClient(append([]mcp.ClientOption{
mcp.WithProvider(mcp.ProviderClaude),
mcp.WithModel(DefaultClaudeModel),
mcp.WithBaseURL(DefaultClaudeBaseURL),
}, opts...)...).(*mcp.Client)
c := &ClaudeClient{Client: baseClient}
baseClient.Hooks = c // wire dynamic dispatch to ClaudeClient
return c
}
// ── Hook overrides ────────────────────────────────────────────────────────────
// SetAPIKey stores credentials and optional custom endpoint / model.
func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.Log.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.Log.Infof("🔧 [MCP] Claude BaseURL: %s", customURL)
}
if customModel != "" {
c.Model = customModel
c.Log.Infof("🔧 [MCP] Claude Model: %s", customModel)
}
}
// SetAuthHeader uses x-api-key instead of Authorization: Bearer.
func (c *ClaudeClient) SetAuthHeader(h http.Header) {
h.Set("x-api-key", c.APIKey)
h.Set("anthropic-version", "2023-06-01")
}
// BuildUrl targets /messages instead of /chat/completions.
func (c *ClaudeClient) BuildUrl() string {
return fmt.Sprintf("%s/messages", c.BaseURL)
}
// BuildMCPRequestBody builds the Anthropic wire format for the simple
// CallWithMessages path (no tool support).
func (c *ClaudeClient) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
return map[string]any{
"model": c.Model,
"max_tokens": c.MaxTokens,
"system": systemPrompt,
"messages": []map[string]string{
{"role": "user", "content": userPrompt},
},
}
}
// BuildRequestBodyFromRequest converts a *Request into the Anthropic Messages
// API wire format.
func (c *ClaudeClient) BuildRequestBodyFromRequest(req *mcp.Request) map[string]any {
// ── 1. Separate system prompt from conversation messages ──────────────────
var systemPrompt string
var convMsgs []mcp.Message
for _, m := range req.Messages {
if m.Role == "system" {
systemPrompt = m.Content
} else {
convMsgs = append(convMsgs, m)
}
}
// ── 2. Convert messages to Anthropic format ───────────────────────────────
anthropicMsgs := ConvertMessagesToAnthropic(convMsgs)
// ── 3. Convert tool definitions (parameters → input_schema) ──────────────
var anthropicTools []map[string]any
for _, t := range req.Tools {
anthropicTools = append(anthropicTools, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": t.Function.Parameters,
})
}
// ── 4. Assemble request body ──────────────────────────────────────────────
body := map[string]any{
"model": req.Model,
"max_tokens": c.MaxTokens,
"system": systemPrompt,
"messages": anthropicMsgs,
}
if len(anthropicTools) > 0 {
body["tools"] = anthropicTools
}
// tool_choice: Anthropic uses an object, not a string.
switch req.ToolChoice {
case "auto":
body["tool_choice"] = map[string]any{"type": "auto"}
case "any":
body["tool_choice"] = map[string]any{"type": "any"}
case "none", "":
// omit — no tool_choice sent
}
if req.Temperature != nil {
body["temperature"] = *req.Temperature
}
return body
}
// ConvertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message
// slice to Anthropic's messages array.
func ConvertMessagesToAnthropic(msgs []mcp.Message) []map[string]any {
var out []map[string]any
for i := 0; i < len(msgs); {
msg := msgs[i]
switch {
// ── Assistant message carrying tool calls ─────────────────────────────
case msg.Role == "assistant" && len(msg.ToolCalls) > 0:
var blocks []map[string]any
for _, tc := range msg.ToolCalls {
// Arguments are a JSON string; Claude wants a parsed object.
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{"_raw": tc.Function.Arguments}
}
blocks = append(blocks, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
out = append(out, map[string]any{
"role": "assistant",
"content": blocks,
})
i++
// ── Tool result message(s) → single user turn ─────────────────────────
case msg.Role == "tool":
// Collect all consecutive tool-result messages.
var blocks []map[string]any
for i < len(msgs) && msgs[i].Role == "tool" {
blocks = append(blocks, map[string]any{
"type": "tool_result",
"tool_use_id": msgs[i].ToolCallID,
"content": msgs[i].Content,
})
i++
}
out = append(out, map[string]any{
"role": "user",
"content": blocks,
})
// ── Regular user / assistant text message ─────────────────────────────
default:
out = append(out, map[string]any{
"role": msg.Role,
"content": msg.Content,
})
i++
}
}
return out
}
// ── Response parsers ──────────────────────────────────────────────────────────
// ParseMCPResponse extracts the plain-text reply from an Anthropic response.
func (c *ClaudeClient) ParseMCPResponse(body []byte) (string, error) {
r, err := c.ParseMCPResponseFull(body)
if err != nil {
return "", err
}
return r.Content, nil
}
// ParseMCPResponseFull extracts both text and tool calls from an Anthropic
// response envelope.
func (c *ClaudeClient) ParseMCPResponseFull(body []byte) (*mcp.LLMResponse, error) {
var raw struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("failed to parse Anthropic response: %w — body: %s", err, body)
}
if raw.Error != nil {
return nil, fmt.Errorf("Anthropic API error: %s — %s", raw.Error.Type, raw.Error.Message)
}
total := raw.Usage.InputTokens + raw.Usage.OutputTokens
if mcp.TokenUsageCallback != nil && total > 0 {
mcp.TokenUsageCallback(mcp.TokenUsage{
Provider: c.Provider,
Model: c.Model,
PromptTokens: raw.Usage.InputTokens,
CompletionTokens: raw.Usage.OutputTokens,
TotalTokens: total,
})
}
result := &mcp.LLMResponse{}
for _, block := range raw.Content {
switch block.Type {
case "text":
result.Content = block.Text
case "tool_use":
// Input is a JSON object; serialise back to a JSON string so it
// matches the ToolCallFunction.Arguments field (always a string).
argsJSON, err := json.Marshal(block.Input)
if err != nil {
argsJSON = []byte("{}")
}
result.ToolCalls = append(result.ToolCalls, mcp.ToolCall{
ID: block.ID,
Type: "function",
Function: mcp.ToolCallFunction{
Name: block.Name,
Arguments: string(argsJSON),
},
})
}
}
return result, nil
}