Skip to content

Commit 014085d

Browse files
Merge remote-tracking branch 'origin/main' into feature/execution-plans-ui
# Conflicts: # internal/executionplans/types_test.go
2 parents c956944 + 9aa8aae commit 014085d

46 files changed

Lines changed: 3106 additions & 505 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config/config.example.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ guardrails:
107107
# mode: "inject"
108108
# content: "Follow safety guidelines."
109109

110+
fallback:
111+
default_mode: "off" # "off", "manual", or "auto"
112+
manual_rules_path: "config/fallback.example.json" # optional JSON map: {"model": ["fallback-1", "provider/model"]}; required when fallback.default_mode or any fallback.overrides.*.mode is "manual"
113+
overrides:
114+
"gpt-4o":
115+
mode: "manual" # use only manual fallbacks for this model; requires manual_rules_path
116+
"claude-sonnet-4":
117+
mode: "off" # disable fallback just for this model
118+
110119
providers:
111120
openai:
112121
type: openai

config/config.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
package config
33

44
import (
5+
"encoding/json"
56
"fmt"
7+
"io"
68
"math"
79
"os"
810
"reflect"
@@ -37,6 +39,7 @@ type Config struct {
3739
HTTP HTTPConfig `yaml:"http"`
3840
Admin AdminConfig `yaml:"admin"`
3941
Guardrails GuardrailsConfig `yaml:"guardrails"`
42+
Fallback FallbackConfig `yaml:"fallback"`
4043
ExecutionPlans ExecutionPlansConfig `yaml:"execution_plans"`
4144
Resilience ResilienceConfig `yaml:"resilience"`
4245
}
@@ -86,6 +89,53 @@ type RawRetryConfig struct {
8689
JitterFactor *float64 `yaml:"jitter_factor"`
8790
}
8891

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+
89139
// AdminConfig holds configuration for the admin API and dashboard UI.
90140
type AdminConfig struct {
91141
// EndpointsEnabled controls whether the admin REST API is active
@@ -261,6 +311,34 @@ type MongoDBStorageConfig struct {
261311
Database string `yaml:"database" env:"MONGODB_DATABASE"`
262312
}
263313

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+
264342
// CacheConfig holds model and response cache configuration.
265343
type CacheConfig struct {
266344
Model ModelCacheConfig `yaml:"model"`
@@ -574,6 +652,9 @@ func buildDefaultConfig() *Config {
574652
Timeout: 600,
575653
ResponseHeaderTimeout: 600,
576654
},
655+
Fallback: FallbackConfig{
656+
DefaultMode: FallbackModeOff,
657+
},
577658
ExecutionPlans: ExecutionPlansConfig{
578659
RefreshInterval: time.Minute,
579660
},
@@ -607,6 +688,10 @@ func Load() (*LoadResult, error) {
607688
return nil, err
608689
}
609690

691+
if err := loadFallbackConfig(&cfg.Fallback); err != nil {
692+
return nil, err
693+
}
694+
610695
// When no model cache backend was specified at all, default to local.
611696
if cfg.Cache.Model.Local == nil && cfg.Cache.Model.Redis == nil {
612697
cfg.Cache.Model.Local = &LocalCacheConfig{}
@@ -673,6 +758,137 @@ func applyYAML(cfg *Config) (map[string]RawProviderConfig, error) {
673758
return rawProviders, nil
674759
}
675760

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+
676892
// applyEnvOverrides walks cfg's struct fields and applies env var overrides
677893
// based on `env` struct tags. Maps are skipped.
678894
func applyEnvOverrides(cfg *Config) error {

0 commit comments

Comments
 (0)