Files
aiops/backend/pkg/agentcli/llm/anthropic.go
T
LoganandClaude 0b2e43ea9e feat(llm): 大模型双协议(OpenAI+Anthropic)接入 + SSH凭证修复 + 容量预测AI解读
后端双协议: 新增 anthropic.go(Complete/Stream/工具转换) + client.go 工厂按 Protocol 路由; llm.Client 加 Stream; llm_chat 三方法收敛复用 llm.Client; resolveProtocol 按 provider 路由; Agent CLI 注入协议。编码套餐(GLM-5.2/Kimi/MiMo/Anthropic)走 Anthropic /v1/messages。

SSH凭证修复: applyCredential 正确解析凭证 JSON 的 private_key(旧实现把整个 JSON 当私钥 → ssh.ParsePrivateKey 失败 → ssh-key 登录不通); AssignCredential/批量导入/单台 CRUD 三处统一; 单台补齐 credential_id 字段(原被忽略)。

容量预测: 新增 AI 深度解读(buildCapacityInsightPrompt + PredictiveCapacityTab 按钮, 复用 aiApi.chat)。

其他: DeepSeek 模型名更新为 deepseek-v4-flash/pro; 智能助手去掉重复模型配置按钮; LLMConfig 精简生成参数(Claude Code 式)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-28 22:37:46 +08:00

338 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// anthropic.go - Anthropic Messages 协议 LLM 客户端实现 // Logan.Li
// 支持官方 Claude(api.anthropic.com)及智谱 GLM 编码套餐(open.bigmodel.cn/api/anthropic)等
// 所有 Anthropic /v1/messages 兼容端点。与 openai.go 实现同一 Client 接口,由 client.go 按 Protocol 路由。
// 鉴权统一用 Authorization: Bearer(智谱编码套餐实测通过;官方 Claude 较新版本亦支持)+ anthropic-version。
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// AnthropicClient Anthropic Messages 协议客户端 // Logan.Li
type AnthropicClient struct {
cfg Config
client *http.Client
}
// NewAnthropicClient 创建 Anthropic 兼容客户端 // Logan.Li
func NewAnthropicClient(cfg Config) Client {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 180 * time.Second
}
return &AnthropicClient{cfg: cfg, client: &http.Client{Timeout: timeout}}
}
// Name 返回客户端名称 // Logan.Li
func (c *AnthropicClient) Name() string { return "anthropic(" + c.cfg.Model + ")" }
// ---- 请求体结构(Anthropic Messages 格式) // Logan.Li ----
// anthropicMessage Anthropic 消息:content 可为字符串或 block 数组,用 RawMessage 原样嵌入。
type anthropicMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
// anthropicRequest Anthropic /v1/messages 请求体 // Logan.Li
type anthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"` // system 抽到顶层(不在 messages 内)
Messages []anthropicMessage `json:"messages"`
Tools []anthropicTool `json:"tools,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
}
// anthropicTool Anthropic 工具定义(input_schema,对应 OpenAI 的 parameters) // Logan.Li
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"input_schema"`
}
// 响应 content block:text 或 tool_use(互斥字段) // Logan.Li
type anthropicBlock struct {
Type string `json:"type"` // text | tool_use
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"` // tool_use
Name string `json:"name,omitempty"` // tool_use
Input json.RawMessage `json:"input,omitempty"` // tool_use
}
// 请求侧 content block(assistant 输出 tool_use / user 输出 tool_result) // Logan.Li
type textBlock struct {
Type string `json:"type"` // 固定 "text"
Text string `json:"text"`
}
type toolUseBlock struct {
Type string `json:"type"` // 固定 "tool_use"
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
}
type toolResultBlock struct {
Type string `json:"type"` // 固定 "tool_result"
ToolUseID string `json:"tool_use_id"`
Content string `json:"content"`
}
// anthropicResponse Anthropic /v1/messages 响应体 // Logan.Li
type anthropicResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Content []anthropicBlock `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,omitempty"`
}
// buildMessages 把 OpenAI 风格 Message[] 转成 Anthropic messages + 顶层 system // Logan.Li
// - system role 抽到顶层 system 字段
// - tool role(工具结果)→ user 消息的 tool_result 块
// - assistant 带 tool_calls → content 含 tool_use 块
func (c *AnthropicClient) buildMessages(req *CompletionRequest) (string, []anthropicMessage) {
var system string
var msgs []anthropicMessage
for _, m := range req.Messages {
switch m.Role {
case "system":
if system == "" {
system = m.Content
} else {
system += "\n" + m.Content
}
case "tool":
raw, _ := json.Marshal([]toolResultBlock{{
Type: "tool_result", ToolUseID: m.ToolCallID, Content: m.Content,
}})
msgs = append(msgs, anthropicMessage{Role: "user", Content: raw})
case "assistant":
// assistant 消息可能含文本 + tool_use 计划,组装成 content block 数组
blocks := make([]interface{}, 0, 1+len(m.ToolCalls))
if m.Content != "" {
blocks = append(blocks, textBlock{Type: "text", Text: m.Content})
}
for _, tc := range m.ToolCalls {
args := json.RawMessage("{}")
if strings.TrimSpace(tc.Function.Arguments) != "" {
args = json.RawMessage(tc.Function.Arguments)
}
blocks = append(blocks, toolUseBlock{
Type: "tool_use", ID: tc.ID, Name: tc.Function.Name, Input: args,
})
}
raw, _ := json.Marshal(blocks)
msgs = append(msgs, anthropicMessage{Role: "assistant", Content: raw})
default: // user
raw, _ := json.Marshal(m.Content)
msgs = append(msgs, anthropicMessage{Role: "user", Content: raw})
}
}
return system, msgs
}
// buildTools OpenAI Tool[](parameters) → Anthropic [](input_schema) // Logan.Li
func buildTools(tools []Tool) []anthropicTool {
out := make([]anthropicTool, 0, len(tools))
for _, t := range tools {
out = append(out, anthropicTool{
Name: t.Function.Name, Description: t.Function.Description, InputSchema: t.Function.Parameters,
})
}
return out
}
// buildRequest 构造 Anthropic 请求体(Complete 与 Stream 共用) // Logan.Li
func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]byte, error) {
system, msgs := c.buildMessages(req)
payload := anthropicRequest{
Model: c.cfg.Model, MaxTokens: c.cfg.MaxTokens, System: system,
Messages: msgs, Tools: buildTools(req.Tools),
Temperature: c.cfg.Temperature, Stream: stream,
}
if payload.MaxTokens <= 0 {
payload.MaxTokens = 4096
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
return body, nil
}
// newHTTPRequest 构造 POST /v1/messages 请求(Bearer + anthropic-version) // Logan.Li
func (c *AnthropicClient) newHTTPRequest(ctx context.Context, body []byte) (*http.Request, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Endpoint+"/v1/messages", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("构造请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
httpReq.Header.Set("anthropic-version", "2023-06-01")
return httpReq, nil
}
// Complete 一次性补全(Anthropic /v1/messages) // Logan.Li
// 响应 content blocks:text 拼为 Content,tool_use 转为 ToolCalls;usage 映射。
func (c *AnthropicClient) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
body, err := c.buildRequest(req, false)
if err != nil {
return nil, err
}
httpReq, err := c.newHTTPRequest(ctx, body)
if err != nil {
return nil, err
}
resp, err := c.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("LLM 连接失败: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("LLM 返回错误 (HTTP %d): %s", resp.StatusCode, extractErrorMessage(raw))
}
var parsed anthropicResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("解析 LLM 响应失败: %w", err)
}
if parsed.Error != nil && parsed.Error.Message != "" {
return nil, fmt.Errorf("LLM 错误: %s", parsed.Error.Message)
}
var textBuf strings.Builder
var toolCalls []ToolCall
for _, b := range parsed.Content {
switch b.Type {
case "text":
textBuf.WriteString(b.Text)
case "tool_use":
args := string(b.Input)
if strings.TrimSpace(args) == "" {
args = "{}"
}
toolCalls = append(toolCalls, ToolCall{
ID: b.ID, Type: "function",
Function: FunctionCall{Name: b.Name, Arguments: args},
})
}
}
return &CompletionResponse{
ID: parsed.ID,
Model: parsed.Model,
Message: Message{
Role: "assistant",
Content: textBuf.String(),
ToolCalls: toolCalls,
},
Usage: Usage{
PromptTokens: parsed.Usage.InputTokens,
CompletionTokens: parsed.Usage.OutputTokens,
TotalTokens: parsed.Usage.InputTokens + parsed.Usage.OutputTokens,
},
}, nil
}
// Stream 流式补全(Anthropic SSE) // Logan.Li
// 逐 token 回调 text_delta;usage 从 message_start(input) + message_delta(output) 取。
// 注:流式主要用于纯对话(智能助手),tool_use 流式增量在此不组装(Agent CLI 走 Complete)。
func (c *AnthropicClient) Stream(ctx context.Context, req *CompletionRequest, onDelta func(string)) (*CompletionResponse, error) {
body, err := c.buildRequest(req, true)
if err != nil {
return nil, err
}
httpReq, err := c.newHTTPRequest(ctx, body)
if err != nil {
return nil, err
}
httpReq.Header.Set("Accept", "text/event-stream")
streamClient := &http.Client{Timeout: 0} // 流式由 ctx 控制超时
resp, err := streamClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("LLM 连接失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("LLM 返回错误 (HTTP %d): %s", resp.StatusCode, extractErrorMessage(raw))
}
var contentBuilder strings.Builder
var usage Usage
var eventType string
reader := bufio.NewReader(resp.Body)
for {
line, readErr := reader.ReadString('\n')
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "event:"):
eventType = strings.TrimSpace(strings.TrimPrefix(trimmed, "event:"))
case strings.HasPrefix(trimmed, "data:"):
data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if data != "" && data != "[DONE]" {
switch eventType {
case "content_block_delta":
var d struct {
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"delta"`
}
if json.Unmarshal([]byte(data), &d) == nil && d.Delta.Type == "text_delta" && d.Delta.Text != "" {
contentBuilder.WriteString(d.Delta.Text)
if onDelta != nil {
onDelta(d.Delta.Text)
}
}
case "message_start":
var d struct {
Message struct {
Usage struct {
InputTokens int `json:"input_tokens"`
} `json:"usage"`
} `json:"message"`
}
if json.Unmarshal([]byte(data), &d) == nil {
usage.PromptTokens = d.Message.Usage.InputTokens
}
case "message_delta":
var d struct {
Usage struct {
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if json.Unmarshal([]byte(data), &d) == nil {
usage.CompletionTokens = d.Usage.OutputTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
}
}
eventType = "" // 消费完 data 重置,防默认事件串用
}
if readErr != nil {
break
}
}
return &CompletionResponse{
Model: c.cfg.Model,
Message: Message{Role: "assistant", Content: contentBuilder.String()},
Usage: usage,
}, nil
}