| 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 |