Files
opsxdb/server/internal/ai/remote_tools.go
T
logan cd319ce7bd feat(ai): 阶段 2 Ask|Agent 共用 aitools Runtime 真查库
- 侧栏 Ask|Agent 分段;Ask 仍走 /api/ai/chat,Agent 走 /api/ai/agent
- RunAgent 多轮 tool call → 进程内 aitools(与 MCP 同源只读/白名单/审计)
- 回复下步骤时间线;openai-chat tools(DeepSeek);agent-accept 冒烟
2026-10-15 11:51:39 +08:00

144 lines
4.3 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.
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 兼容协议的 tools 调用(DeepSeek / OpenAI / 多数兼容网关)。
// anthropic / responses 本轮不支持 Agent tool calling,返回明确错误。
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,
}
body, err := json.Marshal(payload)
if err != nil {
return ToolsTurn{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body))
if err != nil {
return ToolsTurn{}, 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 ToolsTurn{}, 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 parseToolsTurn(raw, resp.StatusCode)
}
func parseToolsTurn(raw []byte, status int) (ToolsTurn, 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 ToolsTurn{}, fmt.Errorf("AI 服务 HTTP %d: %s", status, msg)
}
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
}
// 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 生成)")
}
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
}