mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 00:07:01 +08:00
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:
@@ -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)
|
||||
}
|
||||
205
mcp/client.go
205
mcp/client.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
37
mcp/hooks.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
69
mcp/provider/deepseek.go
Normal 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
73
mcp/provider/gemini.go
Normal 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
73
mcp/provider/grok.go
Normal 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
73
mcp/provider/kimi.go
Normal 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
73
mcp/provider/minimax.go
Normal 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
73
mcp/provider/openai.go
Normal 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)
|
||||
}
|
||||
83
mcp/provider/options_test.go
Normal file
83
mcp/provider/options_test.go
Normal 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
74
mcp/provider/qwen.go
Normal 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
31
mcp/providers.go
Normal 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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
20
mcp/registry.go
Normal 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...)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user