mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18:37 +00:00
feat(providers/dashscope): add Qwen 3.5 series support with per-model thinking capability (#215)
* feat(providers/dashscope): guard enable_thinking injection by per-model capability check Introduces ModelThinkingCapable interface and ModelSupportsThinking field on ChatRequest so DashScope can skip thinking-param injection for models that do not support it (e.g. qwen3-plus, qwen3-turbo), preventing \"model not supported\" API errors. - types.go: add ModelThinkingCapable interface + ModelSupportsThinking *bool on ChatRequest - dashscope.go: add dashscopeThinkingModels whitelist + ModelSupportsThinking(); honour pre-computed hint - loop.go: detect ModelThinkingCapable and set hint on ChatRequest before LLM call - provider_models.go: add qwen3.5-plus / qwen3.5-turbo to DashScope model list - dashscope_test.go: full test suite for whitelist, injection, hint override, budget mapping * Fix review code. --------- Co-authored-by: Nguyen Gia Hoang Vinh <vinhngh@runsystem.net>
This commit is contained in:
1 parent
a1495fc317
commit
a44dbf2ba4
5 files changed
+262
-7
No files matched your search
@@ -479,6 +479,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
if l.thinkingLevel != "" && l.thinkingLevel != "off" {
|
||||
if tc, ok := l.provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
|
||||
chatReq.Options[providers.OptThinkingLevel] = l.thinkingLevel
|
||||
// For providers with per-model thinking capability (e.g. DashScope),
|
||||
// pre-compute whether this specific model supports thinking and pass it
|
||||
// along so the provider doesn't need to re-derive it.
|
||||
if mc, ok := l.provider.(providers.ModelThinkingCapable); ok {
|
||||
supports := mc.ModelSupportsThinking(l.model)
|
||||
chatReq.ModelSupportsThinking = &supports
|
||||
}
|
||||
} else {
|
||||
slog.Debug("thinking_level ignored: provider does not support thinking",
|
||||
"provider", l.provider.Name(), "level", l.thinkingLevel)
|
||||
|
||||
@@ -215,7 +215,10 @@ func minimaxModels() []ModelInfo {
|
||||
// DashScope does not expose a standard /v1/models endpoint.
|
||||
func dashScopeModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
// Chat / text
|
||||
// Qwen3.5 series — Text Generation + Deep Thinking + Visual Understanding
|
||||
{ID: "qwen3.5-plus", Name: "Qwen 3.5 Plus"},
|
||||
{ID: "qwen3.5-turbo", Name: "Qwen 3.5 Turbo"},
|
||||
// Qwen3 hosted series — Text + Thinking
|
||||
{ID: "qwen3-max", Name: "Qwen 3 Max"},
|
||||
{ID: "qwen3-plus", Name: "Qwen 3 Plus"},
|
||||
{ID: "qwen3-turbo", Name: "Qwen 3 Turbo"},
|
||||
|
||||
@@ -11,6 +11,23 @@ const (
|
||||
dashscopeDefaultModel = "qwen3-max"
|
||||
)
|
||||
|
||||
// dashscopeThinkingModels lists DashScope models that accept the
|
||||
// enable_thinking / thinking_budget parameters (Qwen3 open-weight and Qwen3.5 series).
|
||||
// Models NOT in this set (e.g. qwen3-plus, qwen3-turbo) will silently
|
||||
// skip thinking injection to avoid API "model not supported" errors.
|
||||
var dashscopeThinkingModels = map[string]bool{
|
||||
// Qwen3.5 series — thinking + vision
|
||||
"qwen3.5-plus": true,
|
||||
"qwen3.5-turbo": true,
|
||||
// Qwen3 hosted
|
||||
"qwen3-max": true,
|
||||
// Qwen3 open-weight (available as hosted inference)
|
||||
"qwen3-235b-a22b": true,
|
||||
"qwen3-32b": true,
|
||||
"qwen3-14b": true,
|
||||
"qwen3-8b": true,
|
||||
}
|
||||
|
||||
// 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().
|
||||
@@ -33,12 +50,32 @@ func NewDashScopeProvider(apiKey, apiBase, defaultModel string) *DashScopeProvid
|
||||
func (p *DashScopeProvider) Name() string { return "dashscope" }
|
||||
func (p *DashScopeProvider) SupportsThinking() bool { return true }
|
||||
|
||||
// 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) {
|
||||
// Map thinking_level to DashScope-specific params before passing to OpenAI base
|
||||
if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" {
|
||||
// ModelSupportsThinking implements ModelThinkingCapable.
|
||||
// Returns true only for models that accept enable_thinking / thinking_budget.
|
||||
func (p *DashScopeProvider) ModelSupportsThinking(model string) bool {
|
||||
return dashscopeThinkingModels[p.resolveModel(model)]
|
||||
}
|
||||
|
||||
// applyThinkingGuard maps thinking_level to DashScope-specific params
|
||||
// (enable_thinking / thinking_budget) only when the model supports it.
|
||||
// Returns the (possibly mutated) request. Shared by Chat and ChatStream.
|
||||
func (p *DashScopeProvider) applyThinkingGuard(req ChatRequest) ChatRequest {
|
||||
level, ok := req.Options[OptThinkingLevel].(string)
|
||||
if !ok || level == "" || level == "off" {
|
||||
return req
|
||||
}
|
||||
|
||||
// Determine if this model supports thinking:
|
||||
// 1. Prefer the pre-computed hint from the agent loop (ModelSupportsThinking field).
|
||||
// 2. Fall back to local map lookup.
|
||||
var supportsThinking bool
|
||||
if req.ModelSupportsThinking != nil {
|
||||
supportsThinking = *req.ModelSupportsThinking
|
||||
} else {
|
||||
supportsThinking = p.ModelSupportsThinking(req.Model)
|
||||
}
|
||||
|
||||
if supportsThinking {
|
||||
// Clone Options to avoid mutating caller's map
|
||||
opts := make(map[string]any, len(req.Options)+2)
|
||||
maps.Copy(opts, req.Options)
|
||||
@@ -46,8 +83,25 @@ func (p *DashScopeProvider) ChatStream(ctx context.Context, req ChatRequest, onC
|
||||
opts[OptThinkingBudget] = dashscopeThinkingBudget(level)
|
||||
delete(opts, OptThinkingLevel) // don't pass generic key to OpenAI buildRequestBody
|
||||
req.Options = opts
|
||||
} else {
|
||||
slog.Debug("dashscope: model does not support thinking, skipping enable_thinking",
|
||||
"model", p.resolveModel(req.Model), "requested_level", level)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// Chat overrides OpenAIProvider.Chat to apply the per-model thinking guard.
|
||||
func (p *DashScopeProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
|
||||
return p.OpenAIProvider.Chat(ctx, p.applyThinkingGuard(req))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
req = p.applyThinkingGuard(req)
|
||||
|
||||
if len(req.Tools) > 0 {
|
||||
slog.Debug("dashscope: tools present, falling back to non-streaming Chat")
|
||||
resp, err := p.Chat(ctx, req)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newDashScopeTestServer sets up a mock SSE server for ChatStream calls and
|
||||
// returns both the server and a pointer that will hold the last captured request body.
|
||||
func newDashScopeTestServer(t *testing.T) (*httptest.Server, *map[string]any) {
|
||||
t.Helper()
|
||||
captured := &map[string]any{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(captured); err != nil {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
}
|
||||
// stream=false path (Chat): return JSON
|
||||
if v, _ := (*captured)["stream"].(bool); !v {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
|
||||
return
|
||||
}
|
||||
// stream=true path (ChatStream): return SSE
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n")
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server, captured
|
||||
}
|
||||
|
||||
// callDashScopeStream sends req through ChatStream and returns the captured body.
|
||||
func callDashScopeStream(t *testing.T, req ChatRequest) map[string]any {
|
||||
t.Helper()
|
||||
server, captured := newDashScopeTestServer(t)
|
||||
p := NewDashScopeProvider("test-key", server.URL, "")
|
||||
p.retryConfig.Attempts = 1
|
||||
p.ChatStream(context.Background(), req, nil) //nolint:errcheck
|
||||
return *captured
|
||||
}
|
||||
|
||||
// TestDashScopeModelSupportsThinking verifies the whitelist is correct.
|
||||
func TestDashScopeModelSupportsThinking(t *testing.T) {
|
||||
p := NewDashScopeProvider("key", "", "")
|
||||
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"qwen3.5-plus", true},
|
||||
{"qwen3.5-turbo", true},
|
||||
{"qwen3-max", true},
|
||||
{"qwen3-235b-a22b", true},
|
||||
{"qwen3-32b", true},
|
||||
{"qwen3-14b", true},
|
||||
{"qwen3-8b", true},
|
||||
// Models that do NOT support thinking:
|
||||
{"qwen3-plus", false},
|
||||
{"qwen3-turbo", false},
|
||||
{"qwen2-72b-instruct", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := p.ModelSupportsThinking(tt.model)
|
||||
if got != tt.want {
|
||||
t.Errorf("ModelSupportsThinking(%q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeThinkingInjected_WhenModelSupports verifies enable_thinking IS sent
|
||||
// for a model on the whitelist (qwen3.5-plus) when thinking_level is set.
|
||||
// ChatStream is used because that's the path where thinking params are injected.
|
||||
func TestDashScopeThinkingInjected_WhenModelSupports(t *testing.T) {
|
||||
body := callDashScopeStream(t, ChatRequest{
|
||||
Model: "qwen3.5-plus",
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
Options: map[string]any{OptThinkingLevel: "medium"},
|
||||
})
|
||||
|
||||
if body["enable_thinking"] != true {
|
||||
t.Errorf("enable_thinking = %v, want true for qwen3.5-plus with thinking_level=medium", body["enable_thinking"])
|
||||
}
|
||||
budget, _ := body["thinking_budget"].(float64)
|
||||
if budget <= 0 {
|
||||
t.Errorf("thinking_budget = %v, want > 0", body["thinking_budget"])
|
||||
}
|
||||
if _, hasThinkingLevel := body["thinking_level"]; hasThinkingLevel {
|
||||
t.Error("thinking_level should be removed from body (converted to enable_thinking)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeThinkingNotInjected_WhenModelDoesNotSupport verifies enable_thinking
|
||||
// is NOT sent for qwen3-plus (not on whitelist), even when thinking_level is set.
|
||||
// This is the root cause fix: previously this caused "model 'qwen3-plus' is not supported".
|
||||
func TestDashScopeThinkingNotInjected_WhenModelDoesNotSupport(t *testing.T) {
|
||||
body := callDashScopeStream(t, ChatRequest{
|
||||
Model: "qwen3-plus",
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
Options: map[string]any{OptThinkingLevel: "medium"},
|
||||
})
|
||||
|
||||
if _, has := body["enable_thinking"]; has {
|
||||
t.Errorf("enable_thinking should NOT be sent for qwen3-plus, got: %v", body["enable_thinking"])
|
||||
}
|
||||
if _, has := body["thinking_budget"]; has {
|
||||
t.Errorf("thinking_budget should NOT be sent for qwen3-plus, got: %v", body["thinking_budget"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeThinkingNotInjected_ViaHint verifies that the ModelSupportsThinking=false
|
||||
// hint suppresses injection even for a whitelisted model.
|
||||
func TestDashScopeThinkingNotInjected_ViaHint(t *testing.T) {
|
||||
falseVal := false
|
||||
body := callDashScopeStream(t, ChatRequest{
|
||||
Model: "qwen3.5-plus", // on whitelist, but hint overrides
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
Options: map[string]any{OptThinkingLevel: "high"},
|
||||
ModelSupportsThinking: &falseVal,
|
||||
})
|
||||
|
||||
if _, has := body["enable_thinking"]; has {
|
||||
t.Error("enable_thinking should NOT be sent when ModelSupportsThinking=false hint is set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeThinkingInjected_ViaHint verifies that a true hint on a
|
||||
// non-whitelisted model still triggers injection (explicit caller override).
|
||||
func TestDashScopeThinkingInjected_ViaHint(t *testing.T) {
|
||||
trueVal := true
|
||||
body := callDashScopeStream(t, ChatRequest{
|
||||
Model: "qwen3-plus", // not on whitelist, but hint explicitly allows
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
Options: map[string]any{OptThinkingLevel: "low"},
|
||||
ModelSupportsThinking: &trueVal,
|
||||
})
|
||||
|
||||
if body["enable_thinking"] != true {
|
||||
t.Errorf("enable_thinking = %v, want true when ModelSupportsThinking=true hint is set", body["enable_thinking"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeNoThinkingLevel verifies that without thinking_level,
|
||||
// enable_thinking is never injected regardless of model.
|
||||
func TestDashScopeNoThinkingLevel(t *testing.T) {
|
||||
body := callDashScopeStream(t, ChatRequest{
|
||||
Model: "qwen3-max",
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
Options: map[string]any{OptMaxTokens: 1},
|
||||
})
|
||||
|
||||
if _, has := body["enable_thinking"]; has {
|
||||
t.Error("enable_thinking should NOT be sent without thinking_level")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashScopeThinkingBudgetValues verifies thinking_budget level mapping.
|
||||
func TestDashScopeThinkingBudgetValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
level string
|
||||
want int
|
||||
}{
|
||||
{"low", 4096},
|
||||
{"medium", 16384},
|
||||
{"high", 32768},
|
||||
{"unknown", 16384}, // default
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := dashscopeThinkingBudget(tt.level)
|
||||
if got != tt.want {
|
||||
t.Errorf("dashscopeThinkingBudget(%q) = %d, want %d", tt.level, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,25 @@ type ThinkingCapable interface {
|
||||
SupportsThinking() bool
|
||||
}
|
||||
|
||||
// ModelThinkingCapable is optionally implemented by providers where thinking
|
||||
// support varies per model (e.g. DashScope: qwen3-max supports it, qwen3-plus does not).
|
||||
// This allows the agent loop to pass per-model capability hints to the provider.
|
||||
type ModelThinkingCapable interface {
|
||||
ModelSupportsThinking(model string) bool
|
||||
}
|
||||
|
||||
// ChatRequest contains the input for a Chat/ChatStream call.
|
||||
type ChatRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []ToolDefinition `json:"tools,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
|
||||
// ModelSupportsThinking is set by the agent loop when the provider implements
|
||||
// ModelThinkingCapable. Providers use this to decide whether to inject
|
||||
// enable_thinking / thinking_budget parameters for the requested model.
|
||||
// nil = unknown (assume capable, for backward compatibility).
|
||||
ModelSupportsThinking *bool `json:"-"`
|
||||
}
|
||||
|
||||
// ChatResponse is the result from an LLM call.
|
||||
|
||||
Reference in new issue
Block a user