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
This commit is contained in:
1 parent
1d6e89449a
commit
2336c9433c
17 files changed
+1324
-197
No files matched your search
@@ -24,35 +24,33 @@ type ToolChat interface {
|
||||
ChatWithTools(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error)
|
||||
}
|
||||
|
||||
// ChatWithTools openai-chat 兼容协议的 tools 调用(DeepSeek / OpenAI / 多数兼容网关)。
|
||||
// anthropic / responses 本轮不支持 Agent tool calling,返回明确错误。
|
||||
// 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
|
||||
}
|
||||
if p.protocol != "" && p.protocol != ProtoChat {
|
||||
return ToolsTurn{}, fmt.Errorf("Agent 模式目前仅支持 openai-chat 协议(当前: %s);请改用 DeepSeek / OpenAI 兼容端点", p.protocol)
|
||||
}
|
||||
m := strings.TrimSpace(model)
|
||||
if m == "" {
|
||||
m = p.model
|
||||
}
|
||||
urlStr := endpoint(p.baseURL, "chat/completions")
|
||||
payload := map[string]any{
|
||||
"model": m,
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"tool_choice": "auto",
|
||||
"max_tokens": remoteMaxTokens,
|
||||
"temperature": 0.2,
|
||||
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 ToolsTurn{}, err
|
||||
return nil, 0, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return ToolsTurn{}, err
|
||||
return nil, 0, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
switch p.authStyle {
|
||||
@@ -65,17 +63,174 @@ func (p *RemoteProvider) ChatWithTools(ctx context.Context, model string, messag
|
||||
}
|
||||
resp, err := p.hc.Do(httpReq)
|
||||
if err != nil {
|
||||
return ToolsTurn{}, fmt.Errorf("AI 请求失败: %w", err)
|
||||
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 ToolsTurn{}, fmt.Errorf("读取 AI 响应失败: %w", err)
|
||||
return nil, resp.StatusCode, fmt.Errorf("读取 AI 响应失败: %w", err)
|
||||
}
|
||||
return parseToolsTurn(raw, resp.StatusCode)
|
||||
return raw, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func parseToolsTurn(raw []byte, status int) (ToolsTurn, error) {
|
||||
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"`
|
||||
@@ -90,7 +245,14 @@ func parseToolsTurn(raw []byte, status int) (ToolsTurn, error) {
|
||||
} else if errBody.Message != "" {
|
||||
msg = errBody.Message
|
||||
}
|
||||
return ToolsTurn{}, fmt.Errorf("AI 服务 HTTP %d: %s", status, msg)
|
||||
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 {
|
||||
@@ -119,25 +281,108 @@ func parseToolsTurn(raw []byte, status int) (ToolsTurn, error) {
|
||||
if ch.Message.Content != nil {
|
||||
content = strings.TrimSpace(*ch.Message.Content)
|
||||
}
|
||||
return ToolsTurn{
|
||||
Content: content,
|
||||
ToolCalls: ch.Message.ToolCalls,
|
||||
RawFinish: ch.FinishReason,
|
||||
}, nil
|
||||
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 等(stub 仅支持 Ask 生成)")
|
||||
return nil, fmt.Errorf("Agent 需要真实 LLM:请在设置中配置 DeepSeek / OpenAI / Anthropic 等(stub 仅支持 Ask 生成)")
|
||||
}
|
||||
if cfg.APIKey == "" && cfg.AuthStyle != AuthNone {
|
||||
return nil, fmt.Errorf("Agent 需要 API Key:请在设置中填写后重试")
|
||||
}
|
||||
p := NewRemoteProvider(cfg)
|
||||
if p.protocol != "" && p.protocol != ProtoChat {
|
||||
return nil, fmt.Errorf("Agent 模式目前仅支持 openai-chat 协议(当前: %s)", p.protocol)
|
||||
}
|
||||
return p, nil
|
||||
return NewRemoteProvider(cfg), nil
|
||||
}
|
||||
Reference in new issue
Block a user