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