Merge 43014cd36e into c119e0d5e8
This commit is contained in:
commit
7c7aec2a7a
14 changed files with 1694 additions and 18 deletions
|
|
@ -127,7 +127,7 @@
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
"_comment": "WeCom Bot - Easier setup, supports group chats",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
|
@ -153,6 +153,17 @@
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5,
|
"reply_timeout": 5,
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
|
"wecom_aibot": {
|
||||||
|
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
|
||||||
|
"enabled": false,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18791,
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"max_steps": 10,
|
||||||
|
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
61
docs/design/issue-783-investigation-and-fix-plan.zh.md
Normal file
61
docs/design/issue-783-investigation-and-fix-plan.zh.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Issue #783 调研与修复执行文档
|
||||||
|
|
||||||
|
## 1. 问题澄清(已确认)
|
||||||
|
|
||||||
|
- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。
|
||||||
|
- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。
|
||||||
|
- 影响:
|
||||||
|
- fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`。
|
||||||
|
- `defaults.provider` 为空时,日志出现 `provider=` 空值。
|
||||||
|
|
||||||
|
## 2. 本次目标
|
||||||
|
|
||||||
|
- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。
|
||||||
|
- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。
|
||||||
|
- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。
|
||||||
|
- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。
|
||||||
|
|
||||||
|
## 3. 联网最佳实践调研结论(已完成)
|
||||||
|
|
||||||
|
- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。
|
||||||
|
- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。
|
||||||
|
- [x] 将外部建议映射为本仓库可执行约束。
|
||||||
|
|
||||||
|
外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档):
|
||||||
|
|
||||||
|
- 优先显式配置,不依赖字符串切分推断 provider。
|
||||||
|
- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。
|
||||||
|
- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。
|
||||||
|
|
||||||
|
参考链接:
|
||||||
|
|
||||||
|
- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection
|
||||||
|
- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks
|
||||||
|
- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion
|
||||||
|
- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture
|
||||||
|
- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/
|
||||||
|
|
||||||
|
与本仓库对应的可执行约束:
|
||||||
|
|
||||||
|
- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。
|
||||||
|
- 未命中映射时保留旧解析行为,保证兼容性。
|
||||||
|
- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。
|
||||||
|
|
||||||
|
## 4. 实施步骤(顺序执行)
|
||||||
|
|
||||||
|
- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。
|
||||||
|
- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。
|
||||||
|
- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。
|
||||||
|
- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。
|
||||||
|
- [x] Step 5: 运行质量门禁(LSP + `make check`)。
|
||||||
|
|
||||||
|
## 5. 执行记录
|
||||||
|
|
||||||
|
- 状态:已完成
|
||||||
|
- 已完成改动:
|
||||||
|
- `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。
|
||||||
|
- `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。
|
||||||
|
- `pkg/providers/fallback_test.go`:新增别名解析与去重测试。
|
||||||
|
- `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。
|
||||||
|
- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。
|
||||||
|
- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。
|
||||||
|
|
@ -92,7 +92,47 @@ func NewAgentInstance(
|
||||||
Primary: model,
|
Primary: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
}
|
}
|
||||||
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
|
resolveFromModelList := func(raw string) (string, bool) {
|
||||||
|
ensureProtocol := func(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(model, "/") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return "openai/" + model
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||||
|
return ensureProtocol(mc.Model), true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||||
|
if fullModel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fullModel == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
|
if modelID == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
|
|
|
||||||
|
|
@ -93,3 +93,77 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
||||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "step-3.5-flash",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "step-3.5-flash",
|
||||||
|
Model: "openrouter/stepfun/step-3.5-flash:free",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != "openrouter" {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "glm-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "glm-5",
|
||||||
|
Model: "glm-5",
|
||||||
|
APIBase: "https://api.z.ai/api/coding/paas/v4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != "openai" {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != "glm-5" {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,10 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("wecom", "WeCom")
|
m.initChannel("wecom", "WeCom")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.WeComAIBot.Enabled && m.config.Channels.WeComAIBot.Token != "" {
|
||||||
|
m.initChannel("wecom_aibot", "WeCom AI Bot")
|
||||||
|
}
|
||||||
|
|
||||||
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
|
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
|
||||||
m.initChannel("wecom_app", "WeCom App")
|
m.initChannel("wecom_app", "WeCom App")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
provider = "configured default"
|
provider = "configured default"
|
||||||
}
|
}
|
||||||
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
|
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
||||||
c.config.Agents.Defaults.GetModelName(), provider)
|
c.config.Agents.Defaults.GetModelName(), provider)
|
||||||
|
|
||||||
case "channels":
|
case "channels":
|
||||||
|
|
|
||||||
1134
pkg/channels/wecom/aibot.go
Normal file
1134
pkg/channels/wecom/aibot.go
Normal file
File diff suppressed because it is too large
Load diff
219
pkg/channels/wecom/aibot_test.go
Normal file
219
pkg/channels/wecom/aibot_test.go
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
package wecom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWeComAIBotChannel(t *testing.T) {
|
||||||
|
t.Run("success with valid config", func(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
WebhookPath: "/webhook/test",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == nil {
|
||||||
|
t.Fatal("Expected channel to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.Name() != "wecom_aibot" {
|
||||||
|
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error with missing token", func(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing token, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error with missing encoding key", func(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing encoding key, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComAIBotChannelStartStop(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create channel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test Start
|
||||||
|
if err := ch.Start(ctx); err != nil {
|
||||||
|
t.Fatalf("Failed to start channel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ch.IsRunning() {
|
||||||
|
t.Error("Expected channel to be running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Stop
|
||||||
|
if err := ch.Stop(ctx); err != nil {
|
||||||
|
t.Fatalf("Failed to stop channel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.IsRunning() {
|
||||||
|
t.Error("Expected channel to be stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComAIBotChannelWebhookPath(t *testing.T) {
|
||||||
|
t.Run("default path", func(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
expectedPath := "/webhook/wecom-aibot"
|
||||||
|
if ch.WebhookPath() != expectedPath {
|
||||||
|
t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom path", func(t *testing.T) {
|
||||||
|
customPath := "/custom/webhook"
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
WebhookPath: customPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
if ch.WebhookPath() != customPath {
|
||||||
|
t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateStreamID(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
// Generate multiple IDs and check they are unique
|
||||||
|
ids := make(map[string]bool)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
id := ch.generateStreamID()
|
||||||
|
|
||||||
|
if len(id) != 10 {
|
||||||
|
t.Errorf("Expected stream ID length 10, got %d", len(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ids[id] {
|
||||||
|
t.Errorf("Duplicate stream ID generated: %s", id)
|
||||||
|
}
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecrypt(t *testing.T) {
|
||||||
|
// Use a valid 43-character base64 key (企业微信标准格式)
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
plaintext := "Hello, World!"
|
||||||
|
receiveid := ""
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
encrypted, err := ch.encryptMessage(plaintext, receiveid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to encrypt message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if encrypted == "" {
|
||||||
|
t.Fatal("Encrypted message is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decrypt message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignature(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
encrypt := "encrypted_msg"
|
||||||
|
|
||||||
|
signature := ch.generateSignature(timestamp, nonce, encrypt)
|
||||||
|
|
||||||
|
if signature == "" {
|
||||||
|
t.Error("Generated signature is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature using verifySignature function
|
||||||
|
if !verifySignature(cfg.Token, signature, timestamp, nonce, encrypt) {
|
||||||
|
t.Error("Generated signature does not verify correctly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,4 +13,7 @@ func init() {
|
||||||
channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
return NewWeComAppChannel(cfg.Channels.WeComApp, b)
|
return NewWeComAppChannel(cfg.Channels.WeComApp, b)
|
||||||
})
|
})
|
||||||
|
channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ type AgentDefaults struct {
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||||
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
|
|
@ -191,19 +191,20 @@ func (d *AgentDefaults) GetModelName() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelsConfig struct {
|
type ChannelsConfig struct {
|
||||||
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
||||||
Telegram TelegramConfig `json:"telegram"`
|
Telegram TelegramConfig `json:"telegram"`
|
||||||
Feishu FeishuConfig `json:"feishu"`
|
Feishu FeishuConfig `json:"feishu"`
|
||||||
Discord DiscordConfig `json:"discord"`
|
Discord DiscordConfig `json:"discord"`
|
||||||
MaixCam MaixCamConfig `json:"maixcam"`
|
MaixCam MaixCamConfig `json:"maixcam"`
|
||||||
QQ QQConfig `json:"qq"`
|
QQ QQConfig `json:"qq"`
|
||||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
WeCom WeComConfig `json:"wecom"`
|
WeCom WeComConfig `json:"wecom"`
|
||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
Pico PicoConfig `json:"pico"`
|
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||||
|
Pico PicoConfig `json:"pico"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
|
|
@ -359,6 +360,19 @@ type WeComAppConfig struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WeComAIBotConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
|
||||||
|
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
|
||||||
|
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"`
|
||||||
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
|
||||||
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
|
||||||
|
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
|
||||||
|
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
|
||||||
|
}
|
||||||
|
|
||||||
type PicoConfig struct {
|
type PicoConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -324,6 +325,25 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "config.json")
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if err := SaveConfig(path, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), `"model": ""`) {
|
||||||
|
t.Fatalf("saved config should include empty legacy model field, got: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConfig_Complete verifies all config fields are set
|
// TestConfig_Complete verifies all config fields are set
|
||||||
func TestConfig_Complete(t *testing.T) {
|
func TestConfig_Complete(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,18 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
ReplyTimeout: 5,
|
ReplyTimeout: 5,
|
||||||
},
|
},
|
||||||
|
WeComAIBot: WeComAIBotConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Token: "",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
WebhookHost: "0.0.0.0",
|
||||||
|
WebhookPort: 18791,
|
||||||
|
WebhookPath: "/webhook/wecom-aibot",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
ReplyTimeout: 5,
|
||||||
|
MaxSteps: 10,
|
||||||
|
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
|
},
|
||||||
Pico: PicoConfig{
|
Pico: PicoConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
Token: "",
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,26 @@ func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
|
||||||
|
|
||||||
// ResolveCandidates parses model config into a deduplicated candidate list.
|
// ResolveCandidates parses model config into a deduplicated candidate list.
|
||||||
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
|
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
|
||||||
|
return ResolveCandidatesWithLookup(cfg, defaultProvider, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveCandidatesWithLookup(
|
||||||
|
cfg ModelConfig,
|
||||||
|
defaultProvider string,
|
||||||
|
lookup func(raw string) (resolved string, ok bool),
|
||||||
|
) []FallbackCandidate {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var candidates []FallbackCandidate
|
var candidates []FallbackCandidate
|
||||||
|
|
||||||
addCandidate := func(raw string) {
|
addCandidate := func(raw string) {
|
||||||
ref := ParseModelRef(raw, defaultProvider)
|
candidateRaw := strings.TrimSpace(raw)
|
||||||
|
if lookup != nil {
|
||||||
|
if resolved, ok := lookup(candidateRaw); ok {
|
||||||
|
candidateRaw = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := ParseModelRef(candidateRaw, defaultProvider)
|
||||||
if ref == nil {
|
if ref == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -453,6 +453,75 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "step-3.5-flash",
|
||||||
|
Fallbacks: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := func(raw string) (string, bool) {
|
||||||
|
if raw == "step-3.5-flash" {
|
||||||
|
return "openrouter/stepfun/step-3.5-flash:free", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
if candidates[0].Provider != "openrouter" {
|
||||||
|
t.Fatalf("provider = %q, want openrouter", candidates[0].Provider)
|
||||||
|
}
|
||||||
|
if candidates[0].Model != "stepfun/step-3.5-flash:free" {
|
||||||
|
t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidatesWithLookup_DeduplicateAfterLookup(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "step-3.5-flash",
|
||||||
|
Fallbacks: []string{"openrouter/stepfun/step-3.5-flash:free"},
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := func(raw string) (string, bool) {
|
||||||
|
if raw == "step-3.5-flash" {
|
||||||
|
return "openrouter/stepfun/step-3.5-flash:free", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "glm-5",
|
||||||
|
Fallbacks: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := func(raw string) (string, bool) {
|
||||||
|
if raw == "glm-5" {
|
||||||
|
return "glm-5", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidatesWithLookup(cfg, "openai", lookup)
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
if candidates[0].Provider != "openai" {
|
||||||
|
t.Fatalf("provider = %q, want openai", candidates[0].Provider)
|
||||||
|
}
|
||||||
|
if candidates[0].Model != "glm-5" {
|
||||||
|
t.Fatalf("model = %q, want glm-5", candidates[0].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFallbackExhaustedError_Message(t *testing.T) {
|
func TestFallbackExhaustedError_Message(t *testing.T) {
|
||||||
e := &FallbackExhaustedError{
|
e := &FallbackExhaustedError{
|
||||||
Attempts: []FallbackAttempt{
|
Attempts: []FallbackAttempt{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue