- 优化 openai_compat 的 HTTP 错误处理,支持解析结构化 JSON 错误体 - 在报错中补充 api_base、model、path、message、code 等上下文信息 - 增加 SiliconFlow 作为一等 OpenAI-compatible provider 别名 - 支持在设置 api_base 时直接使用上游原始模型名(如 Pro/zai-org/GLM-4.7) - 更新 README、README.zh.md 和 config.example.json 中的 SiliconFlow / api_base 配置示例 - 修复 Windows 下 Codex CLI mock 测试无法执行临时脚本的问题 update:主要完善了 PicoClaw 对飞书的支持,重点包括以下内容: 基于飞书官方 Go SDK 完善飞书通道能力 补全飞书消息收发相关实现 增强对飞书富文本/卡片消息的兼容处理 对齐 Python SDK 示例中的已有功能范围 增强飞书媒体消息处理能力 支持从飞书消息中递归提取所有图片、文件等附件信息 支持多图片、多附件消息的解析与处理 优化附件下载与媒体引用传递流程 接入现有图片/视觉处理链路 将飞书图片消息接入 PicoClaw 现有多模态处理流程 使图片能够随用户文本一起进入模型请求 为后续视觉模型识图能力打通基础链路 修复 Feishu/Windows amd64 相关构建与运行问题 清理 Feishu 包内重复定义与残留实现 修复 64 位 Windows 构建过程中的编译问题 修复因错误架构判断导致的飞书通道不可用问题 改进 OpenAI-compatible Provider 对 GLM/Zhipu 多模态请求的兼容性 针对 glm / zhipu 兼容端点增加图片请求格式适配 调整多模态消息序列化逻辑 对齐 glm-4.6v-flash 官方样例的请求结构 补充测试并完成回归验证 增加飞书消息解析、多附件提取、多模态序列化等测试 已通过 pkg/providers/openai_compat、pkg/channels/feishu、pkg/tools 相关测试
377 lines
9.8 KiB
Go
377 lines
9.8 KiB
Go
// PicoClaw - Ultra-lightweight personal AI agent
|
|
// License: MIT
|
|
//
|
|
// Copyright (c) 2026 PicoClaw contributors
|
|
|
|
package providers
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
func TestExtractProtocol(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
wantProtocol string
|
|
wantModelID string
|
|
}{
|
|
{
|
|
name: "openai with prefix",
|
|
model: "openai/gpt-4o",
|
|
wantProtocol: "openai",
|
|
wantModelID: "gpt-4o",
|
|
},
|
|
{
|
|
name: "anthropic with prefix",
|
|
model: "anthropic/claude-sonnet-4.6",
|
|
wantProtocol: "anthropic",
|
|
wantModelID: "claude-sonnet-4.6",
|
|
},
|
|
{
|
|
name: "no prefix - defaults to openai",
|
|
model: "gpt-4o",
|
|
wantProtocol: "openai",
|
|
wantModelID: "gpt-4o",
|
|
},
|
|
{
|
|
name: "groq with prefix",
|
|
model: "groq/llama-3.1-70b",
|
|
wantProtocol: "groq",
|
|
wantModelID: "llama-3.1-70b",
|
|
},
|
|
{
|
|
name: "empty string",
|
|
model: "",
|
|
wantProtocol: "openai",
|
|
wantModelID: "",
|
|
},
|
|
{
|
|
name: "with whitespace",
|
|
model: " openai/gpt-4 ",
|
|
wantProtocol: "openai",
|
|
wantModelID: "gpt-4",
|
|
},
|
|
{
|
|
name: "multiple slashes",
|
|
model: "nvidia/meta/llama-3.1-8b",
|
|
wantProtocol: "nvidia",
|
|
wantModelID: "meta/llama-3.1-8b",
|
|
},
|
|
{
|
|
name: "siliconflow with nested model path",
|
|
model: "siliconflow/Pro/zai-org/GLM-4.7",
|
|
wantProtocol: "siliconflow",
|
|
wantModelID: "Pro/zai-org/GLM-4.7",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
protocol, modelID := ExtractProtocol(tt.model)
|
|
if protocol != tt.wantProtocol {
|
|
t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol)
|
|
}
|
|
if modelID != tt.wantModelID {
|
|
t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_OpenAI(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-openai",
|
|
Model: "openai/gpt-4o",
|
|
APIKey: "test-key",
|
|
APIBase: "https://api.example.com/v1",
|
|
}
|
|
|
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
|
}
|
|
if modelID != "gpt-4o" {
|
|
t.Errorf("modelID = %q, want %q", modelID, "gpt-4o")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
protocol string
|
|
}{
|
|
{"openai", "openai"},
|
|
{"groq", "groq"},
|
|
{"openrouter", "openrouter"},
|
|
{"cerebras", "cerebras"},
|
|
{"vivgrid", "vivgrid"},
|
|
{"qwen", "qwen"},
|
|
{"vllm", "vllm"},
|
|
{"deepseek", "deepseek"},
|
|
{"ollama", "ollama"},
|
|
{"siliconflow", "siliconflow"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-" + tt.protocol,
|
|
Model: tt.protocol + "/test-model",
|
|
APIKey: "test-key",
|
|
}
|
|
|
|
provider, _, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
|
|
// Verify we got an HTTPProvider for all these protocols
|
|
if _, ok := provider.(*HTTPProvider); !ok {
|
|
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetDefaultAPIBase_LiteLLM(t *testing.T) {
|
|
if got := getDefaultAPIBase("litellm"); got != "http://localhost:4000/v1" {
|
|
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "litellm", got, "http://localhost:4000/v1")
|
|
}
|
|
}
|
|
|
|
func TestGetDefaultAPIBase_SiliconFlow(t *testing.T) {
|
|
if got := getDefaultAPIBase("siliconflow"); got != "https://api.siliconflow.cn/v1" {
|
|
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "siliconflow", got, "https://api.siliconflow.cn/v1")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-litellm",
|
|
Model: "litellm/my-proxy-alias",
|
|
APIKey: "test-key",
|
|
APIBase: "http://localhost:4000/v1",
|
|
}
|
|
|
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
|
}
|
|
if modelID != "my-proxy-alias" {
|
|
t.Errorf("modelID = %q, want %q", modelID, "my-proxy-alias")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-anthropic",
|
|
Model: "anthropic/claude-sonnet-4.6",
|
|
APIKey: "test-key",
|
|
}
|
|
|
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
|
}
|
|
if modelID != "claude-sonnet-4.6" {
|
|
t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4.6")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_Antigravity(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-antigravity",
|
|
Model: "antigravity/gemini-2.0-flash",
|
|
}
|
|
|
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
|
}
|
|
if modelID != "gemini-2.0-flash" {
|
|
t.Errorf("modelID = %q, want %q", modelID, "gemini-2.0-flash")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-claude-cli",
|
|
Model: "claude-cli/claude-sonnet-4.6",
|
|
}
|
|
|
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
|
}
|
|
if modelID != "claude-sonnet-4.6" {
|
|
t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4.6")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_CodexCLI(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-codex-cli",
|
|
Model: "codex-cli/codex",
|
|
}
|
|
|
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
|
}
|
|
if modelID != "codex" {
|
|
t.Errorf("modelID = %q, want %q", modelID, "codex")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-no-key",
|
|
Model: "openai/gpt-4o",
|
|
}
|
|
|
|
_, _, err := CreateProviderFromConfig(cfg)
|
|
if err == nil {
|
|
t.Fatal("CreateProviderFromConfig() expected error for missing API key")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-unknown",
|
|
Model: "unknown-protocol/model",
|
|
APIKey: "test-key",
|
|
}
|
|
|
|
_, _, err := CreateProviderFromConfig(cfg)
|
|
if err == nil {
|
|
t.Fatal("CreateProviderFromConfig() expected error for unknown protocol without api_base")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_UnknownProtocolWithAPIBaseFallsBackToHTTP(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-unknown-fallback",
|
|
Model: "Pro/zai-org/GLM-4.7",
|
|
APIKey: "test-key",
|
|
APIBase: "https://api.siliconflow.cn/v1",
|
|
}
|
|
|
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
|
}
|
|
if _, ok := provider.(*HTTPProvider); !ok {
|
|
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
|
}
|
|
if modelID != "Pro/zai-org/GLM-4.7" {
|
|
t.Errorf("modelID = %q, want %q", modelID, "Pro/zai-org/GLM-4.7")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_NilConfig(t *testing.T) {
|
|
_, _, err := CreateProviderFromConfig(nil)
|
|
if err == nil {
|
|
t.Fatal("CreateProviderFromConfig(nil) expected error")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_EmptyModel(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-empty",
|
|
Model: "",
|
|
}
|
|
|
|
_, _, err := CreateProviderFromConfig(cfg)
|
|
if err == nil {
|
|
t.Fatal("CreateProviderFromConfig() expected error for empty model")
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(1500 * time.Millisecond)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-timeout",
|
|
Model: "openai/gpt-4o",
|
|
APIBase: server.URL,
|
|
RequestTimeout: 1,
|
|
}
|
|
|
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
if modelID != "gpt-4o" {
|
|
t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o")
|
|
}
|
|
|
|
_, err = provider.Chat(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hi"}},
|
|
nil,
|
|
modelID,
|
|
nil,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("Chat() expected timeout error, got nil")
|
|
}
|
|
errMsg := err.Error()
|
|
if !strings.Contains(errMsg, "context deadline exceeded") && !strings.Contains(errMsg, "Client.Timeout exceeded") {
|
|
t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
|
|
}
|
|
}
|
|
|
|
func TestCreateProviderFromConfig_SiliconFlowNestedModelPath(t *testing.T) {
|
|
cfg := &config.ModelConfig{
|
|
ModelName: "test-siliconflow",
|
|
Model: "siliconflow/Pro/zai-org/GLM-4.7",
|
|
APIKey: "test-key",
|
|
}
|
|
|
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
|
}
|
|
if _, ok := provider.(*HTTPProvider); !ok {
|
|
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
|
}
|
|
if modelID != "Pro/zai-org/GLM-4.7" {
|
|
t.Errorf("modelID = %q, want %q", modelID, "Pro/zai-org/GLM-4.7")
|
|
}
|
|
}
|