返回 DeepSeek-Reasonix
effort.go
根目录 / internal / config / effort.go
1 package config
2
3 import (
4 "fmt"
5 "strings"
6
7 "reasonix/internal/provider/openai"
8 )
9
10 const (
11 ReasoningProtocolAuto = "auto"
12 ReasoningProtocolDeepSeek = "deepseek"
13 ReasoningProtocolGLM = "glm"
14 ReasoningProtocolOpenAI = "openai"
15 ReasoningProtocolNone = "none"
16 )
17
18 // EffortCapability describes the abstract effort levels a provider/model can set
19 // through the /effort command.
20 type EffortCapability struct {
21 Supported bool
22 Levels []string
23 Default string
24 }
25
26 type modelReasoningCapability struct {
27 Protocol string
28 Levels []string
29 Default string
30 Aliases map[string]string
31 }
32
33 var modelReasoningCapabilities = map[string]modelReasoningCapability{
34 "deepseek-v4-flash": {
35 Protocol: ReasoningProtocolDeepSeek,
36 Levels: []string{"disabled", "low", "high", "max"},
37 Default: "high",
38 Aliases: map[string]string{"xhigh": "high"},
39 },
40 "deepseek-v4-pro": {Protocol: ReasoningProtocolDeepSeek, Levels: []string{"disabled", "high", "max"}, Default: "high"},
41 }
42
43 // EffortCapabilityForEntry returns the user-facing /effort levels for a resolved
44 // provider entry. Provider implementations still decide how a stored effort is
45 // serialized into requests.
46 func EffortCapabilityForEntry(e *ProviderEntry) EffortCapability {
47 if explicitReasoningProtocol(e) == ReasoningProtocolNone {
48 return EffortCapability{}
49 }
50 supported := normalizedSupportedEfforts(e)
51 if len(supported) > 0 {
52 levels := make([]string, 0, len(supported)+1)
53 levels = append(levels, "auto")
54 levels = append(levels, supported...)
55 def := normalizeEffortLevel(e.DefaultEffort)
56 if def == "" || !containsString(supported, def) {
57 def = supported[0]
58 }
59 return EffortCapability{Supported: true, Levels: levels, Default: def}
60 }
61 switch explicitReasoningProtocol(e) {
62 case ReasoningProtocolDeepSeek:
63 if cap, ok := resolvedModelReasoningCapability(e); ok && cap.Protocol == ReasoningProtocolDeepSeek {
64 return effortCapabilityFromModel(cap)
65 }
66 return deepSeekEffortCapability()
67 case ReasoningProtocolGLM:
68 return glmEffortCapability()
69 case ReasoningProtocolOpenAI:
70 if isMimoEntry(e) {
71 // MiMo's Responses API documents a binary thinking knob: "none"
72 // disables reasoning; every other legal value enables it. The
73 // vendor accepts the OpenAI depth vocabulary but exposes no real
74 // low/medium/high difference, so mirror the documented contract.
75 return mimoEffortCapability()
76 }
77 return openAIEffortCapability()
78 }
79 if cap, ok := resolvedModelReasoningCapability(e); ok {
80 return effortCapabilityFromModel(cap)
81 }
82 switch ReasoningProtocolForEntry(e) {
83 case ReasoningProtocolDeepSeek:
84 return deepSeekEffortCapability()
85 case ReasoningProtocolGLM:
86 return glmEffortCapability()
87 case ReasoningProtocolOpenAI:
88 return openAIEffortCapability()
89 }
90 switch {
91 case isMiniMaxEntry(e):
92 // MiniMax-M3 only exposes a binary thinking knob (adaptive|disabled)
93 // on its OpenAI-compatible endpoint, so /effort mirrors the API
94 // vocabulary verbatim. Default is "adaptive" because the M3 model
95 // runs with thinking on out of the box; "auto" means "don't override
96 // the model default" (== adaptive for M3).
97 return EffortCapability{Supported: true, Levels: []string{"auto", "adaptive", "disabled"}, Default: "adaptive"}
98 case isZhipuEntry(e):
99 // Zhipu GLM exposes a binary thinking knob (enabled|disabled) on its
100 // OpenAI-compatible endpoint and ignores reasoning_effort, so /effort
101 // mirrors that vocabulary. Default is "enabled" because GLM runs with
102 // thinking on out of the box; "auto" means "don't override the model
103 // default" (== enabled for GLM).
104 return glmEffortCapability()
105 case isLongCatEntry(e):
106 // LongCat exposes the same binary thinking vocabulary on its
107 // OpenAI-compatible endpoint and documents no reasoning_effort depth scale.
108 return EffortCapability{Supported: true, Levels: []string{"auto", "enabled", "disabled"}, Default: "enabled"}
109 case isOllamaCloudEntry(e):
110 // Ollama Cloud accepts top-level reasoning_effort values low|medium|
111 // high|max. "none" means omit the field so the hosted model runs without
112 // thinking. Leave auto as the default so existing traffic stays provider-
113 // default until the user chooses an effort explicitly.
114 return EffortCapability{Supported: true, Levels: []string{"auto", "none", "low", "medium", "high", "max"}, Default: "auto"}
115 case e != nil && e.Kind == "anthropic":
116 return EffortCapability{Supported: true, Levels: []string{"auto", "low", "medium", "high", "xhigh", "max"}, Default: "auto"}
117 default:
118 return EffortCapability{}
119 }
120 }
121
122 // NormalizeEffort maps a user-supplied /effort level into the value stored in
123 // config. Empty means auto/provider default.
124 func NormalizeEffort(e *ProviderEntry, raw string) (string, error) {
125 level := normalizeEffortLevel(raw)
126 if level == "" {
127 return "", fmt.Errorf("usage: /effort auto|<level>")
128 }
129 if level == "auto" {
130 return "", nil
131 }
132 if explicitReasoningProtocol(e) == ReasoningProtocolNone {
133 return "", effortNotConfigurableError(e)
134 }
135 supported := normalizedSupportedEfforts(e)
136 if len(supported) > 0 {
137 if containsString(supported, level) {
138 return level, nil
139 }
140 return "", fmt.Errorf("usage: /effort auto|%s", strings.Join(supported, "|"))
141 }
142 // V4 Flash 0731 added a real low depth. Keep this model-scoped: Pro and
143 // generic DeepSeek-compatible endpoints still normalize low to high unless
144 // they explicitly advertise a different supported_efforts list.
145 if cap, ok := resolvedModelReasoningCapability(e); ok {
146 explicit := explicitReasoningProtocol(e)
147 if explicit == "" || explicit == cap.Protocol {
148 if containsString(cap.Levels, level) {
149 return level, nil
150 }
151 if normalized, ok := cap.Aliases[level]; ok && containsString(cap.Levels, normalized) {
152 return normalized, nil
153 }
154 }
155 }
156 switch ReasoningProtocolForEntry(e) {
157 case ReasoningProtocolDeepSeek:
158 switch level {
159 case "disabled":
160 return "disabled", nil
161 case "off": // retired DeepSeek "no thinking" → disabled
162 return "disabled", nil
163 case "high", "max":
164 return level, nil
165 case "low", "medium":
166 return "high", nil
167 case "xhigh":
168 return "max", nil
169 default:
170 return "", fmt.Errorf("usage: /effort auto|disabled|high|max")
171 }
172 case ReasoningProtocolOpenAI:
173 if isMimoEntry(e) {
174 switch level {
175 case "none", "low", "medium", "high":
176 return level, nil
177 default:
178 return "", fmt.Errorf("usage: /effort auto|none|low|medium|high")
179 }
180 }
181 switch level {
182 case "low", "medium", "high":
183 return level, nil
184 default:
185 return "", fmt.Errorf("usage: /effort auto|low|medium|high")
186 }
187 case ReasoningProtocolGLM:
188 return normalizeGLMEffort(level)
189 }
190 switch {
191 case isMiniMaxEntry(e):
192 // The M3 knob is binary; map Anthropic / OpenAI-style levels onto the
193 // nearest valid value so a stale /effort high|low still works. "off"
194 // is a retired DeepSeek level meaning "no thinking" — on M3 that maps
195 // to "disabled" rather than the model default, since M3 actually
196 // supports a "thinking off" mode and "off" is the natural request.
197 switch level {
198 case "adaptive", "disabled":
199 return level, nil
200 case "off":
201 return "disabled", nil
202 case "low", "medium", "high":
203 return "adaptive", nil
204 case "xhigh", "max":
205 return "disabled", nil
206 default:
207 return "", fmt.Errorf("usage: /effort auto|adaptive|disabled")
208 }
209 case isZhipuEntry(e):
210 // GLM's knob is binary (enabled|disabled); map Anthropic / OpenAI-style
211 // depth levels onto the nearest valid value so a stale /effort high|low
212 // still works. "off" is a retired DeepSeek level meaning "no thinking",
213 // which maps to "disabled".
214 return normalizeGLMEffort(level)
215 case isLongCatEntry(e):
216 // LongCat's knob is binary (enabled|disabled); depth-like aliases mean
217 // thinking on, while the legacy off spellings disable it.
218 switch level {
219 case "enabled", "disabled":
220 return level, nil
221 case "off":
222 return "disabled", nil
223 case "low", "medium", "high", "xhigh", "max":
224 return "enabled", nil
225 default:
226 return "", fmt.Errorf("usage: /effort auto|enabled|disabled")
227 }
228 case isOllamaCloudEntry(e):
229 switch level {
230 case "none", "disabled", "off":
231 return "none", nil
232 case "low", "medium", "high", "max":
233 return level, nil
234 case "xhigh":
235 return "max", nil
236 default:
237 return "", fmt.Errorf("usage: /effort auto|none|low|medium|high|max")
238 }
239 case e != nil && e.Kind == "anthropic":
240 switch level {
241 case "low", "medium", "high", "xhigh", "max":
242 return level, nil
243 default:
244 return "", fmt.Errorf("usage: /effort auto|low|medium|high|xhigh|max")
245 }
246 default:
247 return "", effortNotConfigurableError(e)
248 }
249 }
250
251 // EffortDisplay returns the selected /effort level, using "auto" for provider
252 // default.
253 func EffortDisplay(e *ProviderEntry) string {
254 if e == nil || strings.TrimSpace(e.Effort) == "" {
255 return "auto"
256 }
257 return normalizeEffortLevel(e.Effort)
258 }
259
260 // EffectiveEffort resolves the provider-visible effort value. Explicit
261 // ProviderEntry.Effort wins; otherwise a configured SupportedEfforts list makes
262 // DefaultEffort (or the first supported level) the runtime default. Empty means
263 // provider default / omit the provider-specific effort field.
264 func EffectiveEffort(e *ProviderEntry) string {
265 if e == nil {
266 return ""
267 }
268 if effort := normalizeStoredEffort(e.Effort); effort != "" {
269 return effort
270 }
271 supported := normalizedSupportedEfforts(e)
272 if len(supported) == 0 {
273 return ""
274 }
275 def := normalizeEffortLevel(e.DefaultEffort)
276 if def == "" || !containsString(supported, def) {
277 return supported[0]
278 }
279 return def
280 }
281
282 func normalizeEffortConfig(c *Config) {
283 if c == nil {
284 return
285 }
286 for i := range c.Providers {
287 normalizeProviderEffortFields(&c.Providers[i])
288 }
289 }
290
291 func normalizeProviderEffortFields(e *ProviderEntry) {
292 if e == nil {
293 return
294 }
295 e.Headers = normalizedProviderHeaders(e.Headers)
296 e.Effort = normalizeStoredEffort(e.Effort)
297 e.ReasoningProtocol = normalizeReasoningProtocol(e.ReasoningProtocol)
298 e.DefaultEffort = normalizeEffortLevel(e.DefaultEffort)
299 e.SupportedEfforts = normalizedSupportedEfforts(e)
300 e.ModelOverrides = normalizedModelOverrides(e.ModelOverrides)
301 }
302
303 func normalizeStoredEffort(raw string) string {
304 level := normalizeEffortLevel(raw)
305 if level == "auto" || level == "off" {
306 return ""
307 }
308 return level
309 }
310
311 // ReasoningProtocolForEntry resolves the provider request shape for reasoning
312 // controls. Explicit config wins, then the model capability registry, then legacy
313 // endpoint heuristics.
314 func ReasoningProtocolForEntry(e *ProviderEntry) string {
315 if explicit := explicitReasoningProtocol(e); explicit != "" {
316 return explicit
317 }
318 if cap, ok := resolvedModelReasoningCapability(e); ok {
319 return cap.Protocol
320 }
321 if isTokenRhythmGLMEntry(e) {
322 return ReasoningProtocolGLM
323 }
324 if isDeepSeekEntry(e) {
325 return ReasoningProtocolDeepSeek
326 }
327 return ""
328 }
329
330 func explicitReasoningProtocol(e *ProviderEntry) string {
331 if e == nil {
332 return ""
333 }
334 protocol := normalizeReasoningProtocol(e.ReasoningProtocol)
335 if protocol == ReasoningProtocolAuto {
336 return ""
337 }
338 return protocol
339 }
340
341 func normalizeReasoningProtocol(raw string) string {
342 switch strings.ToLower(strings.TrimSpace(raw)) {
343 case "", ReasoningProtocolAuto:
344 return ""
345 case ReasoningProtocolDeepSeek, ReasoningProtocolGLM, ReasoningProtocolOpenAI, ReasoningProtocolNone:
346 return strings.ToLower(strings.TrimSpace(raw))
347 default:
348 return ""
349 }
350 }
351
352 // isDeepSeekEntry reports whether the entry points at DeepSeek's API. The
353 // actual host matching lives in provider/openai so the openai package and
354 // the config layer stay in lockstep when new gateways are added.
355 func isDeepSeekEntry(e *ProviderEntry) bool {
356 return e != nil && e.Kind == "openai" && openai.IsDeepSeek(e.BaseURL)
357 }
358
359 // isMiniMaxEntry reports whether the entry points at MiniMax's OpenAI-compatible
360 // endpoint. See openai.IsMiniMax for the host-matching rule; the entry-wrapper
361 // just gates on the openai kind.
362 func isMiniMaxEntry(e *ProviderEntry) bool {
363 return e != nil && e.Kind == "openai" && openai.IsMiniMax(e.BaseURL)
364 }
365
366 // isZhipuEntry reports whether the entry points at Zhipu's OpenAI-compatible
367 // endpoint for GLM models. See openai.IsZhipu for the host-matching rule; the
368 // entry-wrapper just gates on the openai kind.
369 func isZhipuEntry(e *ProviderEntry) bool {
370 return e != nil && e.Kind == "openai" && openai.IsZhipu(e.BaseURL)
371 }
372
373 // isTokenRhythmGLMEntry upgrades older Token Rhythm configurations that predate
374 // per-model protocol overrides. Keep the rule scoped to the gateway and exact
375 // official model IDs so unrelated mixed-model providers retain their existing
376 // request shape.
377 func isTokenRhythmGLMEntry(e *ProviderEntry) bool {
378 if e == nil || e.Kind != "openai" || !openai.IsTokenRhythm(e.BaseURL) {
379 return false
380 }
381 switch strings.ToLower(strings.TrimSpace(e.Model)) {
382 case "glm-5", "glm-5.1", "glm-5.2":
383 return true
384 default:
385 return false
386 }
387 }
388
389 // isLongCatEntry reports whether the entry points at LongCat's OpenAI-compatible
390 // endpoint. See openai.IsLongCat for the host-matching rule.
391 func isLongCatEntry(e *ProviderEntry) bool {
392 return e != nil && e.Kind == "openai" && openai.IsLongCat(e.BaseURL)
393 }
394
395 // isOllamaCloudEntry reports whether the entry points at hosted Ollama Cloud,
396 // whose OpenAI-compatible endpoint accepts reasoning_effort=max. Local Ollama
397 // endpoints intentionally do not match.
398 func isOllamaCloudEntry(e *ProviderEntry) bool {
399 return e != nil && e.Kind == "openai" && openai.IsOllamaCloud(e.BaseURL)
400 }
401
402 // isMimoEntry reports whether the entry points at Xiaomi MiMo's Responses API
403 // (api.xiaomimimo.com). Host matching mirrors provider/responses.DetectVendor
404 // but lives in the config layer to avoid an import cycle (control → config,
405 // not control → provider). Host-based exact/suffix matching (not full-URL
406 // substring) so unrelated or attacker-controlled URLs can't enable MiMo
407 // effort. The kind check is intentionally absent: MiMo is served through both
408 // kind="responses" and kind="openai" presets.
409 func isMimoEntry(e *ProviderEntry) bool {
410 if e == nil {
411 return false
412 }
413 host := officialProviderHost(e.BaseURL)
414 return host == "api.xiaomimimo.com" || strings.HasSuffix(host, ".xiaomimimo.com")
415 }
416
417 // mimoEffortCapability mirrors MiMo's documented binary thinking knob: "none"
418 // disables reasoning, every other legal value enables it (no real depth
419 // difference server-side). The vendor accepts the OpenAI depth vocabulary.
420 func mimoEffortCapability() EffortCapability {
421 return EffortCapability{Supported: true, Levels: []string{"auto", "none", "low", "medium", "high"}, Default: "auto"}
422 }
423
424 func resolvedModelReasoningCapability(e *ProviderEntry) (modelReasoningCapability, bool) {
425 if e == nil || e.Kind != "openai" {
426 return modelReasoningCapability{}, false
427 }
428 cap, ok := modelReasoningCapabilities[strings.ToLower(strings.TrimSpace(e.Model))]
429 return cap, ok
430 }
431
432 func effortCapabilityFromModel(cap modelReasoningCapability) EffortCapability {
433 levels := make([]string, 0, len(cap.Levels)+1)
434 levels = append(levels, "auto")
435 levels = append(levels, cap.Levels...)
436 def := normalizeEffortLevel(cap.Default)
437 if def == "" || !containsString(cap.Levels, def) {
438 def = "auto"
439 }
440 return EffortCapability{Supported: true, Levels: levels, Default: def}
441 }
442
443 func deepSeekEffortCapability() EffortCapability {
444 return EffortCapability{Supported: true, Levels: []string{"auto", "disabled", "high", "max"}, Default: "high"}
445 }
446
447 func openAIEffortCapability() EffortCapability {
448 return EffortCapability{Supported: true, Levels: []string{"auto", "low", "medium", "high"}, Default: "auto"}
449 }
450
451 func glmEffortCapability() EffortCapability {
452 return EffortCapability{Supported: true, Levels: []string{"auto", "enabled", "disabled"}, Default: "enabled"}
453 }
454
455 func normalizeGLMEffort(level string) (string, error) {
456 switch level {
457 case "enabled", "disabled":
458 return level, nil
459 case "off":
460 return "disabled", nil
461 case "low", "medium", "high", "xhigh", "max":
462 return "enabled", nil
463 default:
464 return "", fmt.Errorf("usage: /effort auto|enabled|disabled")
465 }
466 }
467
468 func effortNotConfigurableError(e *ProviderEntry) error {
469 name := ""
470 if e != nil {
471 name = e.Name
472 }
473 if name == "" {
474 name = "this model"
475 }
476 return fmt.Errorf("effort is not configurable for %s", name)
477 }
478
479 func containsString(haystack []string, needle string) bool {
480 for _, s := range haystack {
481 if s == needle {
482 return true
483 }
484 }
485 return false
486 }
487
488 func normalizeEffortLevel(s string) string {
489 return strings.ToLower(strings.TrimSpace(s))
490 }
491
492 func normalizedSupportedEfforts(e *ProviderEntry) []string {
493 if e == nil || len(e.SupportedEfforts) == 0 {
494 return nil
495 }
496 return normalizedEffortLevels(e.SupportedEfforts)
497 }
498
499 func normalizedEffortLevels(levels []string) []string {
500 if len(levels) == 0 {
501 return nil
502 }
503 out := make([]string, 0, len(levels))
504 seen := map[string]bool{}
505 for _, raw := range levels {
506 level := normalizeEffortLevel(raw)
507 if level == "" || level == "auto" || seen[level] {
508 continue
509 }
510 seen[level] = true
511 out = append(out, level)
512 }
513 return out
514 }
515
516 func normalizedProviderHeaders(headers map[string]string) map[string]string {
517 if len(headers) == 0 {
518 return nil
519 }
520 out := make(map[string]string, len(headers))
521 for rawName, rawValue := range headers {
522 name := strings.TrimSpace(rawName)
523 value := strings.TrimSpace(rawValue)
524 if name == "" || value == "" {
525 continue
526 }
527 out[name] = value
528 }
529 if len(out) == 0 {
530 return nil
531 }
532 return out
533 }
534
535 func normalizedModelOverrides(overrides map[string]ProviderModelOverride) map[string]ProviderModelOverride {
536 if len(overrides) == 0 {
537 return nil
538 }
539 out := make(map[string]ProviderModelOverride, len(overrides))
540 for rawModel, ov := range overrides {
541 model := strings.TrimSpace(rawModel)
542 if model == "" {
543 continue
544 }
545 ov.ReasoningProtocol = normalizeReasoningProtocol(ov.ReasoningProtocol)
546 ov.SupportedEfforts = normalizedEffortLevels(ov.SupportedEfforts)
547 ov.DefaultEffort = normalizeEffortLevel(ov.DefaultEffort)
548 if ov.ContextWindow < 0 {
549 ov.ContextWindow = 0
550 }
551 if ov.DefaultEffort != "" && !containsString(ov.SupportedEfforts, ov.DefaultEffort) {
552 ov.DefaultEffort = ""
553 }
554 if ov.ReasoningProtocol == "" && len(ov.SupportedEfforts) == 0 && ov.DefaultEffort == "" && ov.Vision == nil && ov.ContextWindow == 0 {
555 continue
556 }
557 out[model] = ov
558 }
559 if len(out) == 0 {
560 return nil
561 }
562 return out
563 }
564
564 lines GO