Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions devshard/cmd/devshardctl/paramvalidators/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,19 @@ func (h ForceLiteralParameter) HandleParameter(ctx ParameterContext) error {
return nil
}

// DefaultLiteralParameter writes Value only when absent, so a route default never overrules the caller.
type DefaultLiteralParameter struct {
Value any
}

func (h DefaultLiteralParameter) HandleParameter(ctx ParameterContext) error {
if _, exists := ctx.Document[ctx.Parameter]; exists {
return nil
}
ctx.Document[ctx.Parameter] = h.Value
return nil
}

// CapUintParameter caps a uint64-shaped field to Max.
type CapUintParameter struct {
Min uint64
Expand Down
2 changes: 2 additions & 0 deletions devshard/cmd/devshardctl/paramvalidators/reasoning.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ func (v ReasoningValidator) Validate(vctx ValidatorContext) error {
if !ok {
return nil
}
// Record the refusal: on a route that defaults the effort, absent reads as unspecified.
if enabled, ok := inner["enabled"].(bool); ok && !enabled {
vctx.Document["reasoning_effort"] = "none"
return nil
}
effort, hasEffort := inner["effort"]
Expand Down
4 changes: 1 addition & 3 deletions devshard/cmd/devshardctl/paramvalidators/reasoning_effort.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ var (
ErrReasoningEffortValue = errors.New("reasoning_effort: unsupported value")
)

// Re-check the strip wiring in the catalog whenever a reasoning-capable model is
// added to devshard: today every routed model is non-reasoning so the catalog
// strips reasoning_effort for all of them via ModelScopedParameterHandler{Models: nil}.
type ReasoningEffortValidator struct{}

var allowedReasoningEffortValues = map[string]struct{}{
Expand All @@ -22,6 +19,7 @@ var allowedReasoningEffortValues = map[string]struct{}{
"medium": {},
"high": {},
"xhigh": {},
"max": {}, // DeepSeek-V4 only
}

func (v ReasoningEffortValidator) Validate(vctx ValidatorContext) error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

func TestReasoningEffortValidatorAccepts(t *testing.T) {
v := ReasoningEffortValidator{}
for _, value := range []string{"none", "minimal", "low", "medium", "high", "xhigh"} {
for _, value := range []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"} {
t.Run(value, func(t *testing.T) {
doc := parseDocument(t, `{"reasoning_effort":"`+value+`"}`)
require.NoError(t, v.Validate(ValidatorContext{Document: doc}))
Expand All @@ -34,7 +34,7 @@ func TestReasoningEffortValidatorRejects(t *testing.T) {
{name: "not a string (bool)", body: `{"reasoning_effort":true}`, wantErr: ErrReasoningEffortShape},
{name: "not a string (null)", body: `{"reasoning_effort":null}`, wantErr: ErrReasoningEffortShape},
{name: "not a string (object)", body: `{"reasoning_effort":{"effort":"high"}}`, wantErr: ErrReasoningEffortShape},
{name: "unknown enum value", body: `{"reasoning_effort":"max"}`, wantErr: ErrReasoningEffortValue},
{name: "unknown enum value", body: `{"reasoning_effort":"maximum"}`, wantErr: ErrReasoningEffortValue},
{name: "wrong case", body: `{"reasoning_effort":"High"}`, wantErr: ErrReasoningEffortValue},
{name: "empty string", body: `{"reasoning_effort":""}`, wantErr: ErrReasoningEffortValue},
}
Expand Down
6 changes: 4 additions & 2 deletions devshard/cmd/devshardctl/paramvalidators/reasoning_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,16 @@ func TestReasoningValidatorDropsNonEffortKeys(t *testing.T) {
require.Equal(t, "medium", doc["reasoning_effort"])
}

func TestReasoningValidatorEnabledFalseDropsEverything(t *testing.T) {
// Dropping the wrapper without a trace would leave the request indistinguishable from one that never
// mentioned reasoning, which a route defaulting the effort reads as permission to fill it in.
func TestReasoningValidatorEnabledFalseRecordsTheRefusal(t *testing.T) {
v := ReasoningValidator{}
doc := parseDocument(t, `{"reasoning":{"enabled":false,"effort":"high"}}`)

require.NoError(t, v.Validate(ValidatorContext{Document: doc}))

require.NotContains(t, doc, "reasoning")
require.NotContains(t, doc, "reasoning_effort", "enabled:false must override any effort")
require.Equal(t, "none", doc["reasoning_effort"], "enabled:false must override any effort")
}

func TestReasoningValidatorEnabledTrueLiftsEffort(t *testing.T) {
Expand Down
8 changes: 6 additions & 2 deletions devshard/cmd/devshardctl/request_filters_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,13 @@ const (
// Routed model identifiers. The parameter catalog and the message processor
// both dispatch on these strings.
const (
kimiK26ModelID = "moonshotai/Kimi-K2.6"
miniMaxM27ModelID = "MiniMaxAI/MiniMax-M2.7"
kimiK26ModelID = "moonshotai/Kimi-K2.6"
miniMaxM27ModelID = "MiniMaxAI/MiniMax-M2.7"
deepSeekV4Flash0731ModelID = "deepseek-ai/DeepSeek-V4-Flash-0731"
)

// An omitted reasoning_effort renders as "high"; "max" is the strongest prefix the encoder defines.
const deepSeekDefaultReasoningEffort = "max"

// Sentinel content used by message normalization when an upstream tool result is empty.
const emptyToolResultContent = "<empty tool result>"
6 changes: 2 additions & 4 deletions devshard/cmd/devshardctl/request_filters_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,15 +457,13 @@ func defaultVLLMParameterCatalog() VLLMParameterCatalog {
withRule(RequestFilterStagePreValidation, DocumentValidatorHandler{
Validator: paramvalidators.ReasoningValidator{},
}),
// reasoning_effort: enum-validate then strip. Models: nil keeps the strip
// universal until a reasoning-capable model is routed. List models in Models
// to start forwarding.
newParameter("reasoning_effort").
withRule(RequestFilterStagePreValidation, DocumentValidatorHandler{
Validator: paramvalidators.ReasoningEffortValidator{},
}).
withRule(RequestFilterStagePreValidation, ModelScopedParameterHandler{
Models: nil,
Models: []string{deepSeekV4Flash0731ModelID},
Handler: ParameterHandlerAdapter{Handler: paramvalidators.DefaultLiteralParameter{Value: deepSeekDefaultReasoningEffort}},
UnmatchedHandler: ParameterHandlerAdapter{Handler: paramvalidators.StripParameter{}},
}),
// MiniMax-M2.7 has no chat_template knob for enable_thinking (vLLM #36778);
Expand Down
81 changes: 75 additions & 6 deletions devshard/cmd/devshardctl/request_filters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2599,26 +2599,95 @@ func TestNormalizeChatRequestExtraBodyEmptyObjectJustDrops(t *testing.T) {
require.NotContains(t, raw, "extra_body")
}

func TestNormalizeChatRequestStripsReasoningEffort(t *testing.T) {
// vLLM derives enable_thinking from this field, so forwarding it to a route that only
// declares that variable would flip thinking while the effort level itself goes nowhere.
func TestNormalizeChatRequestStripsReasoningEffortOffTheReasoningRoute(t *testing.T) {
cases := []struct{ name, body string }{
{name: "high", body: `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`},
{name: "none", body: `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`},
{name: "xhigh", body: `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`},
{name: "max", body: `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"max"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, _, err := normalizeChatRequest([]byte(tc.body))
for _, model := range []string{kimiK26ModelID, miniMaxM27ModelID, ""} {
t.Run(tc.name+"/"+model, func(t *testing.T) {
out, _, err := normalizeChatRequestForModel([]byte(tc.body), model)
require.NoError(t, err)
var raw map[string]any
require.NoError(t, json.Unmarshal(out, &raw))
require.NotContains(t, raw, "reasoning_effort")
})
}
}
}

// DeepSeek-V4 is the one route whose renderer reads the value, and vLLM merges it into
// chat_template_kwargs itself, so the top-level field has to survive unmirrored.
func TestNormalizeChatRequestForwardsReasoningEffortToDeepSeek(t *testing.T) {
for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"} {
t.Run(effort, func(t *testing.T) {
body := `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"` + effort + `"}`
out, _, err := normalizeChatRequestForModel([]byte(body), deepSeekV4Flash0731ModelID)
require.NoError(t, err)
var raw map[string]any
require.NoError(t, json.Unmarshal(out, &raw))
require.Equal(t, effort, raw["reasoning_effort"])
require.NotContains(t, raw, "chat_template_kwargs", "vLLM does the merge; mirroring here would double it")
})
}
}

// An omitted field renders as "high", which is the level reported to degrade into repetition over
// long tool-calling sessions, so the route defaults to the strongest prefix instead of the encoder's.
func TestNormalizeChatRequestDefaultsDeepSeekReasoningEffortToMax(t *testing.T) {
out, _, err := normalizeChatRequestForModel([]byte(`{"messages":[{"role":"user","content":"hi"}]}`), deepSeekV4Flash0731ModelID)
require.NoError(t, err)
var raw map[string]any
require.NoError(t, json.Unmarshal(out, &raw))
require.Equal(t, "max", raw["reasoning_effort"])
}

// The default fills a gap; it must never overrule a level the caller picked, including a weaker one.
func TestNormalizeChatRequestKeepsAnExplicitDeepSeekReasoningEffort(t *testing.T) {
for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh"} {
t.Run(effort, func(t *testing.T) {
body := `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"` + effort + `"}`
out, _, err := normalizeChatRequestForModel([]byte(body), deepSeekV4Flash0731ModelID)
require.NoError(t, err)
var raw map[string]any
require.NoError(t, json.Unmarshal(out, &raw))
require.Equal(t, effort, raw["reasoning_effort"])
})
}
}

// reasoning.enabled=false drops the wrapper, so without recording the refusal as "none" the route
// default would read the gap as unspecified and switch thinking back on at maximum effort.
func TestNormalizeChatRequestReasoningDisabledSurvivesTheDeepSeekDefault(t *testing.T) {
body := `{"messages":[{"role":"user","content":"hi"}],"reasoning":{"enabled":false}}`
out, _, err := normalizeChatRequestForModel([]byte(body), deepSeekV4Flash0731ModelID)
require.NoError(t, err)
var raw map[string]any
require.NoError(t, json.Unmarshal(out, &raw))
require.Equal(t, "none", raw["reasoning_effort"])
}

// The default belongs to the one route that reads the field; elsewhere it would flip thinking.
func TestNormalizeChatRequestDoesNotDefaultReasoningEffortOffTheReasoningRoute(t *testing.T) {
for _, model := range []string{kimiK26ModelID, miniMaxM27ModelID, ""} {
t.Run(model, func(t *testing.T) {
out, _, err := normalizeChatRequestForModel([]byte(`{"messages":[{"role":"user","content":"hi"}]}`), model)
require.NoError(t, err)
var raw map[string]any
require.NoError(t, json.Unmarshal(out, &raw))
require.NotContains(t, raw, "reasoning_effort", "all routed models are non-reasoning today, field must be stripped")
require.NotContains(t, raw, "reasoning_effort")
})
}
}

func TestNormalizeChatRequestRejectsInvalidReasoningEffort(t *testing.T) {
cases := []struct{ name, body string }{
{name: "unknown enum", body: `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"max"}`},
{name: "unknown enum", body: `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"maximum"}`},
{name: "non-string", body: `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":5}`},
{name: "empty", body: `{"messages":[{"role":"user","content":"hi"}],"reasoning_effort":""}`},
}
Expand Down Expand Up @@ -2653,7 +2722,7 @@ func TestNormalizeChatRequestReasoningEnabledFalseOverridesEffort(t *testing.T)
}

func TestNormalizeChatRequestReasoningInvalidEffortRejected(t *testing.T) {
body := `{"messages":[{"role":"user","content":"hi"}],"reasoning":{"effort":"max"}}`
body := `{"messages":[{"role":"user","content":"hi"}],"reasoning":{"effort":"maximum"}}`
_, _, err := normalizeChatRequest([]byte(body))
require.Error(t, err, "invalid effort must surface from ReasoningEffortValidator after lift")
require.Contains(t, err.Error(), "reasoning_effort")
Expand Down
3 changes: 2 additions & 1 deletion docs/chat-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ OpenAI-compatible chat completions, routed to Kimi-K2.6 / Qwen3-235B / MiniMax-M
- [Per-model overrides: Kimi-K2.6](kimi-k2.6.md)
- [Per-model overrides: Qwen3-235B-A22B-Instruct-2507](qwen3-235b-a22b-instruct-2507.md)
- [Per-model overrides: MiniMax-M2.7](minimax-m2.7.md)
- [Per-model overrides: DeepSeek-V4-Flash-0731](deepseek-v4-flash-0731.md)
- [Why was my param stripped/rejected?](troubleshooting.md)
- [Client agents compatibility](agents.md)
- [Source citations](references.md)
Expand Down Expand Up @@ -62,7 +63,7 @@ OpenAI-compatible chat completions, routed to Kimi-K2.6 / Qwen3-235B / MiniMax-M
| `cache_key` | string | — | silent-strip ([why](troubleshooting.md#strip-cache_key)) | [[Moonshot-1]](references.md#moonshot) |
| `extra_headers` | object | — | silent-strip ([why](troubleshooting.md#strip-extra_headers)) | [[OpenAI-5]](references.md#openai) |
| `extra_body` | object | — | unwrap to top-level ([why](troubleshooting.md#unwrap-extra_body)) | [[OpenAI-5]](references.md#openai) |
| `reasoning_effort` | enum string | | validated then stripped ([why](troubleshooting.md#strip-reasoning_effort)) | [[vLLM-1]](references.md#vllm), [[OpenAI-4]](references.md#openai) |
| `reasoning_effort` | enum string | `max` on DeepSeek-V4 only | validated everywhere; on [DeepSeek-V4-Flash-0731](deepseek-v4-flash-0731.md) an explicit value is forwarded and an omitted one defaults to `max`, stripped on every other route ([why](troubleshooting.md#strip-reasoning_effort)) | [[vLLM-1]](references.md#vllm), [[OpenAI-4]](references.md#openai) |
| `reasoning` | object | — | translate `effort` → `reasoning_effort` ([why](troubleshooting.md#translate-reasoning)) | [[OpenRouter-4]](references.md#openrouter) |
| `enable_thinking` | bool | — | translate to chat_template_kwargs ([why](troubleshooting.md#translate-enable_thinking)) | [[Qwen-3]](references.md#qwen) |
| `thinking_config` | object | — | silent-strip ([why](troubleshooting.md#strip-thinking_config)) | — |
Expand Down
Loading
Loading