diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index f87cfa4e1..22cb7b2b8 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -221,6 +221,10 @@ func buildRequestBody( // Add tool_use blocks for _, tc := range msg.ToolCalls { + if strings.TrimSpace(tc.Name) == "" { + continue + } + // Handle nil Arguments (GLM-4 may return null input) input := tc.Arguments if input == nil { diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 1e2b199ae..e93b36083 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -11,6 +11,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" + "github.com/sipeed/picoclaw/pkg/providers/azure" ) // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. @@ -94,10 +95,29 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, ), modelID, nil + case "azure", "azure-openai": + // Azure OpenAI uses deployment-based URLs, api-key header auth, + // and always sends max_completion_tokens. + if cfg.APIKey == "" { + return nil, "", fmt.Errorf("api_key is required for azure protocol") + } + if cfg.APIBase == "" { + return nil, "", fmt.Errorf( + "api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)", + ) + } + return azure.NewProviderWithTimeout( + cfg.APIKey, + cfg.APIBase, + cfg.Proxy, + cfg.RequestTimeout, + ), modelID, nil + case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian", - "minimax", "longcat", "modelscope", "novita": + "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", + "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "coding-plan", "alibaba-coding", "qwen-coding": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -154,6 +174,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, ), modelID, nil + case "coding-plan-anthropic", "alibaba-coding-anthropic": + // Alibaba Coding Plan with Anthropic-compatible API + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + if cfg.APIKey == "" { + return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) + } + return anthropicmessages.NewProviderWithTimeout( + cfg.APIKey, + apiBase, + cfg.RequestTimeout, + ), modelID, nil + case "antigravity": return NewAntigravityProvider(), modelID, nil @@ -313,6 +348,14 @@ func getDefaultAPIBase(protocol string) string { return "https://ark.cn-beijing.volces.com/api/v3" case "qwen": return "https://dashscope.aliyuncs.com/compatible-mode/v1" + case "qwen-intl", "qwen-international", "dashscope-intl": + return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + case "qwen-us", "dashscope-us": + return "https://dashscope-us.aliyuncs.com/compatible-mode/v1" + case "coding-plan", "alibaba-coding", "qwen-coding": + return "https://coding-intl.dashscope.aliyuncs.com/v1" + case "coding-plan-anthropic", "alibaba-coding-anthropic": + return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" case "vllm": return "http://localhost:8000/v1" case "mistral": diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 50d30e96a..8b9ddeecd 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -64,6 +64,12 @@ func TestExtractProtocol(t *testing.T) { wantProtocol: "nvidia", wantModelID: "meta/llama-3.1-8b", }, + { + name: "azure with prefix", + model: "azure/my-gpt5-deployment", + wantProtocol: "azure", + wantModelID: "my-gpt5-deployment", + }, } for _, tt := range tests { @@ -400,3 +406,200 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) { t.Fatalf("Chat() error = %q, want timeout-related error", errMsg) } } + +func TestCreateProviderFromConfig_Azure(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "azure-gpt5", + Model: "azure/my-gpt5-deployment", + APIKey: "test-azure-key", + APIBase: "https://my-resource.openai.azure.com", + } + + 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-gpt5-deployment" { + t.Errorf("modelID = %q, want %q", modelID, "my-gpt5-deployment") + } +} + +func TestCreateProviderFromConfig_AzureOpenAIAlias(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "azure-gpt4", + Model: "azure-openai/my-deployment", + APIKey: "test-azure-key", + APIBase: "https://my-resource.openai.azure.com", + } + + 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-deployment" { + t.Errorf("modelID = %q, want %q", modelID, "my-deployment") + } +} + +func TestCreateProviderFromConfig_AzureMissingAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "azure-gpt5", + Model: "azure/my-gpt5-deployment", + APIBase: "https://my-resource.openai.azure.com", + } + + _, _, err := CreateProviderFromConfig(cfg) + if err == nil { + t.Fatal("CreateProviderFromConfig() expected error for missing API key") + } +} + +func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "azure-gpt5", + Model: "azure/my-gpt5-deployment", + APIKey: "test-azure-key", + } + + _, _, err := CreateProviderFromConfig(cfg) + if err == nil { + t.Fatal("CreateProviderFromConfig() expected error for missing API base") + } +} + +func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"qwen-international", "qwen-international"}, + {"dashscope-intl", "dashscope-intl"}, + {"qwen-intl", "qwen-intl"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/qwen-max", + 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 != "qwen-max" { + t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + +func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"qwen-us", "qwen-us"}, + {"dashscope-us", "dashscope-us"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/qwen-max", + 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 != "qwen-max" { + t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + +func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"coding-plan-anthropic", "coding-plan-anthropic"}, + {"alibaba-coding-anthropic", "alibaba-coding-anthropic"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/claude-sonnet-4-20250514", + 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-20250514" { + t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514") + } + // coding-plan-anthropic uses Anthropic Messages provider + // Verify it's the anthropic messages provider by checking interface + var _ LLMProvider = provider + }) + } +} + +func TestGetDefaultAPIBase_CodingPlanAnthropic(t *testing.T) { + expectedURL := "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + if got := getDefaultAPIBase("coding-plan-anthropic"); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "coding-plan-anthropic", got, expectedURL) + } + if got := getDefaultAPIBase("alibaba-coding-anthropic"); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "alibaba-coding-anthropic", got, expectedURL) + } +} + +func TestGetDefaultAPIBase_QwenIntlAliases(t *testing.T) { + expectedURL := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + for _, protocol := range []string{"qwen-intl", "qwen-international", "dashscope-intl"} { + if got := getDefaultAPIBase(protocol); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) + } + } +} + +func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { + expectedURL := "https://dashscope-us.aliyuncs.com/compatible-mode/v1" + for _, protocol := range []string{"qwen-us", "dashscope-us"} { + if got := getDefaultAPIBase(protocol); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) + } + } +}