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
35 changes: 23 additions & 12 deletions internal/provider/responses/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func New(cfg Config) provider.Provider {
baseURL: strings.TrimRight(cfg.BaseURL, "/"), model: cfg.Model, effort: cfg.Effort,
vendor: vendor, caps: cap, mode: cfg.mode(), sessionCache: sessionCache, webSearch: cfg.WebSearch, maxOutputTokens: maxOutputTokens,
vision: vision,
http: httpClient, idleTimeout: defaultStreamIdleTimeout,
http: httpClient, idleTimeout: cap.streamIdleTimeout,
}
}

Expand Down Expand Up @@ -207,16 +207,20 @@ func (c *client) MissingToolCallReasoningWarningIdentity() string {
// without reasoning only for vendors whose endpoint reliably emits it.
// DeepSeek's official API emits tool-call reasoning for its pro-tier models,
// so a missing chain-of-thought there is a real degradation worth one warning.
// MiMo documents reasoning alongside tool calls but does not guarantee it on
// every round (observed: mimo-v2.5-pro tool-call turn with empty reasoning),
// so a missing chain-of-thought is endpoint-conditional, not a degradation
// signal — silence the warning. Capability-driven (review #7234):
// toolCallReasoning=false vendors (DashScope) never warn — no round-trip
// contract; singleSegmentReasoning=true vendors (MiMo) never warn — their
// tool-call thinking is a single optional segment. Only multi-segment
// thinking vendors that require replay (DeepSeek) warn, scoped to non-flash.
// MiMo preserves reasoning on replay but does not guarantee it every round
// (observed: mimo-v2.5-pro tool-call turn with empty reasoning), so a missing
// chain-of-thought is endpoint-conditional, not a degradation signal — silence
// the warning. toolCallReasoning=false vendors (DashScope) never warn — no
// round-trip contract. This mirrors openai.go's model-scoped gate.
func (c *client) WarnOnMissingToolCallReasoning() bool {
if !c.caps.toolCallReasoning || c.caps.singleSegmentReasoning {
if !c.caps.toolCallReasoning {
return false
}
// MiMo: preserves reasoning on replay but does not guarantee it every
// round — a missing chain-of-thought is endpoint-conditional, not a
// degradation worth a warning (observed: mimo-v2.5-pro tool-call turn
// with empty reasoning).
if c.vendor == "mimo" {
return false
}
model := strings.ToLower(strings.TrimSpace(c.model))
Expand Down Expand Up @@ -300,8 +304,15 @@ func (c *client) buildRequestBody(req provider.Request) (map[string]any, bool, [
case "disabled", "off":
effort = "none"
}
if effort != "" {
body["reasoning"] = map[string]any{"effort": effort}
if effort != "" || c.caps.summaryMode != "" {
reasoning := map[string]any{}
if effort != "" {
reasoning["effort"] = effort
}
if c.caps.summaryMode != "" {
reasoning["summary"] = c.caps.summaryMode
}
body["reasoning"] = reasoning
}
maxOutputTokens := req.MaxTokens
if maxOutputTokens == 0 {
Expand Down
27 changes: 26 additions & 1 deletion internal/provider/responses/responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ func TestVendorCapabilityTableCoversKnownEndpoints(t *testing.T) {
singleSegment bool
}{
{"https://api.deepseek.com", "deepseek", true, true, false, false},
{"https://api.xiaomimimo.com/v1", "mimo", true, true, true, true},
{"https://api.xiaomimimo.com/v1", "mimo", true, true, true, false},
{"https://dashscope.aliyuncs.com/compatible-mode/v1", "dashscope", false, false, false, false},
{"https://example.com/v1", "", false, false, false, false},
}
Expand Down Expand Up @@ -1174,3 +1174,28 @@ func TestVendorTableMaxOutputTokens(t *testing.T) {
t.Fatalf("deepseek auto budget = %#v, want high-reasoning %d", got, provider.DefaultHighReasoningOutputTokens)
}
}

func TestMiMoEmitsSummaryModeEvenWhenEffortEmpty(t *testing.T) {
// MiMo's default effort is "auto" (normalized to empty), so the reasoning
// object must still be emitted when caps.summaryMode is set — otherwise
// summaryMode="none" is dropped and the server falls back to emitting
// reasoning summaries (truncation root cause: MiMo folds the summary back
// into context, doubling thinking each turn).
c := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com", Model: "mimo-v2.5"}).(*client)
if c.caps.summaryMode != "none" {
t.Fatalf("mimo summaryMode = %q, want none", c.caps.summaryMode)
}
body, _, _ := c.buildRequestBody(provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
reasoning, ok := body["reasoning"].(map[string]any)
if !ok {
t.Fatalf("mimo with empty effort must still send reasoning object (summaryMode set), body=%v", body)
}
if got := reasoning["summary"]; got != "none" {
t.Fatalf("reasoning.summary = %#v, want none (must be sent even with empty effort)", got)
}
if _, hasEffort := reasoning["effort"]; hasEffort {
t.Fatalf("reasoning.effort should be omitted for empty effort, got %#v", reasoning)
}
}
39 changes: 30 additions & 9 deletions internal/provider/responses/vendor.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package responses
import (
"net/url"
"strings"
"time"

"reasonix/internal/provider"
)
Expand Down Expand Up @@ -50,14 +51,20 @@ type vendorCapabilities struct {

// defaultMaxOutputTokens is the max_output_tokens sent when the caller
// did not request one (req.MaxTokens == 0). Zero means "leave unset and
// let the server use its own default". MiMo's server default (32768)
// covers reasoning + visible output, and its thinking mode can spend a
// large chunk of that budget on reasoning before the visible answer —
// truncating tool calls mid-JSON on long turns. Raise it to the next
// documented tier (65536, within the allowed [1, 131072] range) so the
// answer survives long reasoning.
// let the server use its own default". The default (32K) covers
// reasoning + visible output; long reasoning turns can spend most of it
// before the visible answer, truncating tool calls mid-JSON. Users must
// raise it manually (e.g. 128000, MiMo-Code's MIMO_OUTPUT_TOKEN_MAX
// within [1, 131072]) for long-reasoning workloads.
defaultMaxOutputTokens int

// summaryMode, when non-empty, is sent as reasoning.summary in the
// request body. MiMo-Code's codex config sets model_reasoning_summary
// = "none" to tell the server NOT to emit reasoning summaries, keeping
// the output budget for visible content. Empty means "do not send
// reasoning.summary" (the OpenAI default).
summaryMode string

// compactionOutputTokens is the separate budget for native/summary
// compaction calls. Zero means "no dedicated compaction budget; fall
// back to ordinary summarize without inheriting a large default".
Expand All @@ -72,6 +79,12 @@ type vendorCapabilities struct {
// chain-of-thought echoed each turn and inflating reasoning output
// until truncation. Only send it where the wire demands it.
summaryRequired bool

// streamIdleTimeout overrides the default SSE stream idle timeout for
// this vendor. MiMo's cold-path TTFT can reach ~5 minutes for long
// reasoning turns; the default 120s would abort prematurely. Zero means
// use defaultStreamIdleTimeout.
streamIdleTimeout time.Duration
}

var vendorTable = map[string]vendorCapabilities{
Expand Down Expand Up @@ -102,11 +115,19 @@ var vendorTable = map[string]vendorCapabilities{
stateless: true,
sessionCacheHeader: false,
toolCallReasoning: true,
singleSegmentReasoning: true,
singleSegmentReasoning: false,
ignoresTemperature: true,
// Coding-agent default 32K; users may raise explicitly. Not 128K auto.
// Default 32K is too small for long reasoning turns: the budget
// covers reasoning + visible output, so tool calls truncate mid-JSON.
// Users must raise it manually to 128000 (MiMo-Code's
// MIMO_OUTPUT_TOKEN_MAX) for long-reasoning workloads.
defaultMaxOutputTokens: provider.DefaultReasoningOutputTokens,
compactionOutputTokens: provider.DefaultOrdinaryOutputTokens,
summaryMode: "none",
// MiMo only accepts effort values: none, low, medium, high
// (case-sensitive lowercase). auto/disabled/off/HIGH are rejected
// with HTTP 400. NormalizeEffort in effort.go handles the mapping.
streamIdleTimeout: 8 * time.Minute, // cold-path TTFT ~5min
compactionOutputTokens: 4096,
},
// "" (unknown OpenAI-compatible endpoint) → zero value = default behavior.
// Unknown gateways deliberately do NOT inherit a large max-output default.
Expand Down
Loading