- Agent 支持 SSE(步骤边推、正文分块),JSON 非流式 fallback - tool calling 覆盖 openai-chat / anthropic / responses(单测解析回灌) - P1:mcp.json mode=read_write 才可写;需 confirm_danger;Agent 剥离模型自带令牌 - GET /api/ai/mcp-status、POST /api/ai/agent/confirm;审计 source=mcp|agent
389 lines
11 KiB
Go
389 lines
11 KiB
Go
package ai
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"opsxdb/server/internal/aitools"
|
||
)
|
||
|
||
// ToolsTurn 一轮带 tools 的模型回复:要么最终文本,要么一组 tool_calls。
|
||
type ToolsTurn struct {
|
||
Content string
|
||
ToolCalls []ToolCall
|
||
RawFinish string
|
||
}
|
||
|
||
// ToolChat 可挂载 tools 的 LLM(Agent 循环依赖此接口,便于单测注入)。
|
||
type ToolChat interface {
|
||
ChatWithTools(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error)
|
||
}
|
||
|
||
// ChatWithTools 按协议分派:openai-chat / anthropic messages / openai-responses。
|
||
func (p *RemoteProvider) ChatWithTools(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
|
||
if err := validateEndpoint(p.baseURL, p.allowHTTP); err != nil {
|
||
return ToolsTurn{}, err
|
||
}
|
||
m := strings.TrimSpace(model)
|
||
if m == "" {
|
||
m = p.model
|
||
}
|
||
switch p.protocol {
|
||
case ProtoAnthropic:
|
||
return p.chatToolsAnthropic(ctx, m, messages, tools)
|
||
case ProtoResponses:
|
||
return p.chatToolsResponses(ctx, m, messages, tools)
|
||
default:
|
||
return p.chatToolsOpenAI(ctx, m, messages, tools)
|
||
}
|
||
}
|
||
|
||
func (p *RemoteProvider) doJSON(ctx context.Context, urlStr string, payload any) ([]byte, int, error) {
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
switch p.authStyle {
|
||
case AuthXAPIKey:
|
||
httpReq.Header.Set("x-api-key", p.apiKey)
|
||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||
case AuthNone:
|
||
default:
|
||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||
}
|
||
resp, err := p.hc.Do(httpReq)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("AI 请求失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
if err != nil {
|
||
return nil, resp.StatusCode, fmt.Errorf("读取 AI 响应失败: %w", err)
|
||
}
|
||
return raw, resp.StatusCode, nil
|
||
}
|
||
|
||
func (p *RemoteProvider) chatToolsOpenAI(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
|
||
urlStr := endpoint(p.baseURL, "chat/completions")
|
||
payload := map[string]any{
|
||
"model": model,
|
||
"messages": messages,
|
||
"tools": tools,
|
||
"tool_choice": "auto",
|
||
"max_tokens": remoteMaxTokens,
|
||
"temperature": 0.2,
|
||
}
|
||
raw, status, err := p.doJSON(ctx, urlStr, payload)
|
||
if err != nil {
|
||
return ToolsTurn{}, err
|
||
}
|
||
return parseToolsTurnChat(raw, status)
|
||
}
|
||
|
||
func (p *RemoteProvider) chatToolsAnthropic(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
|
||
urlStr := anthropicEndpoint(p.baseURL)
|
||
system, anthMsgs := splitAnthropicMessages(messages)
|
||
payload := map[string]any{
|
||
"model": model,
|
||
"max_tokens": remoteMaxTokens,
|
||
"temperature": 0.2,
|
||
"tools": openaiToolsToAnthropic(tools),
|
||
"messages": anthMsgs,
|
||
}
|
||
if system != "" {
|
||
payload["system"] = system
|
||
}
|
||
raw, status, err := p.doJSON(ctx, urlStr, payload)
|
||
if err != nil {
|
||
return ToolsTurn{}, err
|
||
}
|
||
return parseToolsTurnAnthropic(raw, status)
|
||
}
|
||
|
||
func (p *RemoteProvider) chatToolsResponses(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
|
||
urlStr := endpoint(p.baseURL, "responses")
|
||
instructions, input := splitResponsesInput(messages)
|
||
payload := map[string]any{
|
||
"model": model,
|
||
"instructions": instructions,
|
||
"input": input,
|
||
"tools": openaiToolsToResponses(tools),
|
||
"max_output_tokens": remoteMaxTokens,
|
||
}
|
||
raw, status, err := p.doJSON(ctx, urlStr, payload)
|
||
if err != nil {
|
||
return ToolsTurn{}, err
|
||
}
|
||
return parseToolsTurnResponses(raw, status)
|
||
}
|
||
|
||
func splitAnthropicMessages(messages []Message) (system string, out []map[string]any) {
|
||
for _, m := range messages {
|
||
switch m.Role {
|
||
case "system":
|
||
if system == "" {
|
||
system = m.Content
|
||
} else {
|
||
system += "\n" + m.Content
|
||
}
|
||
case "assistant":
|
||
content := []map[string]any{}
|
||
if strings.TrimSpace(m.Content) != "" {
|
||
content = append(content, map[string]any{"type": "text", "text": m.Content})
|
||
}
|
||
for _, tc := range m.ToolCalls {
|
||
var input any = map[string]any{}
|
||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
|
||
content = append(content, map[string]any{
|
||
"type": "tool_use",
|
||
"id": tc.ID,
|
||
"name": tc.Function.Name,
|
||
"input": input,
|
||
})
|
||
}
|
||
out = append(out, map[string]any{"role": "assistant", "content": content})
|
||
case "tool":
|
||
out = append(out, map[string]any{
|
||
"role": "user",
|
||
"content": []map[string]any{{
|
||
"type": "tool_result",
|
||
"tool_use_id": m.ToolCallID,
|
||
"content": m.Content,
|
||
}},
|
||
})
|
||
default: // user
|
||
out = append(out, map[string]any{"role": "user", "content": m.Content})
|
||
}
|
||
}
|
||
return system, out
|
||
}
|
||
|
||
func splitResponsesInput(messages []Message) (instructions string, input []map[string]any) {
|
||
for _, m := range messages {
|
||
switch m.Role {
|
||
case "system":
|
||
if instructions == "" {
|
||
instructions = m.Content
|
||
} else {
|
||
instructions += "\n" + m.Content
|
||
}
|
||
case "assistant":
|
||
item := map[string]any{"role": "assistant", "content": m.Content}
|
||
if len(m.ToolCalls) > 0 {
|
||
calls := make([]map[string]any, 0, len(m.ToolCalls))
|
||
for _, tc := range m.ToolCalls {
|
||
calls = append(calls, map[string]any{
|
||
"id": tc.ID,
|
||
"type": "function_call",
|
||
"name": tc.Function.Name,
|
||
"arguments": tc.Function.Arguments,
|
||
})
|
||
}
|
||
item["tool_calls"] = calls
|
||
}
|
||
input = append(input, item)
|
||
case "tool":
|
||
input = append(input, map[string]any{
|
||
"type": "function_call_output",
|
||
"call_id": m.ToolCallID,
|
||
"output": m.Content,
|
||
})
|
||
default:
|
||
input = append(input, map[string]any{"role": "user", "content": m.Content})
|
||
}
|
||
}
|
||
return instructions, input
|
||
}
|
||
|
||
func openaiToolsToAnthropic(tools []aitools.OpenAITool) []map[string]any {
|
||
out := make([]map[string]any, 0, len(tools))
|
||
for _, t := range tools {
|
||
out = append(out, map[string]any{
|
||
"name": t.Function.Name,
|
||
"description": t.Function.Description,
|
||
"input_schema": t.Function.Parameters,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func openaiToolsToResponses(tools []aitools.OpenAITool) []map[string]any {
|
||
out := make([]map[string]any, 0, len(tools))
|
||
for _, t := range tools {
|
||
out = append(out, map[string]any{
|
||
"type": "function",
|
||
"name": t.Function.Name,
|
||
"description": t.Function.Description,
|
||
"parameters": t.Function.Parameters,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func httpErrMessage(raw []byte, status int) error {
|
||
var errBody struct {
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
Message string `json:"message"`
|
||
}
|
||
_ = json.Unmarshal(raw, &errBody)
|
||
if status < 200 || status >= 300 {
|
||
msg := "未知错误"
|
||
if errBody.Error != nil && errBody.Error.Message != "" {
|
||
msg = errBody.Error.Message
|
||
} else if errBody.Message != "" {
|
||
msg = errBody.Message
|
||
}
|
||
return fmt.Errorf("AI 服务 HTTP %d: %s", status, msg)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func parseToolsTurnChat(raw []byte, status int) (ToolsTurn, error) {
|
||
if err := httpErrMessage(raw, status); err != nil {
|
||
return ToolsTurn{}, err
|
||
}
|
||
var out struct {
|
||
Choices []struct {
|
||
FinishReason string `json:"finish_reason"`
|
||
Message struct {
|
||
Role string `json:"role"`
|
||
Content *string `json:"content"`
|
||
ToolCalls []ToolCall `json:"tool_calls"`
|
||
} `json:"message"`
|
||
} `json:"choices"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return ToolsTurn{}, fmt.Errorf("解析 AI 响应失败(HTTP %d)", status)
|
||
}
|
||
if out.Error != nil {
|
||
return ToolsTurn{}, fmt.Errorf("AI 服务返回错误: %s", out.Error.Message)
|
||
}
|
||
if len(out.Choices) == 0 {
|
||
return ToolsTurn{}, fmt.Errorf("AI 服务未返回候选结果")
|
||
}
|
||
ch := out.Choices[0]
|
||
content := ""
|
||
if ch.Message.Content != nil {
|
||
content = strings.TrimSpace(*ch.Message.Content)
|
||
}
|
||
return ToolsTurn{Content: content, ToolCalls: ch.Message.ToolCalls, RawFinish: ch.FinishReason}, nil
|
||
}
|
||
|
||
func parseToolsTurnAnthropic(raw []byte, status int) (ToolsTurn, error) {
|
||
if err := httpErrMessage(raw, status); err != nil {
|
||
return ToolsTurn{}, err
|
||
}
|
||
var out struct {
|
||
Content []struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text"`
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Input json.RawMessage `json:"input"`
|
||
} `json:"content"`
|
||
StopReason string `json:"stop_reason"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return ToolsTurn{}, fmt.Errorf("解析 Anthropic 响应失败(HTTP %d)", status)
|
||
}
|
||
var content strings.Builder
|
||
var calls []ToolCall
|
||
for _, c := range out.Content {
|
||
switch c.Type {
|
||
case "text":
|
||
content.WriteString(c.Text)
|
||
case "tool_use":
|
||
args := "{}"
|
||
if len(c.Input) > 0 {
|
||
args = string(c.Input)
|
||
}
|
||
calls = append(calls, ToolCall{
|
||
ID: c.ID,
|
||
Type: "function",
|
||
Function: ToolCallFunc{Name: c.Name, Arguments: args},
|
||
})
|
||
}
|
||
}
|
||
return ToolsTurn{Content: strings.TrimSpace(content.String()), ToolCalls: calls, RawFinish: out.StopReason}, nil
|
||
}
|
||
|
||
func parseToolsTurnResponses(raw []byte, status int) (ToolsTurn, error) {
|
||
if err := httpErrMessage(raw, status); err != nil {
|
||
return ToolsTurn{}, err
|
||
}
|
||
var out struct {
|
||
OutputText string `json:"output_text"`
|
||
Output []struct {
|
||
Type string `json:"type"`
|
||
ID string `json:"id"`
|
||
CallID string `json:"call_id"`
|
||
Name string `json:"name"`
|
||
Arguments string `json:"arguments"`
|
||
Content []struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text"`
|
||
} `json:"content"`
|
||
} `json:"output"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return ToolsTurn{}, fmt.Errorf("解析 responses 响应失败(HTTP %d)", status)
|
||
}
|
||
var content strings.Builder
|
||
if out.OutputText != "" {
|
||
content.WriteString(out.OutputText)
|
||
}
|
||
var calls []ToolCall
|
||
for _, o := range out.Output {
|
||
switch o.Type {
|
||
case "message":
|
||
for _, c := range o.Content {
|
||
if c.Type == "output_text" || c.Type == "text" || c.Text != "" {
|
||
content.WriteString(c.Text)
|
||
}
|
||
}
|
||
case "function_call":
|
||
id := o.CallID
|
||
if id == "" {
|
||
id = o.ID
|
||
}
|
||
args := o.Arguments
|
||
if args == "" {
|
||
args = "{}"
|
||
}
|
||
calls = append(calls, ToolCall{
|
||
ID: id,
|
||
Type: "function",
|
||
Function: ToolCallFunc{Name: o.Name, Arguments: args},
|
||
})
|
||
}
|
||
}
|
||
return ToolsTurn{Content: strings.TrimSpace(content.String()), ToolCalls: calls}, nil
|
||
}
|
||
|
||
// DefaultToolChat 返回可跑 Agent 的远端 Provider;stub / 缺 key 时返回错误说明。
|
||
func DefaultToolChat() (ToolChat, error) {
|
||
cfg := LoadConfig()
|
||
if cfg.Provider == "" || cfg.Provider == "stub" {
|
||
return nil, fmt.Errorf("Agent 需要真实 LLM:请在设置中配置 DeepSeek / OpenAI / Anthropic 等(stub 仅支持 Ask 生成)")
|
||
}
|
||
if cfg.APIKey == "" && cfg.AuthStyle != AuthNone {
|
||
return nil, fmt.Errorf("Agent 需要 API Key:请在设置中填写后重试")
|
||
}
|
||
return NewRemoteProvider(cfg), nil
|
||
}
|