mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 08:16:56 +08:00
refactor(mcp): route buildRequestBodyFromRequest through hooks + full Anthropic format
Problem: callWithRequest/Full/Stream all called client.buildRequestBodyFromRequest
directly (not via hooks), so ClaudeClient could never override it. This meant
tool calling sent OpenAI format to Anthropic (wrong field names, wrong roles).
Changes:
mcp/interface.go
- Add buildRequestBodyFromRequest(*Request) map[string]any to clientHooks
- Improve comments: document what each hook group does and why
mcp/client.go
- All three paths (callWithRequest, callWithRequestFull, CallWithRequestStream)
now call client.hooks.buildRequestBodyFromRequest — ClaudeClient picks up
mcp/claude_client.go
- Full rewrite with format comparison table in package doc
- buildRequestBodyFromRequest: produces correct Anthropic wire format
* system prompt → top-level "system" field
* tools: parameters → input_schema, no "type:function" wrapper
* tool_choice "auto" → {"type":"auto"} object
* assistant tool calls → content[{type:tool_use, id, name, input}]
* role=tool results → role=user content[{type:tool_result,...}]
* consecutive tool results merged into single user turn
- convertMessagesToAnthropic: handles all three message types
- parseMCPResponseFull: extracts text + tool_use blocks
- parseMCPResponse: delegates to parseMCPResponseFull
All mcp and agent tests pass.
This commit is contained in:
@@ -1,3 +1,19 @@
|
|||||||
|
// Package mcp — ClaudeClient implements the Anthropic Messages API.
|
||||||
|
//
|
||||||
|
// Wire-format differences from the OpenAI-compatible base Client:
|
||||||
|
//
|
||||||
|
// ┌─────────────────────┬───────────────────────────┬─────────────────────────────────┐
|
||||||
|
// │ Concept │ OpenAI format │ Anthropic format │
|
||||||
|
// ├─────────────────────┼───────────────────────────┼─────────────────────────────────┤
|
||||||
|
// │ Endpoint │ /v1/chat/completions │ /v1/messages │
|
||||||
|
// │ Auth header │ Authorization: Bearer xxx │ x-api-key: xxx │
|
||||||
|
// │ System prompt │ messages[0] role=system │ top-level "system" field │
|
||||||
|
// │ Tool definition │ type=function + parameters │ name + description + input_schema│
|
||||||
|
// │ Tool choice │ "auto" (string) │ {"type":"auto"} (object) │
|
||||||
|
// │ Assistant tool call │ tool_calls array │ content[{type:tool_use,...}] │
|
||||||
|
// │ Tool result │ role=tool + tool_call_id │ role=user content[tool_result] │
|
||||||
|
// │ Max tokens │ max_tokens │ max_tokens (same) │
|
||||||
|
// └─────────────────────┴───────────────────────────┴─────────────────────────────────┘
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,75 +28,64 @@ const (
|
|||||||
DefaultClaudeModel = "claude-opus-4-6"
|
DefaultClaudeModel = "claude-opus-4-6"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 {
|
type ClaudeClient struct {
|
||||||
*Client
|
*Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClaudeClient creates Claude client (backward compatible)
|
// NewClaudeClient creates a ClaudeClient with default settings.
|
||||||
func NewClaudeClient() AIClient {
|
func NewClaudeClient() AIClient {
|
||||||
return NewClaudeClientWithOptions()
|
return NewClaudeClientWithOptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClaudeClientWithOptions creates Claude client (supports options pattern)
|
// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides.
|
||||||
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
|
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
|
||||||
// 1. Create Claude preset options
|
baseClient := NewClient(append([]ClientOption{
|
||||||
claudeOpts := []ClientOption{
|
|
||||||
WithProvider(ProviderClaude),
|
WithProvider(ProviderClaude),
|
||||||
WithModel(DefaultClaudeModel),
|
WithModel(DefaultClaudeModel),
|
||||||
WithBaseURL(DefaultClaudeBaseURL),
|
WithBaseURL(DefaultClaudeBaseURL),
|
||||||
}
|
}, opts...)...).(*Client)
|
||||||
|
|
||||||
// 2. Merge user options (user options have higher priority)
|
c := &ClaudeClient{Client: baseClient}
|
||||||
allOpts := append(claudeOpts, opts...)
|
baseClient.hooks = c // wire dynamic dispatch to ClaudeClient
|
||||||
|
return c
|
||||||
// 3. Create base client
|
|
||||||
baseClient := NewClient(allOpts...).(*Client)
|
|
||||||
|
|
||||||
// 4. Create Claude client
|
|
||||||
claudeClient := &ClaudeClient{
|
|
||||||
Client: baseClient,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Set hooks to point to ClaudeClient (implement dynamic dispatch)
|
|
||||||
baseClient.hooks = claudeClient
|
|
||||||
|
|
||||||
return claudeClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
// ── Hook overrides ────────────────────────────────────────────────────────────
|
||||||
c.APIKey = apiKey
|
|
||||||
|
|
||||||
|
// SetAPIKey stores credentials and optional custom endpoint / model.
|
||||||
|
func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) {
|
||||||
|
c.APIKey = apiKey
|
||||||
if len(apiKey) > 8 {
|
if len(apiKey) > 8 {
|
||||||
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
||||||
}
|
}
|
||||||
if customURL != "" {
|
if customURL != "" {
|
||||||
c.BaseURL = customURL
|
c.BaseURL = customURL
|
||||||
c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL)
|
c.logger.Infof("🔧 [MCP] Claude BaseURL: %s", customURL)
|
||||||
} else {
|
|
||||||
c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL)
|
|
||||||
}
|
}
|
||||||
if customModel != "" {
|
if customModel != "" {
|
||||||
c.Model = customModel
|
c.Model = customModel
|
||||||
c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel)
|
c.logger.Infof("🔧 [MCP] Claude Model: %s", customModel)
|
||||||
} else {
|
|
||||||
c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer
|
// setAuthHeader uses x-api-key instead of Authorization: Bearer.
|
||||||
func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) {
|
func (c *ClaudeClient) setAuthHeader(h http.Header) {
|
||||||
reqHeaders.Set("x-api-key", c.APIKey)
|
h.Set("x-api-key", c.APIKey)
|
||||||
reqHeaders.Set("anthropic-version", "2023-06-01")
|
h.Set("anthropic-version", "2023-06-01")
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildUrl Claude uses /messages endpoint
|
// buildUrl targets /messages instead of /chat/completions.
|
||||||
func (c *ClaudeClient) buildUrl() string {
|
func (c *ClaudeClient) buildUrl() string {
|
||||||
return fmt.Sprintf("%s/messages", c.BaseURL)
|
return fmt.Sprintf("%s/messages", c.BaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMCPRequestBody Claude has different request format
|
// 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 {
|
||||||
requestBody := map[string]any{
|
return map[string]any{
|
||||||
"model": c.Model,
|
"model": c.Model,
|
||||||
"max_tokens": c.MaxTokens,
|
"max_tokens": c.MaxTokens,
|
||||||
"system": systemPrompt,
|
"system": systemPrompt,
|
||||||
@@ -88,13 +93,169 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[
|
|||||||
{"role": "user", "content": userPrompt},
|
{"role": "user", "content": userPrompt},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestBody
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMCPResponseFull Claude response format — handles both text and tool_use blocks.
|
// 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 {
|
||||||
|
// ── 1. Separate system prompt from conversation messages ──────────────────
|
||||||
|
var systemPrompt string
|
||||||
|
var convMsgs []Message
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
if m.Role == "system" {
|
||||||
|
systemPrompt = m.Content
|
||||||
|
} else {
|
||||||
|
convMsgs = append(convMsgs, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Convert messages to Anthropic format ───────────────────────────────
|
||||||
|
anthropicMsgs := convertMessagesToAnthropic(convMsgs)
|
||||||
|
|
||||||
|
// ── 3. Convert tool definitions (parameters → input_schema) ──────────────
|
||||||
|
var anthropicTools []map[string]any
|
||||||
|
for _, t := range req.Tools {
|
||||||
|
anthropicTools = append(anthropicTools, map[string]any{
|
||||||
|
"name": t.Function.Name,
|
||||||
|
"description": t.Function.Description,
|
||||||
|
"input_schema": t.Function.Parameters,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Assemble request body ──────────────────────────────────────────────
|
||||||
|
body := map[string]any{
|
||||||
|
"model": req.Model,
|
||||||
|
"max_tokens": c.MaxTokens,
|
||||||
|
"system": systemPrompt,
|
||||||
|
"messages": anthropicMsgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(anthropicTools) > 0 {
|
||||||
|
body["tools"] = anthropicTools
|
||||||
|
}
|
||||||
|
|
||||||
|
// tool_choice: Anthropic uses an object, not a string.
|
||||||
|
switch req.ToolChoice {
|
||||||
|
case "auto":
|
||||||
|
body["tool_choice"] = map[string]any{"type": "auto"}
|
||||||
|
case "any":
|
||||||
|
body["tool_choice"] = map[string]any{"type": "any"}
|
||||||
|
case "none", "":
|
||||||
|
// omit — no tool_choice sent
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Temperature != nil {
|
||||||
|
body["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message
|
||||||
|
// slice to Anthropic's messages array.
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
var out []map[string]any
|
||||||
|
|
||||||
|
for i := 0; i < len(msgs); {
|
||||||
|
msg := msgs[i]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
// ── Assistant message carrying tool calls ─────────────────────────────
|
||||||
|
case msg.Role == "assistant" && len(msg.ToolCalls) > 0:
|
||||||
|
var blocks []map[string]any
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
// Arguments are a JSON string; Claude wants a parsed object.
|
||||||
|
var input map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||||
|
input = map[string]any{"_raw": tc.Function.Arguments}
|
||||||
|
}
|
||||||
|
blocks = append(blocks, map[string]any{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.ID,
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"input": input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": blocks,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
|
||||||
|
// ── Tool result message(s) → single user turn ─────────────────────────
|
||||||
|
case msg.Role == "tool":
|
||||||
|
// Collect all consecutive tool-result messages.
|
||||||
|
var blocks []map[string]any
|
||||||
|
for i < len(msgs) && msgs[i].Role == "tool" {
|
||||||
|
blocks = append(blocks, map[string]any{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": msgs[i].ToolCallID,
|
||||||
|
"content": msgs[i].Content,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": blocks,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Regular user / assistant text message ─────────────────────────────
|
||||||
|
default:
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"role": msg.Role,
|
||||||
|
"content": msg.Content,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response parsers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// parseMCPResponse extracts the plain-text reply from an Anthropic response.
|
||||||
|
// Used by CallWithMessages / CallWithRequest (no tool support).
|
||||||
|
func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
||||||
|
r, err := c.parseMCPResponseFull(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMCPResponseFull extracts both text and tool calls from an Anthropic
|
||||||
|
// response envelope.
|
||||||
|
//
|
||||||
|
// 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) (*LLMResponse, error) {
|
||||||
var response struct {
|
var raw struct {
|
||||||
Content []struct {
|
Content []struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
@@ -112,31 +273,33 @@ func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
|||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &response); err != nil {
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
|
return nil, fmt.Errorf("failed to parse Anthropic response: %w — body: %s", err, body)
|
||||||
}
|
}
|
||||||
if response.Error != nil {
|
if raw.Error != nil {
|
||||||
return nil, fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message)
|
return nil, fmt.Errorf("Anthropic API error: %s — %s", raw.Error.Type, raw.Error.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens
|
total := raw.Usage.InputTokens + raw.Usage.OutputTokens
|
||||||
if TokenUsageCallback != nil && totalTokens > 0 {
|
if TokenUsageCallback != nil && total > 0 {
|
||||||
TokenUsageCallback(TokenUsage{
|
TokenUsageCallback(TokenUsage{
|
||||||
Provider: c.Provider,
|
Provider: c.Provider,
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
PromptTokens: response.Usage.InputTokens,
|
PromptTokens: raw.Usage.InputTokens,
|
||||||
CompletionTokens: response.Usage.OutputTokens,
|
CompletionTokens: raw.Usage.OutputTokens,
|
||||||
TotalTokens: totalTokens,
|
TotalTokens: total,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &LLMResponse{}
|
result := &LLMResponse{}
|
||||||
for _, block := range response.Content {
|
for _, block := range raw.Content {
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
case "text":
|
case "text":
|
||||||
result.Content = block.Text
|
result.Content = block.Text
|
||||||
|
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
// Claude returns arguments as a JSON object (Input field); convert to string.
|
// Input is a JSON object; serialise back to a JSON string so it
|
||||||
|
// matches the ToolCallFunction.Arguments field (always a string).
|
||||||
argsJSON, err := json.Marshal(block.Input)
|
argsJSON, err := json.Marshal(block.Input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
argsJSON = []byte("{}")
|
argsJSON = []byte("{}")
|
||||||
@@ -153,54 +316,3 @@ func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
|||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMCPResponse Claude has different response format
|
|
||||||
func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
|
||||||
var response struct {
|
|
||||||
Content []struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
} `json:"content"`
|
|
||||||
Usage struct {
|
|
||||||
InputTokens int `json:"input_tokens"`
|
|
||||||
OutputTokens int `json:"output_tokens"`
|
|
||||||
} `json:"usage"`
|
|
||||||
Error *struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &response); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.Error != nil {
|
|
||||||
return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(response.Content) == 0 {
|
|
||||||
return "", fmt.Errorf("Claude returned empty content, body: %s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report token usage if callback is set
|
|
||||||
totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens
|
|
||||||
if TokenUsageCallback != nil && totalTokens > 0 {
|
|
||||||
TokenUsageCallback(TokenUsage{
|
|
||||||
Provider: c.Provider,
|
|
||||||
Model: c.Model,
|
|
||||||
PromptTokens: response.Usage.InputTokens,
|
|
||||||
CompletionTokens: response.Usage.OutputTokens,
|
|
||||||
TotalTokens: totalTokens,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find text content
|
|
||||||
for _, content := range response.Content {
|
|
||||||
if content.Type == "text" {
|
|
||||||
return content.Text, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("no text content in Claude response")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
|||||||
func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
|
func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||||
client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL)
|
client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL)
|
||||||
|
|
||||||
requestBody := client.buildRequestBodyFromRequest(req)
|
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -512,44 +512,36 @@ func (client *Client) callWithRequest(req *Request) (string, error) {
|
|||||||
client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL)
|
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.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages))
|
||||||
|
|
||||||
// Build request body (from Request object)
|
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||||
requestBody := client.buildRequestBodyFromRequest(req)
|
|
||||||
|
|
||||||
// Serialize request body
|
|
||||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build URL
|
|
||||||
url := client.hooks.buildUrl()
|
url := client.hooks.buildUrl()
|
||||||
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
|
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
|
||||||
|
|
||||||
// Create HTTP request
|
|
||||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send HTTP request
|
|
||||||
resp, err := client.httpClient.Do(httpReq)
|
resp, err := client.httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to send request: %w", err)
|
return "", fmt.Errorf("failed to send request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Read response body
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check HTTP status code
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response
|
|
||||||
result, err := client.hooks.parseMCPResponse(body)
|
result, err := client.hooks.parseMCPResponse(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fail to parse AI server response: %w", err)
|
return "", fmt.Errorf("fail to parse AI server response: %w", err)
|
||||||
@@ -651,7 +643,7 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
|
|||||||
}
|
}
|
||||||
req.Stream = true
|
req.Stream = true
|
||||||
|
|
||||||
requestBody := client.buildRequestBodyFromRequest(req)
|
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -22,19 +22,40 @@ type AIClient interface {
|
|||||||
CallWithRequestFull(req *Request) (*LLMResponse, error)
|
CallWithRequestFull(req *Request) (*LLMResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientHooks internal hook interface (for subclass to override specific steps)
|
// clientHooks is the internal dispatch interface used to implement per-provider
|
||||||
// These methods are only used inside the package to implement dynamic dispatch
|
// 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 {
|
type clientHooks interface {
|
||||||
// Hook methods that can be overridden by subclass
|
// ── Simple CallWithMessages path ────────────────────────────────────────
|
||||||
|
|
||||||
call(systemPrompt, userPrompt string) (string, error)
|
call(systemPrompt, userPrompt string) (string, error)
|
||||||
|
|
||||||
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
|
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
|
||||||
|
|
||||||
|
// ── Shared request plumbing ─────────────────────────────────────────────
|
||||||
buildUrl() string
|
buildUrl() string
|
||||||
buildRequest(url string, jsonData []byte) (*http.Request, error)
|
buildRequest(url string, jsonData []byte) (*http.Request, error)
|
||||||
setAuthHeader(reqHeaders http.Header)
|
setAuthHeader(reqHeaders http.Header)
|
||||||
marshalRequestBody(requestBody map[string]any) ([]byte, error)
|
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)
|
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)
|
parseMCPResponseFull(body []byte) (*LLMResponse, error)
|
||||||
|
|
||||||
isRetryableError(err error) bool
|
isRetryableError(err error) bool
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user