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
This commit is contained in:
tinkle-community
2026-03-11 23:58:13 +08:00
parent 6a30e11ee5
commit 8e294a5eed
103 changed files with 6391 additions and 8984 deletions

View File

@@ -1,248 +0,0 @@
package mcp
import (
"encoding/json"
"net/http"
"testing"
)
// ── buildRequestBodyFromRequest ────────────────────────────────────────────────
func TestClaudeClient_BuildRequestBody_SystemPromptLifted(t *testing.T) {
c := newTestClaudeClient()
req := &Request{
Model: "claude-opus-4-6",
Messages: []Message{
{Role: "system", Content: "You are helpful."},
{Role: "user", Content: "Hello"},
},
}
body := c.buildRequestBodyFromRequest(req)
if body["system"] != "You are helpful." {
t.Errorf("system not lifted to top level: %v", body["system"])
}
msgs := body["messages"].([]map[string]any)
if len(msgs) != 1 || msgs[0]["role"] != "user" {
t.Errorf("system message should be removed from messages array: %v", msgs)
}
}
func TestClaudeClient_BuildRequestBody_ToolsUseInputSchema(t *testing.T) {
c := newTestClaudeClient()
req := &Request{
Model: "claude-opus-4-6",
Messages: []Message{{Role: "user", Content: "hi"}},
Tools: []Tool{{
Type: "function",
Function: FunctionDef{
Name: "my_tool",
Description: "does stuff",
Parameters: map[string]any{"type": "object"},
},
}},
}
body := c.buildRequestBodyFromRequest(req)
tools, ok := body["tools"].([]map[string]any)
if !ok || len(tools) != 1 {
t.Fatalf("tools not set correctly: %v", body["tools"])
}
tool := tools[0]
if tool["name"] != "my_tool" {
t.Errorf("tool name wrong: %v", tool["name"])
}
if tool["input_schema"] == nil {
t.Error("tool must use input_schema, not parameters")
}
if _, hasParams := tool["parameters"]; hasParams {
t.Error("tool must NOT have parameters key (Anthropic uses input_schema)")
}
}
func TestClaudeClient_BuildRequestBody_ToolChoiceObject(t *testing.T) {
c := newTestClaudeClient()
req := &Request{
Model: "claude-opus-4-6",
Messages: []Message{{Role: "user", Content: "hi"}},
ToolChoice: "auto",
}
body := c.buildRequestBodyFromRequest(req)
tc, ok := body["tool_choice"].(map[string]any)
if !ok {
t.Fatalf("tool_choice must be an object, got: %T %v", body["tool_choice"], body["tool_choice"])
}
if tc["type"] != "auto" {
t.Errorf("tool_choice.type must be 'auto', got: %v", tc["type"])
}
}
// ── convertMessagesToAnthropic ─────────────────────────────────────────────────
func TestConvertMessages_AssistantToolCall(t *testing.T) {
msgs := []Message{
{
Role: "assistant",
ToolCalls: []ToolCall{{
ID: "tc1",
Type: "function",
Function: ToolCallFunction{Name: "api_request", Arguments: `{"method":"GET","path":"/api/x","body":{}}`},
}},
},
}
out := convertMessagesToAnthropic(msgs)
if len(out) != 1 {
t.Fatalf("expected 1 message, got %d", len(out))
}
msg := out[0]
if msg["role"] != "assistant" {
t.Errorf("role should be assistant: %v", msg["role"])
}
blocks := msg["content"].([]map[string]any)
if len(blocks) != 1 || blocks[0]["type"] != "tool_use" {
t.Errorf("content should be tool_use block: %v", blocks)
}
if blocks[0]["id"] != "tc1" {
t.Errorf("tool_use id wrong: %v", blocks[0]["id"])
}
// Input must be parsed JSON object, not a string.
input, ok := blocks[0]["input"].(map[string]any)
if !ok {
t.Errorf("tool_use input must be map, got %T", blocks[0]["input"])
}
if input["method"] != "GET" {
t.Errorf("input.method wrong: %v", input)
}
}
func TestConvertMessages_ToolResultMergedIntoUserTurn(t *testing.T) {
// Anthropic requires strictly alternating turns; consecutive tool results
// must be merged into a single user message.
msgs := []Message{
{Role: "tool", ToolCallID: "tc1", Content: `{"result":"a"}`},
{Role: "tool", ToolCallID: "tc2", Content: `{"result":"b"}`},
}
out := convertMessagesToAnthropic(msgs)
if len(out) != 1 {
t.Fatalf("consecutive tool results must be merged into one user turn, got %d messages", len(out))
}
if out[0]["role"] != "user" {
t.Errorf("tool results must become role=user: %v", out[0]["role"])
}
blocks := out[0]["content"].([]map[string]any)
if len(blocks) != 2 {
t.Errorf("expected 2 tool_result blocks, got %d", len(blocks))
}
if blocks[0]["type"] != "tool_result" || blocks[1]["type"] != "tool_result" {
t.Errorf("blocks should be tool_result: %v", blocks)
}
if blocks[0]["tool_use_id"] != "tc1" || blocks[1]["tool_use_id"] != "tc2" {
t.Errorf("tool_use_id mismatch: %v", blocks)
}
}
// ── parseMCPResponseFull ───────────────────────────────────────────────────────
func TestClaudeClient_ParseResponse_TextOnly(t *testing.T) {
c := newTestClaudeClient()
body := []byte(`{
"content": [{"type":"text","text":"Hello from Claude"}],
"usage": {"input_tokens": 10, "output_tokens": 5}
}`)
resp, err := c.parseMCPResponseFull(body)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "Hello from Claude" {
t.Errorf("content mismatch: %q", resp.Content)
}
if len(resp.ToolCalls) != 0 {
t.Errorf("expected no tool calls: %v", resp.ToolCalls)
}
}
func TestClaudeClient_ParseResponse_ToolUse(t *testing.T) {
c := newTestClaudeClient()
body := []byte(`{
"content": [{
"type": "tool_use",
"id": "toolu_01abc",
"name": "api_request",
"input": {"method":"POST","path":"/api/strategies","body":{"name":"BTC策略"}}
}],
"usage": {"input_tokens": 100, "output_tokens": 30}
}`)
resp, err := c.parseMCPResponseFull(body)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls))
}
tc := resp.ToolCalls[0]
if tc.ID != "toolu_01abc" {
t.Errorf("tool call ID wrong: %v", tc.ID)
}
if tc.Function.Name != "api_request" {
t.Errorf("function name wrong: %v", tc.Function.Name)
}
// Arguments must be a valid JSON string.
var args map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
t.Errorf("arguments not valid JSON: %q — %v", tc.Function.Arguments, err)
}
if args["method"] != "POST" {
t.Errorf("args.method wrong: %v", args)
}
}
func TestClaudeClient_ParseResponse_APIError(t *testing.T) {
c := newTestClaudeClient()
body := []byte(`{"error":{"type":"authentication_error","message":"invalid x-api-key"}}`)
_, err := c.parseMCPResponseFull(body)
if err == nil {
t.Fatal("expected error for API error response")
}
if err.Error() == "" {
t.Error("error message should not be empty")
}
}
// ── Auth header ────────────────────────────────────────────────────────────────
func TestClaudeClient_SetAuthHeader(t *testing.T) {
c := newTestClaudeClient()
c.APIKey = "sk-ant-test123"
// net/http.Header canonicalizes keys (x-api-key → X-Api-Key).
h := make(http.Header)
c.setAuthHeader(h)
if got := h.Get("x-api-key"); got != "sk-ant-test123" {
t.Errorf("x-api-key header not set correctly: %q", got)
}
if h.Get("anthropic-version") == "" {
t.Error("anthropic-version header must be set")
}
// Must NOT use Authorization: Bearer (that's OpenAI format).
if h.Get("Authorization") != "" {
t.Error("Claude must use x-api-key, not Authorization header")
}
}
func TestClaudeClient_BuildUrl(t *testing.T) {
c := newTestClaudeClient()
url := c.buildUrl()
if url != DefaultClaudeBaseURL+"/messages" {
t.Errorf("URL should be /messages endpoint, got: %s", url)
}
}
// ── helpers ────────────────────────────────────────────────────────────────────
func newTestClaudeClient() *ClaudeClient {
return NewClaudeClientWithOptions().(*ClaudeClient)
}

View File

@@ -60,14 +60,14 @@ type Client struct {
UseFullURL bool // Whether to use full URL (without appending /chat/completions)
MaxTokens int // Maximum tokens for AI response
httpClient *http.Client
logger Logger // Logger (replaceable)
config *Config // Config object (stores all configurations)
HTTPClient *http.Client // Exported for sub-packages
Log Logger // Exported for sub-packages
Cfg *Config // Exported for sub-packages
// hooks are used to implement dynamic dispatch (polymorphism)
// When DeepSeekClient embeds Client, hooks point to DeepSeekClient
// This way methods called in call() are automatically dispatched to the overridden version in subclass
hooks clientHooks
// Hooks are used to implement dynamic dispatch (polymorphism)
// When provider.DeepSeekClient embeds Client, Hooks point to DeepSeekClient
// This way methods called in Call() are automatically dispatched to the overridden version
Hooks ClientHooks
}
// New creates default client (backward compatible)
@@ -80,21 +80,22 @@ func New() AIClient {
// NewClient creates client (supports options pattern)
//
// Usage examples:
// // Basic usage (backward compatible)
// client := mcp.NewClient()
//
// // Custom logger
// client := mcp.NewClient(mcp.WithLogger(customLogger))
// // Basic usage (backward compatible)
// client := mcp.NewClient()
//
// // Custom timeout
// client := mcp.NewClient(mcp.WithTimeout(60*time.Second))
// // Custom logger
// client := mcp.NewClient(mcp.WithLogger(customLogger))
//
// // Combine multiple options
// client := mcp.NewClient(
// mcp.WithDeepSeekConfig("sk-xxx"),
// mcp.WithLogger(customLogger),
// mcp.WithTimeout(60*time.Second),
// )
// // Custom timeout
// client := mcp.NewClient(mcp.WithTimeout(60*time.Second))
//
// // Combine multiple options
// client := mcp.NewClient(
// mcp.WithDeepSeekConfig("sk-xxx"),
// mcp.WithLogger(customLogger),
// mcp.WithTimeout(60*time.Second),
// )
func NewClient(opts ...ClientOption) AIClient {
// 1. Create default config
cfg := DefaultConfig()
@@ -112,9 +113,9 @@ func NewClient(opts ...ClientOption) AIClient {
Model: cfg.Model,
MaxTokens: cfg.MaxTokens,
UseFullURL: cfg.UseFullURL,
httpClient: cfg.HTTPClient,
logger: cfg.Logger,
config: cfg,
HTTPClient: cfg.HTTPClient,
Log: cfg.Logger,
Cfg: cfg,
}
// 4. Set default Provider (if not set)
@@ -125,7 +126,7 @@ func NewClient(opts ...ClientOption) AIClient {
}
// 5. Set hooks to point to self
client.hooks = client
client.Hooks = client
return client
}
@@ -148,7 +149,7 @@ func (client *Client) SetAPIKey(apiKey, apiURL, customModel string) {
}
func (client *Client) SetTimeout(timeout time.Duration) {
client.httpClient.Timeout = timeout
client.HTTPClient.Timeout = timeout
}
// CallWithMessages template method - fixed retry flow (cannot be overridden)
@@ -159,32 +160,32 @@ func (client *Client) CallWithMessages(systemPrompt, userPrompt string) (string,
// Fixed retry flow
var lastErr error
maxRetries := client.config.MaxRetries
maxRetries := client.Cfg.MaxRetries
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
client.logger.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
client.Log.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
}
// Call the fixed single-call flow
result, err := client.hooks.call(systemPrompt, userPrompt)
result, err := client.Hooks.Call(systemPrompt, userPrompt)
if err == nil {
if attempt > 1 {
client.logger.Infof("✓ AI API retry succeeded")
client.Log.Infof("✓ AI API retry succeeded")
}
return result, nil
}
lastErr = err
// Check if error is retryable via hooks (supports custom retry strategy in subclass)
if !client.hooks.isRetryableError(err) {
// Check if error is retryable via hooks (supports custom retry strategy)
if !client.Hooks.IsRetryableError(err) {
return "", err
}
// Wait before retry
if attempt < maxRetries {
waitTime := client.config.RetryWaitBase * time.Duration(attempt)
client.logger.Infof("⏳ Waiting %v before retry...", waitTime)
waitTime := client.Cfg.RetryWaitBase * time.Duration(attempt)
client.Log.Infof("⏳ Waiting %v before retry...", waitTime)
time.Sleep(waitTime)
}
}
@@ -192,11 +193,11 @@ func (client *Client) CallWithMessages(systemPrompt, userPrompt string) (string,
return "", fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
}
func (client *Client) setAuthHeader(reqHeader http.Header) {
func (client *Client) SetAuthHeader(reqHeader http.Header) {
reqHeader.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey))
}
func (client *Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
func (client *Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
// Build messages array
messages := []map[string]string{}
@@ -217,7 +218,7 @@ func (client *Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[s
requestBody := map[string]interface{}{
"model": client.Model,
"messages": messages,
"temperature": client.config.Temperature, // Use configured temperature
"temperature": client.Cfg.Temperature, // Use configured temperature
}
// OpenAI newer models use max_completion_tokens instead of max_tokens
if client.Provider == ProviderOpenAI {
@@ -228,8 +229,8 @@ func (client *Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[s
return requestBody
}
// can be used to marshal the request body and can be overridden
func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, error) {
// MarshalRequestBody can be used to marshal the request body and can be overridden
func (client *Client) MarshalRequestBody(requestBody map[string]any) ([]byte, error) {
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to serialize request: %w", err)
@@ -237,17 +238,17 @@ func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, er
return jsonData, nil
}
func (client *Client) parseMCPResponse(body []byte) (string, error) {
r, err := client.parseMCPResponseFull(body)
func (client *Client) ParseMCPResponse(body []byte) (string, error) {
r, err := client.ParseMCPResponseFull(body)
if err != nil {
return "", err
}
return r.Content, nil
}
// parseMCPResponseFull parses the OpenAI-format response body and returns both
// ParseMCPResponseFull parses the OpenAI-format response body and returns both
// the text content and any tool calls.
func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
func (client *Client) ParseMCPResponseFull(body []byte) (*LLMResponse, error) {
var result struct {
Choices []struct {
Message struct {
@@ -288,14 +289,14 @@ func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
}, nil
}
func (client *Client) buildUrl() string {
func (client *Client) BuildUrl() string {
if client.UseFullURL {
return client.BaseURL
}
return fmt.Sprintf("%s/chat/completions", client.BaseURL)
}
func (client *Client) buildRequest(url string, jsonData []byte) (*http.Request, error) {
func (client *Client) BuildRequest(url string, jsonData []byte) (*http.Request, error) {
// Create HTTP request
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
@@ -304,42 +305,42 @@ func (client *Client) buildRequest(url string, jsonData []byte) (*http.Request,
req.Header.Set("Content-Type", "application/json")
// Set auth header via hooks (supports overriding in subclass)
client.hooks.setAuthHeader(req.Header)
// Set auth header via hooks (supports overriding)
client.Hooks.SetAuthHeader(req.Header)
return req, nil
}
// call single AI API call (fixed flow, cannot be overridden)
func (client *Client) call(systemPrompt, userPrompt string) (string, error) {
// Call single AI API call (fixed flow, cannot be overridden)
func (client *Client) Call(systemPrompt, userPrompt string) (string, error) {
// Print current AI configuration
client.logger.Infof("📡 [%s] Request AI Server: BaseURL: %s", client.String(), client.BaseURL)
client.logger.Debugf("[%s] UseFullURL: %v", client.String(), client.UseFullURL)
client.Log.Infof("📡 [%s] Request AI Server: BaseURL: %s", client.String(), client.BaseURL)
client.Log.Debugf("[%s] UseFullURL: %v", client.String(), client.UseFullURL)
if len(client.APIKey) > 8 {
client.logger.Debugf("[%s] API Key: %s...%s", client.String(), client.APIKey[:4], client.APIKey[len(client.APIKey)-4:])
client.Log.Debugf("[%s] API Key: %s...%s", client.String(), client.APIKey[:4], client.APIKey[len(client.APIKey)-4:])
}
// Step 1: Build request body (via hooks for dynamic dispatch)
requestBody := client.hooks.buildMCPRequestBody(systemPrompt, userPrompt)
requestBody := client.Hooks.BuildMCPRequestBody(systemPrompt, userPrompt)
// Step 2: Serialize request body (via hooks for dynamic dispatch)
jsonData, err := client.hooks.marshalRequestBody(requestBody)
jsonData, err := client.Hooks.MarshalRequestBody(requestBody)
if err != nil {
return "", err
}
// Step 3: Build URL (via hooks for dynamic dispatch)
url := client.hooks.buildUrl()
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
url := client.Hooks.BuildUrl()
client.Log.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
// Step 4: Create HTTP request (fixed logic)
req, err := client.hooks.buildRequest(url, jsonData)
req, err := client.Hooks.BuildRequest(url, jsonData)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Step 5: Send HTTP request (fixed logic)
resp, err := client.httpClient.Do(req)
resp, err := client.HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
@@ -357,7 +358,7 @@ func (client *Client) call(systemPrompt, userPrompt string) (string, error) {
}
// Step 8: Parse response (via hooks for dynamic dispatch)
result, err := client.hooks.parseMCPResponse(body)
result, err := client.Hooks.ParseMCPResponse(body)
if err != nil {
return "", fmt.Errorf("fail to parse AI server response: %w", err)
}
@@ -370,11 +371,11 @@ func (client *Client) String() string {
client.Provider, client.Model)
}
// isRetryableError determines if error is retryable (network errors, timeouts, etc.)
func (client *Client) isRetryableError(err error) bool {
// IsRetryableError determines if error is retryable (network errors, timeouts, etc.)
func (client *Client) IsRetryableError(err error) bool {
errStr := err.Error()
// Network errors, timeouts, EOF, etc. can be retried
for _, retryable := range client.config.RetryableErrors {
for _, retryable := range client.Cfg.RetryableErrors {
if strings.Contains(errStr, retryable) {
return true
}
@@ -387,20 +388,6 @@ func (client *Client) isRetryableError(err error) bool {
// ============================================================
// CallWithRequest calls AI API using Request object (supports advanced features)
//
// This method supports:
// - Multi-turn conversation history
// - Fine-grained parameter control (temperature, top_p, penalties, etc.)
// - Function Calling / Tools
// - Streaming response (future support)
//
// Usage example:
// request := NewRequestBuilder().
// WithSystemPrompt("You are helpful").
// WithUserPrompt("Hello").
// WithTemperature(0.8).
// Build()
// result, err := client.CallWithRequest(request)
func (client *Client) CallWithRequest(req *Request) (string, error) {
if client.APIKey == "" {
return "", fmt.Errorf("AI API key not set, please call SetAPIKey first")
@@ -413,32 +400,32 @@ func (client *Client) CallWithRequest(req *Request) (string, error) {
// Fixed retry flow
var lastErr error
maxRetries := client.config.MaxRetries
maxRetries := client.Cfg.MaxRetries
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
client.logger.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
client.Log.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
}
// Call single request
result, err := client.callWithRequest(req)
if err == nil {
if attempt > 1 {
client.logger.Infof("✓ AI API retry succeeded")
client.Log.Infof("✓ AI API retry succeeded")
}
return result, nil
}
lastErr = err
// Check if error is retryable
if !client.hooks.isRetryableError(err) {
if !client.Hooks.IsRetryableError(err) {
return "", err
}
// Wait before retry
if attempt < maxRetries {
waitTime := client.config.RetryWaitBase * time.Duration(attempt)
client.logger.Infof("⏳ Waiting %v before retry...", waitTime)
waitTime := client.Cfg.RetryWaitBase * time.Duration(attempt)
client.Log.Infof("⏳ Waiting %v before retry...", waitTime)
time.Sleep(waitTime)
}
}
@@ -456,21 +443,21 @@ func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
}
var lastErr error
maxRetries := client.config.MaxRetries
maxRetries := client.Cfg.MaxRetries
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
client.logger.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
client.Log.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
}
result, err := client.callWithRequestFull(req)
if err == nil {
return result, nil
}
lastErr = err
if !client.hooks.isRetryableError(err) {
if !client.Hooks.IsRetryableError(err) {
return nil, err
}
if attempt < maxRetries {
waitTime := client.config.RetryWaitBase * time.Duration(attempt)
waitTime := client.Cfg.RetryWaitBase * time.Duration(attempt)
time.Sleep(waitTime)
}
}
@@ -479,21 +466,21 @@ func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
// callWithRequestFull single call that returns LLMResponse (content + tool calls).
func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL)
client.Log.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL)
requestBody := client.hooks.buildRequestBodyFromRequest(req)
jsonData, err := client.hooks.marshalRequestBody(requestBody)
requestBody := client.Hooks.BuildRequestBodyFromRequest(req)
jsonData, err := client.Hooks.MarshalRequestBody(requestBody)
if err != nil {
return nil, err
}
url := client.hooks.buildUrl()
httpReq, err := client.hooks.buildRequest(url, jsonData)
url := client.Hooks.BuildUrl()
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.httpClient.Do(httpReq)
resp, err := client.HTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
@@ -507,31 +494,31 @@ func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
return nil, fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
}
return client.hooks.parseMCPResponseFull(body)
return client.Hooks.ParseMCPResponseFull(body)
}
// callWithRequest single AI API call (using Request object)
func (client *Client) callWithRequest(req *Request) (string, error) {
// Print current AI configuration
client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL)
client.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages))
client.Log.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL)
client.Log.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages))
requestBody := client.hooks.buildRequestBodyFromRequest(req)
requestBody := client.Hooks.BuildRequestBodyFromRequest(req)
jsonData, err := client.hooks.marshalRequestBody(requestBody)
jsonData, err := client.Hooks.MarshalRequestBody(requestBody)
if err != nil {
return "", err
}
url := client.hooks.buildUrl()
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
url := client.Hooks.BuildUrl()
client.Log.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
httpReq, err := client.hooks.buildRequest(url, jsonData)
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.httpClient.Do(httpReq)
resp, err := client.HTTPClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
@@ -546,7 +533,7 @@ func (client *Client) callWithRequest(req *Request) (string, error) {
return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
}
result, err := client.hooks.parseMCPResponse(body)
result, err := client.Hooks.ParseMCPResponse(body)
if err != nil {
return "", fmt.Errorf("fail to parse AI server response: %w", err)
}
@@ -554,8 +541,8 @@ func (client *Client) callWithRequest(req *Request) (string, error) {
return result, nil
}
// buildRequestBodyFromRequest builds request body from Request object
func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
// BuildRequestBodyFromRequest builds request body from Request object
func (client *Client) BuildRequestBodyFromRequest(req *Request) map[string]any {
// Convert Message to API format — must use map[string]any to support
// tool-call messages (tool_calls, tool_call_id fields).
messages := make([]map[string]any, 0, len(req.Messages))
@@ -586,7 +573,7 @@ func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
requestBody["temperature"] = *req.Temperature
} else {
// If not set in Request, use Client's configuration
requestBody["temperature"] = client.config.Temperature
requestBody["temperature"] = client.Cfg.Temperature
}
// OpenAI newer models use max_completion_tokens instead of max_tokens
@@ -647,19 +634,19 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
}
req.Stream = true
requestBody := client.hooks.buildRequestBodyFromRequest(req)
jsonData, err := client.hooks.marshalRequestBody(requestBody)
requestBody := client.Hooks.BuildRequestBodyFromRequest(req)
jsonData, err := client.Hooks.MarshalRequestBody(requestBody)
if err != nil {
return "", err
}
url := client.hooks.buildUrl()
httpReq, err := client.hooks.buildRequest(url, jsonData)
url := client.Hooks.BuildUrl()
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
if err != nil {
return "", err
}
// Idle-timeout watchdog: cancel the request if no SSE line arrives for 30 seconds.
// Idle-timeout watchdog: cancel the request if no SSE line arrives for 60 seconds.
// This breaks the scanner out of an indefinitely blocking Read on a hung connection.
const idleTimeout = 60 * time.Second
ctx, cancel := context.WithCancel(context.Background())
@@ -689,7 +676,7 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
}()
httpReq = httpReq.WithContext(ctx)
resp, err := client.httpClient.Do(httpReq)
resp, err := client.HTTPClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("streaming request failed: %w", err)
}

View File

@@ -27,16 +27,16 @@ func TestNewClient_Default(t *testing.T) {
t.Error("MaxTokens should be positive")
}
if c.logger == nil {
t.Error("logger should not be nil")
if c.Log == nil {
t.Error("Log should not be nil")
}
if c.httpClient == nil {
t.Error("httpClient should not be nil")
if c.HTTPClient == nil {
t.Error("HTTPClient should not be nil")
}
if c.hooks == nil {
t.Error("hooks should not be nil")
if c.Hooks == nil {
t.Error("Hooks should not be nil")
}
}
@@ -54,12 +54,12 @@ func TestNewClient_WithOptions(t *testing.T) {
c := client.(*Client)
if c.logger != mockLogger {
t.Error("logger should be set from option")
if c.Log != mockLogger {
t.Error("Log should be set from option")
}
if c.httpClient != mockHTTP {
t.Error("httpClient should be set from option")
if c.HTTPClient != mockHTTP {
t.Error("HTTPClient should be set from option")
}
if c.MaxTokens != 4000 {
@@ -174,7 +174,7 @@ func TestClient_Retry_Success(t *testing.T) {
WithMaxRetries(3),
)
// Since our client uses hooks.call, need special handling
// Since our client uses Hooks.Call, need special handling
// Here we test that CallWithMessages will invoke retry logic
c := client.(*Client)
@@ -242,7 +242,7 @@ func TestClient_BuildMCPRequestBody(t *testing.T) {
client := NewClient()
c := client.(*Client)
body := c.buildMCPRequestBody("system prompt", "user prompt")
body := c.BuildMCPRequestBody("system prompt", "user prompt")
if body == nil {
t.Fatal("body should not be nil")
@@ -300,7 +300,7 @@ func TestClient_BuildUrl(t *testing.T) {
)
c := client.(*Client)
url := c.buildUrl()
url := c.BuildUrl()
if url != tt.expected {
t.Errorf("expected '%s', got '%s'", tt.expected, url)
}
@@ -313,7 +313,7 @@ func TestClient_SetAuthHeader(t *testing.T) {
c := client.(*Client)
headers := make(http.Header)
c.setAuthHeader(headers)
c.SetAuthHeader(headers)
authHeader := headers.Get("Authorization")
if authHeader != "Bearer test-api-key" {
@@ -359,7 +359,7 @@ func TestClient_IsRetryableError(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := c.isRetryableError(tt.err)
result := c.IsRetryableError(tt.err)
if result != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, result)
}
@@ -378,8 +378,8 @@ func TestClient_SetTimeout(t *testing.T) {
client.SetTimeout(newTimeout)
c := client.(*Client)
if c.httpClient.Timeout != newTimeout {
t.Errorf("expected timeout %v, got %v", newTimeout, c.httpClient.Timeout)
if c.HTTPClient.Timeout != newTimeout {
t.Errorf("expected timeout %v, got %v", newTimeout, c.HTTPClient.Timeout)
}
}

View File

@@ -84,7 +84,7 @@ func TestConfig_Temperature_IsUsed(t *testing.T) {
c := client.(*Client)
// Build request body
requestBody := c.buildMCPRequestBody("system", "user")
requestBody := c.BuildMCPRequestBody("system", "user")
// Verify temperature field
temp, ok := requestBody["temperature"].(float64)
@@ -201,7 +201,7 @@ func TestConfig_RetryableErrors_IsUsed(t *testing.T) {
c := client.(*Client)
// Modify config's RetryableErrors (no WithRetryableErrors option yet)
c.config.RetryableErrors = customRetryableErrors
c.Cfg.RetryableErrors = customRetryableErrors
tests := []struct {
name string
@@ -227,7 +227,7 @@ func TestConfig_RetryableErrors_IsUsed(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := c.isRetryableError(tt.err)
result := c.IsRetryableError(tt.err)
if result != tt.retryable {
t.Errorf("expected isRetryableError(%v) = %v, got %v", tt.err, tt.retryable, result)
}
@@ -244,19 +244,19 @@ func TestConfig_DefaultValues(t *testing.T) {
c := client.(*Client)
// Verify default values
if c.config.MaxRetries != 3 {
t.Errorf("default MaxRetries should be 3, got %d", c.config.MaxRetries)
if c.Cfg.MaxRetries != 3 {
t.Errorf("default MaxRetries should be 3, got %d", c.Cfg.MaxRetries)
}
if c.config.Temperature != 0.5 {
t.Errorf("default Temperature should be 0.5, got %f", c.config.Temperature)
if c.Cfg.Temperature != 0.5 {
t.Errorf("default Temperature should be 0.5, got %f", c.Cfg.Temperature)
}
if c.config.RetryWaitBase != 2*time.Second {
t.Errorf("default RetryWaitBase should be 2s, got %v", c.config.RetryWaitBase)
if c.Cfg.RetryWaitBase != 2*time.Second {
t.Errorf("default RetryWaitBase should be 2s, got %v", c.Cfg.RetryWaitBase)
}
if len(c.config.RetryableErrors) == 0 {
if len(c.Cfg.RetryableErrors) == 0 {
t.Error("default RetryableErrors should not be empty")
}
}

View File

@@ -1,83 +0,0 @@
package mcp
import (
"net/http"
)
const (
ProviderDeepSeek = "deepseek"
DefaultDeepSeekBaseURL = "https://api.deepseek.com"
DefaultDeepSeekModel = "deepseek-chat"
)
type DeepSeekClient struct {
*Client
}
// NewDeepSeekClient creates DeepSeek client (backward compatible)
//
// Deprecated: Recommend using NewDeepSeekClientWithOptions for better flexibility
func NewDeepSeekClient() AIClient {
return NewDeepSeekClientWithOptions()
}
// NewDeepSeekClientWithOptions creates DeepSeek client (supports options pattern)
//
// Usage examples:
// // Basic usage
// client := mcp.NewDeepSeekClientWithOptions()
//
// // Custom configuration
// client := mcp.NewDeepSeekClientWithOptions(
// mcp.WithAPIKey("sk-xxx"),
// mcp.WithLogger(customLogger),
// mcp.WithTimeout(60*time.Second),
// )
func NewDeepSeekClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create DeepSeek preset options
deepseekOpts := []ClientOption{
WithProvider(ProviderDeepSeek),
WithModel(DefaultDeepSeekModel),
WithBaseURL(DefaultDeepSeekBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(deepseekOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create DeepSeek client
dsClient := &DeepSeekClient{
Client: baseClient,
}
// 5. Set hooks to point to DeepSeekClient (implement dynamic dispatch)
baseClient.hooks = dsClient
return dsClient
}
func (dsClient *DeepSeekClient) SetAPIKey(apiKey string, customURL string, customModel string) {
dsClient.APIKey = apiKey
if len(apiKey) > 8 {
dsClient.logger.Infof("🔧 [MCP] DeepSeek API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
dsClient.BaseURL = customURL
dsClient.logger.Infof("🔧 [MCP] DeepSeek using custom BaseURL: %s", customURL)
} else {
dsClient.logger.Infof("🔧 [MCP] DeepSeek using default BaseURL: %s", dsClient.BaseURL)
}
if customModel != "" {
dsClient.Model = customModel
dsClient.logger.Infof("🔧 [MCP] DeepSeek using custom Model: %s", customModel)
} else {
dsClient.logger.Infof("🔧 [MCP] DeepSeek using default Model: %s", dsClient.Model)
}
}
func (dsClient *DeepSeekClient) setAuthHeader(reqHeaders http.Header) {
dsClient.Client.setAuthHeader(reqHeaders)
}

View File

@@ -1,272 +0,0 @@
package mcp
import (
"testing"
"time"
)
// ============================================================
// Test DeepSeekClient Creation and Configuration
// ============================================================
func TestNewDeepSeekClient_Default(t *testing.T) {
client := NewDeepSeekClient()
if client == nil {
t.Fatal("client should not be nil")
}
// Type assertion check
dsClient, ok := client.(*DeepSeekClient)
if !ok {
t.Fatal("client should be *DeepSeekClient")
}
// Verify default values
if dsClient.Provider != ProviderDeepSeek {
t.Errorf("Provider should be '%s', got '%s'", ProviderDeepSeek, dsClient.Provider)
}
if dsClient.BaseURL != DefaultDeepSeekBaseURL {
t.Errorf("BaseURL should be '%s', got '%s'", DefaultDeepSeekBaseURL, dsClient.BaseURL)
}
if dsClient.Model != DefaultDeepSeekModel {
t.Errorf("Model should be '%s', got '%s'", DefaultDeepSeekModel, dsClient.Model)
}
if dsClient.logger == nil {
t.Error("logger should not be nil")
}
if dsClient.httpClient == nil {
t.Error("httpClient should not be nil")
}
}
func TestNewDeepSeekClientWithOptions(t *testing.T) {
mockLogger := NewMockLogger()
customModel := "deepseek-v2"
customAPIKey := "sk-custom-key"
client := NewDeepSeekClientWithOptions(
WithLogger(mockLogger),
WithModel(customModel),
WithAPIKey(customAPIKey),
WithMaxTokens(4000),
)
dsClient := client.(*DeepSeekClient)
// Verify custom options are applied
if dsClient.logger != mockLogger {
t.Error("logger should be set from option")
}
if dsClient.Model != customModel {
t.Error("Model should be set from option")
}
if dsClient.APIKey != customAPIKey {
t.Error("APIKey should be set from option")
}
if dsClient.MaxTokens != 4000 {
t.Error("MaxTokens should be 4000")
}
// Verify DeepSeek default values are retained
if dsClient.Provider != ProviderDeepSeek {
t.Errorf("Provider should still be '%s'", ProviderDeepSeek)
}
if dsClient.BaseURL != DefaultDeepSeekBaseURL {
t.Errorf("BaseURL should still be '%s'", DefaultDeepSeekBaseURL)
}
}
// ============================================================
// Test SetAPIKey
// ============================================================
func TestDeepSeekClient_SetAPIKey(t *testing.T) {
mockLogger := NewMockLogger()
client := NewDeepSeekClientWithOptions(
WithLogger(mockLogger),
)
dsClient := client.(*DeepSeekClient)
// Test setting API Key (default URL and Model)
dsClient.SetAPIKey("sk-test-key-12345678", "", "")
if dsClient.APIKey != "sk-test-key-12345678" {
t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", dsClient.APIKey)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
if len(logs) == 0 {
t.Error("should have logged API key setting")
}
// Verify BaseURL and Model remain default
if dsClient.BaseURL != DefaultDeepSeekBaseURL {
t.Error("BaseURL should remain default")
}
if dsClient.Model != DefaultDeepSeekModel {
t.Error("Model should remain default")
}
}
func TestDeepSeekClient_SetAPIKey_WithCustomURL(t *testing.T) {
mockLogger := NewMockLogger()
client := NewDeepSeekClientWithOptions(
WithLogger(mockLogger),
)
dsClient := client.(*DeepSeekClient)
customURL := "https://custom.api.com/v1"
dsClient.SetAPIKey("sk-test-key-12345678", customURL, "")
if dsClient.BaseURL != customURL {
t.Errorf("BaseURL should be '%s', got '%s'", customURL, dsClient.BaseURL)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomURLLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] DeepSeek using custom BaseURL: %s" {
hasCustomURLLog = true
break
}
}
if !hasCustomURLLog {
t.Error("should have logged custom BaseURL")
}
}
func TestDeepSeekClient_SetAPIKey_WithCustomModel(t *testing.T) {
mockLogger := NewMockLogger()
client := NewDeepSeekClientWithOptions(
WithLogger(mockLogger),
)
dsClient := client.(*DeepSeekClient)
customModel := "deepseek-v3"
dsClient.SetAPIKey("sk-test-key-12345678", "", customModel)
if dsClient.Model != customModel {
t.Errorf("Model should be '%s', got '%s'", customModel, dsClient.Model)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomModelLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] DeepSeek using custom Model: %s" {
hasCustomModelLog = true
break
}
}
if !hasCustomModelLog {
t.Error("should have logged custom Model")
}
}
// ============================================================
// Test Integration Features
// ============================================================
func TestDeepSeekClient_CallWithMessages_Success(t *testing.T) {
mockHTTP := NewMockHTTPClient()
mockHTTP.SetSuccessResponse("DeepSeek AI response")
mockLogger := NewMockLogger()
client := NewDeepSeekClientWithOptions(
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(mockLogger),
WithAPIKey("sk-test-key"),
)
result, err := client.CallWithMessages("system prompt", "user prompt")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if result != "DeepSeek AI response" {
t.Errorf("expected 'DeepSeek AI response', got '%s'", result)
}
// Verify request
requests := mockHTTP.GetRequests()
if len(requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(requests))
}
req := requests[0]
// Verify URL
expectedURL := DefaultDeepSeekBaseURL + "/chat/completions"
if req.URL.String() != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String())
}
// Verify Authorization header
authHeader := req.Header.Get("Authorization")
if authHeader != "Bearer sk-test-key" {
t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader)
}
// Verify Content-Type
if req.Header.Get("Content-Type") != "application/json" {
t.Error("Content-Type should be application/json")
}
}
func TestDeepSeekClient_Timeout(t *testing.T) {
client := NewDeepSeekClientWithOptions(
WithTimeout(30 * time.Second),
)
dsClient := client.(*DeepSeekClient)
if dsClient.httpClient.Timeout != 30*time.Second {
t.Errorf("expected timeout 30s, got %v", dsClient.httpClient.Timeout)
}
// Test SetTimeout
client.SetTimeout(60 * time.Second)
if dsClient.httpClient.Timeout != 60*time.Second {
t.Errorf("expected timeout 60s after SetTimeout, got %v", dsClient.httpClient.Timeout)
}
}
// ============================================================
// Test hooks Mechanism
// ============================================================
func TestDeepSeekClient_HooksIntegration(t *testing.T) {
client := NewDeepSeekClientWithOptions()
dsClient := client.(*DeepSeekClient)
// Verify hooks point to dsClient itself (implements polymorphism)
if dsClient.hooks != dsClient {
t.Error("hooks should point to dsClient for polymorphism")
}
// Verify buildUrl uses DeepSeek configuration
url := dsClient.buildUrl()
expectedURL := DefaultDeepSeekBaseURL + "/chat/completions"
if url != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, url)
}
}

View File

@@ -6,6 +6,7 @@ import (
"time"
"nofx/mcp"
"nofx/mcp/provider"
)
// ============================================================
@@ -24,7 +25,7 @@ func Example_backward_compatible() {
func Example_deepseek_backward_compatible() {
// DeepSeek old code continues to work
client := mcp.NewDeepSeekClient()
client := provider.NewDeepSeekClient()
client.SetAPIKey("sk-xxx", "", "")
result, _ := client.CallWithMessages("system", "user")
@@ -141,12 +142,12 @@ func Example_custom_http_client() {
func Example_deepseek_new_api() {
// Basic usage
client := mcp.NewDeepSeekClientWithOptions(
client := provider.NewDeepSeekClientWithOptions(
mcp.WithAPIKey("sk-xxx"),
)
// Advanced usage
client = mcp.NewDeepSeekClientWithOptions(
client = provider.NewDeepSeekClientWithOptions(
mcp.WithAPIKey("sk-xxx"),
mcp.WithLogger(&CustomLogger{}),
mcp.WithTimeout(90*time.Second),
@@ -163,12 +164,12 @@ func Example_deepseek_new_api() {
func Example_qwen_new_api() {
// Basic usage
client := mcp.NewQwenClientWithOptions(
client := provider.NewQwenClientWithOptions(
mcp.WithAPIKey("sk-xxx"),
)
// Advanced usage
client = mcp.NewQwenClientWithOptions(
client = provider.NewQwenClientWithOptions(
mcp.WithAPIKey("sk-xxx"),
mcp.WithLogger(&CustomLogger{}),
mcp.WithTimeout(90*time.Second),
@@ -185,7 +186,7 @@ func Example_qwen_new_api() {
func Example_trader_migration() {
// Old code (continues to work)
oldStyleClient := func(apiKey, customURL, customModel string) mcp.AIClient {
client := mcp.NewDeepSeekClient()
client := provider.NewDeepSeekClient()
client.SetAPIKey(apiKey, customURL, customModel)
return client
}
@@ -204,7 +205,7 @@ func Example_trader_migration() {
opts = append(opts, mcp.WithModel(customModel))
}
return mcp.NewDeepSeekClientWithOptions(opts...)
return provider.NewDeepSeekClientWithOptions(opts...)
}
// Both approaches work
@@ -230,13 +231,7 @@ func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
}
func Example_testing_with_mock() {
// Use Mock during testing
// mockHTTP := &MockHTTPClient{
// Response: `{"choices":[{"message":{"content":"test response"}}]}`,
// }
client := mcp.NewClient(
// mcp.WithHTTPClient(mockHTTP), // Use mockHTTP in actual tests
mcp.WithLogger(mcp.NewNoopLogger()), // Disable logging
)
@@ -258,7 +253,6 @@ func Example_environment_specific() {
// Production environment: structured logging + timeout protection
prodClient := mcp.NewClient(
mcp.WithDeepSeekConfig("sk-xxx"),
// mcp.WithLogger(&ZapLogger{}), // Production-grade logging
mcp.WithTimeout(30*time.Second),
mcp.WithMaxRetries(3),
)
@@ -273,7 +267,7 @@ func Example_environment_specific() {
func Example_real_world_usage() {
// Create client with complete configuration
client := mcp.NewDeepSeekClientWithOptions(
client := provider.NewDeepSeekClientWithOptions(
mcp.WithAPIKey("sk-xxxxxxxxxx"),
mcp.WithTimeout(60*time.Second),
mcp.WithMaxRetries(5),

View File

@@ -1,71 +0,0 @@
package mcp
import (
"net/http"
)
const (
ProviderGemini = "gemini"
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
DefaultGeminiModel = "gemini-3-pro-preview"
)
type GeminiClient struct {
*Client
}
// NewGeminiClient creates Gemini client (backward compatible)
func NewGeminiClient() AIClient {
return NewGeminiClientWithOptions()
}
// NewGeminiClientWithOptions creates Gemini client (supports options pattern)
func NewGeminiClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create Gemini preset options
geminiOpts := []ClientOption{
WithProvider(ProviderGemini),
WithModel(DefaultGeminiModel),
WithBaseURL(DefaultGeminiBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(geminiOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create Gemini client
geminiClient := &GeminiClient{
Client: baseClient,
}
// 5. Set hooks to point to GeminiClient (implement dynamic dispatch)
baseClient.hooks = geminiClient
return geminiClient
}
func (c *GeminiClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] Gemini API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] Gemini using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] Gemini using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] Gemini using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] Gemini using default Model: %s", c.Model)
}
}
// Gemini OpenAI-compatible API uses standard Bearer auth
func (c *GeminiClient) setAuthHeader(reqHeaders http.Header) {
c.Client.setAuthHeader(reqHeaders)
}

View File

@@ -1,71 +0,0 @@
package mcp
import (
"net/http"
)
const (
ProviderGrok = "grok"
DefaultGrokBaseURL = "https://api.x.ai/v1"
DefaultGrokModel = "grok-3-latest"
)
type GrokClient struct {
*Client
}
// NewGrokClient creates Grok client (backward compatible)
func NewGrokClient() AIClient {
return NewGrokClientWithOptions()
}
// NewGrokClientWithOptions creates Grok client (supports options pattern)
func NewGrokClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create Grok preset options
grokOpts := []ClientOption{
WithProvider(ProviderGrok),
WithModel(DefaultGrokModel),
WithBaseURL(DefaultGrokBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(grokOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create Grok client
grokClient := &GrokClient{
Client: baseClient,
}
// 5. Set hooks to point to GrokClient (implement dynamic dispatch)
baseClient.hooks = grokClient
return grokClient
}
func (c *GrokClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] Grok API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] Grok using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] Grok using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] Grok using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] Grok using default Model: %s", c.Model)
}
}
// Grok uses standard OpenAI-compatible API with Bearer auth
func (c *GrokClient) setAuthHeader(reqHeaders http.Header) {
c.Client.setAuthHeader(reqHeaders)
}

37
mcp/hooks.go Normal file
View File

@@ -0,0 +1,37 @@
package mcp
import "net/http"
// ClientHooks is the dispatch interface used to implement per-provider
// polymorphism without Go's lack of virtual methods.
//
// Each method can be overridden by an embedding struct (e.g. provider.ClaudeClient).
// The base *Client provides OpenAI-compatible defaults; providers with a
// different wire format (Anthropic, Gemini native, etc.) override only what
// differs. All call-path methods in client.go invoke these via c.Hooks so
// that the override is always picked up at runtime.
type ClientHooks interface {
// ── Simple CallWithMessages path ────────────────────────────────────────
Call(systemPrompt, userPrompt string) (string, error)
BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
// ── Shared request plumbing ─────────────────────────────────────────────
BuildUrl() string
BuildRequest(url string, jsonData []byte) (*http.Request, error)
SetAuthHeader(reqHeaders http.Header)
MarshalRequestBody(requestBody map[string]any) ([]byte, error)
// ── Advanced (Request-object) path ──────────────────────────────────────
// BuildRequestBodyFromRequest converts a *Request into the provider's
// native wire-format map.
BuildRequestBodyFromRequest(req *Request) map[string]any
// ParseMCPResponse extracts the plain-text reply from a non-streaming
// response body.
ParseMCPResponse(body []byte) (string, error)
// ParseMCPResponseFull extracts both text and tool calls.
ParseMCPResponseFull(body []byte) (*LLMResponse, error)
IsRetryableError(err error) bool
}

View File

@@ -1,10 +1,15 @@
package mcp
import (
"net/http"
"time"
)
// ClientEmbedder is implemented by provider types that embed *Client,
// allowing generic extraction of the underlying base client (e.g. for cloning).
type ClientEmbedder interface {
BaseClient() *Client
}
// AIClient public AI client interface (for external use)
type AIClient interface {
SetAPIKey(apiKey string, customURL string, customModel string)
@@ -21,41 +26,3 @@ type AIClient interface {
// (LLMResponse.ToolCalls), but not both.
CallWithRequestFull(req *Request) (*LLMResponse, error)
}
// clientHooks is the internal dispatch interface used to implement per-provider
// polymorphism without Go's lack of virtual methods.
//
// Each method can be overridden by an embedding struct (e.g. ClaudeClient).
// The base *Client provides OpenAI-compatible defaults; providers with a
// different wire format (Anthropic, Gemini native, etc.) override only what
// differs. All call-path methods in client.go invoke these via c.hooks so
// that the override is always picked up at runtime.
type clientHooks interface {
// ── Simple CallWithMessages path ────────────────────────────────────────
call(systemPrompt, userPrompt string) (string, error)
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
// ── Shared request plumbing ─────────────────────────────────────────────
buildUrl() string
buildRequest(url string, jsonData []byte) (*http.Request, error)
setAuthHeader(reqHeaders http.Header)
marshalRequestBody(requestBody map[string]any) ([]byte, error)
// ── Advanced (Request-object) path ──────────────────────────────────────
// buildRequestBodyFromRequest converts a *Request into the provider's
// native wire-format map. Providers that use a different protocol (e.g.
// Anthropic uses "input_schema" for tools, "tool_use" content blocks, and
// a top-level "system" field) override this method.
buildRequestBodyFromRequest(req *Request) map[string]any
// parseMCPResponse extracts the plain-text reply from a non-streaming
// response body.
parseMCPResponse(body []byte) (string, error)
// parseMCPResponseFull extracts both text and tool calls. Providers whose
// response envelope differs from the OpenAI choices[] structure (e.g.
// Anthropic content[] with tool_use blocks) override this method.
parseMCPResponseFull(body []byte) (*LLMResponse, error)
isRetryableError(err error) bool
}

View File

@@ -1,71 +0,0 @@
package mcp
import (
"net/http"
)
const (
ProviderKimi = "kimi"
DefaultKimiBaseURL = "https://api.moonshot.ai/v1" // Global endpoint (use api.moonshot.cn for China)
DefaultKimiModel = "moonshot-v1-auto"
)
type KimiClient struct {
*Client
}
// NewKimiClient creates Kimi (Moonshot) client (backward compatible)
func NewKimiClient() AIClient {
return NewKimiClientWithOptions()
}
// NewKimiClientWithOptions creates Kimi client (supports options pattern)
func NewKimiClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create Kimi preset options
kimiOpts := []ClientOption{
WithProvider(ProviderKimi),
WithModel(DefaultKimiModel),
WithBaseURL(DefaultKimiBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(kimiOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create Kimi client
kimiClient := &KimiClient{
Client: baseClient,
}
// 5. Set hooks to point to KimiClient (implement dynamic dispatch)
baseClient.hooks = kimiClient
return kimiClient
}
func (c *KimiClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] Kimi API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] Kimi using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] Kimi using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] Kimi using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] Kimi using default Model: %s", c.Model)
}
}
// Kimi uses standard OpenAI-compatible API, so we just use the base client methods
func (c *KimiClient) setAuthHeader(reqHeaders http.Header) {
c.Client.setAuthHeader(reqHeaders)
}

View File

@@ -1,83 +0,0 @@
package mcp
import (
"net/http"
)
const (
ProviderMiniMax = "minimax"
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
DefaultMiniMaxModel = "MiniMax-M2.5"
)
type MiniMaxClient struct {
*Client
}
// NewMiniMaxClient creates MiniMax client (backward compatible)
func NewMiniMaxClient() AIClient {
return NewMiniMaxClientWithOptions()
}
// NewMiniMaxClientWithOptions creates MiniMax client (supports options pattern)
//
// Usage examples:
//
// // Basic usage
// client := mcp.NewMiniMaxClientWithOptions()
//
// // Custom configuration
// client := mcp.NewMiniMaxClientWithOptions(
// mcp.WithAPIKey("sk-xxx"),
// mcp.WithLogger(customLogger),
// mcp.WithTimeout(60*time.Second),
// )
func NewMiniMaxClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create MiniMax preset options
minimaxOpts := []ClientOption{
WithProvider(ProviderMiniMax),
WithModel(DefaultMiniMaxModel),
WithBaseURL(DefaultMiniMaxBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(minimaxOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create MiniMax client
minimaxClient := &MiniMaxClient{
Client: baseClient,
}
// 5. Set hooks to point to MiniMaxClient (implement dynamic dispatch)
baseClient.hooks = minimaxClient
return minimaxClient
}
func (c *MiniMaxClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] MiniMax API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] MiniMax using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] MiniMax using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] MiniMax using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] MiniMax using default Model: %s", c.Model)
}
}
// MiniMax uses standard OpenAI-compatible API with Bearer auth
func (c *MiniMaxClient) setAuthHeader(reqHeaders http.Header) {
c.Client.setAuthHeader(reqHeaders)
}

View File

@@ -1,272 +0,0 @@
package mcp
import (
"testing"
"time"
)
// ============================================================
// Test MiniMaxClient Creation and Configuration
// ============================================================
func TestNewMiniMaxClient_Default(t *testing.T) {
client := NewMiniMaxClient()
if client == nil {
t.Fatal("client should not be nil")
}
// Type assertion check
mmClient, ok := client.(*MiniMaxClient)
if !ok {
t.Fatal("client should be *MiniMaxClient")
}
// Verify default values
if mmClient.Provider != ProviderMiniMax {
t.Errorf("Provider should be '%s', got '%s'", ProviderMiniMax, mmClient.Provider)
}
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Errorf("BaseURL should be '%s', got '%s'", DefaultMiniMaxBaseURL, mmClient.BaseURL)
}
if mmClient.Model != DefaultMiniMaxModel {
t.Errorf("Model should be '%s', got '%s'", DefaultMiniMaxModel, mmClient.Model)
}
if mmClient.logger == nil {
t.Error("logger should not be nil")
}
if mmClient.httpClient == nil {
t.Error("httpClient should not be nil")
}
}
func TestNewMiniMaxClientWithOptions(t *testing.T) {
mockLogger := NewMockLogger()
customModel := "MiniMax-M2.5-highspeed"
customAPIKey := "sk-custom-key"
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
WithModel(customModel),
WithAPIKey(customAPIKey),
WithMaxTokens(4000),
)
mmClient := client.(*MiniMaxClient)
// Verify custom options are applied
if mmClient.logger != mockLogger {
t.Error("logger should be set from option")
}
if mmClient.Model != customModel {
t.Error("Model should be set from option")
}
if mmClient.APIKey != customAPIKey {
t.Error("APIKey should be set from option")
}
if mmClient.MaxTokens != 4000 {
t.Error("MaxTokens should be 4000")
}
// Verify MiniMax default values are retained
if mmClient.Provider != ProviderMiniMax {
t.Errorf("Provider should still be '%s'", ProviderMiniMax)
}
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Errorf("BaseURL should still be '%s'", DefaultMiniMaxBaseURL)
}
}
// ============================================================
// Test SetAPIKey
// ============================================================
func TestMiniMaxClient_SetAPIKey(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
// Test setting API Key (default URL and Model)
mmClient.SetAPIKey("sk-test-key-12345678", "", "")
if mmClient.APIKey != "sk-test-key-12345678" {
t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", mmClient.APIKey)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
if len(logs) == 0 {
t.Error("should have logged API key setting")
}
// Verify BaseURL and Model remain default
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Error("BaseURL should remain default")
}
if mmClient.Model != DefaultMiniMaxModel {
t.Error("Model should remain default")
}
}
func TestMiniMaxClient_SetAPIKey_WithCustomURL(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
customURL := "https://api.minimaxi.com/v1"
mmClient.SetAPIKey("sk-test-key-12345678", customURL, "")
if mmClient.BaseURL != customURL {
t.Errorf("BaseURL should be '%s', got '%s'", customURL, mmClient.BaseURL)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomURLLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] MiniMax using custom BaseURL: %s" {
hasCustomURLLog = true
break
}
}
if !hasCustomURLLog {
t.Error("should have logged custom BaseURL")
}
}
func TestMiniMaxClient_SetAPIKey_WithCustomModel(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
customModel := "MiniMax-M2.5-highspeed"
mmClient.SetAPIKey("sk-test-key-12345678", "", customModel)
if mmClient.Model != customModel {
t.Errorf("Model should be '%s', got '%s'", customModel, mmClient.Model)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomModelLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] MiniMax using custom Model: %s" {
hasCustomModelLog = true
break
}
}
if !hasCustomModelLog {
t.Error("should have logged custom Model")
}
}
// ============================================================
// Test Integration Features
// ============================================================
func TestMiniMaxClient_CallWithMessages_Success(t *testing.T) {
mockHTTP := NewMockHTTPClient()
mockHTTP.SetSuccessResponse("MiniMax AI response")
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(mockLogger),
WithAPIKey("sk-test-key"),
)
result, err := client.CallWithMessages("system prompt", "user prompt")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if result != "MiniMax AI response" {
t.Errorf("expected 'MiniMax AI response', got '%s'", result)
}
// Verify request
requests := mockHTTP.GetRequests()
if len(requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(requests))
}
req := requests[0]
// Verify URL
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
if req.URL.String() != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String())
}
// Verify Authorization header
authHeader := req.Header.Get("Authorization")
if authHeader != "Bearer sk-test-key" {
t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader)
}
// Verify Content-Type
if req.Header.Get("Content-Type") != "application/json" {
t.Error("Content-Type should be application/json")
}
}
func TestMiniMaxClient_Timeout(t *testing.T) {
client := NewMiniMaxClientWithOptions(
WithTimeout(30 * time.Second),
)
mmClient := client.(*MiniMaxClient)
if mmClient.httpClient.Timeout != 30*time.Second {
t.Errorf("expected timeout 30s, got %v", mmClient.httpClient.Timeout)
}
// Test SetTimeout
client.SetTimeout(60 * time.Second)
if mmClient.httpClient.Timeout != 60*time.Second {
t.Errorf("expected timeout 60s after SetTimeout, got %v", mmClient.httpClient.Timeout)
}
}
// ============================================================
// Test hooks Mechanism
// ============================================================
func TestMiniMaxClient_HooksIntegration(t *testing.T) {
client := NewMiniMaxClientWithOptions()
mmClient := client.(*MiniMaxClient)
// Verify hooks point to mmClient itself (implements polymorphism)
if mmClient.hooks != mmClient {
t.Error("hooks should point to mmClient for polymorphism")
}
// Verify buildUrl uses MiniMax configuration
url := mmClient.buildUrl()
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
if url != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, url)
}
}

View File

@@ -247,7 +247,7 @@ func NewMockClientHooks() *MockClientHooks {
return &MockClientHooks{}
}
func (m *MockClientHooks) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
func (m *MockClientHooks) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
m.BuildRequestBodyCalled++
if m.BuildRequestBodyFunc != nil {
return m.BuildRequestBodyFunc(systemPrompt, userPrompt)
@@ -261,7 +261,7 @@ func (m *MockClientHooks) buildMCPRequestBody(systemPrompt, userPrompt string) m
}
}
func (m *MockClientHooks) buildUrl() string {
func (m *MockClientHooks) BuildUrl() string {
m.BuildUrlCalled++
if m.BuildUrlFunc != nil {
return m.BuildUrlFunc()
@@ -269,12 +269,12 @@ func (m *MockClientHooks) buildUrl() string {
return "https://api.test.com/chat/completions"
}
func (m *MockClientHooks) setAuthHeader(headers http.Header) {
func (m *MockClientHooks) SetAuthHeader(headers http.Header) {
m.SetAuthHeaderCalled++
headers.Set("Authorization", "Bearer test-key")
}
func (m *MockClientHooks) marshalRequestBody(body map[string]any) ([]byte, error) {
func (m *MockClientHooks) MarshalRequestBody(body map[string]any) ([]byte, error) {
m.MarshalRequestCalled++
if m.MarshalRequestBodyFunc != nil {
return m.MarshalRequestBodyFunc(body)
@@ -282,7 +282,7 @@ func (m *MockClientHooks) marshalRequestBody(body map[string]any) ([]byte, error
return json.Marshal(body)
}
func (m *MockClientHooks) parseMCPResponse(body []byte) (string, error) {
func (m *MockClientHooks) ParseMCPResponse(body []byte) (string, error) {
m.ParseResponseCalled++
if m.ParseResponseFunc != nil {
return m.ParseResponseFunc(body)
@@ -290,7 +290,15 @@ func (m *MockClientHooks) parseMCPResponse(body []byte) (string, error) {
return "mocked response", nil
}
func (m *MockClientHooks) isRetryableError(err error) bool {
func (m *MockClientHooks) ParseMCPResponseFull(body []byte) (*LLMResponse, error) {
r, err := m.ParseMCPResponse(body)
if err != nil {
return nil, err
}
return &LLMResponse{Content: r}, nil
}
func (m *MockClientHooks) IsRetryableError(err error) bool {
m.IsRetryableErrorCalled++
if m.IsRetryableErrorFunc != nil {
return m.IsRetryableErrorFunc(err)
@@ -298,13 +306,17 @@ func (m *MockClientHooks) isRetryableError(err error) bool {
return false
}
func (m *MockClientHooks) buildRequest(url string, jsonData []byte) (*http.Request, error) {
func (m *MockClientHooks) BuildRequest(url string, jsonData []byte) (*http.Request, error) {
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
m.setAuthHeader(req.Header)
m.SetAuthHeader(req.Header)
return req, nil
}
func (m *MockClientHooks) call(systemPrompt, userPrompt string) (string, error) {
func (m *MockClientHooks) Call(systemPrompt, userPrompt string) (string, error) {
return "mocked call result", nil
}
func (m *MockClientHooks) BuildRequestBodyFromRequest(req *Request) map[string]any {
return map[string]any{"model": "test-model"}
}

View File

@@ -1,71 +0,0 @@
package mcp
import (
"net/http"
)
const (
ProviderOpenAI = "openai"
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
DefaultOpenAIModel = "gpt-5.4"
)
type OpenAIClient struct {
*Client
}
// NewOpenAIClient creates OpenAI client (backward compatible)
func NewOpenAIClient() AIClient {
return NewOpenAIClientWithOptions()
}
// NewOpenAIClientWithOptions creates OpenAI client (supports options pattern)
func NewOpenAIClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create OpenAI preset options
openaiOpts := []ClientOption{
WithProvider(ProviderOpenAI),
WithModel(DefaultOpenAIModel),
WithBaseURL(DefaultOpenAIBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(openaiOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create OpenAI client
openaiClient := &OpenAIClient{
Client: baseClient,
}
// 5. Set hooks to point to OpenAIClient (implement dynamic dispatch)
baseClient.hooks = openaiClient
return openaiClient
}
func (c *OpenAIClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] OpenAI API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] OpenAI using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] OpenAI using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] OpenAI using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] OpenAI using default Model: %s", c.Model)
}
}
// OpenAI uses standard Bearer auth
func (c *OpenAIClient) setAuthHeader(reqHeaders http.Header) {
c.Client.setAuthHeader(reqHeaders)
}

View File

@@ -279,8 +279,8 @@ func TestOptionsWithNewClient(t *testing.T) {
t.Error("Model should be set from options")
}
if c.logger != mockLogger {
t.Error("logger should be set from options")
if c.Log != mockLogger {
t.Error("Log should be set from options")
}
if c.MaxTokens != 4000 {
@@ -288,78 +288,4 @@ func TestOptionsWithNewClient(t *testing.T) {
}
}
func TestOptionsWithDeepSeekClient(t *testing.T) {
mockLogger := NewMockLogger()
client := NewDeepSeekClientWithOptions(
WithAPIKey("sk-deepseek-key"),
WithLogger(mockLogger),
WithMaxTokens(5000),
)
dsClient := client.(*DeepSeekClient)
// Verify DeepSeek default values
if dsClient.Provider != ProviderDeepSeek {
t.Error("Provider should be DeepSeek")
}
if dsClient.BaseURL != DefaultDeepSeekBaseURL {
t.Error("BaseURL should be DeepSeek default")
}
if dsClient.Model != DefaultDeepSeekModel {
t.Error("Model should be DeepSeek default")
}
// Verify custom options
if dsClient.APIKey != "sk-deepseek-key" {
t.Error("APIKey should be set from options")
}
if dsClient.logger != mockLogger {
t.Error("logger should be set from options")
}
if dsClient.MaxTokens != 5000 {
t.Error("MaxTokens should be 5000")
}
}
func TestOptionsWithQwenClient(t *testing.T) {
mockLogger := NewMockLogger()
client := NewQwenClientWithOptions(
WithAPIKey("sk-qwen-key"),
WithLogger(mockLogger),
WithMaxTokens(6000),
)
qwenClient := client.(*QwenClient)
// Verify Qwen default values
if qwenClient.Provider != ProviderQwen {
t.Error("Provider should be Qwen")
}
if qwenClient.BaseURL != DefaultQwenBaseURL {
t.Error("BaseURL should be Qwen default")
}
if qwenClient.Model != DefaultQwenModel {
t.Error("Model should be Qwen default")
}
// Verify custom options
if qwenClient.APIKey != "sk-qwen-key" {
t.Error("APIKey should be set from options")
}
if qwenClient.logger != mockLogger {
t.Error("logger should be set from options")
}
if qwenClient.MaxTokens != 6000 {
t.Error("MaxTokens should be 6000")
}
}
// Provider-specific option tests are in mcp/provider/options_test.go

View File

@@ -1,4 +1,4 @@
package mcp
package payment
import (
"crypto/ecdsa"
@@ -14,12 +14,13 @@ import (
"github.com/ethereum/go-ethereum/crypto"
"golang.org/x/crypto/sha3"
"nofx/mcp"
)
const (
ProviderBlockRunBase = "blockrun-base"
DefaultBlockRunBaseURL = "https://blockrun.ai"
DefaultBlockRunModel = "gpt-5.4"
DefaultBlockRunModel = "gpt-5.4"
BlockRunChatEndpoint = "/api/v1/chat/completions"
BaseUSDCContract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
BaseChainID int64 = 8453
@@ -28,10 +29,16 @@ const (
// EIP-712 type hashes for USDC TransferWithAuthorization (ERC-3009)
var (
eip712DomainTypeHash = keccak256String("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
eip712DomainTypeHash = keccak256String("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
transferWithAuthTypeHash = keccak256String("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
)
func init() {
mcp.RegisterProvider(mcp.ProviderBlockRunBase, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewBlockRunBaseClientWithOptions(opts...)
})
}
func keccak256String(s string) []byte {
h := sha3.NewLegacyKeccak256()
h.Write([]byte(s))
@@ -48,71 +55,72 @@ func keccak256Bytes(data ...[]byte) []byte {
// BlockRunBaseClient implements AIClient using BlockRun's API with x402 v2 EIP-712 payment signing.
type BlockRunBaseClient struct {
*Client
*mcp.Client
privateKey *ecdsa.PrivateKey
}
func (c *BlockRunBaseClient) BaseClient() *mcp.Client { return c.Client }
// NewBlockRunBaseClient creates a BlockRun Base wallet client (backward compatible).
func NewBlockRunBaseClient() AIClient {
func NewBlockRunBaseClient() mcp.AIClient {
return NewBlockRunBaseClientWithOptions()
}
// NewBlockRunBaseClientWithOptions creates a BlockRun Base wallet client.
func NewBlockRunBaseClientWithOptions(opts ...ClientOption) AIClient {
baseOpts := []ClientOption{
WithProvider(ProviderBlockRunBase),
WithModel(DefaultBlockRunModel),
WithBaseURL(DefaultBlockRunBaseURL),
func NewBlockRunBaseClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
baseOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderBlockRunBase),
mcp.WithModel(DefaultBlockRunModel),
mcp.WithBaseURL(DefaultBlockRunBaseURL),
}
allOpts := append(baseOpts, opts...)
baseClient := NewClient(allOpts...).(*Client)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
baseClient.UseFullURL = true
baseClient.BaseURL = DefaultBlockRunBaseURL + BlockRunChatEndpoint
c := &BlockRunBaseClient{Client: baseClient}
baseClient.hooks = c
baseClient.Hooks = c
return c
}
// SetAPIKey stores the EVM private key (hex, with or without 0x prefix).
// customModel selects the AI model to use (e.g. "claude-sonnet-4.6"); empty means default.
func (c *BlockRunBaseClient) SetAPIKey(apiKey string, customURL string, customModel string) {
hexKey := strings.TrimPrefix(apiKey, "0x")
privKey, err := crypto.HexToECDSA(hexKey)
if err != nil {
c.logger.Warnf("⚠️ [MCP] BlockRun Base: invalid private key: %v", err)
c.Log.Warnf("⚠️ [MCP] BlockRun Base: invalid private key: %v", err)
} else {
c.privateKey = privKey
c.APIKey = apiKey
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
c.logger.Infof("🔧 [MCP] BlockRun Base wallet: %s", addr)
c.Log.Infof("🔧 [MCP] BlockRun Base wallet: %s", addr)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] BlockRun Base model: %s", customModel)
c.Log.Infof("🔧 [MCP] BlockRun Base model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] BlockRun Base model: %s", DefaultBlockRunModel)
c.Log.Infof("🔧 [MCP] BlockRun Base model: %s", DefaultBlockRunModel)
}
}
func (c *BlockRunBaseClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
func (c *BlockRunBaseClient) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, error) {
return x402Call(c.Client, c.signPayment, "BlockRun Base", systemPrompt, userPrompt)
func (c *BlockRunBaseClient) Call(systemPrompt, userPrompt string) (string, error) {
return X402Call(c.Client, c.signPayment, "BlockRun Base", systemPrompt, userPrompt)
}
func (c *BlockRunBaseClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
return x402CallFull(c.Client, c.signPayment, "BlockRun Base", req)
func (c *BlockRunBaseClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
return X402CallFull(c.Client, c.signPayment, "BlockRun Base", req)
}
// signPayment parses the Payment-Required header (x402 v2) and returns a signed payment value.
func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error) {
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "BlockRun Base")
return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "BlockRun Base")
}
// signX402Payment is the shared EIP-712 signing logic for x402 v2 on Base USDC.
// SignX402Payment is the shared EIP-712 signing logic for x402 v2 on Base USDC.
// Used by both BlockRunBaseClient and Claw402Client.
func signX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt x402AcceptOption, resource *x402Resource) (string, error) {
func SignX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt X402AcceptOption, resource *X402Resource) (string, error) {
recipient := opt.PayTo
amount := opt.Amount
network := opt.Network
@@ -224,7 +232,6 @@ func signX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt x402Ac
// buildDomainSeparatorDynamic builds the EIP-712 domain separator using runtime values.
func buildDomainSeparatorDynamic(name, version, network, asset string) ([]byte, error) {
// Extract chain ID from network string like "eip155:8453"
chainID := new(big.Int).SetInt64(BaseChainID)
if strings.HasPrefix(network, "eip155:") {
parts := strings.SplitN(network, ":", 2)
@@ -311,8 +318,6 @@ func hexToBytes32(s string) ([]byte, error) {
func parseBigInt(s string) (*big.Int, error) {
n := new(big.Int)
// Only treat as hex when explicitly prefixed with 0x/0X.
// x402 amounts are always decimal strings (e.g. "3000" = 0.003 USDC).
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
if _, ok := n.SetString(s[2:], 16); ok {
return n, nil
@@ -335,11 +340,11 @@ func leftPad32(b []byte) []byte {
return padded
}
// buildUrl returns the full BlockRun endpoint URL.
func (c *BlockRunBaseClient) buildUrl() string {
// BuildUrl returns the full BlockRun endpoint URL.
func (c *BlockRunBaseClient) BuildUrl() string {
return DefaultBlockRunBaseURL + BlockRunChatEndpoint
}
func (c *BlockRunBaseClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
return x402BuildRequest(url, jsonData)
func (c *BlockRunBaseClient) BuildRequest(url string, jsonData []byte) (*http.Request, error) {
return X402BuildRequest(url, jsonData)
}

View File

@@ -1,4 +1,4 @@
package mcp
package payment
import (
"context"
@@ -12,10 +12,11 @@ import (
"github.com/gagliardetto/solana-go/programs/compute-budget"
"github.com/gagliardetto/solana-go/programs/token"
"github.com/gagliardetto/solana-go/rpc"
"nofx/mcp"
)
const (
ProviderBlockRunSol = "blockrun-sol"
DefaultBlockRunSolURL = "https://sol.blockrun.ai"
SolanaUSDCMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
SolanaNetwork = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
@@ -26,62 +27,69 @@ const (
computeUnitPrice = uint64(1)
)
func init() {
mcp.RegisterProvider(mcp.ProviderBlockRunSol, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewBlockRunSolClientWithOptions(opts...)
})
}
// BlockRunSolClient implements AIClient using BlockRun's Solana x402 v2 payment protocol.
type BlockRunSolClient struct {
*Client
*mcp.Client
keypair solana.PrivateKey
}
func (c *BlockRunSolClient) BaseClient() *mcp.Client { return c.Client }
// NewBlockRunSolClient creates a BlockRun Solana wallet client (backward compatible).
func NewBlockRunSolClient() AIClient {
func NewBlockRunSolClient() mcp.AIClient {
return NewBlockRunSolClientWithOptions()
}
// NewBlockRunSolClientWithOptions creates a BlockRun Solana wallet client.
func NewBlockRunSolClientWithOptions(opts ...ClientOption) AIClient {
baseOpts := []ClientOption{
WithProvider(ProviderBlockRunSol),
WithModel(DefaultBlockRunModel),
WithBaseURL(DefaultBlockRunSolURL),
func NewBlockRunSolClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
baseOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderBlockRunSol),
mcp.WithModel(DefaultBlockRunModel),
mcp.WithBaseURL(DefaultBlockRunSolURL),
}
allOpts := append(baseOpts, opts...)
baseClient := NewClient(allOpts...).(*Client)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
baseClient.UseFullURL = true
baseClient.BaseURL = DefaultBlockRunSolURL + BlockRunChatEndpoint
c := &BlockRunSolClient{Client: baseClient}
baseClient.hooks = c
baseClient.Hooks = c
return c
}
// SetAPIKey stores the Solana wallet private key (base58-encoded 64-byte keypair).
// customModel selects the AI model; empty means default.
func (c *BlockRunSolClient) SetAPIKey(apiKey string, customURL string, customModel string) {
kp, err := solana.PrivateKeyFromBase58(strings.TrimSpace(apiKey))
if err != nil {
c.logger.Warnf("⚠️ [MCP] BlockRun Sol: failed to parse private key: %v", err)
c.Log.Warnf("⚠️ [MCP] BlockRun Sol: failed to parse private key: %v", err)
return
}
c.keypair = kp
c.APIKey = apiKey
c.logger.Infof("🔧 [MCP] BlockRun Sol wallet: %s", kp.PublicKey().String())
c.Log.Infof("🔧 [MCP] BlockRun Sol wallet: %s", kp.PublicKey().String())
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] BlockRun Sol model: %s", customModel)
c.Log.Infof("🔧 [MCP] BlockRun Sol model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] BlockRun Sol model: %s", DefaultBlockRunModel)
c.Log.Infof("🔧 [MCP] BlockRun Sol model: %s", DefaultBlockRunModel)
}
}
func (c *BlockRunSolClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
func (c *BlockRunSolClient) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
func (c *BlockRunSolClient) call(systemPrompt, userPrompt string) (string, error) {
return x402Call(c.Client, c.signSolanaPayment, "BlockRun Sol", systemPrompt, userPrompt)
func (c *BlockRunSolClient) Call(systemPrompt, userPrompt string) (string, error) {
return X402Call(c.Client, c.signSolanaPayment, "BlockRun Sol", systemPrompt, userPrompt)
}
func (c *BlockRunSolClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
return x402CallFull(c.Client, c.signSolanaPayment, "BlockRun Sol", req)
func (c *BlockRunSolClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
return X402CallFull(c.Client, c.signSolanaPayment, "BlockRun Sol", req)
}
// signSolanaPayment parses the Payment-Required header and builds a signed x402 v2 Solana payload.
@@ -90,18 +98,18 @@ func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string,
return "", fmt.Errorf("no private key set for BlockRun Sol wallet")
}
decoded, err := x402DecodeHeader(paymentHeaderB64)
decoded, err := X402DecodeHeader(paymentHeaderB64)
if err != nil {
return "", err
}
var req x402v2PaymentRequired
var req X402v2PaymentRequired
if err := json.Unmarshal(decoded, &req); err != nil {
return "", fmt.Errorf("failed to parse x402 v2 Solana header: %w", err)
}
// Find the Solana option
var opt *x402AcceptOption
var opt *X402AcceptOption
for i := range req.Accepts {
if strings.HasPrefix(req.Accepts[i].Network, "solana:") {
opt = &req.Accepts[i]
@@ -174,11 +182,9 @@ func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string,
}
// buildSolanaTransferTx builds a partial-signed VersionedTransaction for SPL USDC TransferChecked.
// The fee payer (CDP facilitator) slot is left with a zero signature; only the user signs.
func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr string) (string, error) {
ownerPubkey := c.keypair.PublicKey()
// Parse recipient and feePayer
recipientPK, err := solana.PublicKeyFromBase58(recipient)
if err != nil {
return "", fmt.Errorf("invalid recipient address: %w", err)
@@ -189,13 +195,11 @@ func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr
}
mintPK := solana.MustPublicKeyFromBase58(SolanaUSDCMint)
// Parse amount
var amountU64 uint64
if _, err := fmt.Sscanf(amountStr, "%d", &amountU64); err != nil {
return "", fmt.Errorf("invalid amount %q: %w", amountStr, err)
}
// Derive ATAs
sourceATA, _, err := solana.FindAssociatedTokenAddress(ownerPubkey, mintPK)
if err != nil {
return "", fmt.Errorf("failed to derive source ATA: %w", err)
@@ -205,7 +209,6 @@ func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr
return "", fmt.Errorf("failed to derive dest ATA: %w", err)
}
// Fetch latest blockhash from Solana mainnet
rpcClient := rpc.New(SolanaMainnetRPC)
bhResp, err := rpcClient.GetLatestBlockhash(context.Background(), rpc.CommitmentFinalized)
if err != nil {
@@ -213,7 +216,6 @@ func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr
}
recentBlockhash := bhResp.Value.Blockhash
// Build instructions: ComputeBudgetSetLimit, ComputeBudgetSetPrice, TransferChecked
setLimitIx, err := computebudget.NewSetComputeUnitLimitInstruction(computeUnitLimit).ValidateAndBuild()
if err != nil {
return "", fmt.Errorf("failed to build SetComputeUnitLimit: %w", err)
@@ -235,7 +237,6 @@ func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr
return "", fmt.Errorf("failed to build TransferChecked: %w", err)
}
// Build transaction with feePayer as payer (matches Python SDK)
tx, err := solana.NewTransaction(
[]solana.Instruction{setLimitIx, setPriceIx, transferIx},
recentBlockhash,
@@ -245,9 +246,6 @@ func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr
return "", fmt.Errorf("failed to build transaction: %w", err)
}
// Partial sign: user signs; fee_payer (CDP) co-signs on server side
// The transaction has 2 signers: [feePayer (index 0), owner (index 1)]
// We sign only our index (owner).
_, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey {
if key.Equals(ownerPubkey) {
return &c.keypair
@@ -258,7 +256,6 @@ func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr
return "", fmt.Errorf("failed to sign transaction: %w", err)
}
// Serialize transaction
txBytes, err := tx.MarshalBinary()
if err != nil {
return "", fmt.Errorf("failed to serialize transaction: %w", err)
@@ -267,11 +264,11 @@ func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr
return base64.StdEncoding.EncodeToString(txBytes), nil
}
// buildUrl returns the full BlockRun Solana endpoint URL.
func (c *BlockRunSolClient) buildUrl() string {
// BuildUrl returns the full BlockRun Solana endpoint URL.
func (c *BlockRunSolClient) BuildUrl() string {
return DefaultBlockRunSolURL + BlockRunChatEndpoint
}
func (c *BlockRunSolClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
return x402BuildRequest(url, jsonData)
func (c *BlockRunSolClient) BuildRequest(url string, jsonData []byte) (*http.Request, error) {
return X402BuildRequest(url, jsonData)
}

View File

@@ -1,4 +1,4 @@
package mcp
package payment
import (
"crypto/ecdsa"
@@ -6,11 +6,13 @@ import (
"strings"
"github.com/ethereum/go-ethereum/crypto"
"nofx/mcp"
"nofx/mcp/provider"
)
const (
ProviderClaw402 = "claw402"
DefaultClaw402URL = "https://claw402.ai"
DefaultClaw402URL = "https://claw402.ai"
DefaultClaw402Model = "deepseek"
)
@@ -39,35 +41,42 @@ var claw402ModelEndpoints = map[string]string{
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
}
func init() {
mcp.RegisterProvider(mcp.ProviderClaw402, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewClaw402ClientWithOptions(opts...)
})
}
// Claw402Client implements AIClient using claw402.ai's x402 v2 USDC payment gateway.
// Reuses the same EIP-712 signing as BlockRunBaseClient (same Base chain + USDC contract).
// When the selected model routes to an Anthropic endpoint, it automatically uses
// the Anthropic wire format for requests and responses (via an internal ClaudeClient).
type Claw402Client struct {
*Client
*mcp.Client
privateKey *ecdsa.PrivateKey
claudeProxy *ClaudeClient // non-nil when endpoint is /anthropic/
claudeProxy *provider.ClaudeClient // non-nil when endpoint is /anthropic/
}
func (c *Claw402Client) BaseClient() *mcp.Client { return c.Client }
// NewClaw402Client creates a claw402 client (backward compatible).
func NewClaw402Client() AIClient {
func NewClaw402Client() mcp.AIClient {
return NewClaw402ClientWithOptions()
}
// NewClaw402ClientWithOptions creates a claw402 client with options.
func NewClaw402ClientWithOptions(opts ...ClientOption) AIClient {
baseOpts := []ClientOption{
WithProvider(ProviderClaw402),
WithModel(DefaultClaw402Model),
WithBaseURL(DefaultClaw402URL),
func NewClaw402ClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
baseOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderClaw402),
mcp.WithModel(DefaultClaw402Model),
mcp.WithBaseURL(DefaultClaw402URL),
}
allOpts := append(baseOpts, opts...)
baseClient := NewClient(allOpts...).(*Client)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
baseClient.UseFullURL = true
baseClient.BaseURL = DefaultClaw402URL + claw402ModelEndpoints[DefaultClaw402Model]
c := &Claw402Client{Client: baseClient}
baseClient.hooks = c
baseClient.Hooks = c
return c
}
@@ -76,12 +85,12 @@ func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) {
hexKey := strings.TrimPrefix(apiKey, "0x")
privKey, err := crypto.HexToECDSA(hexKey)
if err != nil {
c.logger.Warnf("⚠️ [MCP] Claw402: invalid private key: %v", err)
c.Log.Warnf("⚠️ [MCP] Claw402: invalid private key: %v", err)
} else {
c.privateKey = privKey
c.APIKey = apiKey
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
c.logger.Infof("🔧 [MCP] Claw402 wallet: %s", addr)
c.Log.Infof("🔧 [MCP] Claw402 wallet: %s", addr)
}
if customModel != "" {
c.Model = customModel
@@ -91,11 +100,11 @@ func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) {
// Anthropic endpoints need different wire format (Messages API)
if strings.Contains(endpoint, "/anthropic/") {
c.claudeProxy = &ClaudeClient{Client: c.Client}
c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s (Anthropic format)", c.Model, endpoint)
c.claudeProxy = &provider.ClaudeClient{Client: c.Client}
c.Log.Infof("🔧 [MCP] Claw402 model: %s → %s (Anthropic format)", c.Model, endpoint)
} else {
c.claudeProxy = nil
c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint)
c.Log.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint)
}
}
@@ -111,56 +120,56 @@ func (c *Claw402Client) resolveEndpoint() string {
return claw402ModelEndpoints[DefaultClaw402Model]
}
func (c *Claw402Client) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
func (c *Claw402Client) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
func (c *Claw402Client) call(systemPrompt, userPrompt string) (string, error) {
return x402Call(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt)
func (c *Claw402Client) Call(systemPrompt, userPrompt string) (string, error) {
return X402Call(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt)
}
func (c *Claw402Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
return x402CallFull(c.Client, c.signPayment, "Claw402", req)
func (c *Claw402Client) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
return X402CallFull(c.Client, c.signPayment, "Claw402", req)
}
// signPayment signs x402 v2 EIP-712 payment (same Base chain + USDC as BlockRunBase).
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
}
// ── Format overrides for Anthropic endpoints ─────────────────────────────────
func (c *Claw402Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
func (c *Claw402Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
if c.claudeProxy != nil {
return c.claudeProxy.buildMCPRequestBody(systemPrompt, userPrompt)
return c.claudeProxy.BuildMCPRequestBody(systemPrompt, userPrompt)
}
return c.Client.buildMCPRequestBody(systemPrompt, userPrompt)
return c.Client.BuildMCPRequestBody(systemPrompt, userPrompt)
}
func (c *Claw402Client) buildRequestBodyFromRequest(req *Request) map[string]any {
func (c *Claw402Client) BuildRequestBodyFromRequest(req *mcp.Request) map[string]any {
if c.claudeProxy != nil {
return c.claudeProxy.buildRequestBodyFromRequest(req)
return c.claudeProxy.BuildRequestBodyFromRequest(req)
}
return c.Client.buildRequestBodyFromRequest(req)
return c.Client.BuildRequestBodyFromRequest(req)
}
func (c *Claw402Client) parseMCPResponse(body []byte) (string, error) {
func (c *Claw402Client) ParseMCPResponse(body []byte) (string, error) {
if c.claudeProxy != nil {
return c.claudeProxy.parseMCPResponse(body)
return c.claudeProxy.ParseMCPResponse(body)
}
return c.Client.parseMCPResponse(body)
return c.Client.ParseMCPResponse(body)
}
func (c *Claw402Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
func (c *Claw402Client) ParseMCPResponseFull(body []byte) (*mcp.LLMResponse, error) {
if c.claudeProxy != nil {
return c.claudeProxy.parseMCPResponseFull(body)
return c.claudeProxy.ParseMCPResponseFull(body)
}
return c.Client.parseMCPResponseFull(body)
return c.Client.ParseMCPResponseFull(body)
}
// buildUrl returns the full claw402 endpoint URL.
func (c *Claw402Client) buildUrl() string {
// BuildUrl returns the full claw402 endpoint URL.
func (c *Claw402Client) BuildUrl() string {
return c.BaseURL
}
func (c *Claw402Client) buildRequest(url string, jsonData []byte) (*http.Request, error) {
return x402BuildRequest(url, jsonData)
func (c *Claw402Client) BuildRequest(url string, jsonData []byte) (*http.Request, error) {
return X402BuildRequest(url, jsonData)
}

View File

@@ -1,4 +1,4 @@
package mcp
package payment
import (
"bytes"
@@ -11,28 +11,30 @@ import (
"time"
"github.com/ethereum/go-ethereum/crypto"
"nofx/mcp"
)
const (
// x402MaxPaymentRetries is the number of retries for 5xx errors on the
// X402MaxPaymentRetries is the number of retries for 5xx errors on the
// payment-signed request. The same payment signature is reused (no double-charge).
x402MaxPaymentRetries = 3
X402MaxPaymentRetries = 3
// x402RetryBaseWait is the base wait between payment retry attempts.
x402RetryBaseWait = 3 * time.Second
// X402RetryBaseWait is the base wait between payment retry attempts.
X402RetryBaseWait = 3 * time.Second
)
// ── Shared x402 types ────────────────────────────────────────────────────────
// x402v2PaymentRequired is the structure of the Payment-Required header (x402 v2).
type x402v2PaymentRequired struct {
// X402v2PaymentRequired is the structure of the Payment-Required header (x402 v2).
type X402v2PaymentRequired struct {
X402Version int `json:"x402Version"`
Accepts []x402AcceptOption `json:"accepts"`
Resource *x402Resource `json:"resource"`
Accepts []X402AcceptOption `json:"accepts"`
Resource *X402Resource `json:"resource"`
}
// x402AcceptOption is a payment option from the x402 v2 header.
type x402AcceptOption struct {
// X402AcceptOption is a payment option from the x402 v2 header.
type X402AcceptOption struct {
Scheme string `json:"scheme"`
Network string `json:"network"`
Amount string `json:"amount"`
@@ -42,22 +44,22 @@ type x402AcceptOption struct {
Extra map[string]string `json:"extra"`
}
// x402Resource describes the resource being paid for.
type x402Resource struct {
// X402Resource describes the resource being paid for.
type X402Resource struct {
URL string `json:"url"`
Description string `json:"description"`
MimeType string `json:"mimeType"`
}
// x402SignFunc is a callback that signs an x402 payment header and returns the
// X402SignFunc is a callback that signs an x402 payment header and returns the
// base64-encoded payment signature.
type x402SignFunc func(paymentHeaderB64 string) (string, error)
type X402SignFunc func(paymentHeaderB64 string) (string, error)
// ── Shared x402 helpers ──────────────────────────────────────────────────────
// x402DecodeHeader decodes a base64-encoded x402 Payment-Required header,
// X402DecodeHeader decodes a base64-encoded x402 Payment-Required header,
// trying RawStdEncoding first then StdEncoding as fallback.
func x402DecodeHeader(b64 string) ([]byte, error) {
func X402DecodeHeader(b64 string) ([]byte, error) {
decoded, err := base64.RawStdEncoding.DecodeString(b64)
if err != nil {
decoded, err = base64.StdEncoding.DecodeString(b64)
@@ -68,19 +70,19 @@ func x402DecodeHeader(b64 string) ([]byte, error) {
return decoded, nil
}
// signBasePaymentHeader decodes a base64 x402 header, parses it, and signs with
// SignBasePaymentHeader decodes a base64 x402 header, parses it, and signs with
// EIP-712 (USDC TransferWithAuthorization). Shared by BlockRunBase and Claw402.
func signBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {
func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {
if privateKey == nil {
return "", fmt.Errorf("no private key set for %s wallet", providerName)
}
decoded, err := x402DecodeHeader(paymentHeaderB64)
decoded, err := X402DecodeHeader(paymentHeaderB64)
if err != nil {
return "", err
}
var req x402v2PaymentRequired
var req X402v2PaymentRequired
if err := json.Unmarshal(decoded, &req); err != nil {
return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err)
}
@@ -89,19 +91,16 @@ func signBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string
}
senderAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
return signX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
return SignX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
}
// doX402Request executes an HTTP request and handles the x402 v2 payment flow.
// On a 402 response it reads the Payment-Required (or X-Payment-Required) header,
// signs via signFn, retries with Payment-Signature, and logs the Payment-Response
// header (tx hash) on success.
func doX402Request(
// DoX402Request executes an HTTP request and handles the x402 v2 payment flow.
func DoX402Request(
httpClient *http.Client,
buildReqFn func() (*http.Request, error),
signFn x402SignFunc,
signFn X402SignFunc,
providerTag string,
logger Logger,
logger mcp.Logger,
) ([]byte, error) {
req, err := buildReqFn()
if err != nil {
@@ -133,10 +132,9 @@ func doX402Request(
}
// Retry loop for 5xx errors on the payment-signed request.
// Reuses the same payment signature — no double-charge.
var lastBody []byte
var lastStatus int
for attempt := 1; attempt <= x402MaxPaymentRetries; attempt++ {
for attempt := 1; attempt <= X402MaxPaymentRetries; attempt++ {
req2, err := buildReqFn()
if err != nil {
return nil, fmt.Errorf("failed to build retry request: %w", err)
@@ -146,10 +144,10 @@ func doX402Request(
resp2, err := httpClient.Do(req2)
if err != nil {
if attempt < x402MaxPaymentRetries {
wait := x402RetryBaseWait * time.Duration(attempt)
if attempt < X402MaxPaymentRetries {
wait := X402RetryBaseWait * time.Duration(attempt)
logger.Warnf("⚠️ [%s] Payment request failed: %v, retrying in %v (%d/%d)...",
providerTag, err, wait, attempt+1, x402MaxPaymentRetries)
providerTag, err, wait, attempt+1, X402MaxPaymentRetries)
time.Sleep(wait)
continue
}
@@ -175,11 +173,11 @@ func doX402Request(
lastBody = body2
lastStatus = resp2.StatusCode
// Retry on 5xx server errors (502, 503, 520, etc.)
if resp2.StatusCode >= 500 && attempt < x402MaxPaymentRetries {
wait := x402RetryBaseWait * time.Duration(attempt)
// Retry on 5xx server errors
if resp2.StatusCode >= 500 && attempt < X402MaxPaymentRetries {
wait := X402RetryBaseWait * time.Duration(attempt)
logger.Warnf("⚠️ [%s] Server error (status %d), retrying in %v (%d/%d)...",
providerTag, resp2.StatusCode, wait, attempt+1, x402MaxPaymentRetries)
providerTag, resp2.StatusCode, wait, attempt+1, X402MaxPaymentRetries)
time.Sleep(wait)
continue
}
@@ -201,8 +199,8 @@ func doX402Request(
return body, nil
}
// x402BuildRequest creates a POST request with Content-Type but no auth header.
func x402BuildRequest(url string, jsonData []byte) (*http.Request, error) {
// X402BuildRequest creates a POST request with Content-Type but no auth header.
func X402BuildRequest(url string, jsonData []byte) (*http.Request, error) {
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("fail to build request: %w", err)
@@ -211,30 +209,30 @@ func x402BuildRequest(url string, jsonData []byte) (*http.Request, error) {
return req, nil
}
// x402SetAuthHeader is a no-op — x402 providers authenticate via payment signing.
func x402SetAuthHeader(_ http.Header) {}
// X402SetAuthHeader is a no-op — x402 providers authenticate via payment signing.
func X402SetAuthHeader(_ http.Header) {}
// x402Call handles the x402 payment flow for the simple CallWithMessages path.
func x402Call(c *Client, signFn x402SignFunc, tag string, systemPrompt, userPrompt string) (string, error) {
c.logger.Infof("📡 [%s] Request AI Server: %s", tag, c.BaseURL)
// X402Call handles the x402 payment flow for the simple CallWithMessages path.
func X402Call(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt, userPrompt string) (string, error) {
c.Log.Infof("📡 [%s] Request AI Server: %s", tag, c.BaseURL)
requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt)
jsonData, err := c.hooks.marshalRequestBody(requestBody)
requestBody := c.Hooks.BuildMCPRequestBody(systemPrompt, userPrompt)
jsonData, err := c.Hooks.MarshalRequestBody(requestBody)
if err != nil {
return "", err
}
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
}, signFn, tag, c.logger)
body, err := DoX402Request(c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log)
if err != nil {
return "", err
}
return c.hooks.parseMCPResponse(body)
return c.Hooks.ParseMCPResponse(body)
}
// x402CallFull handles the x402 payment flow for the advanced Request path.
func x402CallFull(c *Client, signFn x402SignFunc, tag string, req *Request) (*LLMResponse, error) {
// X402CallFull handles the x402 payment flow for the advanced Request path.
func X402CallFull(c *mcp.Client, signFn X402SignFunc, tag string, req *mcp.Request) (*mcp.LLMResponse, error) {
if c.APIKey == "" {
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
}
@@ -242,19 +240,19 @@ func x402CallFull(c *Client, signFn x402SignFunc, tag string, req *Request) (*LL
req.Model = c.Model
}
c.logger.Infof("📡 [%s] Request AI (full): %s", tag, c.BaseURL)
c.Log.Infof("📡 [%s] Request AI (full): %s", tag, c.BaseURL)
requestBody := c.hooks.buildRequestBodyFromRequest(req)
jsonData, err := c.hooks.marshalRequestBody(requestBody)
requestBody := c.Hooks.BuildRequestBodyFromRequest(req)
jsonData, err := c.Hooks.MarshalRequestBody(requestBody)
if err != nil {
return nil, err
}
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
}, signFn, tag, c.logger)
body, err := DoX402Request(c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log)
if err != nil {
return nil, err
}
return c.hooks.parseMCPResponseFull(body)
return c.Hooks.ParseMCPResponseFull(body)
}

View File

@@ -1,4 +1,4 @@
// Package mcp — ClaudeClient implements the Anthropic Messages API.
// Package provider — ClaudeClient implements the Anthropic Messages API.
//
// Wire-format differences from the OpenAI-compatible base Client:
//
@@ -14,42 +14,51 @@
// │ Tool result │ role=tool + tool_call_id │ role=user content[tool_result] │
// │ Max tokens │ max_tokens │ max_tokens (same) │
// └─────────────────────┴───────────────────────────┴─────────────────────────────────┘
package mcp
package provider
import (
"encoding/json"
"fmt"
"net/http"
"nofx/mcp"
)
const (
ProviderClaude = "claude"
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 {
*Client
*mcp.Client
}
func (c *ClaudeClient) BaseClient() *mcp.Client { return c.Client }
// NewClaudeClient creates a ClaudeClient with default settings.
func NewClaudeClient() AIClient {
func NewClaudeClient() mcp.AIClient {
return NewClaudeClientWithOptions()
}
// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides.
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
baseClient := NewClient(append([]ClientOption{
WithProvider(ProviderClaude),
WithModel(DefaultClaudeModel),
WithBaseURL(DefaultClaudeBaseURL),
}, opts...)...).(*Client)
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
baseClient.Hooks = c // wire dynamic dispatch to ClaudeClient
return c
}
@@ -59,32 +68,32 @@ func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
c.Log.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] Claude BaseURL: %s", customURL)
c.Log.Infof("🔧 [MCP] Claude BaseURL: %s", customURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] Claude Model: %s", 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) {
// 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 {
// 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
// BuildMCPRequestBody builds the Anthropic wire format for the simple
// CallWithMessages path (no tool support).
func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
func (c *ClaudeClient) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
return map[string]any{
"model": c.Model,
"max_tokens": c.MaxTokens,
@@ -95,23 +104,12 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[
}
}
// buildRequestBodyFromRequest converts a *Request into the Anthropic Messages
// API wire format. This is the key override that makes tool calling work
// correctly with Claude.
//
// Conversions applied:
//
// - System messages are lifted to the top-level "system" field.
// - Tool definitions: parameters → input_schema, wrapper removed.
// - Assistant messages with ToolCalls → content[{type:tool_use,...}].
// - Tool result messages (role=tool) → role=user with tool_result blocks.
// Consecutive tool results are merged into a single user turn (Anthropic
// requires strictly alternating user/assistant turns).
// - tool_choice "auto"/"any" → {"type":"auto"/"any"} object.
func (c *ClaudeClient) buildRequestBodyFromRequest(req *Request) map[string]any {
// 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 []Message
var convMsgs []mcp.Message
for _, m := range req.Messages {
if m.Role == "system" {
systemPrompt = m.Content
@@ -121,7 +119,7 @@ func (c *ClaudeClient) buildRequestBodyFromRequest(req *Request) map[string]any
}
// ── 2. Convert messages to Anthropic format ───────────────────────────────
anthropicMsgs := convertMessagesToAnthropic(convMsgs)
anthropicMsgs := ConvertMessagesToAnthropic(convMsgs)
// ── 3. Convert tool definitions (parameters → input_schema) ──────────────
var anthropicTools []map[string]any
@@ -162,16 +160,9 @@ func (c *ClaudeClient) buildRequestBodyFromRequest(req *Request) map[string]any
return body
}
// convertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message
// ConvertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message
// slice to Anthropic's messages array.
//
// Rules:
// 1. role=assistant + ToolCalls → role=assistant, content=[tool_use, ...]
// 2. role=tool (result) → role=user, content=[tool_result, ...]
// Consecutive tool-result messages are merged into one user turn so the
// conversation always alternates user/assistant.
// 3. All other messages → {role, content} as-is.
func convertMessagesToAnthropic(msgs []Message) []map[string]any {
func ConvertMessagesToAnthropic(msgs []mcp.Message) []map[string]any {
var out []map[string]any
for i := 0; i < len(msgs); {
@@ -232,29 +223,18 @@ func convertMessagesToAnthropic(msgs []Message) []map[string]any {
// ── Response parsers ──────────────────────────────────────────────────────────
// parseMCPResponse extracts the plain-text reply from an Anthropic response.
// Used by CallWithMessages / CallWithRequest (no tool support).
func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
r, err := c.parseMCPResponseFull(body)
// 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
// ParseMCPResponseFull extracts both text and tool calls from an Anthropic
// response envelope.
//
// Anthropic response shape:
//
// {
// "content": [
// {"type": "text", "text": "..."},
// {"type": "tool_use", "id": "...", "name": "...", "input": {...}}
// ],
// "stop_reason": "tool_use" | "end_turn"
// }
func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
func (c *ClaudeClient) ParseMCPResponseFull(body []byte) (*mcp.LLMResponse, error) {
var raw struct {
Content []struct {
Type string `json:"type"`
@@ -281,8 +261,8 @@ func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
}
total := raw.Usage.InputTokens + raw.Usage.OutputTokens
if TokenUsageCallback != nil && total > 0 {
TokenUsageCallback(TokenUsage{
if mcp.TokenUsageCallback != nil && total > 0 {
mcp.TokenUsageCallback(mcp.TokenUsage{
Provider: c.Provider,
Model: c.Model,
PromptTokens: raw.Usage.InputTokens,
@@ -291,7 +271,7 @@ func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
})
}
result := &LLMResponse{}
result := &mcp.LLMResponse{}
for _, block := range raw.Content {
switch block.Type {
case "text":
@@ -304,10 +284,10 @@ func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
if err != nil {
argsJSON = []byte("{}")
}
result.ToolCalls = append(result.ToolCalls, ToolCall{
result.ToolCalls = append(result.ToolCalls, mcp.ToolCall{
ID: block.ID,
Type: "function",
Function: ToolCallFunction{
Function: mcp.ToolCallFunction{
Name: block.Name,
Arguments: string(argsJSON),
},

69
mcp/provider/deepseek.go Normal file
View File

@@ -0,0 +1,69 @@
package provider
import (
"net/http"
"nofx/mcp"
)
func init() {
mcp.RegisterProvider(mcp.ProviderDeepSeek, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewDeepSeekClientWithOptions(opts...)
})
}
type DeepSeekClient struct {
*mcp.Client
}
func (c *DeepSeekClient) BaseClient() *mcp.Client { return c.Client }
// NewDeepSeekClient creates DeepSeek client (backward compatible)
//
// Deprecated: Recommend using NewDeepSeekClientWithOptions for better flexibility
func NewDeepSeekClient() mcp.AIClient {
return NewDeepSeekClientWithOptions()
}
// NewDeepSeekClientWithOptions creates DeepSeek client (supports options pattern)
func NewDeepSeekClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
deepseekOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderDeepSeek),
mcp.WithModel(mcp.DefaultDeepSeekModel),
mcp.WithBaseURL(mcp.DefaultDeepSeekBaseURL),
}
allOpts := append(deepseekOpts, opts...)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
dsClient := &DeepSeekClient{
Client: baseClient,
}
baseClient.Hooks = dsClient
return dsClient
}
func (dsClient *DeepSeekClient) SetAPIKey(apiKey string, customURL string, customModel string) {
dsClient.APIKey = apiKey
if len(apiKey) > 8 {
dsClient.Log.Infof("🔧 [MCP] DeepSeek API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
dsClient.BaseURL = customURL
dsClient.Log.Infof("🔧 [MCP] DeepSeek using custom BaseURL: %s", customURL)
} else {
dsClient.Log.Infof("🔧 [MCP] DeepSeek using default BaseURL: %s", dsClient.BaseURL)
}
if customModel != "" {
dsClient.Model = customModel
dsClient.Log.Infof("🔧 [MCP] DeepSeek using custom Model: %s", customModel)
} else {
dsClient.Log.Infof("🔧 [MCP] DeepSeek using default Model: %s", dsClient.Model)
}
}
func (dsClient *DeepSeekClient) SetAuthHeader(reqHeaders http.Header) {
dsClient.Client.SetAuthHeader(reqHeaders)
}

73
mcp/provider/gemini.go Normal file
View File

@@ -0,0 +1,73 @@
package provider
import (
"net/http"
"nofx/mcp"
)
const (
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
DefaultGeminiModel = "gemini-3-pro-preview"
)
func init() {
mcp.RegisterProvider(mcp.ProviderGemini, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewGeminiClientWithOptions(opts...)
})
}
type GeminiClient struct {
*mcp.Client
}
func (c *GeminiClient) BaseClient() *mcp.Client { return c.Client }
// NewGeminiClient creates Gemini client (backward compatible)
func NewGeminiClient() mcp.AIClient {
return NewGeminiClientWithOptions()
}
// NewGeminiClientWithOptions creates Gemini client (supports options pattern)
func NewGeminiClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
geminiOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderGemini),
mcp.WithModel(DefaultGeminiModel),
mcp.WithBaseURL(DefaultGeminiBaseURL),
}
allOpts := append(geminiOpts, opts...)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
geminiClient := &GeminiClient{
Client: baseClient,
}
baseClient.Hooks = geminiClient
return geminiClient
}
func (c *GeminiClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.Log.Infof("🔧 [MCP] Gemini API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.Log.Infof("🔧 [MCP] Gemini using custom BaseURL: %s", customURL)
} else {
c.Log.Infof("🔧 [MCP] Gemini using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.Log.Infof("🔧 [MCP] Gemini using custom Model: %s", customModel)
} else {
c.Log.Infof("🔧 [MCP] Gemini using default Model: %s", c.Model)
}
}
// Gemini OpenAI-compatible API uses standard Bearer auth
func (c *GeminiClient) SetAuthHeader(reqHeaders http.Header) {
c.Client.SetAuthHeader(reqHeaders)
}

73
mcp/provider/grok.go Normal file
View File

@@ -0,0 +1,73 @@
package provider
import (
"net/http"
"nofx/mcp"
)
const (
DefaultGrokBaseURL = "https://api.x.ai/v1"
DefaultGrokModel = "grok-3-latest"
)
func init() {
mcp.RegisterProvider(mcp.ProviderGrok, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewGrokClientWithOptions(opts...)
})
}
type GrokClient struct {
*mcp.Client
}
func (c *GrokClient) BaseClient() *mcp.Client { return c.Client }
// NewGrokClient creates Grok client (backward compatible)
func NewGrokClient() mcp.AIClient {
return NewGrokClientWithOptions()
}
// NewGrokClientWithOptions creates Grok client (supports options pattern)
func NewGrokClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
grokOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderGrok),
mcp.WithModel(DefaultGrokModel),
mcp.WithBaseURL(DefaultGrokBaseURL),
}
allOpts := append(grokOpts, opts...)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
grokClient := &GrokClient{
Client: baseClient,
}
baseClient.Hooks = grokClient
return grokClient
}
func (c *GrokClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.Log.Infof("🔧 [MCP] Grok API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.Log.Infof("🔧 [MCP] Grok using custom BaseURL: %s", customURL)
} else {
c.Log.Infof("🔧 [MCP] Grok using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.Log.Infof("🔧 [MCP] Grok using custom Model: %s", customModel)
} else {
c.Log.Infof("🔧 [MCP] Grok using default Model: %s", c.Model)
}
}
// Grok uses standard OpenAI-compatible API with Bearer auth
func (c *GrokClient) SetAuthHeader(reqHeaders http.Header) {
c.Client.SetAuthHeader(reqHeaders)
}

73
mcp/provider/kimi.go Normal file
View File

@@ -0,0 +1,73 @@
package provider
import (
"net/http"
"nofx/mcp"
)
const (
DefaultKimiBaseURL = "https://api.moonshot.ai/v1" // Global endpoint (use api.moonshot.cn for China)
DefaultKimiModel = "moonshot-v1-auto"
)
func init() {
mcp.RegisterProvider(mcp.ProviderKimi, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewKimiClientWithOptions(opts...)
})
}
type KimiClient struct {
*mcp.Client
}
func (c *KimiClient) BaseClient() *mcp.Client { return c.Client }
// NewKimiClient creates Kimi (Moonshot) client (backward compatible)
func NewKimiClient() mcp.AIClient {
return NewKimiClientWithOptions()
}
// NewKimiClientWithOptions creates Kimi client (supports options pattern)
func NewKimiClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
kimiOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderKimi),
mcp.WithModel(DefaultKimiModel),
mcp.WithBaseURL(DefaultKimiBaseURL),
}
allOpts := append(kimiOpts, opts...)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
kimiClient := &KimiClient{
Client: baseClient,
}
baseClient.Hooks = kimiClient
return kimiClient
}
func (c *KimiClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.Log.Infof("🔧 [MCP] Kimi API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.Log.Infof("🔧 [MCP] Kimi using custom BaseURL: %s", customURL)
} else {
c.Log.Infof("🔧 [MCP] Kimi using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.Log.Infof("🔧 [MCP] Kimi using custom Model: %s", customModel)
} else {
c.Log.Infof("🔧 [MCP] Kimi using default Model: %s", c.Model)
}
}
// Kimi uses standard OpenAI-compatible API
func (c *KimiClient) SetAuthHeader(reqHeaders http.Header) {
c.Client.SetAuthHeader(reqHeaders)
}

73
mcp/provider/minimax.go Normal file
View File

@@ -0,0 +1,73 @@
package provider
import (
"net/http"
"nofx/mcp"
)
const (
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
DefaultMiniMaxModel = "MiniMax-M2.5"
)
func init() {
mcp.RegisterProvider(mcp.ProviderMiniMax, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewMiniMaxClientWithOptions(opts...)
})
}
type MiniMaxClient struct {
*mcp.Client
}
func (c *MiniMaxClient) BaseClient() *mcp.Client { return c.Client }
// NewMiniMaxClient creates MiniMax client (backward compatible)
func NewMiniMaxClient() mcp.AIClient {
return NewMiniMaxClientWithOptions()
}
// NewMiniMaxClientWithOptions creates MiniMax client (supports options pattern)
func NewMiniMaxClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
minimaxOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderMiniMax),
mcp.WithModel(DefaultMiniMaxModel),
mcp.WithBaseURL(DefaultMiniMaxBaseURL),
}
allOpts := append(minimaxOpts, opts...)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
minimaxClient := &MiniMaxClient{
Client: baseClient,
}
baseClient.Hooks = minimaxClient
return minimaxClient
}
func (c *MiniMaxClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.Log.Infof("🔧 [MCP] MiniMax API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.Log.Infof("🔧 [MCP] MiniMax using custom BaseURL: %s", customURL)
} else {
c.Log.Infof("🔧 [MCP] MiniMax using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.Log.Infof("🔧 [MCP] MiniMax using custom Model: %s", customModel)
} else {
c.Log.Infof("🔧 [MCP] MiniMax using default Model: %s", c.Model)
}
}
// MiniMax uses standard OpenAI-compatible API with Bearer auth
func (c *MiniMaxClient) SetAuthHeader(reqHeaders http.Header) {
c.Client.SetAuthHeader(reqHeaders)
}

73
mcp/provider/openai.go Normal file
View File

@@ -0,0 +1,73 @@
package provider
import (
"net/http"
"nofx/mcp"
)
const (
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
DefaultOpenAIModel = "gpt-5.4"
)
func init() {
mcp.RegisterProvider(mcp.ProviderOpenAI, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewOpenAIClientWithOptions(opts...)
})
}
type OpenAIClient struct {
*mcp.Client
}
func (c *OpenAIClient) BaseClient() *mcp.Client { return c.Client }
// NewOpenAIClient creates OpenAI client (backward compatible)
func NewOpenAIClient() mcp.AIClient {
return NewOpenAIClientWithOptions()
}
// NewOpenAIClientWithOptions creates OpenAI client (supports options pattern)
func NewOpenAIClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
openaiOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderOpenAI),
mcp.WithModel(DefaultOpenAIModel),
mcp.WithBaseURL(DefaultOpenAIBaseURL),
}
allOpts := append(openaiOpts, opts...)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
openaiClient := &OpenAIClient{
Client: baseClient,
}
baseClient.Hooks = openaiClient
return openaiClient
}
func (c *OpenAIClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.Log.Infof("🔧 [MCP] OpenAI API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.Log.Infof("🔧 [MCP] OpenAI using custom BaseURL: %s", customURL)
} else {
c.Log.Infof("🔧 [MCP] OpenAI using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.Log.Infof("🔧 [MCP] OpenAI using custom Model: %s", customModel)
} else {
c.Log.Infof("🔧 [MCP] OpenAI using default Model: %s", c.Model)
}
}
// OpenAI uses standard Bearer auth
func (c *OpenAIClient) SetAuthHeader(reqHeaders http.Header) {
c.Client.SetAuthHeader(reqHeaders)
}

View File

@@ -0,0 +1,83 @@
package provider
import (
"testing"
"nofx/mcp"
)
func TestOptionsWithDeepSeekClient(t *testing.T) {
logger := mcp.NewNoopLogger()
client := NewDeepSeekClientWithOptions(
mcp.WithAPIKey("sk-deepseek-key"),
mcp.WithLogger(logger),
mcp.WithMaxTokens(5000),
)
dsClient := client.(*DeepSeekClient)
// Verify DeepSeek default values
if dsClient.Provider != mcp.ProviderDeepSeek {
t.Error("Provider should be DeepSeek")
}
if dsClient.BaseURL != mcp.DefaultDeepSeekBaseURL {
t.Error("BaseURL should be DeepSeek default")
}
if dsClient.Model != mcp.DefaultDeepSeekModel {
t.Error("Model should be DeepSeek default")
}
// Verify custom options
if dsClient.APIKey != "sk-deepseek-key" {
t.Error("APIKey should be set from options")
}
if dsClient.Log != logger {
t.Error("Log should be set from options")
}
if dsClient.MaxTokens != 5000 {
t.Error("MaxTokens should be 5000")
}
}
func TestOptionsWithQwenClient(t *testing.T) {
logger := mcp.NewNoopLogger()
client := NewQwenClientWithOptions(
mcp.WithAPIKey("sk-qwen-key"),
mcp.WithLogger(logger),
mcp.WithMaxTokens(6000),
)
qwenClient := client.(*QwenClient)
// Verify Qwen default values
if qwenClient.Provider != mcp.ProviderQwen {
t.Error("Provider should be Qwen")
}
if qwenClient.BaseURL != mcp.DefaultQwenBaseURL {
t.Error("BaseURL should be Qwen default")
}
if qwenClient.Model != mcp.DefaultQwenModel {
t.Error("Model should be Qwen default")
}
// Verify custom options
if qwenClient.APIKey != "sk-qwen-key" {
t.Error("APIKey should be set from options")
}
if qwenClient.Log != logger {
t.Error("Log should be set from options")
}
if qwenClient.MaxTokens != 6000 {
t.Error("MaxTokens should be 6000")
}
}

74
mcp/provider/qwen.go Normal file
View File

@@ -0,0 +1,74 @@
package provider
import (
"net/http"
"nofx/mcp"
)
const (
DefaultQwenBaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
DefaultQwenModel = "qwen3-max"
)
func init() {
mcp.RegisterProvider(mcp.ProviderQwen, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewQwenClientWithOptions(opts...)
})
}
type QwenClient struct {
*mcp.Client
}
func (c *QwenClient) BaseClient() *mcp.Client { return c.Client }
// NewQwenClient creates Qwen client (backward compatible)
//
// Deprecated: Recommend using NewQwenClientWithOptions for better flexibility
func NewQwenClient() mcp.AIClient {
return NewQwenClientWithOptions()
}
// NewQwenClientWithOptions creates Qwen client (supports options pattern)
func NewQwenClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
qwenOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderQwen),
mcp.WithModel(DefaultQwenModel),
mcp.WithBaseURL(DefaultQwenBaseURL),
}
allOpts := append(qwenOpts, opts...)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
qwenClient := &QwenClient{
Client: baseClient,
}
baseClient.Hooks = qwenClient
return qwenClient
}
func (qwenClient *QwenClient) SetAPIKey(apiKey string, customURL string, customModel string) {
qwenClient.APIKey = apiKey
if len(apiKey) > 8 {
qwenClient.Log.Infof("🔧 [MCP] Qwen API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
qwenClient.BaseURL = customURL
qwenClient.Log.Infof("🔧 [MCP] Qwen using custom BaseURL: %s", customURL)
} else {
qwenClient.Log.Infof("🔧 [MCP] Qwen using default BaseURL: %s", qwenClient.BaseURL)
}
if customModel != "" {
qwenClient.Model = customModel
qwenClient.Log.Infof("🔧 [MCP] Qwen using custom Model: %s", customModel)
} else {
qwenClient.Log.Infof("🔧 [MCP] Qwen using default Model: %s", qwenClient.Model)
}
}
func (qwenClient *QwenClient) SetAuthHeader(reqHeaders http.Header) {
qwenClient.Client.SetAuthHeader(reqHeaders)
}

31
mcp/providers.go Normal file
View File

@@ -0,0 +1,31 @@
package mcp
// Provider name constants — kept in the mcp package so that client.go can
// reference them for default configuration without importing sub-packages.
// Provider sub-packages re-use these same values.
const (
ProviderDeepSeek = "deepseek"
ProviderOpenAI = "openai"
ProviderClaude = "claude"
ProviderQwen = "qwen"
ProviderGemini = "gemini"
ProviderGrok = "grok"
ProviderKimi = "kimi"
ProviderMiniMax = "minimax"
ProviderBlockRunBase = "blockrun-base"
ProviderBlockRunSol = "blockrun-sol"
ProviderClaw402 = "claw402"
// Default DeepSeek configuration (used as fallback in NewClient)
DefaultDeepSeekBaseURL = "https://api.deepseek.com"
DefaultDeepSeekModel = "deepseek-chat"
// Default Qwen configuration (used by WithQwenConfig convenience option)
DefaultQwenBaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
DefaultQwenModel = "qwen3-max"
// Default MiniMax configuration (used by WithMiniMaxConfig convenience option)
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
DefaultMiniMaxModel = "MiniMax-M2.5"
)

View File

@@ -1,83 +0,0 @@
package mcp
import (
"net/http"
)
const (
ProviderQwen = "qwen"
DefaultQwenBaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
DefaultQwenModel = "qwen3-max"
)
type QwenClient struct {
*Client
}
// NewQwenClient creates Qwen client (backward compatible)
//
// Deprecated: Recommend using NewQwenClientWithOptions for better flexibility
func NewQwenClient() AIClient {
return NewQwenClientWithOptions()
}
// NewQwenClientWithOptions creates Qwen client (supports options pattern)
//
// Usage examples:
// // Basic usage
// client := mcp.NewQwenClientWithOptions()
//
// // Custom configuration
// client := mcp.NewQwenClientWithOptions(
// mcp.WithAPIKey("sk-xxx"),
// mcp.WithLogger(customLogger),
// mcp.WithTimeout(60*time.Second),
// )
func NewQwenClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create Qwen preset options
qwenOpts := []ClientOption{
WithProvider(ProviderQwen),
WithModel(DefaultQwenModel),
WithBaseURL(DefaultQwenBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(qwenOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create Qwen client
qwenClient := &QwenClient{
Client: baseClient,
}
// 5. Set hooks to point to QwenClient (implement dynamic dispatch)
baseClient.hooks = qwenClient
return qwenClient
}
func (qwenClient *QwenClient) SetAPIKey(apiKey string, customURL string, customModel string) {
qwenClient.APIKey = apiKey
if len(apiKey) > 8 {
qwenClient.logger.Infof("🔧 [MCP] Qwen API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
qwenClient.BaseURL = customURL
qwenClient.logger.Infof("🔧 [MCP] Qwen using custom BaseURL: %s", customURL)
} else {
qwenClient.logger.Infof("🔧 [MCP] Qwen using default BaseURL: %s", qwenClient.BaseURL)
}
if customModel != "" {
qwenClient.Model = customModel
qwenClient.logger.Infof("🔧 [MCP] Qwen using custom Model: %s", customModel)
} else {
qwenClient.logger.Infof("🔧 [MCP] Qwen using default Model: %s", qwenClient.Model)
}
}
func (qwenClient *QwenClient) setAuthHeader(reqHeaders http.Header) {
qwenClient.Client.setAuthHeader(reqHeaders)
}

View File

@@ -1,272 +0,0 @@
package mcp
import (
"testing"
"time"
)
// ============================================================
// Test QwenClient Creation and Configuration
// ============================================================
func TestNewQwenClient_Default(t *testing.T) {
client := NewQwenClient()
if client == nil {
t.Fatal("client should not be nil")
}
// Type assertion check
qwenClient, ok := client.(*QwenClient)
if !ok {
t.Fatal("client should be *QwenClient")
}
// Verify default values
if qwenClient.Provider != ProviderQwen {
t.Errorf("Provider should be '%s', got '%s'", ProviderQwen, qwenClient.Provider)
}
if qwenClient.BaseURL != DefaultQwenBaseURL {
t.Errorf("BaseURL should be '%s', got '%s'", DefaultQwenBaseURL, qwenClient.BaseURL)
}
if qwenClient.Model != DefaultQwenModel {
t.Errorf("Model should be '%s', got '%s'", DefaultQwenModel, qwenClient.Model)
}
if qwenClient.logger == nil {
t.Error("logger should not be nil")
}
if qwenClient.httpClient == nil {
t.Error("httpClient should not be nil")
}
}
func TestNewQwenClientWithOptions(t *testing.T) {
mockLogger := NewMockLogger()
customModel := "qwen-plus"
customAPIKey := "sk-custom-qwen-key"
client := NewQwenClientWithOptions(
WithLogger(mockLogger),
WithModel(customModel),
WithAPIKey(customAPIKey),
WithMaxTokens(4000),
)
qwenClient := client.(*QwenClient)
// Verify custom options are applied
if qwenClient.logger != mockLogger {
t.Error("logger should be set from option")
}
if qwenClient.Model != customModel {
t.Error("Model should be set from option")
}
if qwenClient.APIKey != customAPIKey {
t.Error("APIKey should be set from option")
}
if qwenClient.MaxTokens != 4000 {
t.Error("MaxTokens should be 4000")
}
// Verify Qwen default values are retained
if qwenClient.Provider != ProviderQwen {
t.Errorf("Provider should still be '%s'", ProviderQwen)
}
if qwenClient.BaseURL != DefaultQwenBaseURL {
t.Errorf("BaseURL should still be '%s'", DefaultQwenBaseURL)
}
}
// ============================================================
// Test SetAPIKey
// ============================================================
func TestQwenClient_SetAPIKey(t *testing.T) {
mockLogger := NewMockLogger()
client := NewQwenClientWithOptions(
WithLogger(mockLogger),
)
qwenClient := client.(*QwenClient)
// Test setting API Key (default URL and Model)
qwenClient.SetAPIKey("sk-test-key-12345678", "", "")
if qwenClient.APIKey != "sk-test-key-12345678" {
t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", qwenClient.APIKey)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
if len(logs) == 0 {
t.Error("should have logged API key setting")
}
// Verify BaseURL and Model remain default
if qwenClient.BaseURL != DefaultQwenBaseURL {
t.Error("BaseURL should remain default")
}
if qwenClient.Model != DefaultQwenModel {
t.Error("Model should remain default")
}
}
func TestQwenClient_SetAPIKey_WithCustomURL(t *testing.T) {
mockLogger := NewMockLogger()
client := NewQwenClientWithOptions(
WithLogger(mockLogger),
)
qwenClient := client.(*QwenClient)
customURL := "https://custom.qwen.api.com/v1"
qwenClient.SetAPIKey("sk-test-key-12345678", customURL, "")
if qwenClient.BaseURL != customURL {
t.Errorf("BaseURL should be '%s', got '%s'", customURL, qwenClient.BaseURL)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomURLLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] Qwen using custom BaseURL: %s" {
hasCustomURLLog = true
break
}
}
if !hasCustomURLLog {
t.Error("should have logged custom BaseURL")
}
}
func TestQwenClient_SetAPIKey_WithCustomModel(t *testing.T) {
mockLogger := NewMockLogger()
client := NewQwenClientWithOptions(
WithLogger(mockLogger),
)
qwenClient := client.(*QwenClient)
customModel := "qwen-turbo"
qwenClient.SetAPIKey("sk-test-key-12345678", "", customModel)
if qwenClient.Model != customModel {
t.Errorf("Model should be '%s', got '%s'", customModel, qwenClient.Model)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomModelLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] Qwen using custom Model: %s" {
hasCustomModelLog = true
break
}
}
if !hasCustomModelLog {
t.Error("should have logged custom Model")
}
}
// ============================================================
// Test Integration Features
// ============================================================
func TestQwenClient_CallWithMessages_Success(t *testing.T) {
mockHTTP := NewMockHTTPClient()
mockHTTP.SetSuccessResponse("Qwen AI response")
mockLogger := NewMockLogger()
client := NewQwenClientWithOptions(
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(mockLogger),
WithAPIKey("sk-test-key"),
)
result, err := client.CallWithMessages("system prompt", "user prompt")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if result != "Qwen AI response" {
t.Errorf("expected 'Qwen AI response', got '%s'", result)
}
// Verify request
requests := mockHTTP.GetRequests()
if len(requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(requests))
}
req := requests[0]
// Verify URL
expectedURL := DefaultQwenBaseURL + "/chat/completions"
if req.URL.String() != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String())
}
// Verify Authorization header
authHeader := req.Header.Get("Authorization")
if authHeader != "Bearer sk-test-key" {
t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader)
}
// Verify Content-Type
if req.Header.Get("Content-Type") != "application/json" {
t.Error("Content-Type should be application/json")
}
}
func TestQwenClient_Timeout(t *testing.T) {
client := NewQwenClientWithOptions(
WithTimeout(30 * time.Second),
)
qwenClient := client.(*QwenClient)
if qwenClient.httpClient.Timeout != 30*time.Second {
t.Errorf("expected timeout 30s, got %v", qwenClient.httpClient.Timeout)
}
// Test SetTimeout
client.SetTimeout(60 * time.Second)
if qwenClient.httpClient.Timeout != 60*time.Second {
t.Errorf("expected timeout 60s after SetTimeout, got %v", qwenClient.httpClient.Timeout)
}
}
// ============================================================
// Test hooks Mechanism
// ============================================================
func TestQwenClient_HooksIntegration(t *testing.T) {
client := NewQwenClientWithOptions()
qwenClient := client.(*QwenClient)
// Verify hooks point to qwenClient itself (implements polymorphism)
if qwenClient.hooks != qwenClient {
t.Error("hooks should point to qwenClient for polymorphism")
}
// Verify buildUrl uses Qwen configuration
url := qwenClient.buildUrl()
expectedURL := DefaultQwenBaseURL + "/chat/completions"
if url != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, url)
}
}

20
mcp/registry.go Normal file
View File

@@ -0,0 +1,20 @@
package mcp
// providerRegistry maps provider names to factory functions.
var providerRegistry = map[string]func(...ClientOption) AIClient{}
// RegisterProvider registers a provider factory function.
// Called by provider/payment sub-packages in their init() functions.
func RegisterProvider(name string, factory func(...ClientOption) AIClient) {
providerRegistry[name] = factory
}
// NewAIClientByProvider creates an AIClient by provider name using the registry.
// Returns nil if the provider is not registered.
func NewAIClientByProvider(name string, opts ...ClientOption) AIClient {
factory, ok := providerRegistry[name]
if !ok {
return nil
}
return factory(opts...)
}

View File

@@ -450,10 +450,10 @@ func TestClient_CallWithRequest_UsesClientModel(t *testing.T) {
mockHTTP.SetSuccessResponse("Response")
mockLogger := NewMockLogger()
client := NewDeepSeekClientWithOptions(
client := NewClient(
WithDeepSeekConfig("sk-test-key"),
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(mockLogger),
WithAPIKey("sk-test-key"),
)
// Request does not set model, should use Client's model