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 }