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:
cevin 2026-02-12 11:22:04 +08:00
parent ddd6fca1be
commit c9db46afd0
4 changed files with 1058 additions and 1 deletions

300
docs/llm-debug-guide.md Normal file
View 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. 错误发生的上下文

View 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 并执行相应操作

View 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"
}

View file

@ -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
}