Files
logan 2336c9433c feat(ai): Agent SSE + Anthropic/responses tools + P1 确认写
- 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
2026-10-15 13:00:40 +08:00

389 lines
11 KiB
Go
Raw Permalink 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.
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
}