diff --git a/README.md b/README.md index f2bd53c9..2a47b94e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Go](https://img.shields.io/badge/Go_1.25-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL_18-316192?style=flat-square&logo=postgresql&logoColor=white)](https://www.postgresql.org/) [![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white)](https://www.docker.com/) [![WebSocket](https://img.shields.io/badge/WebSocket-010101?style=flat-square&logo=socket.io&logoColor=white)](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) [![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-000000?style=flat-square&logo=opentelemetry&logoColor=white)](https://opentelemetry.io/) [![Anthropic](https://img.shields.io/badge/Anthropic-191919?style=flat-square&logo=anthropic&logoColor=white)](https://www.anthropic.com/) [![OpenAI](https://img.shields.io/badge/OpenAI_Compatible-412991?style=flat-square&logo=openai&logoColor=white)](https://openai.com/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE) -**GoClaw** is a multi-agent AI gateway that connects LLMs to your tools, channels, and data — deployed as a single Go binary with zero runtime dependencies. It orchestrates agent teams, inter-agent delegation, and quality-gated workflows across 11+ LLM providers with full multi-tenant isolation. +**GoClaw** is a multi-agent AI gateway that connects LLMs to your tools, channels, and data — deployed as a single Go binary with zero runtime dependencies. It orchestrates agent teams, inter-agent delegation, and quality-gated workflows across 13+ LLM providers with full multi-tenant isolation. A Go port of [OpenClaw](https://github.com/openclaw/openclaw) with enhanced security, multi-tenant PostgreSQL, and production-grade observability. @@ -16,7 +16,7 @@ A Go port of [OpenClaw](https://github.com/openclaw/openclaw) with enhanced secu - **Multi-Tenant PostgreSQL** — Per-user workspaces, per-user context files, encrypted API keys (AES-256-GCM), isolated sessions — the only Claw project with DB-native multi-tenancy - **Single Binary** — ~25 MB static Go binary, no Node.js runtime, <1s startup, runs on a $5 VPS - **Production Security** — 5-layer defense: rate limiting, prompt injection detection, SSRF protection, shell deny patterns, AES-256-GCM encryption -- **11+ LLM Providers** — Anthropic (native HTTP+SSE with prompt caching), OpenAI, OpenRouter, Groq, DeepSeek, Gemini, Mistral, xAI, MiniMax, Cohere, Perplexity +- **13+ LLM Providers** — Anthropic (native HTTP+SSE with prompt caching), OpenAI, OpenRouter, Groq, DeepSeek, Gemini, Mistral, xAI, MiniMax, Cohere, Perplexity, DashScope (Qwen), Bailian Coding - **5 Messaging Channels** — Telegram, Discord, Zalo, Feishu/Lark, WhatsApp with `/stop` and `/stopall` commands ## Claw Ecosystem @@ -51,7 +51,7 @@ A Go port of [OpenClaw](https://github.com/openclaw/openclaw) with enhanced secu | Messaging channels | 37+ | 15+ | 10+ | 5+ | | Companion apps | macOS, iOS, Android | Python SDK | — | Web dashboard | | Live Canvas / Voice | ✅ (A2UI + TTS/STT) | — | Voice transcription | TTS (4 providers) | -| LLM providers | 10+ | 8 native + 29 compat | 13+ | **11+** | +| LLM providers | 10+ | 8 native + 29 compat | 13+ | **13+** | | Per-user workspaces | ✅ (file-based) | — | — | ✅ (managed mode only) | | Encrypted secrets | — (env vars only) | ✅ ChaCha20-Poly1305 | — (plaintext JSON) | ✅ AES-256-GCM in DB | @@ -78,7 +78,7 @@ graph TB SCHED --> ROUTER["Agent Router"] ROUTER --> LOOP["Agent Loop
think → act → observe"] LOOP --> TOOLS["Tool Registry
fs · exec · web · memory · delegate · team · mcp · custom"] - LOOP --> LLM["LLM Providers
Anthropic (native + prompt caching) · OpenAI-compat (10+)"] + LOOP --> LLM["LLM Providers
Anthropic (native + prompt caching) · OpenAI-compat (12+)"] end subgraph Storage @@ -294,7 +294,7 @@ Quality gates validate agent output before it reaches users. Configured in agent ## Features ### LLM Providers -- **11+ providers** — OpenRouter, Anthropic, OpenAI, Groq, DeepSeek, Gemini, Mistral, xAI, MiniMax, Cohere, Perplexity, and any OpenAI-compatible endpoint +- **13+ providers** — OpenRouter, Anthropic, OpenAI, Groq, DeepSeek, Gemini, Mistral, xAI, MiniMax, Cohere, Perplexity, DashScope (Qwen), Bailian Coding, and any OpenAI-compatible endpoint - **Anthropic native** — Direct HTTP+SSE integration with prompt caching (`cache_control`) for ~90% cost reduction on repeated prefixes - **OpenAI-compatible** — Automatic prompt caching for OpenAI, MiniMax, OpenRouter (cache metrics tracked in traces) @@ -557,6 +557,8 @@ When `GOCLAW_*_API_KEY` environment variables are set, the gateway automatically | `GOCLAW_MINIMAX_API_KEY` | MiniMax | | `GOCLAW_COHERE_API_KEY` | Cohere | | `GOCLAW_PERPLEXITY_API_KEY` | Perplexity | +| `GOCLAW_DASHSCOPE_API_KEY` | DashScope (Qwen) | +| `GOCLAW_BAILIAN_API_KEY` | Bailian Coding | diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 9e2a69a1..e0396d76 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -66,6 +66,20 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { registry.Register(providers.NewOpenAIProvider("perplexity", cfg.Providers.Perplexity.APIKey, "https://api.perplexity.ai", "sonar-pro")) slog.Info("registered provider", "name", "perplexity") } + + if cfg.Providers.DashScope.APIKey != "" { + registry.Register(providers.NewDashScopeProvider(cfg.Providers.DashScope.APIKey, cfg.Providers.DashScope.APIBase, "qwen3-max")) + slog.Info("registered provider", "name", "dashscope") + } + + if cfg.Providers.Bailian.APIKey != "" { + base := cfg.Providers.Bailian.APIBase + if base == "" { + base = "https://coding-intl.dashscope.aliyuncs.com/v1" + } + registry.Register(providers.NewOpenAIProvider("bailian", cfg.Providers.Bailian.APIKey, base, "qwen3.5-plus")) + slog.Info("registered provider", "name", "bailian") + } } // registerProvidersFromDB loads providers from Postgres and registers them. @@ -84,6 +98,14 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi if p.ProviderType == store.ProviderAnthropicNative { registry.Register(providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(p.APIBase))) + } else if p.ProviderType == store.ProviderDashScope { + registry.Register(providers.NewDashScopeProvider(p.APIKey, p.APIBase, "")) + } else if p.ProviderType == store.ProviderBailian { + base := p.APIBase + if base == "" { + base = "https://coding-intl.dashscope.aliyuncs.com/v1" + } + registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) } else { prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "") if p.ProviderType == store.ProviderMiniMax { diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 5d9e811d..6b574bb8 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -553,6 +553,14 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if req.Stream { resp, err = l.provider.ChatStream(ctx, chatReq, func(chunk providers.StreamChunk) { + if chunk.Thinking != "" { + l.emit(AgentEvent{ + Type: protocol.ChatEventThinking, + AgentID: l.id, + RunID: req.RunID, + Payload: map[string]string{"content": chunk.Thinking}, + }) + } if chunk.Content != "" { l.emit(AgentEvent{ Type: protocol.ChatEventChunk, diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 49a5a001..225e2124 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -110,6 +110,8 @@ type ProvidersConfig struct { MiniMax ProviderConfig `json:"minimax"` Cohere ProviderConfig `json:"cohere"` Perplexity ProviderConfig `json:"perplexity"` + DashScope ProviderConfig `json:"dashscope"` + Bailian ProviderConfig `json:"bailian"` } type ProviderConfig struct { @@ -130,7 +132,9 @@ func (c *Config) HasAnyProvider() bool { p.XAI.APIKey != "" || p.MiniMax.APIKey != "" || p.Cohere.APIKey != "" || - p.Perplexity.APIKey != "" + p.Perplexity.APIKey != "" || + p.DashScope.APIKey != "" || + p.Bailian.APIKey != "" } // GatewayConfig controls the gateway server. diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 09eaa8cc..b2a2d0e7 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -105,6 +105,8 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_MINIMAX_API_KEY", &c.Providers.MiniMax.APIKey) envStr("GOCLAW_COHERE_API_KEY", &c.Providers.Cohere.APIKey) envStr("GOCLAW_PERPLEXITY_API_KEY", &c.Providers.Perplexity.APIKey) + envStr("GOCLAW_DASHSCOPE_API_KEY", &c.Providers.DashScope.APIKey) + envStr("GOCLAW_BAILIAN_API_KEY", &c.Providers.Bailian.APIKey) envStr("GOCLAW_GATEWAY_TOKEN", &c.Gateway.Token) envStr("GOCLAW_TELEGRAM_TOKEN", &c.Channels.Telegram.Token) envStr("GOCLAW_DISCORD_TOKEN", &c.Channels.Discord.Token) diff --git a/internal/config/config_secrets.go b/internal/config/config_secrets.go index 6a8a4cb4..8dd74ae9 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -32,6 +32,8 @@ func (c *Config) MaskedCopy() *Config { maskNonEmpty(&cp.Providers.MiniMax.APIKey) maskNonEmpty(&cp.Providers.Cohere.APIKey) maskNonEmpty(&cp.Providers.Perplexity.APIKey) + maskNonEmpty(&cp.Providers.DashScope.APIKey) + maskNonEmpty(&cp.Providers.Bailian.APIKey) // Mask gateway token maskNonEmpty(&cp.Gateway.Token) @@ -77,6 +79,8 @@ func (c *Config) StripSecrets() { c.Providers.MiniMax.APIKey = "" c.Providers.Cohere.APIKey = "" c.Providers.Perplexity.APIKey = "" + c.Providers.DashScope.APIKey = "" + c.Providers.Bailian.APIKey = "" // Gateway token c.Gateway.Token = "" @@ -127,6 +131,8 @@ func (c *Config) StripMaskedSecrets() { stripIfMasked(&c.Providers.MiniMax.APIKey) stripIfMasked(&c.Providers.Cohere.APIKey) stripIfMasked(&c.Providers.Perplexity.APIKey) + stripIfMasked(&c.Providers.DashScope.APIKey) + stripIfMasked(&c.Providers.Bailian.APIKey) // Gateway token stripIfMasked(&c.Gateway.Token) diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index 2fc0a1d7..b5c69f9f 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -51,6 +51,8 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht models, err = fetchAnthropicModels(ctx, p.APIKey) case "gemini_native": models, err = fetchGeminiModels(ctx, p.APIKey) + case "bailian": + models = bailianModels() default: // All other types use OpenAI-compatible /models endpoint apiBase := strings.TrimRight(p.APIBase, "/") @@ -145,6 +147,22 @@ func fetchGeminiModels(ctx context.Context, apiKey string) ([]ModelInfo, error) return models, nil } +// bailianModels returns a hardcoded list of models available on the +// Bailian Coding platform (coding-intl.dashscope.aliyuncs.com). +// The platform does not expose a /v1/models endpoint. +func bailianModels() []ModelInfo { + return []ModelInfo{ + {ID: "qwen3.5-plus", Name: "Qwen 3.5 Plus"}, + {ID: "kimi-k2.5", Name: "Kimi K2.5"}, + {ID: "GLM-5", Name: "GLM-5"}, + {ID: "MiniMax-M2.5", Name: "MiniMax M2.5"}, + {ID: "qwen3-max-2026-01-23", Name: "Qwen 3 Max (2026-01-23)"}, + {ID: "qwen3-coder-next", Name: "Qwen 3 Coder Next"}, + {ID: "qwen3-coder-plus", Name: "Qwen 3 Coder Plus"}, + {ID: "glm-4.7", Name: "GLM 4.7"}, + } +} + // fetchOpenAIModels calls an OpenAI-compatible /models endpoint. func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil) @@ -180,3 +198,4 @@ func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo } return models, nil } + diff --git a/internal/http/providers.go b/internal/http/providers.go index f316640e..803e175a 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -67,6 +67,14 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { if p.ProviderType == store.ProviderAnthropicNative { h.providerReg.Register(providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(p.APIBase))) + } else if p.ProviderType == store.ProviderDashScope { + h.providerReg.Register(providers.NewDashScopeProvider(p.APIKey, p.APIBase, "")) + } else if p.ProviderType == store.ProviderBailian { + base := p.APIBase + if base == "" { + base = "https://coding-intl.dashscope.aliyuncs.com/v1" + } + h.providerReg.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) } else { prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "") if p.ProviderType == store.ProviderMiniMax { diff --git a/internal/providers/dashscope.go b/internal/providers/dashscope.go new file mode 100644 index 00000000..b08df9b0 --- /dev/null +++ b/internal/providers/dashscope.go @@ -0,0 +1,56 @@ +package providers + +import ( + "context" + "log/slog" +) + +const ( + dashscopeDefaultBase = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + dashscopeDefaultModel = "qwen3-max" +) + +// DashScopeProvider wraps OpenAIProvider to handle DashScope-specific behaviors. +// Critical: DashScope does NOT support tools + streaming simultaneously. +// When tools are present, ChatStream falls back to non-streaming Chat(). +type DashScopeProvider struct { + *OpenAIProvider +} + +func NewDashScopeProvider(apiKey, apiBase, defaultModel string) *DashScopeProvider { + if apiBase == "" { + apiBase = dashscopeDefaultBase + } + if defaultModel == "" { + defaultModel = dashscopeDefaultModel + } + return &DashScopeProvider{ + OpenAIProvider: NewOpenAIProvider("dashscope", apiKey, apiBase, defaultModel), + } +} + +func (p *DashScopeProvider) Name() string { return "dashscope" } + +// ChatStream handles DashScope's limitation: tools + streaming cannot coexist. +// When tools are present, falls back to non-streaming Chat() and synthesizes +// chunk callbacks for the caller. +func (p *DashScopeProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { + if len(req.Tools) > 0 { + slog.Debug("dashscope: tools present, falling back to non-streaming Chat") + resp, err := p.Chat(ctx, req) + if err != nil { + return nil, err + } + if onChunk != nil { + if resp.Thinking != "" { + onChunk(StreamChunk{Thinking: resp.Thinking}) + } + if resp.Content != "" { + onChunk(StreamChunk{Content: resp.Content}) + } + onChunk(StreamChunk{Done: true}) + } + return resp, nil + } + return p.OpenAIProvider.ChatStream(ctx, req, onChunk) +} diff --git a/internal/providers/openai.go b/internal/providers/openai.go index 22b8f8b6..d7805538 100644 --- a/internal/providers/openai.go +++ b/internal/providers/openai.go @@ -122,6 +122,12 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ChatRequest, onChun } delta := chunk.Choices[0].Delta + if delta.ReasoningContent != "" { + result.Thinking += delta.ReasoningContent + if onChunk != nil { + onChunk(StreamChunk{Thinking: delta.ReasoningContent}) + } + } if delta.Content != "" { result.Content += delta.Content if onChunk != nil { @@ -326,6 +332,7 @@ func (p *OpenAIProvider) parseResponse(resp *openAIResponse) *ChatResponse { if len(resp.Choices) > 0 { msg := resp.Choices[0].Message result.Content = msg.Content + result.Thinking = msg.ReasoningContent result.FinishReason = resp.Choices[0].FinishReason for _, tc := range msg.ToolCalls { diff --git a/internal/providers/openai_types.go b/internal/providers/openai_types.go index 0458b2bb..4de0aaec 100644 --- a/internal/providers/openai_types.go +++ b/internal/providers/openai_types.go @@ -13,9 +13,10 @@ type openAIChoice struct { } type openAIMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` } type openAIToolCall struct { @@ -54,8 +55,9 @@ type openAIStreamChoice struct { } type openAIStreamDelta struct { - Content string `json:"content,omitempty"` - ToolCalls []openAIStreamToolCall `json:"tool_calls,omitempty"` + Content string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []openAIStreamToolCall `json:"tool_calls,omitempty"` } type openAIStreamToolCall struct { diff --git a/internal/providers/types.go b/internal/providers/types.go index 4434be77..6d52dc91 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -30,6 +30,7 @@ type ChatRequest struct { // ChatResponse is the result from an LLM call. type ChatResponse struct { Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` FinishReason string `json:"finish_reason"` // "stop", "tool_calls", "length" Usage *Usage `json:"usage,omitempty"` diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index d2e44658..28fe9f0a 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -19,6 +19,8 @@ const ( ProviderMiniMax = "minimax_native" ProviderCohere = "cohere" ProviderPerplexity = "perplexity" + ProviderDashScope = "dashscope" + ProviderBailian = "bailian" ) // ValidProviderTypes lists all accepted provider_type values. @@ -34,6 +36,8 @@ var ValidProviderTypes = map[string]bool{ ProviderMiniMax: true, ProviderCohere: true, ProviderPerplexity: true, + ProviderDashScope: true, + ProviderBailian: true, } // LLMProviderData represents an LLM provider configuration. diff --git a/internal/tools/read_image.go b/internal/tools/read_image.go index c1d777c6..3126410f 100644 --- a/internal/tools/read_image.go +++ b/internal/tools/read_image.go @@ -27,12 +27,13 @@ func MediaImagesFromCtx(ctx context.Context) []providers.ImageContent { // --- ReadImageTool --- // visionProviderPriority is the order in which providers are tried for vision. -var visionProviderPriority = []string{"openrouter", "gemini", "anthropic"} +var visionProviderPriority = []string{"openrouter", "gemini", "anthropic", "dashscope"} // visionModelOverrides maps provider names to preferred vision models. // Providers not listed here use their default model. var visionModelOverrides = map[string]string{ "openrouter": "google/gemini-2.5-flash-image", + "dashscope": "qwen3-vl", } // ReadImageTool uses a vision-capable provider to describe images attached to the current message. diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx index 20e139ed..5439848b 100644 --- a/ui/web/src/pages/providers/provider-form-dialog.tsx +++ b/ui/web/src/pages/providers/provider-form-dialog.tsx @@ -40,6 +40,8 @@ const PROVIDER_TYPES = [ { value: "minimax_native", label: "MiniMax (Native)", apiBase: "https://api.minimax.io/v1", placeholder: "" }, { value: "cohere", label: "Cohere", apiBase: "https://api.cohere.ai/compatibility/v1", placeholder: "" }, { value: "perplexity", label: "Perplexity", apiBase: "https://api.perplexity.ai", placeholder: "" }, + { value: "dashscope", label: "DashScope (Qwen)", apiBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", placeholder: "" }, + { value: "bailian", label: "Bailian Coding", apiBase: "https://coding-intl.dashscope.aliyuncs.com/v1", placeholder: "" }, ]; export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: ProviderFormDialogProps) { @@ -139,7 +141,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P setProviderType(v); if (!isEdit) { const preset = PROVIDER_TYPES.find((t) => t.value === v); - if (preset?.apiBase) setApiBase(preset.apiBase); + setApiBase(preset?.apiBase || ""); } }} >