|
2 | 2 | package config |
3 | 3 |
|
4 | 4 | import ( |
| 5 | + "encoding/json" |
5 | 6 | "fmt" |
| 7 | + "io" |
6 | 8 | "math" |
7 | 9 | "os" |
8 | 10 | "reflect" |
@@ -37,6 +39,7 @@ type Config struct { |
37 | 39 | HTTP HTTPConfig `yaml:"http"` |
38 | 40 | Admin AdminConfig `yaml:"admin"` |
39 | 41 | Guardrails GuardrailsConfig `yaml:"guardrails"` |
| 42 | + Fallback FallbackConfig `yaml:"fallback"` |
40 | 43 | ExecutionPlans ExecutionPlansConfig `yaml:"execution_plans"` |
41 | 44 | Resilience ResilienceConfig `yaml:"resilience"` |
42 | 45 | } |
@@ -86,6 +89,53 @@ type RawRetryConfig struct { |
86 | 89 | JitterFactor *float64 `yaml:"jitter_factor"` |
87 | 90 | } |
88 | 91 |
|
| 92 | +// FallbackMode controls how alternate models are selected when the primary |
| 93 | +// model is unavailable. |
| 94 | +type FallbackMode string |
| 95 | + |
| 96 | +const ( |
| 97 | + FallbackModeOff FallbackMode = "off" |
| 98 | + FallbackModeManual FallbackMode = "manual" |
| 99 | + FallbackModeAuto FallbackMode = "auto" |
| 100 | +) |
| 101 | + |
| 102 | +// Valid reports whether mode is one of the supported fallback modes. |
| 103 | +func (m FallbackMode) Valid() bool { |
| 104 | + switch normalizeFallbackMode(m) { |
| 105 | + case FallbackModeOff, FallbackModeManual, FallbackModeAuto: |
| 106 | + return true |
| 107 | + default: |
| 108 | + return false |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +func normalizeFallbackMode(mode FallbackMode) FallbackMode { |
| 113 | + return FallbackMode(strings.ToLower(strings.TrimSpace(string(mode)))) |
| 114 | +} |
| 115 | + |
| 116 | +// FallbackModelOverride holds per-model mode overrides. |
| 117 | +type FallbackModelOverride struct { |
| 118 | + Mode FallbackMode `yaml:"mode" json:"mode"` |
| 119 | +} |
| 120 | + |
| 121 | +// FallbackConfig holds translated-route model fallback policy. |
| 122 | +type FallbackConfig struct { |
| 123 | + // DefaultMode controls the fallback behavior when no per-model override exists. |
| 124 | + // Supported values: "auto", "manual", "off". Default: "off". |
| 125 | + DefaultMode FallbackMode `yaml:"default_mode" env:"FEATURE_FALLBACK_MODE"` |
| 126 | + |
| 127 | + // ManualRulesPath points to a JSON file that maps source model selectors to |
| 128 | + // ordered fallback model selector lists. Empty disables manual rules. |
| 129 | + ManualRulesPath string `yaml:"manual_rules_path" env:"FALLBACK_MANUAL_RULES_PATH"` |
| 130 | + |
| 131 | + // Overrides controls per-model mode overrides. Keys may be bare models |
| 132 | + // ("gpt-4o") or provider-qualified public selectors ("azure/gpt-4o"). |
| 133 | + Overrides map[string]FallbackModelOverride `yaml:"overrides"` |
| 134 | + |
| 135 | + // Manual holds the parsed manual fallback lists loaded from ManualRulesPath. |
| 136 | + Manual map[string][]string `yaml:"-"` |
| 137 | +} |
| 138 | + |
89 | 139 | // AdminConfig holds configuration for the admin API and dashboard UI. |
90 | 140 | type AdminConfig struct { |
91 | 141 | // EndpointsEnabled controls whether the admin REST API is active |
@@ -261,6 +311,34 @@ type MongoDBStorageConfig struct { |
261 | 311 | Database string `yaml:"database" env:"MONGODB_DATABASE"` |
262 | 312 | } |
263 | 313 |
|
| 314 | +// BackendConfig converts the application storage config into the internal storage config. |
| 315 | +func (c StorageConfig) BackendConfig() storage.Config { |
| 316 | + cfg := storage.Config{ |
| 317 | + Type: c.Type, |
| 318 | + SQLite: storage.SQLiteConfig{ |
| 319 | + Path: c.SQLite.Path, |
| 320 | + }, |
| 321 | + PostgreSQL: storage.PostgreSQLConfig{ |
| 322 | + URL: c.PostgreSQL.URL, |
| 323 | + MaxConns: c.PostgreSQL.MaxConns, |
| 324 | + }, |
| 325 | + MongoDB: storage.MongoDBConfig{ |
| 326 | + URL: c.MongoDB.URL, |
| 327 | + Database: c.MongoDB.Database, |
| 328 | + }, |
| 329 | + } |
| 330 | + if cfg.Type == "" { |
| 331 | + cfg.Type = storage.TypeSQLite |
| 332 | + } |
| 333 | + if cfg.SQLite.Path == "" { |
| 334 | + cfg.SQLite.Path = storage.DefaultSQLitePath |
| 335 | + } |
| 336 | + if cfg.MongoDB.Database == "" { |
| 337 | + cfg.MongoDB.Database = "gomodel" |
| 338 | + } |
| 339 | + return cfg |
| 340 | +} |
| 341 | + |
264 | 342 | // CacheConfig holds model and response cache configuration. |
265 | 343 | type CacheConfig struct { |
266 | 344 | Model ModelCacheConfig `yaml:"model"` |
@@ -574,6 +652,9 @@ func buildDefaultConfig() *Config { |
574 | 652 | Timeout: 600, |
575 | 653 | ResponseHeaderTimeout: 600, |
576 | 654 | }, |
| 655 | + Fallback: FallbackConfig{ |
| 656 | + DefaultMode: FallbackModeOff, |
| 657 | + }, |
577 | 658 | ExecutionPlans: ExecutionPlansConfig{ |
578 | 659 | RefreshInterval: time.Minute, |
579 | 660 | }, |
@@ -607,6 +688,10 @@ func Load() (*LoadResult, error) { |
607 | 688 | return nil, err |
608 | 689 | } |
609 | 690 |
|
| 691 | + if err := loadFallbackConfig(&cfg.Fallback); err != nil { |
| 692 | + return nil, err |
| 693 | + } |
| 694 | + |
610 | 695 | // When no model cache backend was specified at all, default to local. |
611 | 696 | if cfg.Cache.Model.Local == nil && cfg.Cache.Model.Redis == nil { |
612 | 697 | cfg.Cache.Model.Local = &LocalCacheConfig{} |
@@ -673,6 +758,137 @@ func applyYAML(cfg *Config) (map[string]RawProviderConfig, error) { |
673 | 758 | return rawProviders, nil |
674 | 759 | } |
675 | 760 |
|
| 761 | +func loadFallbackConfig(cfg *FallbackConfig) error { |
| 762 | + if cfg == nil { |
| 763 | + return nil |
| 764 | + } |
| 765 | + |
| 766 | + cfg.DefaultMode = normalizeFallbackMode(cfg.DefaultMode) |
| 767 | + if cfg.DefaultMode == "" { |
| 768 | + cfg.DefaultMode = FallbackModeOff |
| 769 | + } |
| 770 | + if !cfg.DefaultMode.Valid() { |
| 771 | + return fmt.Errorf("fallback.default_mode must be one of: auto, manual, off") |
| 772 | + } |
| 773 | + |
| 774 | + if len(cfg.Overrides) > 0 { |
| 775 | + normalized := make(map[string]FallbackModelOverride, len(cfg.Overrides)) |
| 776 | + for key, override := range cfg.Overrides { |
| 777 | + key = strings.TrimSpace(key) |
| 778 | + if key == "" { |
| 779 | + return fmt.Errorf("fallback.overrides: model key cannot be empty") |
| 780 | + } |
| 781 | + if _, exists := normalized[key]; exists { |
| 782 | + return fmt.Errorf("fallback.overrides: duplicate model key after trimming: %q", key) |
| 783 | + } |
| 784 | + override.Mode = normalizeFallbackMode(override.Mode) |
| 785 | + if override.Mode == "" { |
| 786 | + return fmt.Errorf("fallback.overrides[%q].mode must be one of: auto, manual, off", key) |
| 787 | + } |
| 788 | + if !override.Mode.Valid() { |
| 789 | + return fmt.Errorf("fallback.overrides[%q].mode must be one of: auto, manual, off", key) |
| 790 | + } |
| 791 | + normalized[key] = override |
| 792 | + } |
| 793 | + cfg.Overrides = normalized |
| 794 | + } |
| 795 | + |
| 796 | + path := strings.TrimSpace(cfg.ManualRulesPath) |
| 797 | + if path == "" { |
| 798 | + if cfg.DefaultMode == FallbackModeManual { |
| 799 | + return fmt.Errorf("fallback.manual_rules_path must be set when fallback.default_mode or any fallback.overrides[].mode is 'manual'") |
| 800 | + } |
| 801 | + for _, override := range cfg.Overrides { |
| 802 | + if override.Mode == FallbackModeManual { |
| 803 | + return fmt.Errorf("fallback.manual_rules_path must be set when fallback.default_mode or any fallback.overrides[].mode is 'manual'") |
| 804 | + } |
| 805 | + } |
| 806 | + } |
| 807 | + if path == "" { |
| 808 | + cfg.Manual = nil |
| 809 | + return nil |
| 810 | + } |
| 811 | + |
| 812 | + raw, err := os.ReadFile(path) |
| 813 | + if err != nil { |
| 814 | + return fmt.Errorf("fallback.manual_rules_path: failed to read %q: %w", path, err) |
| 815 | + } |
| 816 | + |
| 817 | + expanded := expandString(string(raw)) |
| 818 | + decoded := make(map[string][]string) |
| 819 | + decoder := json.NewDecoder(strings.NewReader(expanded)) |
| 820 | + |
| 821 | + token, err := decoder.Token() |
| 822 | + if err != nil { |
| 823 | + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) |
| 824 | + } |
| 825 | + delim, ok := token.(json.Delim) |
| 826 | + if !ok || delim != '{' { |
| 827 | + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: top-level JSON value must be an object", path) |
| 828 | + } |
| 829 | + |
| 830 | + seenKeys := make(map[string]struct{}) |
| 831 | + for decoder.More() { |
| 832 | + token, err := decoder.Token() |
| 833 | + if err != nil { |
| 834 | + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) |
| 835 | + } |
| 836 | + key, ok := token.(string) |
| 837 | + if !ok { |
| 838 | + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: object key must be a string", path) |
| 839 | + } |
| 840 | + if _, exists := seenKeys[key]; exists { |
| 841 | + return fmt.Errorf("fallback.manual_rules_path: duplicate JSON key %q in %q", key, path) |
| 842 | + } |
| 843 | + seenKeys[key] = struct{}{} |
| 844 | + |
| 845 | + var models []string |
| 846 | + if err := decoder.Decode(&models); err != nil { |
| 847 | + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) |
| 848 | + } |
| 849 | + decoded[key] = models |
| 850 | + } |
| 851 | + |
| 852 | + token, err = decoder.Token() |
| 853 | + if err != nil { |
| 854 | + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) |
| 855 | + } |
| 856 | + delim, ok = token.(json.Delim) |
| 857 | + if !ok || delim != '}' { |
| 858 | + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: top-level JSON value must be an object", path) |
| 859 | + } |
| 860 | + |
| 861 | + var trailing json.RawMessage |
| 862 | + if err := decoder.Decode(&trailing); err != io.EOF { |
| 863 | + if err != nil { |
| 864 | + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) |
| 865 | + } |
| 866 | + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: unexpected trailing JSON content", path) |
| 867 | + } |
| 868 | + |
| 869 | + manual := make(map[string][]string, len(decoded)) |
| 870 | + for key, models := range decoded { |
| 871 | + key = strings.TrimSpace(key) |
| 872 | + if key == "" { |
| 873 | + return fmt.Errorf("fallback.manual_rules_path: model key cannot be empty") |
| 874 | + } |
| 875 | + if _, exists := manual[key]; exists { |
| 876 | + return fmt.Errorf("fallback.manual_rules_path: duplicate manual rule key after trimming: %q", key) |
| 877 | + } |
| 878 | + normalized := make([]string, 0, len(models)) |
| 879 | + for _, model := range models { |
| 880 | + model = strings.TrimSpace(model) |
| 881 | + if model == "" { |
| 882 | + continue |
| 883 | + } |
| 884 | + normalized = append(normalized, model) |
| 885 | + } |
| 886 | + manual[key] = normalized |
| 887 | + } |
| 888 | + cfg.Manual = manual |
| 889 | + return nil |
| 890 | +} |
| 891 | + |
676 | 892 | // applyEnvOverrides walks cfg's struct fields and applies env var overrides |
677 | 893 | // based on `env` struct tags. Maps are skipped. |
678 | 894 | func applyEnvOverrides(cfg *Config) error { |
|
0 commit comments