diff --git a/docs/llm-debug-guide.md b/docs/llm-debug-guide.md new file mode 100644 index 000000000..e91d87d67 --- /dev/null +++ b/docs/llm-debug-guide.md @@ -0,0 +1,300 @@ +# LLM Debug 日志指南 + +## 概述 + +本文档说明如何使用新增的 LLM 调试日志来排查 API 调用问题,特别是 `404 page not found` 错误。 + +## 新增的调试日志 + +### 1. Provider 创建日志 (http_provider.go) + +**位置**: `CreateProvider` 函数 + +**日志级别**: +- `DebugCF`: 创建 provider 时的详细信息 +- `InfoCF`: Provider 创建成功 +- `ErrorCF`: 配置错误 + +**记录信息**: +```go +// 创建时的调试信息 +logger.DebugCF("llm", "Creating LLM provider", map[string]interface{}{ + "model": model, + "lower_model": lowerModel, +}) + +// 创建成功 +logger.InfoCF("llm", "Provider created successfully", map[string]interface{}{ + "model": model, + "api_base": apiBase, + "has_api_key": apiKey != "", + "api_key_len": len(apiKey), +}) +``` + +### 2. HTTP 请求日志 + +**位置**: `HTTPProvider.Chat` 函数 + +**发送请求前的日志**: +```go +logger.DebugCF("llm", "Sending LLM request", map[string]interface{}{ + "url": fullURL, + "model": model, + "api_base": p.apiBase, + "has_api_key": p.apiKey != "", + "api_key_len": len(p.apiKey), + "message_count": len(messages), + "tools_count": len(tools), + "request_body": string(jsonData), // 完整请求体 +}) +``` + +**发送请求时的日志**: +```go +logger.DebugCF("llm", "Sending HTTP request", map[string]interface{}{ + "method": "POST", + "url": fullURL, + "headers": req.Header, // 包括 Authorization 等 header +}) +``` + +### 3. HTTP 响应日志 + +**成功接收响应**: +```go +logger.DebugCF("llm", "Received LLM response", map[string]interface{}{ + "status_code": resp.StatusCode, + "status": resp.Status, + "content_length": len(body), + "response_body": string(body), // 完整响应体 +}) +``` + +**错误响应 (非 200 状态码)**: +```go +logger.ErrorCF("llm", "LLM API returned non-OK status", map[string]interface{}{ + "status_code": resp.StatusCode, + "status": resp.Status, + "url": fullURL, + "response_body": string(body), +}) +``` + +## 如何启用调试日志 + +### 方法 1: 通过配置文件 + +修改 `config.yaml`: + +```yaml +logging: + level: debug # 设置为 debug 级别以查看所有日志 + category_levels: + llm: debug # 只启用 llm 相关的 debug 日志 + agent: info # 其他类别保持 info 级别 +``` + +### 方法 2: 通过环境变量 + +```bash +export PICOCLAW_LOG_LEVEL=debug +``` + +或者只针对 llm 分类: + +```bash +export PICOCLAW_LOG_CATEGORY_LLM=debug +``` + +## 排查 404 错误的步骤 + +当遇到 `Error processing message: LLM call failed: API error: 404 page not found` 错误时: + +### 步骤 1: 检查 Provider 创建日志 + +查找日志中的 "Provider created successfully" 消息: + +``` +[INFO] [llm] Provider created successfully + model: gpt-4 + api_base: https://api.openai.com/v1 + has_api_key: true + api_key_len: 51 +``` + +**检查点**: +- ✅ `api_base` 是否正确?(常见错误:末尾多了 `/chat/completions`) +- ✅ `has_api_key` 是否为 true? +- ✅ `model` 名称是否正确? + +### 步骤 2: 检查请求 URL + +查找 "Sending LLM request" 日志: + +``` +[DEBUG] [llm] Sending LLM request + url: https://api.openai.com/v1/chat/completions + model: gpt-4 + api_base: https://api.openai.com/v1 + ... +``` + +**检查点**: +- ✅ 完整的 `url` 是否正确? +- ✅ 路径是否为 `/chat/completions`? +- ✅ 是否有重复的路径(如 `/v1/v1/chat/completions`)? + +### 步骤 3: 检查响应详情 + +查找 "LLM API returned non-OK status" 错误日志: + +``` +[ERROR] [llm] LLM API returned non-OK status + status_code: 404 + status: 404 Not Found + url: https://wrong-url.com/v1/chat/completions + response_body: 404 page not found +``` + +**检查点**: +- ✅ `status_code` 为 404 表示 URL 路径错误 +- ✅ `response_body` 可能包含更详细的错误信息 +- ✅ 对比 `url` 和正确的 API endpoint + +## 常见的 404 错误原因 + +### 1. API Base 配置错误 + +**错误示例**: +```yaml +providers: + openai: + api_base: "https://api.openai.com/v1/chat/completions" # ❌ 错误:包含了完整路径 +``` + +**正确配置**: +```yaml +providers: + openai: + api_base: "https://api.openai.com/v1" # ✅ 正确:只包含 base URL +``` + +### 2. 自定义代理或中转服务配置错误 + +**错误示例**: +```yaml +providers: + openai: + api_base: "https://my-proxy.com" # ❌ 缺少 /v1 路径 +``` + +**正确配置**: +```yaml +providers: + openai: + api_base: "https://my-proxy.com/v1" # ✅ 包含正确的路径 +``` + +### 3. vLLM 或本地模型服务配置 + +**正确示例**: +```yaml +providers: + vllm: + api_base: "http://localhost:8000/v1" # ✅ vLLM 通常也使用 /v1 路径 +``` + +## 调试命令 + +### 查看完整的调试日志 + +```bash +# 启动 picoclaw 并查看所有 debug 日志 +PICOCLAW_LOG_LEVEL=debug ./picoclaw + +# 只看 llm 相关的日志 +PICOCLAW_LOG_LEVEL=debug ./picoclaw | grep '\[llm\]' + +# 保存日志到文件以便分析 +PICOCLAW_LOG_LEVEL=debug ./picoclaw 2>&1 | tee debug.log +``` + +### 使用 jq 格式化 JSON 日志(如果日志是 JSON 格式) + +```bash +PICOCLAW_LOG_LEVEL=debug ./picoclaw 2>&1 | jq -r 'select(.category == "llm")' +``` + +## 示例:完整的调试流程 + +假设遇到 404 错误,以下是完整的调试输出示例: + +``` +[2026-02-12 10:30:00] [DEBUG] [llm] Creating LLM provider + model: gpt-4 + lower_model: gpt-4 + +[2026-02-12 10:30:00] [INFO] [llm] Provider created successfully + model: gpt-4 + api_base: https://api.openai.com/wrong-path # ⚠️ 错误的路径 + has_api_key: true + api_key_len: 51 + +[2026-02-12 10:30:01] [DEBUG] [llm] Sending LLM request + url: https://api.openai.com/wrong-path/chat/completions # ⚠️ 最终的 URL 错误 + model: gpt-4 + message_count: 1 + request_body: {"model":"gpt-4","messages":[...]} + +[2026-02-12 10:30:01] [DEBUG] [llm] Sending HTTP request + method: POST + url: https://api.openai.com/wrong-path/chat/completions + headers: {Content-Type: application/json, Authorization: Bearer sk-...} + +[2026-02-12 10:30:02] [DEBUG] [llm] Received LLM response + status_code: 404 + status: 404 Not Found + response_body: 404 page not found + +[2026-02-12 10:30:02] [ERROR] [llm] LLM API returned non-OK status + status_code: 404 + url: https://api.openai.com/wrong-path/chat/completions + response_body: 404 page not found + +[2026-02-12 10:30:02] [ERROR] [agent] LLM call failed + iteration: 1 + error: API error: 404 page not found +``` + +从这个日志可以清楚地看到: +1. Provider 创建时使用了错误的 `api_base` +2. 最终的请求 URL 拼接错误 +3. 服务器返回 404 错误 + +**解决方案**: 修改配置文件中的 `api_base` 为 `https://api.openai.com/v1` + +## 敏感信息保护 + +注意到在日志中: +- ✅ API Key 只显示长度和前缀,不会完整输出 +- ✅ Authorization header 会被隐藏 +- ⚠️ 完整的请求体和响应体会被记录(debug 级别) + +**生产环境建议**: +- 使用 `info` 或更高级别,避免泄露敏感信息 +- 只在本地开发或受控环境中使用 `debug` 级别 + +## 相关文件 + +- `pkg/providers/http_provider.go` - HTTP Provider 实现和调试日志 +- `pkg/agent/loop.go` - Agent 循环和错误处理 +- `pkg/logger/logger.go` - 日志系统实现 + +## 获取帮助 + +如果调试日志无法解决问题,请在 GitHub Issue 中提供: +1. 完整的配置文件(隐藏 API Key) +2. 相关的调试日志输出 +3. 使用的模型和 provider +4. 错误发生的上下文 diff --git a/docs/skills-debugging-guide.md b/docs/skills-debugging-guide.md new file mode 100644 index 000000000..8a1cd77bd --- /dev/null +++ b/docs/skills-debugging-guide.md @@ -0,0 +1,316 @@ +# PicoClaw Skills 调试指南 + +## 问题排查和解决方案 + +### 问题1: Skills 无法被识别和使用 + +**症状**: +- Agent 有 21 个 skills,但在处理请求时不使用它们 +- 即使明确指定 skill 名称,也提示 skill 不存在 + +**根本原因**: +Skills 文件不在正确的位置。代码从以下位置加载 skills: +1. `~/.picoclaw/workspace/skills/` (workspace skills - 项目级别) +2. `~/.picoclaw/skills/` (全局 skills) +3. 内置 skills 目录 + +但用户的 skills 实际存放在 `~/.claude/skills/`。 + +**解决方案**: +将需要使用的 skills 复制到 workspace: + +```bash +# 复制单个 skill +cp -r ~/.claude/skills/sentinel-search ~/.picoclaw/workspace/skills/ + +# 或者批量复制所有 skills +cp -r ~/.claude/skills/* ~/.picoclaw/workspace/skills/ +``` + +### 问题2: Anthropic API 404 错误 + +**症状**: +``` +Error: LLM call failed: API error: 404 page not found +url=https://meta.nevis.sina.com.cn/wecode/anthropic/chat/completions +``` + +**根本原因**: +- 原来的代码只有一个通用的 `HTTPProvider` +- 它使用 OpenAI 风格的 API 端点:`/chat/completions` +- 但 Anthropic API 使用不同的端点:`/v1/messages` +- 请求/响应格式也完全不同 + +**解决方案**: +创建了专门的 `AnthropicProvider` (pkg/providers/anthropic_provider.go): +- ✅ 使用正确的端点:`/v1/messages` +- ✅ 使用正确的请求头:`x-api-key` 而不是 `Authorization: Bearer` +- ✅ 转换消息格式(system 单独参数、content blocks 等) +- ✅ 正确处理 tool_use 和 tool_result +- ✅ 处理孤立的 tool_result(跳过没有对应 tool_use 的结果) + +### 问题3: Tool call 参数传递错误 + +**症状**: +``` +Error: messages.5.content.1.tool_use.name: String should have at least 1 character +Error: messages.5.content.1.tool_use.input: Input should be a valid dictionary +``` + +**根本原因**: +Agent 在构建 assistant 消息时,将 tool call 信息存储在: +- `tc.Function.Name` - 工具名称 +- `tc.Function.Arguments` - JSON 字符串格式的参数 + +但 AnthropicProvider 最初只读取: +- `tc.Name` - 为空 +- `tc.Arguments` - 为空的 map + +**解决方案**: +在 AnthropicProvider 中添加了兼容逻辑: + +```go +// 提取 name +name := tc.Name +if name == "" && tc.Function != nil { + name = tc.Function.Name +} + +// 提取 arguments +var input map[string]interface{} +if len(tc.Arguments) > 0 { + input = tc.Arguments +} else if tc.Function != nil && tc.Function.Arguments != "" { + // 从 JSON 字符串解析 + json.Unmarshal([]byte(tc.Function.Arguments), &input) +} +``` + +### 问题4: 孤立的 tool_result 导致 API 错误 + +**症状**: +``` +Error: unexpected tool_use_id found in tool_result blocks: toolu_xxx. +Each tool_result block must have a corresponding tool_use block in the previous message. +``` + +**根本原因**: +- 会话历史可能被截断或清理 +- tool_result 保留了,但对应的 tool_use 被删除了 +- Anthropic API 严格要求 tool_result 必须紧跟在包含对应 tool_use 的 assistant 消息后 + +**解决方案**: +添加了 tool_use ID 跟踪机制: + +```go +// 跟踪当前有效的 tool_use IDs +validToolUseIDs := make(map[string]bool) + +// 在 assistant 消息中记录所有 tool_use IDs +for _, tc := range msg.ToolCalls { + validToolUseIDs[tc.ID] = true +} + +// 检查 tool_result 是否有对应的 tool_use +if msg.Role == "tool" && !validToolUseIDs[msg.ToolCallID] { + // 跳过孤立的 tool_result + continue +} +``` + +## Skills 使用流程 + +### 1. Skills 加载 + +Skills 从以下位置按优先级加载: +1. **Workspace skills** (`~/.picoclaw/workspace/skills/`) - 最高优先级,项目专用 +2. **Global skills** (`~/.picoclaw/skills/`) - 中等优先级,用户全局 +3. **Builtin skills** - 最低优先级,系统内置 + +### 2. Skills 在系统提示中的展示 + +Skills 只显示摘要信息: +```xml + + + sentinel-search + Sentinel 安全平台 - 网络资产、服务和 Web 应用搜索工具 + /path/to/SKILL.md + workspace + + +``` + +提示 AI:"To use a skill, read its SKILL.md file using the read_file tool" + +### 3. Skills 使用流程 + +当用户请求使用某个 skill 时: +1. AI 使用 `read_file` 工具读取 `SKILL.md` +2. 理解 skill 的 API 文档和使用方法 +3. 使用 `exec` 工具调用相应的命令/API +4. 处理结果并返回给用户 + +## 测试 Skills + +### 测试 sentinel-search skill + +```bash +# 1. 确保 skill 在正确位置 +ls ~/.picoclaw/workspace/skills/sentinel-search/SKILL.md + +# 2. 明确指定使用 sentinel 平台 +./picoclaw agent -m "使用 sentinel 平台搜索 nginx 服务,查询语句是 fp.nmap.product=nginx" + +# 3. 观察日志,应该看到: +# - list_dir: 列出 skills 目录 +# - read_file: 读取 SKILL.md +# - exec: 执行 curl 命令调用 API +``` + +### 日志分析 + +成功的 skill 使用日志: +``` +[INFO] agent: LLM requested tool calls {tools=[list_dir], count=1, iteration=1} +[INFO] agent: Tool call: list_dir({"path":"/Users/xingyue/.picoclaw/workspace/skills"}) +[INFO] agent: LLM requested tool calls {tools=[read_file], count=1, iteration=2} +[INFO] agent: Tool call: read_file({"path":"...sentinel-search/SKILL.md"}) +[INFO] agent: LLM requested tool calls {tools=[exec], count=1, iteration=3} +[INFO] agent: Tool call: exec({"command":"curl -X POST http://..."}) +``` + +## 调试技巧 + +### 1. 启用 Debug 日志 + +```bash +# 启用所有 debug 日志 +export PICOCLAW_LOG_LEVEL=debug + +# 只启用 llm 分类的 debug 日志 +export PICOCLAW_LOG_CATEGORY_LLM=debug +``` + +### 2. 检查 Skills 是否加载 + +```bash +# 查看 agent 初始化日志 +./picoclaw agent -m "test" 2>&1 | grep "Agent initialized" +# 应该看到: skills_total=22, skills_available=22 +``` + +### 3. 验证 API 连接 + +```bash +# 手动测试 Sentinel API +curl -X POST http://172.16.10.239:31223/api/search \ + -H "Content-Type: application/json" \ + -d '{"query":"fp.nmap.product=nginx","page":1,"page_size":5}' +``` + +### 4. 检查网络连接 + +如果 skill 涉及网络请求,确保: +- ✅ VPN 已连接(如果需要) +- ✅ 防火墙允许访问 +- ✅ 目标服务正常运行 + +## 创建自定义 Skills + +### Skill 目录结构 + +``` +~/.picoclaw/workspace/skills/my-skill/ +├── SKILL.md # Skill 文档(必需) +└── .claude/ # 可选的元数据 + └── config.json +``` + +### SKILL.md 格式 + +```markdown +--- +name: my-skill +description: 简短描述(会显示在 skills 列表中) +version: 1.0.0 +author: your-name +tags: + - tag1 + - tag2 +triggers: + - 触发词1 + - 触发词2 +--- + +# Skill 名称 + +详细的使用说明和 API 文档... + +## 使用示例 + +\```bash +# 示例命令 +curl ... +\``` +``` + +### Best Practices + +1. **清晰的文档** - 提供完整的 API 文档和示例 +2. **错误处理** - 说明常见错误和解决方法 +3. **网络要求** - 明确说明网络依赖和访问限制 +4. **参数说明** - 详细描述所有参数和选项 + +## 相关文件 + +- `pkg/skills/loader.go` - Skills 加载器 +- `pkg/agent/context.go` - Skills 在上下文中的集成 +- `pkg/providers/anthropic_provider.go` - Anthropic API 适配器 +- `docs/llm-debug-guide.md` - LLM 调试指南 + +## 未来改进 + +### 1. Skills 自动同步 + +考虑添加配置选项,自动从 `~/.claude/skills/` 同步到 workspace: + +```yaml +skills: + auto_sync: true + source: ~/.claude/skills +``` + +### 2. Skills 作为工具 + +考虑将常用 skills 直接注册为工具,而不需要每次都 read_file: + +```go +// 将 skill 转换为 tool definition +func (s *Skill) AsToolDefinition() providers.ToolDefinition { + // ... +} +``` + +### 3. Skills 模板 + +提供 skill 创建模板: + +```bash +picoclaw skill new my-skill --template api-wrapper +``` + +## 总结 + +经过调试,现在 PicoClaw 的 skills 系统可以正常工作: + +1. ✅ Skills 从正确的位置加载 +2. ✅ Anthropic API 正确处理消息和工具调用 +3. ✅ Tool calls 的 name 和 arguments 正确提取 +4. ✅ 孤立的 tool_result 被过滤 +5. ✅ 详细的调试日志帮助排查问题 + +Skills 使用流程清晰简单: +1. 将 skill 放到 workspace/skills/ +2. 明确告诉 AI 使用哪个 skill +3. AI 读取 SKILL.md 并执行相应操作 diff --git a/pkg/providers/anthropic_provider.go b/pkg/providers/anthropic_provider.go new file mode 100644 index 000000000..33f690bdb --- /dev/null +++ b/pkg/providers/anthropic_provider.go @@ -0,0 +1,342 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package providers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// AnthropicProvider implements the Anthropic Messages API +type AnthropicProvider struct { + apiKey string + apiBase string + httpClient *http.Client +} + +func NewAnthropicProvider(apiKey, apiBase string) *AnthropicProvider { + if apiBase == "" { + apiBase = "https://api.anthropic.com/v1" + } + return &AnthropicProvider{ + apiKey: apiKey, + apiBase: apiBase, + httpClient: &http.Client{ + Timeout: 0, + }, + } +} + +func (p *AnthropicProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + // Convert messages to Anthropic format + anthropicMessages := make([]map[string]interface{}, 0, len(messages)) + var systemPrompt string + + // Track tool_use IDs from the previous assistant message + validToolUseIDs := make(map[string]bool) + + for i, msg := range messages { + if msg.Role == "system" { + // Anthropic uses separate system parameter + systemPrompt = msg.Content + continue + } + + anthMsg := map[string]interface{}{ + "role": msg.Role, + "content": msg.Content, + } + + // Handle tool results - only include if there's a corresponding tool_use + if msg.Role == "tool" || msg.ToolCallID != "" { + // Check if this tool_call_id was in the previous assistant message + if !validToolUseIDs[msg.ToolCallID] { + logger.WarnCF("llm", "Skipping tool result without corresponding tool_use", + map[string]interface{}{ + "tool_call_id": msg.ToolCallID, + "message_idx": i, + }) + continue // Skip orphaned tool results + } + + anthMsg["role"] = "user" + anthMsg["content"] = []map[string]interface{}{ + { + "type": "tool_result", + "tool_use_id": msg.ToolCallID, + "content": msg.Content, + }, + } + } + + // Handle assistant messages with tool calls + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { + // Clear previous valid IDs and record new ones + validToolUseIDs = make(map[string]bool) + + contentBlocks := make([]map[string]interface{}, 0, len(msg.ToolCalls)+1) + + // Add text content if present + if msg.Content != "" { + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "text", + "text": msg.Content, + }) + } + + // Add tool use blocks + for _, tc := range msg.ToolCalls { + // Extract name from either tc.Name or tc.Function.Name + name := tc.Name + if name == "" && tc.Function != nil { + name = tc.Function.Name + } + + // Extract arguments - they might be in tc.Arguments (map) or tc.Function.Arguments (JSON string) + var input map[string]interface{} + if len(tc.Arguments) > 0 { + input = tc.Arguments + } else if tc.Function != nil && tc.Function.Arguments != "" { + // Parse JSON string to map + if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil { + logger.ErrorCF("llm", "Failed to parse tool arguments", + map[string]interface{}{ + "id": tc.ID, + "name": name, + "error": err.Error(), + "raw": tc.Function.Arguments, + }) + continue + } + } else { + input = make(map[string]interface{}) + } + + logger.DebugCF("llm", "Converting tool call to Anthropic format", + map[string]interface{}{ + "id": tc.ID, + "name": name, + "type": tc.Type, + "input": input, + }) + + if name == "" { + logger.ErrorCF("llm", "Tool call has no name", + map[string]interface{}{ + "id": tc.ID, + "type": tc.Type, + "has_function": tc.Function != nil, + "tc_name": tc.Name, + }) + continue // Skip this tool call + } + + // Record this as a valid tool_use ID + validToolUseIDs[tc.ID] = true + + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "tool_use", + "id": tc.ID, + "name": name, + "input": input, + }) + } + + anthMsg["content"] = contentBlocks + } else if msg.Role == "assistant" { + // Assistant message without tool calls - clear valid IDs + validToolUseIDs = make(map[string]bool) + } + + anthropicMessages = append(anthropicMessages, anthMsg) + } + + // Build request body + requestBody := map[string]interface{}{ + "model": model, + "messages": anthropicMessages, + } + + if systemPrompt != "" { + requestBody["system"] = systemPrompt + } + + // Add tools if present + if len(tools) > 0 { + anthropicTools := make([]map[string]interface{}, 0, len(tools)) + for _, tool := range tools { + anthropicTools = append(anthropicTools, map[string]interface{}{ + "name": tool.Function.Name, + "description": tool.Function.Description, + "input_schema": tool.Function.Parameters, + }) + } + requestBody["tools"] = anthropicTools + } + + // Add max_tokens (required by Anthropic) + if maxTokens, ok := options["max_tokens"].(int); ok { + requestBody["max_tokens"] = maxTokens + } else { + requestBody["max_tokens"] = 8192 // Default + } + + // Add temperature if specified + if temperature, ok := options["temperature"].(float64); ok { + requestBody["temperature"] = temperature + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + fullURL := p.apiBase + "/messages" + + // Debug log: Log request details + logger.DebugCF("llm", "Sending Anthropic API request", + map[string]interface{}{ + "url": fullURL, + "model": model, + "api_base": p.apiBase, + "has_api_key": p.apiKey != "", + "api_key_len": len(p.apiKey), + "message_count": len(anthropicMessages), + "tools_count": len(tools), + "request_body": string(jsonData), + }) + + req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(jsonData)) + if err != nil { + logger.ErrorCF("llm", "Failed to create HTTP request", + map[string]interface{}{ + "url": fullURL, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set Anthropic-specific headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", p.apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + logger.DebugCF("llm", "Sending HTTP request", + map[string]interface{}{ + "method": "POST", + "url": fullURL, + }) + + resp, err := p.httpClient.Do(req) + if err != nil { + logger.ErrorCF("llm", "HTTP request failed", + map[string]interface{}{ + "url": fullURL, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("llm", "Failed to read response body", + map[string]interface{}{ + "status_code": resp.StatusCode, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Debug log: Log response details + logger.DebugCF("llm", "Received Anthropic API response", + map[string]interface{}{ + "status_code": resp.StatusCode, + "status": resp.Status, + "content_length": len(body), + "response_body": string(body), + }) + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("llm", "Anthropic API returned non-OK status", + map[string]interface{}{ + "status_code": resp.StatusCode, + "status": resp.Status, + "url": fullURL, + "response_body": string(body), + }) + return nil, fmt.Errorf("API error: %s", string(body)) + } + + return p.parseResponse(body) +} + +func (p *AnthropicProvider) parseResponse(body []byte) (*LLMResponse, error) { + var apiResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input map[string]interface{} `json:"input,omitempty"` + } `json:"content"` + StopReason string `json:"stop_reason"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + } + + if err := json.Unmarshal(body, &apiResponse); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + // Extract text content and tool calls + var textContent string + toolCalls := make([]ToolCall, 0) + + for _, content := range apiResponse.Content { + switch content.Type { + case "text": + textContent += content.Text + case "tool_use": + toolCalls = append(toolCalls, ToolCall{ + ID: content.ID, + Name: content.Name, + Arguments: content.Input, + }) + } + } + + return &LLMResponse{ + Content: textContent, + ToolCalls: toolCalls, + FinishReason: apiResponse.StopReason, + Usage: &UsageInfo{ + PromptTokens: apiResponse.Usage.InputTokens, + CompletionTokens: apiResponse.Usage.OutputTokens, + TotalTokens: apiResponse.Usage.InputTokens + apiResponse.Usage.OutputTokens, + }, + }, nil +} + +func (p *AnthropicProvider) GetDefaultModel() string { + return "claude-sonnet-4-5" +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 12909df9e..ff811e242 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -16,6 +16,7 @@ import ( "strings" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) type HTTPProvider struct { @@ -24,6 +25,14 @@ type HTTPProvider struct { httpClient *http.Client } +// Helper function to get minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + func NewHTTPProvider(apiKey, apiBase string) *HTTPProvider { return &HTTPProvider{ apiKey: apiKey, @@ -67,8 +76,28 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too return nil, fmt.Errorf("failed to marshal request: %w", err) } - req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData)) + fullURL := p.apiBase + "/chat/completions" + + // Debug log: Log request details + logger.DebugCF("llm", "Sending LLM request", + map[string]interface{}{ + "url": fullURL, + "model": model, + "api_base": p.apiBase, + "has_api_key": p.apiKey != "", + "api_key_len": len(p.apiKey), + "message_count": len(messages), + "tools_count": len(tools), + "request_body": string(jsonData), + }) + + req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(jsonData)) if err != nil { + logger.ErrorCF("llm", "Failed to create HTTP request", + map[string]interface{}{ + "url": fullURL, + "error": err.Error(), + }) return nil, fmt.Errorf("failed to create request: %w", err) } @@ -76,20 +105,57 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too if p.apiKey != "" { authHeader := "Bearer " + p.apiKey req.Header.Set("Authorization", authHeader) + logger.DebugCF("llm", "Authorization header set", + map[string]interface{}{ + "key_prefix": p.apiKey[:min(10, len(p.apiKey))] + "...", + }) } + logger.DebugCF("llm", "Sending HTTP request", + map[string]interface{}{ + "method": "POST", + "url": fullURL, + "headers": req.Header, + }) + resp, err := p.httpClient.Do(req) if err != nil { + logger.ErrorCF("llm", "HTTP request failed", + map[string]interface{}{ + "url": fullURL, + "error": err.Error(), + }) return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { + logger.ErrorCF("llm", "Failed to read response body", + map[string]interface{}{ + "status_code": resp.StatusCode, + "error": err.Error(), + }) return nil, fmt.Errorf("failed to read response: %w", err) } + // Debug log: Log response details + logger.DebugCF("llm", "Received LLM response", + map[string]interface{}{ + "status_code": resp.StatusCode, + "status": resp.Status, + "content_length": len(body), + "response_body": string(body), + }) + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("llm", "LLM API returned non-OK status", + map[string]interface{}{ + "status_code": resp.StatusCode, + "status": resp.Status, + "url": fullURL, + "response_body": string(body), + }) return nil, fmt.Errorf("API error: %s", string(body)) } @@ -177,6 +243,12 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { lowerModel := strings.ToLower(model) + logger.DebugCF("llm", "Creating LLM provider", + map[string]interface{}{ + "model": model, + "lower_model": lowerModel, + }) + switch { case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"): apiKey = cfg.Providers.OpenRouter.APIKey @@ -187,12 +259,23 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { } case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && cfg.Providers.Anthropic.APIKey != "": + // Use dedicated Anthropic provider apiKey = cfg.Providers.Anthropic.APIKey apiBase = cfg.Providers.Anthropic.APIBase if apiBase == "" { apiBase = "https://api.anthropic.com/v1" } + logger.InfoCF("llm", "Anthropic provider created successfully", + map[string]interface{}{ + "model": model, + "api_base": apiBase, + "has_api_key": apiKey != "", + "api_key_len": len(apiKey), + }) + + return NewAnthropicProvider(apiKey, apiBase), nil + case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && cfg.Providers.OpenAI.APIKey != "": apiKey = cfg.Providers.OpenAI.APIKey apiBase = cfg.Providers.OpenAI.APIBase @@ -239,12 +322,28 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { } if apiKey == "" && !strings.HasPrefix(model, "bedrock/") { + logger.ErrorCF("llm", "No API key configured", + map[string]interface{}{ + "model": model, + }) return nil, fmt.Errorf("no API key configured for provider (model: %s)", model) } if apiBase == "" { + logger.ErrorCF("llm", "No API base configured", + map[string]interface{}{ + "model": model, + }) return nil, fmt.Errorf("no API base configured for provider (model: %s)", model) } + logger.InfoCF("llm", "Provider created successfully", + map[string]interface{}{ + "model": model, + "api_base": apiBase, + "has_api_key": apiKey != "", + "api_key_len": len(apiKey), + }) + return NewHTTPProvider(apiKey, apiBase), nil } \ No newline at end of file