后端引擎 backend/pkg/agentcli/(纯引擎库,零业务依赖,剥离 CLI/TUI 外壳): - llm: OpenAI 兼容客户端,复用 model.LLMConfig - tools: ssh_execute(复用 pkg/remoteexec)+ analyze_output + 风险评估黑名单 - safety: 三级安全模式 + 五级风险 + WebSocket 异步确认 - prompt: 五层 System Prompt - core: ReAct 16 轮循环 + 自适应检查点 + 循环检测 - evolver: 四层记忆 + 进化循环,data/agentcli/ 文件存储 集成层: - controller/agent_cli.go: WebSocket 控制器(事件流推流 + 高危确认 + 会话持久化) - model/agent_cli.go: agent_cli_sessions/messages 表 + AutoMigrate - 路由 /ws/agent-cli + /api/v1/aiops/agent-cli/sessions - seed 权限 aiops:agent-cli / aiops:llm-config 前端: - AgentCLI.tsx: 对话流 + 工具块 + 高危确认 + 四模块 tips 面板 - LLMConfig.tsx: 大模型设置独立页 - api/agentCli.ts + Sidebar/App 菜单路由 文档: - docs/agent/ 11 篇(opsxcli 同名文档适配改写 + 00-README 映射总览) 其他: - CLAUDE.md 补充 agent-cli 模块说明 - 修正 .gitignore 根级二进制规则(前导 /,避免误伤 frontend/src/components/aiops/) Co-Authored-By: Claude <noreply@anthropic.com>
146 lines
4.3 KiB
Go
146 lines
4.3 KiB
Go
// openai.go - OpenAI 兼容 LLM 客户端实现 // Logan.Li
|
||
// 复用 aiops 既有 LLM 调用模式(model.LLMConfig + resolveEndpoint + /chat/completions + Bearer)。
|
||
// 支持所有 OpenAI 兼容后端:openai / deepseek / qwen / moonshot / ollama / custom。
|
||
package llm
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
// Config LLM 客户端配置(endpoint 已由上层解析为完整 base url) // Logan.Li
|
||
type Config struct {
|
||
Endpoint string // 完整 base url,如 https://api.deepseek.com/v1
|
||
APIKey string // Bearer token
|
||
Model string // 模型名,如 deepseek-chat / gpt-4o-mini
|
||
Temperature float64 // 采样温度
|
||
MaxTokens int // 单次最大输出 token
|
||
Timeout time.Duration // HTTP 超时(0 表示使用默认 180s)
|
||
}
|
||
|
||
// OpenAIClient OpenAI 兼容客户端 // Logan.Li
|
||
type OpenAIClient struct {
|
||
cfg Config
|
||
client *http.Client
|
||
}
|
||
|
||
// NewClient 创建 OpenAI 兼容客户端 // Logan.Li
|
||
func NewClient(cfg Config) Client {
|
||
timeout := cfg.Timeout
|
||
if timeout <= 0 {
|
||
timeout = 180 * time.Second
|
||
}
|
||
return &OpenAIClient{
|
||
cfg: cfg,
|
||
client: &http.Client{Timeout: timeout},
|
||
}
|
||
}
|
||
|
||
// Name 返回客户端名称 // Logan.Li
|
||
func (c *OpenAIClient) Name() string { return "openai-compatible(" + c.cfg.Model + ")" }
|
||
|
||
// openaiRequest OpenAI chat/completions 请求体 // Logan.Li
|
||
type openaiRequest struct {
|
||
Model string `json:"model"`
|
||
Messages []Message `json:"messages"`
|
||
Tools []Tool `json:"tools,omitempty"`
|
||
ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定
|
||
Temperature float64 `json:"temperature,omitempty"`
|
||
MaxTokens int `json:"max_tokens,omitempty"`
|
||
}
|
||
|
||
// openaiResponse OpenAI chat/completions 响应体 // Logan.Li
|
||
type openaiResponse struct {
|
||
Choices []struct {
|
||
Message struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
ToolCalls []ToolCall `json:"tool_calls"`
|
||
} `json:"message"`
|
||
} `json:"choices"`
|
||
Usage Usage `json:"usage"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
Type string `json:"type"`
|
||
} `json:"error,omitempty"`
|
||
}
|
||
|
||
// Complete 调用 OpenAI 兼容接口完成一轮补全 // Logan.Li
|
||
// 返回 assistant 消息(含思考文本 content 与工具调用计划 tool_calls)+ token 统计。
|
||
func (c *OpenAIClient) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
||
payload := openaiRequest{
|
||
Model: c.cfg.Model,
|
||
Messages: req.Messages,
|
||
Tools: req.Tools,
|
||
ToolChoice: "auto",
|
||
Temperature: c.cfg.Temperature,
|
||
MaxTokens: c.cfg.MaxTokens,
|
||
}
|
||
if payload.MaxTokens <= 0 {
|
||
payload.MaxTokens = 4096
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||
}
|
||
|
||
url := c.cfg.Endpoint + "/chat/completions"
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("构造请求失败: %w", err)
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||
|
||
resp, err := c.client.Do(httpReq)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("LLM 连接失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("LLM 返回错误 (HTTP %d): %s", resp.StatusCode, extractErrorMessage(raw))
|
||
}
|
||
|
||
var parsed openaiResponse
|
||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
return nil, fmt.Errorf("解析 LLM 响应失败: %w", err)
|
||
}
|
||
if parsed.Error != nil && parsed.Error.Message != "" {
|
||
return nil, fmt.Errorf("LLM 错误: %s", parsed.Error.Message)
|
||
}
|
||
if len(parsed.Choices) == 0 {
|
||
return nil, fmt.Errorf("LLM 未返回任何结果")
|
||
}
|
||
|
||
choice := parsed.Choices[0].Message
|
||
return &CompletionResponse{
|
||
Model: c.cfg.Model,
|
||
Message: Message{
|
||
Role: choice.Role,
|
||
Content: choice.Content,
|
||
ToolCalls: choice.ToolCalls,
|
||
},
|
||
Usage: parsed.Usage,
|
||
}, nil
|
||
}
|
||
|
||
// extractErrorMessage 从上游错误响应提取 message 字段 // Logan.Li
|
||
func extractErrorMessage(raw []byte) string {
|
||
var m struct {
|
||
Error struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if json.Unmarshal(raw, &m) == nil && m.Error.Message != "" {
|
||
return m.Error.Message
|
||
}
|
||
return string(raw)
|
||
}
|