first commit
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// normalizeAnthropicURL 把用户填写的 Base URL 规范化为 Anthropic Messages 接口地址。
|
||||
// 兼容多种填写方式:https://api.anthropic.com、.../v1、.../v1/messages、.../v1/chat/completions。
|
||||
func normalizeAnthropicURL(baseURL string) string {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if trimmed == "" {
|
||||
return "https://api.anthropic.com/v1/messages"
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(trimmed, "/v1/chat/completions"):
|
||||
return strings.TrimSuffix(trimmed, "/chat/completions") + "/messages"
|
||||
case strings.HasSuffix(trimmed, "/v1/messages"):
|
||||
return trimmed
|
||||
case strings.HasSuffix(trimmed, "/v1"):
|
||||
return trimmed + "/messages"
|
||||
default:
|
||||
return trimmed + "/v1/messages"
|
||||
}
|
||||
}
|
||||
|
||||
// extractSystemPrompt 汇总消息中的 system 角色内容,Anthropic 要求 system 放在顶层字段。
|
||||
func extractSystemPrompt(messages []Message) string {
|
||||
var parts []string
|
||||
for _, message := range messages {
|
||||
if message.Role == "system" && message.Content != nil && strings.TrimSpace(*message.Content) != "" {
|
||||
parts = append(parts, *message.Content)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// toAnthropicTools 把内部工具定义转换为 Anthropic 的 tools 数组(input_schema 替代 parameters)。
|
||||
func toAnthropicTools(tools []ToolDefinition) []map[string]any {
|
||||
result := make([]map[string]any, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
schema := tool.Function.Parameters
|
||||
if schema == nil {
|
||||
schema = map[string]any{"type": "object"}
|
||||
}
|
||||
result = append(result, map[string]any{
|
||||
"name": tool.Function.Name,
|
||||
"description": tool.Function.Description,
|
||||
"input_schema": schema,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toAnthropicMessages 把内部 Message 列表转换为 Anthropic messages 数组。
|
||||
// system 消息被过滤(走顶层 system 字段);assistant 的 tool_use 与 user 的
|
||||
// tool_result 都以 content block 形式表达。
|
||||
// 注意:Anthropic 要求上一条 assistant 消息中所有 tool_use 的 tool_result
|
||||
// 必须放在紧邻的同一条 user 消息里,因此连续的 tool 结果消息需要合并。
|
||||
func toAnthropicMessages(messages []Message) []map[string]any {
|
||||
result := make([]map[string]any, 0, len(messages))
|
||||
for i := 0; i < len(messages); i++ {
|
||||
message := messages[i]
|
||||
switch message.Role {
|
||||
case "system":
|
||||
continue
|
||||
case "assistant":
|
||||
blocks := make([]map[string]any, 0, 1+len(message.ToolCalls))
|
||||
if message.Content != nil && strings.TrimSpace(*message.Content) != "" {
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": *message.Content})
|
||||
}
|
||||
for _, call := range message.ToolCalls {
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": call.ID,
|
||||
"name": call.Function.Name,
|
||||
"input": parseJSONValue(call.Function.Arguments),
|
||||
})
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": ""})
|
||||
}
|
||||
result = append(result, map[string]any{"role": "assistant", "content": blocks})
|
||||
case "tool":
|
||||
// 合并连续的 tool 消息:同一条 user 消息包含所有 tool_result 块
|
||||
blocks := []map[string]any{{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": message.ToolCallID,
|
||||
"content": contentString(message.Content),
|
||||
}}
|
||||
for i+1 < len(messages) && messages[i+1].Role == "tool" {
|
||||
i++
|
||||
next := messages[i]
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": next.ToolCallID,
|
||||
"content": contentString(next.Content),
|
||||
})
|
||||
}
|
||||
result = append(result, map[string]any{"role": "user", "content": blocks})
|
||||
default: // user
|
||||
blocks := make([]map[string]any, 0, 1)
|
||||
if message.Content != nil && strings.TrimSpace(*message.Content) != "" {
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": *message.Content})
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": ""})
|
||||
}
|
||||
result = append(result, map[string]any{"role": "user", "content": blocks})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseJSONValue 把工具参数 JSON 字符串解析为任意值;解析失败时回退为空对象。
|
||||
func parseJSONValue(raw string) any {
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil || value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func contentString(content *string) string {
|
||||
if content == nil {
|
||||
return ""
|
||||
}
|
||||
return *content
|
||||
}
|
||||
|
||||
// readAnthropicSSE 解析 Anthropic Messages API 的流式响应。
|
||||
// 事件格式为 `event: <type>` 与 `data: <json>` 两行一组。
|
||||
func readAnthropicSSE(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
||||
// content block index -> 正在累积的 tool_use 状态
|
||||
type toolState struct {
|
||||
callIndex int
|
||||
id string
|
||||
name string
|
||||
}
|
||||
tools := make(map[int]*toolState)
|
||||
nextCallIndex := 0
|
||||
|
||||
var eventType string
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event:"):
|
||||
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
continue
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
switch eventType {
|
||||
case "content_block_start":
|
||||
var ev struct {
|
||||
Index int `json:"index"`
|
||||
Block struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"content_block"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
if ev.Block.Type == "tool_use" {
|
||||
state := &toolState{callIndex: nextCallIndex, id: ev.Block.ID, name: ev.Block.Name}
|
||||
nextCallIndex++
|
||||
tools[ev.Index] = state
|
||||
ch <- StreamChunk{ToolCalls: []ToolCallDelta{
|
||||
{Index: state.callIndex, ID: state.id, Name: state.name},
|
||||
}}
|
||||
}
|
||||
case "content_block_delta":
|
||||
var ev struct {
|
||||
Index int `json:"index"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
PartialJSON string `json:"partial_json"`
|
||||
} `json:"delta"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
switch ev.Delta.Type {
|
||||
case "text_delta":
|
||||
ch <- StreamChunk{Content: ev.Delta.Text}
|
||||
case "input_json_delta":
|
||||
if state, ok := tools[ev.Index]; ok {
|
||||
ch <- StreamChunk{ToolCalls: []ToolCallDelta{
|
||||
{Index: state.callIndex, ArgumentsDelta: ev.Delta.PartialJSON},
|
||||
}}
|
||||
}
|
||||
}
|
||||
case "message_delta":
|
||||
var ev struct {
|
||||
Delta struct {
|
||||
StopReason string `json:"stop_reason"`
|
||||
} `json:"delta"`
|
||||
Usage *struct {
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
if ev.Delta.StopReason != "" {
|
||||
ch <- StreamChunk{FinishReason: ev.Delta.StopReason}
|
||||
}
|
||||
if ev.Usage != nil {
|
||||
ch <- StreamChunk{Usage: &Usage{CompletionTokens: ev.Usage.OutputTokens}}
|
||||
}
|
||||
case "error":
|
||||
var ev struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
msg := strings.TrimSpace(ev.Error.Message)
|
||||
if msg == "" {
|
||||
msg = "Anthropic API 错误"
|
||||
}
|
||||
ch <- StreamChunk{Error: errors.New(msg)}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil && ctx.Err() == nil {
|
||||
ch <- StreamChunk{Error: err}
|
||||
}
|
||||
}
|
||||
|
||||
// compressAnthropic 使用 Anthropic 非流式接口执行对话压缩。
|
||||
func (c *LLMClient) compressAnthropic(ctx context.Context, messages []Message) (string, error) {
|
||||
body := map[string]any{
|
||||
"model": c.apiConfig.Model(),
|
||||
"max_tokens": c.cfg.CompactionTokens,
|
||||
"stream": false,
|
||||
"temperature": 0.2,
|
||||
// 关闭思考:推理模型(如 deepseek-v4-flash)默认先输出 thinking 块,
|
||||
// 会把 max_tokens 预算耗尽而拿不到 text 块,导致压缩被判为失败。
|
||||
"thinking": map[string]any{"type": "disabled"},
|
||||
"messages": toAnthropicMessages(messages),
|
||||
}
|
||||
if system := extractSystemPrompt(messages); system != "" {
|
||||
body["system"] = system
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, c.cfg.RequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
respBody, err := c.doSyncRequestWithRetry(ctx, true, normalizeAnthropicURL(c.apiConfig.BaseURL()), payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fallback := ""
|
||||
for _, block := range result.Content {
|
||||
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||
return strings.TrimSpace(block.Text), nil
|
||||
}
|
||||
// 记录 thinking 作为兜底(仅当端点不支持 thinking:disabled 时才会出现)
|
||||
if block.Type == "thinking" && fallback == "" {
|
||||
fallback = strings.TrimSpace(block.Thinking)
|
||||
}
|
||||
}
|
||||
if fallback != "" {
|
||||
return fallback, nil
|
||||
}
|
||||
return "", errors.New("模型没有返回压缩摘要")
|
||||
}
|
||||
Reference in New Issue
Block a user