返回 DeepSeek-Reasonix
effort.go
根目录 / internal / config / effort.go
1 package config
2
3 import (
4 "fmt"
5 "slices"
6 "strings"
7
8 "reasonix/internal/provider"
9 _ "reasonix/internal/provider/anthropic"
10 "reasonix/internal/provider/openai"
11 _ "reasonix/internal/provider/responses"
12 )
13
14 const (
15 ReasoningProtocolAuto = "auto"
16 ReasoningProtocolDeepSeek = "deepseek"
17 ReasoningProtocolGLM = "glm"
18 ReasoningProtocolKimiK3 = "kimi-k3"
19 ReasoningProtocolOpenAI = "openai"
20 ReasoningProtocolNone = "none"
21 )
22
23 // EffortCapability describes the abstract effort levels a provider/model can set
24 // through the /effort command.
25 type EffortCapability struct {
26 Supported bool
27 Levels []string
28 Default string
29 }
30
31 type modelReasoningCapability struct{ Protocol string }
32
33 var modelReasoningCapabilities = map[string]modelReasoningCapability{
34 "deepseek-v4-flash": {Protocol: ReasoningProtocolDeepSeek},
35 "deepseek-v4-pro": {Protocol: ReasoningProtocolDeepSeek},
36 }
37
38 // EffortCapabilityForEntry returns the user-facing /effort levels for a resolved
39 // provider entry. Provider implementations still decide how a stored effort is
40 // serialized into requests.
41 func ReasoningCapabilityForEntry(e *ProviderEntry) provider.ReasoningCapability {
42 e = ResolveReasoningEntry(e)
43 if e == nil {
44 return provider.ReasoningOptions("")
45 }
46 // Resolver-backed entries carry the remote adapter declaration, not a local kind.
47 if e.Kind == "" {
48 if e.ReasoningMetadataUnknown && len(e.SupportedEfforts) == 0 {
49 cap := provider.UnknownReasoning()
50 cap.Default = e.DefaultEffort
51 return cap
52 }
53 return provider.ReasoningOptions(e.DefaultEffort, e.SupportedEfforts...)
54 }
55 cfg := provider.Config{Name: e.Name, BaseURL: e.BaseURL, Model: e.Model, Extra: map[string]any{
56 "thinking": e.Thinking, "reasoning_protocol": ReasoningProtocolForEntry(e),
57 "request_url": e.RequestURL, "chat_url": e.ChatURL,
58 "supported_efforts": normalizedSupportedEfforts(e), "default_effort": normalizeEffortLevel(e.DefaultEffort),
59 }}
60 return provider.ReasoningForConfig(e.Kind, cfg)
61 }
62 func EffortCapabilityForEntry(e *ProviderEntry) EffortCapability {
63 cap := ReasoningCapabilityForEntry(e)
64 if len(cap.Options) == 0 {
65 return EffortCapability{}
66 }
67 def := cap.Default
68 if def == "" {
69 def = "auto"
70 }
71 return EffortCapability{Supported: true, Levels: append([]string{"auto"}, cap.IDs()...), Default: def}
72 }
73
74 // NormalizeEffort maps a user-supplied /effort level into the value stored in
75 // config. Empty means auto/provider default.
76 func NormalizeEffort(e *ProviderEntry, raw string) (string, error) {
77 // auto is the historical spelling for inheriting the provider default.
78 if raw == "auto" {
79 return "", nil
80 }
81 if raw == "" {
82 return "", fmt.Errorf("usage: /effort auto|<level>")
83 }
84 cap := ReasoningCapabilityForEntry(e)
85 model := ""
86 if e != nil {
87 model = e.Model
88 }
89 if err := cap.Validate(model, raw); err != nil {
90 return "", err
91 }
92 return raw, nil
93 }
94
95 // EffortDisplay returns the selected /effort level, using "auto" for provider
96 // default.
97 func EffortDisplay(e *ProviderEntry) string {
98 if e == nil || strings.TrimSpace(e.Effort) == "" {
99 return "auto"
100 }
101 effort := normalizeEffortLevel(e.Effort)
102
103 return effort
104 }
105
106 // EffectiveEffort resolves the provider-visible effort value. Explicit
107 // ProviderEntry.Effort wins; otherwise a configured SupportedEfforts list makes
108 // DefaultEffort (or the first supported level) the runtime default. Empty means
109 // provider default / omit the provider-specific effort field.
110 func EffectiveEffort(e *ProviderEntry) string {
111 e = ResolveReasoningEntry(e)
112 if e == nil {
113 return ""
114 }
115 if effort := normalizeStoredEffort(e.Effort); effort != "" {
116 return migrateStoredDeepSeekEffort(e, effort)
117 }
118 if explicitReasoningProtocol(e) == ReasoningProtocolKimiK3 {
119 return ""
120 }
121 supported := normalizedSupportedEfforts(e)
122 if len(supported) == 0 {
123 return ""
124 }
125 def := normalizeEffortLevel(e.DefaultEffort)
126 if def == "" {
127 return supported[0]
128 }
129 return def
130 }
131
132 func normalizeEffortConfig(c *Config) {
133 if c == nil {
134 return
135 }
136 for i := range c.Providers {
137 normalizeProviderEffortFields(&c.Providers[i])
138 }
139 }
140
141 func normalizeProviderEffortFields(e *ProviderEntry) {
142 if e == nil {
143 return
144 }
145 stripRuntimeReasoningDefaults(e)
146 e.Headers = normalizedProviderHeaders(e.Headers)
147 e.Effort = normalizeStoredEffort(e.Effort)
148 e.ReasoningProtocol = normalizeReasoningProtocol(e.ReasoningProtocol)
149 e.DefaultEffort = normalizeEffortLevel(e.DefaultEffort)
150 e.SupportedEfforts = normalizedSupportedEfforts(e)
151 e.ModelOverrides = normalizedModelOverrides(e.ModelOverrides)
152 }
153
154 func normalizeStoredEffort(raw string) string {
155 level := normalizeEffortLevel(raw)
156 if level == "auto" || level == "off" {
157 return ""
158 }
159 return level
160 }
161
162 // ReasoningProtocolForEntry resolves the provider request shape for reasoning
163 // controls. Explicit config wins, then the model capability registry, then legacy
164 // endpoint heuristics.
165 func ReasoningProtocolForEntry(e *ProviderEntry) string {
166 return reasoningProtocolForResolvedEntry(ResolveReasoningEntry(e))
167 }
168
169 func reasoningProtocolForResolvedEntry(e *ProviderEntry) string {
170 if explicit := explicitReasoningProtocol(e); explicit != "" {
171 return explicit
172 }
173 if e != nil {
174 if contract, ok := provider.LookupOpenCodeGoContract(e.Kind, e.BaseURL, e.RequestURL, e.ChatURL, e.Model); ok {
175 return contract.ReasoningProtocol
176 }
177 }
178 if cap, ok := resolvedModelReasoningCapability(e); ok {
179 return cap.Protocol
180 }
181 if isTokenRhythmGLMEntry(e) {
182 return ReasoningProtocolGLM
183 }
184 if isDeepSeekEntry(e) {
185 return ReasoningProtocolDeepSeek
186 }
187 return ""
188 }
189
190 func explicitReasoningProtocol(e *ProviderEntry) string {
191 if e == nil {
192 return ""
193 }
194 protocol := normalizeReasoningProtocol(e.ReasoningProtocol)
195 if protocol == ReasoningProtocolAuto {
196 return ""
197 }
198 return protocol
199 }
200
201 func normalizeReasoningProtocol(raw string) string {
202 switch strings.ToLower(strings.TrimSpace(raw)) {
203 case "", ReasoningProtocolAuto:
204 return ""
205 case ReasoningProtocolDeepSeek, ReasoningProtocolGLM, ReasoningProtocolKimiK3, ReasoningProtocolOpenAI, ReasoningProtocolNone:
206 return strings.ToLower(strings.TrimSpace(raw))
207 default:
208 return ""
209 }
210 }
211
212 // isDeepSeekEntry reports whether the entry points at DeepSeek's API. The
213 // actual host matching lives in provider/openai so the openai package and
214 // the config layer stay in lockstep when new gateways are added.
215 func isDeepSeekEntry(e *ProviderEntry) bool {
216 return e != nil && e.Kind == "openai" && openai.IsDeepSeek(e.BaseURL)
217 }
218
219 // isMiniMaxEntry reports whether the entry points at MiniMax's OpenAI-compatible
220 // endpoint. See openai.IsMiniMax for the host-matching rule; the entry-wrapper
221 // just gates on the openai kind.
222 func isMiniMaxEntry(e *ProviderEntry) bool {
223 return e != nil && e.Kind == "openai" && openai.IsMiniMax(e.BaseURL)
224 }
225
226 // isZhipuEntry reports whether the entry points at Zhipu's OpenAI-compatible
227 // endpoint for GLM models. See openai.IsZhipu for the host-matching rule; the
228 // entry-wrapper just gates on the openai kind.
229 func isZhipuEntry(e *ProviderEntry) bool {
230 return e != nil && e.Kind == "openai" && openai.IsZhipu(e.BaseURL)
231 }
232
233 // isTokenRhythmGLMEntry upgrades older Token Rhythm configurations that predate
234 // per-model protocol overrides. Keep the rule scoped to the gateway and exact
235 // official model IDs so unrelated mixed-model providers retain their existing
236 // request shape.
237 func isTokenRhythmGLMEntry(e *ProviderEntry) bool {
238 if e == nil || e.Kind != "openai" || !openai.IsTokenRhythm(e.BaseURL) {
239 return false
240 }
241 switch strings.ToLower(strings.TrimSpace(e.Model)) {
242 case "glm-5", "glm-5.1", "glm-5.2":
243 return true
244 default:
245 return false
246 }
247 }
248
249 // isLongCatEntry reports whether the entry points at LongCat's OpenAI-compatible
250 // endpoint. See openai.IsLongCat for the host-matching rule.
251 func isLongCatEntry(e *ProviderEntry) bool {
252 return e != nil && e.Kind == "openai" && openai.IsLongCat(e.BaseURL)
253 }
254
255 // isOllamaCloudEntry reports whether the entry points at hosted Ollama Cloud,
256 // whose OpenAI-compatible endpoint accepts reasoning_effort=max. Local Ollama
257 // endpoints intentionally do not match.
258 func isOllamaCloudEntry(e *ProviderEntry) bool {
259 return e != nil && e.Kind == "openai" && openai.IsOllamaCloud(e.BaseURL)
260 }
261
262 // isMimoEntry reports whether the entry points at Xiaomi MiMo's Responses API
263 // (api.xiaomimimo.com). Host matching mirrors provider/responses.DetectVendor
264 // but lives in the config layer to avoid an import cycle (control → config,
265 // not control → provider). Host-based exact/suffix matching (not full-URL
266 // substring) so unrelated or attacker-controlled URLs can't enable MiMo
267 // effort. The kind check is intentionally absent: MiMo is served through both
268 // kind="responses" and kind="openai" presets.
269 func isMimoEntry(e *ProviderEntry) bool {
270 if e == nil {
271 return false
272 }
273 host := officialProviderHost(e.BaseURL)
274 return host == "api.xiaomimimo.com" || strings.HasSuffix(host, ".xiaomimimo.com")
275 }
276
277 func resolvedModelReasoningCapability(e *ProviderEntry) (modelReasoningCapability, bool) {
278 if e == nil || e.Kind != "openai" {
279 return modelReasoningCapability{}, false
280 }
281 return modelReasoningCapabilityForEntry(e)
282 }
283
284 func modelReasoningCapabilityForEntry(e *ProviderEntry) (modelReasoningCapability, bool) {
285 if e == nil {
286 return modelReasoningCapability{}, false
287 }
288 cap, ok := modelReasoningCapabilities[strings.ToLower(strings.TrimSpace(e.Model))]
289 return cap, ok
290 }
291
292 func containsString(haystack []string, needle string) bool {
293 return slices.Contains(haystack, needle)
294 }
295
296 func normalizeEffortLevel(s string) string {
297 return strings.ToLower(strings.TrimSpace(s))
298 }
299
300 func normalizedSupportedEfforts(e *ProviderEntry) []string {
301 if e == nil || len(e.SupportedEfforts) == 0 {
302 return nil
303 }
304 return normalizedEffortLevels(e.SupportedEfforts)
305 }
306
307 func normalizedEffortLevels(levels []string) []string {
308 if len(levels) == 0 {
309 return nil
310 }
311 out := make([]string, 0, len(levels))
312 seen := map[string]bool{}
313 for _, raw := range levels {
314 level := normalizeEffortLevel(raw)
315 if level == "" || level == "auto" || seen[level] {
316 continue
317 }
318 seen[level] = true
319 out = append(out, level)
320 }
321 return out
322 }
323
324 func normalizedProviderHeaders(headers map[string]string) map[string]string {
325 if len(headers) == 0 {
326 return nil
327 }
328 out := make(map[string]string, len(headers))
329 for rawName, rawValue := range headers {
330 name := strings.TrimSpace(rawName)
331 value := strings.TrimSpace(rawValue)
332 if name == "" || value == "" {
333 continue
334 }
335 out[name] = value
336 }
337 if len(out) == 0 {
338 return nil
339 }
340 return out
341 }
342
343 func normalizedModelOverrides(overrides map[string]ProviderModelOverride) map[string]ProviderModelOverride {
344 if len(overrides) == 0 {
345 return nil
346 }
347 out := make(map[string]ProviderModelOverride, len(overrides))
348 for rawModel, ov := range overrides {
349 ov = explicitModelReasoning(ov)
350 model := strings.TrimSpace(rawModel)
351 if model == "" {
352 continue
353 }
354 ov.ReasoningProtocol = normalizeReasoningProtocol(ov.ReasoningProtocol)
355 ov.SupportedEfforts = normalizedEffortLevels(ov.SupportedEfforts)
356 ov.DefaultEffort = normalizeEffortLevel(ov.DefaultEffort)
357 if ov.ContextWindow < 0 {
358 ov.ContextWindow = 0
359 }
360
361 // One definition of "empty", shared with the renderer. The inline copy
362 // that used to live here omitted MaxOutputTokens, so the loader dropped
363 // an override the renderer would have written back out.
364 if modelOverrideEmpty(ov) {
365 continue
366 }
367 out[model] = ov
368 }
369 if len(out) == 0 {
370 return nil
371 }
372 return out
373 }
374
374 lines GO