feat(ai): 记录 Claude CLI 上游请求入参日志

- 统一 Claude CLI 聊天请求写入 AI 上游请求日志

- 记录脱敏后的 prompt、参数、模型和工具名称

- 补充 CLI 上游日志解析与脱敏测试
This commit is contained in:
Syngnat
2026-06-11 13:04:59 +08:00
parent 19989e4c26
commit 440172aae8
4 changed files with 197 additions and 16 deletions

View File

@@ -89,4 +89,44 @@ describe('aiLocalToolExecutor inspect_ai_upstream_logs', () => {
expect(result.content).toContain('请先发送一次 AI 消息');
expect(result.content).toContain('扩大 lineLimit');
});
it('summarizes CLI upstream requests that complete without an HTTP status code', async () => {
const readAppLogTail = vi.fn().mockResolvedValue({
success: true,
data: {
logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log',
keyword: 'ClaudeCLI',
requestedLineLimit: 160,
lines: [
'2026/06/11 12:20:00.000000 [INFO] AI 上游请求开始requestId=claudecli-123 provider=ClaudeCLI method=CLI endpoint=https://proxy.example.com/api/anthropic body={"command":"claude","args":["-p","[prompt logged separately]"],"prompt":"hello","has_api_key":true}',
'2026/06/11 12:20:01.000000 [INFO] AI 上游请求完成requestId=claudecli-123 provider=ClaudeCLI endpoint=https://proxy.example.com/api/anthropic duration=981ms',
],
},
});
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_ai_upstream_logs', {
provider: 'ClaudeCLI',
includeBody: true,
}),
connections: [],
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
readAppLogTail,
},
});
expect(result.success).toBe(true);
expect(readAppLogTail).toHaveBeenCalledWith(160, 'ClaudeCLI');
expect(result.content).toContain('"requestId":"claudecli-123"');
expect(result.content).toContain('"provider":"ClaudeCLI"');
expect(result.content).toContain('"method":"CLI"');
expect(result.content).toContain('"state":"completed"');
expect(result.content).toContain('"duration":"981ms"');
expect(result.content).toContain('"hasBody":true');
expect(result.content).not.toContain('"status":0');
});
});

View File

@@ -14,6 +14,7 @@ import (
"time"
ai "GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/logger"
)
var claudeLookPath = exec.LookPath
@@ -66,15 +67,29 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
return nil, err
}
requestLog := logAIUpstreamRequestStart(
p.Name(),
"CLI",
claudeCLIEndpointForLog(p.config),
buildClaudeCLIRequestLogBody("json", args, prompt, p.config, req),
)
var requestErr error
defer func() {
logAIUpstreamRequestFinish(requestLog, 0, requestErr)
}()
output, err := cmd.Output()
if err != nil {
if isClaudeCLITimeout(ctx, err) {
return nil, fmt.Errorf("claude CLI 执行超时(%s当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout)
requestErr = fmt.Errorf("claude CLI 执行超时(%s当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout)
return nil, requestErr
}
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr))
requestErr = fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr))
return nil, requestErr
}
return nil, fmt.Errorf("claude CLI 执行失败: %w", err)
requestErr = fmt.Errorf("claude CLI 执行失败: %w", err)
return nil, requestErr
}
// 解析 JSON 输出
@@ -84,7 +99,8 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil
}
if errMsg, hasError := extractClaudeCLIEventError(result); hasError {
return nil, fmt.Errorf("claude CLI 返回错误: %s", errMsg)
requestErr = fmt.Errorf("claude CLI 返回错误: %s", errMsg)
return nil, requestErr
}
return &ai.ChatResponse{Content: result.Result}, nil
@@ -105,19 +121,29 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
args = append(args, "--model", p.config.Model)
}
fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args)
cmd := claudeCommandContext(ctx, "claude", args...)
if err := p.setEnv(cmd); err != nil {
return err
}
requestLog := logAIUpstreamRequestStart(
p.Name(),
"CLI",
claudeCLIEndpointForLog(p.config),
buildClaudeCLIRequestLogBody("stream-json", args, prompt, p.config, req),
)
var requestErr error
defer func() {
logAIUpstreamRequestFinish(requestLog, 0, requestErr)
}()
// 关闭 stdin防止 claude CLI 等待输入
cmd.Stdin = nil
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("创建 stdout 管道失败: %w", err)
requestErr = fmt.Errorf("创建 stdout 管道失败: %w", err)
return requestErr
}
// 捕获 stderr
@@ -125,10 +151,13 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动 claude CLI 失败: %w", err)
requestErr = fmt.Errorf("启动 claude CLI 失败: %w", err)
return requestErr
}
fmt.Printf("[ClaudeCLI DEBUG] Process started, PID: %d\n", cmd.Process.Pid)
if cmd.Process != nil {
logger.Infof("ClaudeCLI 请求进程已启动requestId=%s pid=%d", requestLog.id, cmd.Process.Pid)
}
// 前端已有 loading 动画,无需在 content 中注入"正在思考"
@@ -142,11 +171,9 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
continue
}
fmt.Printf("[ClaudeCLI DEBUG] Line: %s\n", line[:min(len(line), 200)])
var event cliStreamEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
fmt.Printf("[ClaudeCLI DEBUG] Non-JSON line: %s\n", line)
logger.Warnf("ClaudeCLI 忽略非 JSON 输出requestId=%s line=%s", requestLog.id, RedactAIUpstreamLogText(line))
continue
}
@@ -155,6 +182,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
if isClaudeCLISystemRetryEvent(event) {
if errMsg, hasError := extractClaudeCLISystemRetryError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("claude CLI 鉴权失败: %s", errMsg)
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
@@ -165,6 +193,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
case "assistant":
if errMsg, hasError := extractClaudeCLIEventError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("claude CLI 返回错误: %s", errMsg)
_ = cmd.Wait()
return nil
}
@@ -188,6 +217,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
case "result":
if errMsg, hasError := extractClaudeCLIEventError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("claude CLI 返回错误: %s", errMsg)
_ = cmd.Wait()
return nil
}
@@ -198,6 +228,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
case "error":
errMsg, _ := extractClaudeCLIEventError(event)
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("claude CLI 返回错误: %s", errMsg)
_ = cmd.Wait()
return nil
}
@@ -205,11 +236,11 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
waitErr := cmd.Wait()
stderrStr := strings.TrimSpace(stderrBuf.String())
fmt.Printf("[ClaudeCLI DEBUG] Process exited. stderr: %s\n", stderrStr)
if isClaudeCLITimeout(ctx, waitErr) {
requestErr = fmt.Errorf("claude CLI 执行超时(%s当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout)
callback(ai.StreamChunk{
Error: fmt.Sprintf("claude CLI 执行超时(%s当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout),
Error: requestErr.Error(),
Done: true,
})
return nil
@@ -220,6 +251,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
if stderrStr != "" {
errMsg = fmt.Sprintf("claude CLI 异常退出: %s", stderrStr)
}
requestErr = fmt.Errorf("%s", errMsg)
callback(ai.StreamChunk{Error: errMsg, Done: true})
return nil
}
@@ -242,6 +274,51 @@ func isClaudeCLITimeout(ctx context.Context, err error) bool {
return errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded)
}
func claudeCLIEndpointForLog(config ai.ProviderConfig) string {
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if baseURL != "" {
return sanitizeAIUpstreamURL(baseURL)
}
return "claude://cli"
}
func buildClaudeCLIRequestLogBody(outputFormat string, args []string, prompt string, config ai.ProviderConfig, req ai.ChatRequest) map[string]any {
return map[string]any{
"command": "claude",
"args": claudeCLIArgsForLog(args),
"prompt": prompt,
"output_format": outputFormat,
"model": strings.TrimSpace(config.Model),
"base_url": claudeCLIEndpointForLog(config),
"has_api_key": strings.TrimSpace(config.APIKey) != "",
"message_count": len(req.Messages),
"tool_count": len(req.Tools),
"tool_names": claudeCLIToolNamesForLog(req.Tools),
}
}
func claudeCLIArgsForLog(args []string) []string {
result := append([]string(nil), args...)
for i := 0; i < len(result)-1; i++ {
if result[i] == "-p" {
result[i+1] = "[prompt logged separately]"
i++
}
}
return result
}
func claudeCLIToolNamesForLog(tools []ai.Tool) []string {
names := make([]string, 0, len(tools))
for _, tool := range tools {
name := strings.TrimSpace(tool.Function.Name)
if name != "" {
names = append(names, name)
}
}
return names
}
// setEnv 设置 Claude CLI 的环境变量
func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) error {
env, err := buildClaudeCLIEnv(p.config, cmd.Environ(), runtime.GOOS, claudeLookPath, fileExists)

View File

@@ -38,6 +38,60 @@ func TestBuildClaudeCLIEnv_IncludesAnthropicProxyEnv(t *testing.T) {
}
}
func TestBuildClaudeCLIRequestLogBodyRedactsSecretsAndKeepsRequestShape(t *testing.T) {
prompt := "请分析订单表。临时凭证 Bearer abcdefghijklmnopqrstuvwxyz另一个 key 是 sk-live-abcdefghijklmnopqrstuvwxyz"
args := []string{"-p", prompt, "--output-format", "stream-json", "--model", "claude-sonnet"}
originalPromptArg := args[1]
body := buildClaudeCLIRequestLogBody("stream-json", args, prompt, ai.ProviderConfig{
BaseURL: "https://proxy.example.com/api/anthropic?key=proxy-secret&alt=sse",
APIKey: "sk-config-secret-1234567890",
Model: "claude-sonnet",
}, ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: prompt}},
Tools: []ai.Tool{{
Type: "function",
Function: ai.ToolFunction{
Name: "inspect_ai_upstream_logs",
Description: "读取 AI 上游请求日志",
},
}},
})
got := formatAIUpstreamRequestLogBody(body)
for _, want := range []string{
`"command":"claude"`,
`"output_format":"stream-json"`,
`"model":"claude-sonnet"`,
`"has_api_key":true`,
`"message_count":1`,
`"tool_count":1`,
`"[prompt logged separately]"`,
`inspect_ai_upstream_logs`,
`key=%5BREDACTED%5D`,
`[REDACTED]`,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected Claude CLI request log body to contain %q, got %s", want, got)
}
}
for _, leaked := range []string{
"proxy-secret",
"sk-config-secret",
"Bearer abcdefghijklmnopqrstuvwxyz",
"sk-live-abcdefghijklmnopqrstuvwxyz",
} {
if strings.Contains(got, leaked) {
t.Fatalf("Claude CLI request log body leaked %q: %s", leaked, got)
}
}
if args[1] != originalPromptArg {
t.Fatalf("expected original args to remain unchanged, got %q", args[1])
}
}
func TestBuildClaudeCLIEnv_UsesDetectedGitBashOnWindows(t *testing.T) {
env, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) {
switch name {

View File

@@ -75,12 +75,22 @@ func logAIUpstreamRequestFinish(handle aiUpstreamRequestLogHandle, statusCode in
)
return
}
if statusCode > 0 {
logger.Infof(
"AI 上游请求完成requestId=%s provider=%s endpoint=%s status=%d duration=%s",
handle.id,
handle.provider,
handle.endpoint,
statusCode,
duration,
)
return
}
logger.Infof(
"AI 上游请求完成requestId=%s provider=%s endpoint=%s status=%d duration=%s",
"AI 上游请求完成requestId=%s provider=%s endpoint=%s duration=%s",
handle.id,
handle.provider,
handle.endpoint,
statusCode,
duration,
)
}