feat(providers): add DashScope and Bailian Coding providers with reasoning_content support

- Add DashScope (Qwen) native provider with tools+streaming fallback
- Add Bailian Coding provider with hardcoded model list (no /v1/models API)
- Parse reasoning_content in OpenAI-compat streaming/non-streaming responses
- Emit ChatEventThinking events in agent loop for thinking models
- Add vision support for DashScope (qwen3-vl)
- Fix provider form dialog not updating API base URL when switching types
- Update README provider count from 11+ to 13+

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-02-28 14:56:14 +07:00
1 parent 2041b7037b
commit d913f29c69
15 files changed
+157 -13

No files matched your search

+7 -5
View File
@@ -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<br/>think → act → observe"]
LOOP --> TOOLS["Tool Registry<br/>fs · exec · web · memory · delegate · team · mcp · custom"]
LOOP --> LLM["LLM Providers<br/>Anthropic (native + prompt caching) · OpenAI-compat (10+)"]
LOOP --> LLM["LLM Providers<br/>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 |
</details>
+22
View File
@@ -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 {
+8
View File
@@ -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,
+5 -1
View File
@@ -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.
+2
View File
@@ -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)
+6
View File
@@ -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)
+19
View File
@@ -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
}
+8
View File
@@ -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 {
+56
View File
@@ -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)
}
+7
View File
@@ -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 {
+7 -5
View File
@@ -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 {
+1
View File
@@ -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"`
+4
View File
@@ -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.
+2 -1
View File
@@ -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.
@@ -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 || "");
}
}}
>