mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 02:49:49 +08:00
✨ feat(ai): 记录 Claude CLI 上游请求入参日志
- 统一 Claude CLI 聊天请求写入 AI 上游请求日志 - 记录脱敏后的 prompt、参数、模型和工具名称 - 补充 CLI 上游日志解析与脱敏测试
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user