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 }