feat(providers): add Anthropic provider with comprehensive debug logging
This commit introduces dedicated Anthropic provider support and enhanced debugging capabilities for LLM provider operations. Changes: - Add dedicated AnthropicProvider implementation for Claude models - Enhance HTTP provider with detailed debug logging throughout request/response lifecycle - Add comprehensive logging for provider creation, API calls, and error conditions - Include debugging documentation for LLM and skills troubleshooting Benefits: - Better debugging experience when troubleshooting LLM API issues - Native Anthropic API support with proper request/response handling - Detailed logs for API key validation, request bodies, and response status - Documentation to guide users through common debugging scenarios Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ddd6fca1be
commit
c9db46afd0
4 changed files with 1058 additions and 1 deletions
300
docs/llm-debug-guide.md
Normal file
300
docs/llm-debug-guide.md
Normal file
|
|
@ -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. 错误发生的上下文
|
||||
316
docs/skills-debugging-guide.md
Normal file
316
docs/skills-debugging-guide.md
Normal file
|
|
@ -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
|
||||
<skills>
|
||||
<skill>
|
||||
<name>sentinel-search</name>
|
||||
<description>Sentinel 安全平台 - 网络资产、服务和 Web 应用搜索工具</description>
|
||||
<location>/path/to/SKILL.md</location>
|
||||
<source>workspace</source>
|
||||
</skill>
|
||||
</skills>
|
||||
```
|
||||
|
||||
提示 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 并执行相应操作
|
||||
342
pkg/providers/anthropic_provider.go
Normal file
342
pkg/providers/anthropic_provider.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue