diff --git a/.env.template b/.env.template index e53c74811..10ea44371 100644 --- a/.env.template +++ b/.env.template @@ -128,11 +128,11 @@ # description, enabled. A target has model and optional provider + weight. # VIRTUAL_MODELS=[{"source":"smart","strategy":"round_robin","targets":[{"model":"openai/gpt-4o","weight":2},{"model":"anthropic/claude-sonnet-4-6"}]}] -# Fallback & Workflow Configuration +# Failover & Workflow Configuration # Failover is manual-only at runtime and enabled by default. # FAILOVER_ENABLED=true -# JSON file mapping primary model selectors to ordered fallback model lists -# FALLBACK_MANUAL_RULES_PATH=config/fallback.example.json +# JSON file mapping primary model selectors to ordered failover target lists +# FAILOVER_MANUAL_RULES_PATH=config/failover.example.json # Inline JSON object mapping primary model selectors to ordered failover target arrays # FAILOVER_RULES_JSON={"gpt-4o":["azure/gpt-4o","gemini/gemini-2.5-pro"]} # JSON array or boolean object of primary model selectors where failover is disabled diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index ce59e1ac5..f0b1ba67f 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -673,6 +673,271 @@ const docTemplate = `{ ] } }, + "/admin/failover": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "List failover mappings", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/failover.View" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Create or update one failover mapping", + "parameters": [ + { + "description": "Failover mapping", + "name": "mapping", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.upsertFailoverRuleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/failover.View" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete one failover mapping", + "parameters": [ + { + "description": "Failover primary model to remove", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.deleteFailoverRuleRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/admin/failover/generate": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Generate failover mapping suggestions", + "parameters": [ + { + "description": "Optional source model filter", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/admin.generateFailoverRulesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/failover.View" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/admin/failover/reset": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Reset dashboard-managed failover mappings", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/failover.View" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, "/admin/model-pricing-overrides": { "get": { "description": "Lists persisted USD pricing overrides. Selectors support global \"/\", provider-wide \"provider/\", model-wide \"model\", and exact \"provider/model\" scopes.", @@ -1329,6 +1594,52 @@ const docTemplate = `{ ] } }, + "/admin/usage/throughput": { + "get": { + "description": "Returns a fixed, trailing window of token-volume buckets\n(input / output / prompt-cached / locally-cached) at the\nrequested granularity, for the overview live chart.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get the live token-throughput window", + "parameters": [ + { + "type": "string", + "description": "Bucket granularity: second, minute, hour, day", + "name": "granularity", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/usage.TokenThroughput" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, "/admin/usage/user-paths": { "get": { "produces": [ @@ -4088,7 +4399,7 @@ const docTemplate = `{ "DASHBOARD_LIVE_LOGS_ENABLED": { "type": "string" }, - "FEATURE_FALLBACK_MODE": { + "FAILOVER_ENABLED": { "type": "string" }, "GUARDRAILS_ENABLED": { @@ -4299,6 +4610,14 @@ const docTemplate = `{ } } }, + "admin.deleteFailoverRuleRequest": { + "type": "object", + "properties": { + "primary_model": { + "type": "string" + } + } + }, "admin.deleteModelPricingOverrideRequest": { "type": "object", "properties": { @@ -4318,6 +4637,17 @@ const docTemplate = `{ "source" ] }, + "admin.generateFailoverRulesRequest": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "primary_model": { + "type": "string" + } + } + }, "admin.modelAccessResponse": { "type": "object", "properties": { @@ -4463,6 +4793,23 @@ const docTemplate = `{ } } }, + "admin.upsertFailoverRuleRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "fallback_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "primary_model": { + "type": "string" + } + } + }, "admin.upsertModelPricingOverrideRequest": { "type": "object", "properties": { @@ -4766,6 +5113,56 @@ const docTemplate = `{ } } }, + "auditlog.AttemptSnapshot": { + "type": "object", + "properties": { + "duration_ns": { + "type": "integer" + }, + "error_code": { + "type": "string" + }, + "error_message": { + "type": "string" + }, + "error_type": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "model": { + "type": "string" + }, + "provider_name": { + "type": "string" + }, + "provider_type": { + "type": "string" + }, + "response_body": { + "description": "ResponseBody and ResponseHeaders capture the raw upstream error response\nof a failed attempt. ResponseBody is the parsed JSON (or a string when the\nbody is not JSON); ResponseHeaders is redacted. Both are populated only\nwhen audit body/header logging is enabled." + }, + "response_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "seq": { + "type": "integer" + }, + "started_at": { + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, "auditlog.ConversationResult": { "type": "object", "properties": { @@ -4794,6 +5191,13 @@ const docTemplate = `{ "api_key_hash": { "type": "string" }, + "attempts": { + "description": "Attempts captures provider calls made for this logical request. SQL\nstores split this into audit_log_attempts; Mongo stores it embedded.", + "type": "array", + "items": { + "$ref": "#/definitions/auditlog.AttemptSnapshot" + } + }, "error_code": { "type": "string" }, @@ -4945,7 +5349,7 @@ const docTemplate = `{ "cache": { "type": "boolean" }, - "fallback": { + "failover": { "type": "boolean" }, "guardrails": { @@ -6533,6 +6937,35 @@ const docTemplate = `{ } } }, + "failover.View": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_models": { + "type": "array", + "items": { + "type": "string" + } + }, + "managed": { + "type": "boolean" + }, + "managed_source": { + "type": "string" + }, + "primary_model": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "pricingoverrides.Pricing": { "type": "object", "properties": { @@ -6736,6 +7169,12 @@ const docTemplate = `{ "usage.DailyUsage": { "type": "object", "properties": { + "cache_write_input_tokens": { + "type": "integer" + }, + "cached_input_tokens": { + "type": "integer" + }, "date": { "type": "string" }, @@ -6759,6 +7198,10 @@ const docTemplate = `{ }, "total_tokens": { "type": "integer" + }, + "uncached_input_tokens": { + "description": "Provider prompt-cache split of the period's input, folded per row from\nraw_data (same source as the summary). Zero when the storage layer does\nnot populate them.", + "type": "integer" } } }, @@ -6843,6 +7286,43 @@ const docTemplate = `{ } } }, + "usage.ThroughputBucket": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer" + }, + "locally_cached_tokens": { + "type": "integer" + }, + "output_tokens": { + "type": "integer" + }, + "prompt_cached_tokens": { + "type": "integer" + }, + "start": { + "type": "string" + } + } + }, + "usage.TokenThroughput": { + "type": "object", + "properties": { + "bucket_seconds": { + "type": "integer" + }, + "buckets": { + "type": "array", + "items": { + "$ref": "#/definitions/usage.ThroughputBucket" + } + }, + "granularity": { + "type": "string" + } + } + }, "usage.UsageLogEntry": { "type": "object", "properties": { diff --git a/config/config.example.yaml b/config/config.example.yaml index 1cff0cbe0..f36be5eee 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -194,9 +194,9 @@ guardrails: # skip_content_prefix: "### safe" # # prompt: "Custom rewrite instructions here." -fallback: +failover: enabled: true # env: FAILOVER_ENABLED; default true - manual_rules_path: "config/fallback.example.json" # optional JSON map: {"primary-model": ["fallback-1", "provider/model"]} + manual_rules_path: "config/failover.example.json" # optional JSON map: {"primary-model": ["fallback-1", "provider/model"]} rules: "gpt-4o": - "azure/gpt-4o" diff --git a/config/config.go b/config/config.go index 1287f5888..334fea5f1 100644 --- a/config/config.go +++ b/config/config.go @@ -24,7 +24,7 @@ type Config struct { HTTP HTTPConfig `yaml:"http"` Admin AdminConfig `yaml:"admin"` Guardrails GuardrailsConfig `yaml:"guardrails"` - Fallback FallbackConfig `yaml:"fallback"` + Failover FailoverConfig `yaml:"failover"` Workflows WorkflowsConfig `yaml:"workflows"` Resilience ResilienceConfig `yaml:"resilience"` @@ -113,9 +113,9 @@ func buildDefaultConfig() *Config { Timeout: 600, ResponseHeaderTimeout: 600, }, - Fallback: FallbackConfig{ + Failover: FailoverConfig{ Enabled: true, - DefaultMode: FallbackModeManual, + DefaultMode: FailoverModeManual, }, Workflows: WorkflowsConfig{ RefreshInterval: time.Minute, @@ -182,7 +182,7 @@ func Load() (*LoadResult, error) { return nil, fmt.Errorf("models.configured_provider_models_mode must be one of: fallback, allowlist") } - if err := loadFallbackConfig(&cfg.Fallback); err != nil { + if err := loadFailoverConfig(&cfg.Failover); err != nil { return nil, err } diff --git a/config/config_test.go b/config/config_test.go index 75159d104..5fbb15981 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -61,7 +61,7 @@ func clearAllConfigEnvVars(t *testing.T) { "DASHBOARD_LIVE_LOGS_ENABLED", "DASHBOARD_LIVE_LOGS_BUFFER_SIZE", "DASHBOARD_LIVE_LOGS_REPLAY_LIMIT", "DASHBOARD_LIVE_LOGS_HEARTBEAT_SECONDS", "GUARDRAILS_ENABLED", "ENABLE_GUARDRAILS_FOR_BATCH_PROCESSING", - "FEATURE_FALLBACK_MODE", "FALLBACK_MANUAL_RULES_PATH", "FAILOVER_ENABLED", "FAILOVER_RULES_JSON", "FAILOVER_DISABLED_MODELS", "FAILOVER_DISABLED_MODELS_JSON", + "FAILOVER_MODE", "FAILOVER_MANUAL_RULES_PATH", "FAILOVER_ENABLED", "FAILOVER_RULES_JSON", "FAILOVER_DISABLED_MODELS", "FAILOVER_DISABLED_MODELS_JSON", "MODELS_ENABLED_BY_DEFAULT", "KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT", "CONFIGURED_PROVIDER_MODELS_MODE", "HTTP_TIMEOUT", "HTTP_RESPONSE_HEADER_TIMEOUT", "WORKFLOW_REFRESH_INTERVAL", @@ -226,11 +226,11 @@ func TestBuildDefaultConfig(t *testing.T) { if cfg.Guardrails.EnableForBatchProcessing { t.Error("expected Guardrails.EnableForBatchProcessing=false") } - if cfg.Fallback.DefaultMode != FallbackModeManual { - t.Errorf("expected Fallback.DefaultMode=manual, got %q", cfg.Fallback.DefaultMode) + if cfg.Failover.DefaultMode != FailoverModeManual { + t.Errorf("expected Failover.DefaultMode=manual, got %q", cfg.Failover.DefaultMode) } - if !cfg.Fallback.Enabled { - t.Error("expected Fallback.Enabled=true") + if !cfg.Failover.Enabled { + t.Error("expected Failover.Enabled=true") } if cfg.Cache.Response.Simple != nil { t.Errorf("expected Cache.Response.Simple=nil in defaults, got %+v", cfg.Cache.Response.Simple) @@ -637,33 +637,33 @@ logging: }) } -func TestLoad_FallbackManualRules(t *testing.T) { +func TestLoad_FailoverManualRules(t *testing.T) { clearAllConfigEnvVars(t) withTempDir(t, func(dir string) { - manualRulesPath := filepath.Join(dir, "fallback.json") + manualRulesPath := filepath.Join(dir, "failover.json") if err := os.WriteFile(manualRulesPath, []byte(`{ "gpt-4o": ["azure/gpt-4o", "gemini/gemini-2.5-pro"], "claude-sonnet-4": ["openai/gpt-5-mini"] }`), 0644); err != nil { - t.Fatalf("Failed to write fallback rules: %v", err) + t.Fatalf("Failed to write failover rules: %v", err) } type yamlConfig struct { - Fallback struct { + Failover struct { DefaultMode string `yaml:"default_mode"` ManualRulesPath string `yaml:"manual_rules_path"` Overrides map[string]map[string]string `yaml:"overrides"` - } `yaml:"fallback"` + } `yaml:"failover"` } yamlCfg := yamlConfig{} - yamlCfg.Fallback.DefaultMode = "auto" - yamlCfg.Fallback.ManualRulesPath = manualRulesPath - // A legacy fallback.overrides block (removed feature) must still load + yamlCfg.Failover.DefaultMode = "auto" + yamlCfg.Failover.ManualRulesPath = manualRulesPath + // A legacy failover.overrides block (removed feature) must still load // without error and must no longer affect behavior — even mode: off no // longer disables failover. Operators migrate to disabled_models. - yamlCfg.Fallback.Overrides = map[string]map[string]string{ + yamlCfg.Failover.Overrides = map[string]map[string]string{ "gpt-4o": {"mode": "off"}, } @@ -681,26 +681,26 @@ func TestLoad_FallbackManualRules(t *testing.T) { } cfg := result.Config - if cfg.Fallback.DefaultMode != FallbackModeAuto { - t.Fatalf("Fallback.DefaultMode = %q, want %q", cfg.Fallback.DefaultMode, FallbackModeAuto) + if cfg.Failover.DefaultMode != FailoverModeAuto { + t.Fatalf("Failover.DefaultMode = %q, want %q", cfg.Failover.DefaultMode, FailoverModeAuto) } - if cfg.Fallback.Disabled["gpt-4o"] { - t.Fatal("legacy fallback.overrides mode:off must no longer disable failover") + if cfg.Failover.Disabled["gpt-4o"] { + t.Fatal("legacy failover.overrides mode:off must no longer disable failover") } - got := cfg.Fallback.Manual["gpt-4o"] + got := cfg.Failover.Manual["gpt-4o"] want := []string{"azure/gpt-4o", "gemini/gemini-2.5-pro"} if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { - t.Fatalf("Fallback.Manual[gpt-4o] = %v, want %v", got, want) + t.Fatalf("Failover.Manual[gpt-4o] = %v, want %v", got, want) } }) } -func TestLoad_DeprecatedFallbackDefaultModeIsAccepted(t *testing.T) { +func TestLoad_DeprecatedFailoverDefaultModeIsAccepted(t *testing.T) { clearAllConfigEnvVars(t) withTempDir(t, func(dir string) { yaml := ` -fallback: +failover: default_mode: invalid ` if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil { @@ -711,8 +711,8 @@ fallback: if err != nil { t.Fatalf("Load() failed: %v", err) } - if result.Config.Fallback.DefaultMode != FallbackMode("invalid") { - t.Fatalf("Fallback.DefaultMode = %q, want invalid compatibility value", result.Config.Fallback.DefaultMode) + if result.Config.Failover.DefaultMode != FailoverMode("invalid") { + t.Fatalf("Failover.DefaultMode = %q, want invalid compatibility value", result.Config.Failover.DefaultMode) } }) } @@ -739,12 +739,12 @@ models: }) } -func TestLoad_EmptyFallbackOverrideModeIsAccepted(t *testing.T) { +func TestLoad_EmptyFailoverOverrideModeIsAccepted(t *testing.T) { clearAllConfigEnvVars(t) withTempDir(t, func(dir string) { yaml := ` -fallback: +failover: overrides: "gpt-4o": {} ` @@ -758,12 +758,12 @@ fallback: }) } -func TestLoad_ManualFallbackModeAllowsMissingManualRulesPath(t *testing.T) { +func TestLoad_ManualFailoverModeAllowsMissingManualRulesPath(t *testing.T) { clearAllConfigEnvVars(t) withTempDir(t, func(dir string) { yaml := ` -fallback: +failover: default_mode: manual ` if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil { @@ -774,24 +774,24 @@ fallback: if err != nil { t.Fatalf("Load() failed: %v", err) } - if result.Config.Fallback.DefaultMode != FallbackModeManual { - t.Fatalf("Fallback.DefaultMode = %q, want %q", result.Config.Fallback.DefaultMode, FallbackModeManual) + if result.Config.Failover.DefaultMode != FailoverModeManual { + t.Fatalf("Failover.DefaultMode = %q, want %q", result.Config.Failover.DefaultMode, FailoverModeManual) } - if result.Config.Fallback.Manual != nil { - t.Fatalf("Fallback.Manual = %v, want nil", result.Config.Fallback.Manual) + if result.Config.Failover.Manual != nil { + t.Fatalf("Failover.Manual = %v, want nil", result.Config.Failover.Manual) } }) } -func TestLoad_LegacyFallbackOverridesAreIgnored(t *testing.T) { +func TestLoad_LegacyFailoverOverridesAreIgnored(t *testing.T) { clearAllConfigEnvVars(t) withTempDir(t, func(dir string) { - // The removed fallback.overrides block must still load without error + // The removed failover.overrides block must still load without error // (yaml ignores the unknown key) and must have no effect: even mode: off // no longer disables failover. Operators migrate to disabled_models. yamlData := ` -fallback: +failover: overrides: "gpt-4o": mode: "off" @@ -804,35 +804,35 @@ fallback: if err != nil { t.Fatalf("Load() failed: %v", err) } - if result.Config.Fallback.Disabled["gpt-4o"] { - t.Fatal("legacy fallback.overrides mode:off must no longer disable failover") + if result.Config.Failover.Disabled["gpt-4o"] { + t.Fatal("legacy failover.overrides mode:off must no longer disable failover") } - if result.Config.Fallback.Manual != nil { - t.Fatalf("Fallback.Manual = %v, want nil", result.Config.Fallback.Manual) + if result.Config.Failover.Manual != nil { + t.Fatalf("Failover.Manual = %v, want nil", result.Config.Failover.Manual) } }) } -func TestLoad_FallbackManualRulesDuplicateKeyAfterTrim(t *testing.T) { +func TestLoad_FailoverManualRulesDuplicateKeyAfterTrim(t *testing.T) { clearAllConfigEnvVars(t) withTempDir(t, func(dir string) { - manualRulesPath := filepath.Join(dir, "fallback.json") + manualRulesPath := filepath.Join(dir, "failover.json") if err := os.WriteFile(manualRulesPath, []byte(`{ "gpt-4o": ["azure/gpt-4o"], " gpt-4o ": ["gemini/gemini-2.5-pro"] }`), 0644); err != nil { - t.Fatalf("Failed to write fallback rules: %v", err) + t.Fatalf("Failed to write failover rules: %v", err) } type yamlConfig struct { - Fallback struct { + Failover struct { ManualRulesPath string `yaml:"manual_rules_path"` - } `yaml:"fallback"` + } `yaml:"failover"` } yamlCfg := yamlConfig{} - yamlCfg.Fallback.ManualRulesPath = manualRulesPath + yamlCfg.Failover.ManualRulesPath = manualRulesPath yamlData, err := yaml.Marshal(yamlCfg) if err != nil { t.Fatalf("Failed to marshal config.yaml: %v", err) @@ -844,34 +844,34 @@ func TestLoad_FallbackManualRulesDuplicateKeyAfterTrim(t *testing.T) { _, err = Load() if err == nil { - t.Fatal("expected Load() to fail for duplicate fallback manual rule keys after trimming") + t.Fatal("expected Load() to fail for duplicate failover manual rule keys after trimming") } - if !strings.Contains(err.Error(), `fallback.manual_rules_path: duplicate manual rule key after trimming: "gpt-4o"`) { + if !strings.Contains(err.Error(), `failover.manual_rules_path: duplicate manual rule key after trimming: "gpt-4o"`) { t.Fatalf("Load() error = %v, want duplicate trimmed manual rule key error", err) } }) } -func TestLoad_FallbackManualRulesRejectsDuplicateRawJSONKeys(t *testing.T) { +func TestLoad_FailoverManualRulesRejectsDuplicateRawJSONKeys(t *testing.T) { clearAllConfigEnvVars(t) withTempDir(t, func(dir string) { - manualRulesPath := filepath.Join(dir, "fallback.json") + manualRulesPath := filepath.Join(dir, "failover.json") if err := os.WriteFile(manualRulesPath, []byte(`{ "gpt-4o": ["azure/gpt-4o"], "gpt-4o": ["gemini/gemini-2.5-pro"] }`), 0644); err != nil { - t.Fatalf("Failed to write fallback rules: %v", err) + t.Fatalf("Failed to write failover rules: %v", err) } type yamlConfig struct { - Fallback struct { + Failover struct { ManualRulesPath string `yaml:"manual_rules_path"` - } `yaml:"fallback"` + } `yaml:"failover"` } yamlCfg := yamlConfig{} - yamlCfg.Fallback.ManualRulesPath = manualRulesPath + yamlCfg.Failover.ManualRulesPath = manualRulesPath yamlData, err := yaml.Marshal(yamlCfg) if err != nil { t.Fatalf("Failed to marshal config.yaml: %v", err) @@ -883,7 +883,7 @@ func TestLoad_FallbackManualRulesRejectsDuplicateRawJSONKeys(t *testing.T) { _, err = Load() if err == nil { - t.Fatal("expected Load() to fail for duplicate raw JSON keys in fallback manual rules") + t.Fatal("expected Load() to fail for duplicate raw JSON keys in failover manual rules") } if !strings.Contains(err.Error(), `duplicate JSON key "gpt-4o"`) { t.Fatalf("Load() error = %v, want duplicate raw JSON key error", err) @@ -891,25 +891,25 @@ func TestLoad_FallbackManualRulesRejectsDuplicateRawJSONKeys(t *testing.T) { }) } -func TestLoad_FallbackManualRulesRejectsNullValues(t *testing.T) { +func TestLoad_FailoverManualRulesRejectsNullValues(t *testing.T) { clearAllConfigEnvVars(t) withTempDir(t, func(dir string) { - manualRulesPath := filepath.Join(dir, "fallback.json") + manualRulesPath := filepath.Join(dir, "failover.json") if err := os.WriteFile(manualRulesPath, []byte(`{ "gpt-4o": null }`), 0644); err != nil { - t.Fatalf("Failed to write fallback rules: %v", err) + t.Fatalf("Failed to write failover rules: %v", err) } type yamlConfig struct { - Fallback struct { + Failover struct { ManualRulesPath string `yaml:"manual_rules_path"` - } `yaml:"fallback"` + } `yaml:"failover"` } yamlCfg := yamlConfig{} - yamlCfg.Fallback.ManualRulesPath = manualRulesPath + yamlCfg.Failover.ManualRulesPath = manualRulesPath yamlData, err := yaml.Marshal(yamlCfg) if err != nil { t.Fatalf("Failed to marshal config.yaml: %v", err) @@ -921,7 +921,7 @@ func TestLoad_FallbackManualRulesRejectsNullValues(t *testing.T) { _, err = Load() if err == nil { - t.Fatal("expected Load() to fail for null fallback manual rule values") + t.Fatal("expected Load() to fail for null failover manual rule values") } if !strings.Contains(err.Error(), `null not allowed for "gpt-4o"`) { t.Fatalf("Load() error = %v, want null manual rule value error", err) @@ -929,22 +929,22 @@ func TestLoad_FallbackManualRulesRejectsNullValues(t *testing.T) { }) } -func TestLoad_FeatureFallbackModeEnvOverridesFallbackDefaultMode(t *testing.T) { +func TestLoad_FeatureFailoverModeEnvOverridesFailoverDefaultMode(t *testing.T) { clearAllConfigEnvVars(t) - t.Setenv("FEATURE_FALLBACK_MODE", "auto") + t.Setenv("FAILOVER_MODE", "auto") withTempDir(t, func(_ string) { result, err := Load() if err != nil { t.Fatalf("Load() failed: %v", err) } - if result.Config.Fallback.DefaultMode != FallbackModeAuto { - t.Fatalf("Fallback.DefaultMode = %q, want %q", result.Config.Fallback.DefaultMode, FallbackModeAuto) + if result.Config.Failover.DefaultMode != FailoverModeAuto { + t.Fatalf("Failover.DefaultMode = %q, want %q", result.Config.Failover.DefaultMode, FailoverModeAuto) } }) } -func TestLoad_FallbackRulesJSONEnvOnly(t *testing.T) { +func TestLoad_FailoverRulesJSONEnvOnly(t *testing.T) { clearAllConfigEnvVars(t) t.Setenv("FAILOVER_RULES_JSON", `{"gpt-4o":["azure/gpt-4o","gemini/gemini-2.5-pro"]}`) t.Setenv("FAILOVER_DISABLED_MODELS_JSON", `["claude-sonnet-4"]`) @@ -954,23 +954,23 @@ func TestLoad_FallbackRulesJSONEnvOnly(t *testing.T) { if err != nil { t.Fatalf("Load() failed: %v", err) } - got := result.Config.Fallback.Manual["gpt-4o"] + got := result.Config.Failover.Manual["gpt-4o"] want := []string{"azure/gpt-4o", "gemini/gemini-2.5-pro"} if !reflect.DeepEqual(got, want) { - t.Fatalf("Fallback.Manual[gpt-4o] = %v, want %v", got, want) + t.Fatalf("Failover.Manual[gpt-4o] = %v, want %v", got, want) } - if !result.Config.Fallback.Disabled["claude-sonnet-4"] { - t.Fatal("Fallback.Disabled[claude-sonnet-4] = false, want true") + if !result.Config.Failover.Disabled["claude-sonnet-4"] { + t.Fatal("Failover.Disabled[claude-sonnet-4] = false, want true") } }) } -func TestLoad_BlankFallbackDefaultModeResolvesToManual(t *testing.T) { +func TestLoad_BlankFailoverDefaultModeResolvesToManual(t *testing.T) { clearAllConfigEnvVars(t) withTempDir(t, func(dir string) { yaml := ` -fallback: +failover: default_mode: "" ` if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil { @@ -981,8 +981,8 @@ fallback: if err != nil { t.Fatalf("Load() failed: %v", err) } - if result.Config.Fallback.DefaultMode != FallbackModeManual { - t.Fatalf("Fallback.DefaultMode = %q, want %q", result.Config.Fallback.DefaultMode, FallbackModeManual) + if result.Config.Failover.DefaultMode != FailoverModeManual { + t.Fatalf("Failover.DefaultMode = %q, want %q", result.Config.Failover.DefaultMode, FailoverModeManual) } }) } @@ -1088,13 +1088,13 @@ func TestLoad_ConfigExample_UsesNestedModelCacheSettings(t *testing.T) { if err != nil { t.Fatalf("Failed to read config.example.yaml: %v", err) } - fallbackExamplePath, err := filepath.Abs("fallback.example.json") + failoverExamplePath, err := filepath.Abs("failover.example.json") if err != nil { - t.Fatalf("Failed to resolve fallback.example.json path: %v", err) + t.Fatalf("Failed to resolve failover.example.json path: %v", err) } - fallbackExampleData, err := os.ReadFile(fallbackExamplePath) + failoverExampleData, err := os.ReadFile(failoverExamplePath) if err != nil { - t.Fatalf("Failed to read fallback.example.json: %v", err) + t.Fatalf("Failed to read failover.example.json: %v", err) } withTempDir(t, func(dir string) { @@ -1104,8 +1104,8 @@ func TestLoad_ConfigExample_UsesNestedModelCacheSettings(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "config", "config.yaml"), exampleData, 0644); err != nil { t.Fatalf("Failed to write config/config.yaml: %v", err) } - if err := os.WriteFile(filepath.Join(dir, "config", "fallback.example.json"), fallbackExampleData, 0644); err != nil { - t.Fatalf("Failed to write fallback.example.json: %v", err) + if err := os.WriteFile(filepath.Join(dir, "config", "failover.example.json"), failoverExampleData, 0644); err != nil { + t.Fatalf("Failed to write failover.example.json: %v", err) } result, err := Load() diff --git a/config/fallback.example.json b/config/failover.example.json similarity index 100% rename from config/fallback.example.json rename to config/failover.example.json diff --git a/config/fallback.go b/config/failover.go similarity index 73% rename from config/fallback.go rename to config/failover.go index 3b54c129f..8771f8786 100644 --- a/config/fallback.go +++ b/config/failover.go @@ -9,51 +9,51 @@ import ( "strings" ) -type FallbackMode string +type FailoverMode string const ( - FallbackModeOff FallbackMode = "off" - FallbackModeManual FallbackMode = "manual" - FallbackModeAuto FallbackMode = "auto" + FailoverModeOff FailoverMode = "off" + FailoverModeManual FailoverMode = "manual" + FailoverModeAuto FailoverMode = "auto" ) -// Valid reports whether mode is one of the supported fallback modes. -func (m FallbackMode) Valid() bool { - switch normalizeFallbackMode(m) { - case FallbackModeOff, FallbackModeManual, FallbackModeAuto: +// Valid reports whether mode is one of the supported failover modes. +func (m FailoverMode) Valid() bool { + switch normalizeFailoverMode(m) { + case FailoverModeOff, FailoverModeManual, FailoverModeAuto: return true default: return false } } -func normalizeFallbackMode(mode FallbackMode) FallbackMode { - return FallbackMode(strings.ToLower(strings.TrimSpace(string(mode)))) +func normalizeFailoverMode(mode FailoverMode) FailoverMode { + return FailoverMode(strings.ToLower(strings.TrimSpace(string(mode)))) } -// ResolveFallbackDefaultMode canonicalizes the global fallback default mode and +// ResolveFailoverDefaultMode canonicalizes the global failover default mode and // applies the process default when unset. -func ResolveFallbackDefaultMode(mode FallbackMode) FallbackMode { - mode = normalizeFallbackMode(mode) +func ResolveFailoverDefaultMode(mode FailoverMode) FailoverMode { + mode = normalizeFailoverMode(mode) if mode == "" { - return FallbackModeManual + return FailoverModeManual } return mode } -// FallbackConfig holds translated-route model fallback policy. -type FallbackConfig struct { +// FailoverConfig holds translated-route model failover policy. +type FailoverConfig struct { // Enabled controls failover globally. It defaults to true; configured rules - // and workflow policy decide whether any request has fallback candidates. + // and workflow policy decide whether any request has failover candidates. Enabled bool `yaml:"enabled" env:"FAILOVER_ENABLED"` // DefaultMode is a deprecated compatibility field. It is accepted from old - // config files and FEATURE_FALLBACK_MODE, but runtime failover is manual-only. - DefaultMode FallbackMode `yaml:"default_mode" env:"FEATURE_FALLBACK_MODE"` + // config files and FAILOVER_MODE, but runtime failover is manual-only. + DefaultMode FailoverMode `yaml:"default_mode" env:"FAILOVER_MODE"` // ManualRulesPath points to a JSON file that maps source model selectors to - // ordered fallback model selector lists. Empty disables manual rules. - ManualRulesPath string `yaml:"manual_rules_path" env:"FALLBACK_MANUAL_RULES_PATH"` + // ordered failover model selector lists. Empty disables manual rules. + ManualRulesPath string `yaml:"manual_rules_path" env:"FAILOVER_MANUAL_RULES_PATH"` // Rules defines manual failover rules inline in config.yaml. Rules map[string][]string `yaml:"rules"` @@ -68,22 +68,22 @@ type FallbackConfig struct { // env. It accepts either a JSON string array or object with boolean values. DisabledModelsJSON string `yaml:"disabled_models_json" env:"FAILOVER_DISABLED_MODELS_JSON"` - // Manual holds the parsed manual fallback lists loaded from ManualRulesPath. + // Manual holds the parsed manual failover lists loaded from ManualRulesPath. Manual map[string][]string `yaml:"-"` // Disabled holds normalized per-model failover disables. Disabled map[string]bool `yaml:"-"` } -func loadFallbackConfig(cfg *FallbackConfig) error { +func loadFailoverConfig(cfg *FailoverConfig) error { if cfg == nil { return nil } - cfg.DefaultMode = ResolveFallbackDefaultMode(cfg.DefaultMode) + cfg.DefaultMode = ResolveFailoverDefaultMode(cfg.DefaultMode) manual := make(map[string][]string) - if err := mergeFallbackRules(manual, cfg.Rules, "fallback.rules"); err != nil { + if err := mergeFailoverRules(manual, cfg.Rules, "failover.rules"); err != nil { return err } @@ -91,23 +91,23 @@ func loadFallbackConfig(cfg *FallbackConfig) error { if path != "" { raw, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("fallback.manual_rules_path: failed to read %q: %w", path, err) + return fmt.Errorf("failover.manual_rules_path: failed to read %q: %w", path, err) } - decoded, err := decodeFallbackRuleJSON(string(raw), fmt.Sprintf("fallback.manual_rules_path: failed to parse %q", path)) + decoded, err := decodeFailoverRuleJSON(string(raw), fmt.Sprintf("failover.manual_rules_path: failed to parse %q", path)) if err != nil { return err } - if err := mergeFallbackRules(manual, decoded, "fallback.manual_rules_path"); err != nil { + if err := mergeFailoverRules(manual, decoded, "failover.manual_rules_path"); err != nil { return err } } if inline := strings.TrimSpace(cfg.RulesJSON); inline != "" { - decoded, err := decodeFallbackRuleJSON(inline, "fallback.rules_json") + decoded, err := decodeFailoverRuleJSON(inline, "failover.rules_json") if err != nil { return err } - if err := mergeFallbackRules(manual, decoded, "fallback.rules_json"); err != nil { + if err := mergeFailoverRules(manual, decoded, "failover.rules_json"); err != nil { return err } } @@ -117,7 +117,7 @@ func loadFallbackConfig(cfg *FallbackConfig) error { cfg.Manual = manual } - disabled, err := fallbackDisabledModels(cfg) + disabled, err := failoverDisabledModels(cfg) if err != nil { return err } @@ -125,7 +125,7 @@ func loadFallbackConfig(cfg *FallbackConfig) error { return nil } -func decodeFallbackRuleJSON(raw, label string) (map[string][]string, error) { +func decodeFailoverRuleJSON(raw, label string) (map[string][]string, error) { expanded := expandString(raw) decoded := make(map[string][]string) decoder := json.NewDecoder(strings.NewReader(expanded)) @@ -187,7 +187,7 @@ func decodeFallbackRuleJSON(raw, label string) (map[string][]string, error) { return decoded, nil } -func mergeFallbackRules(dst map[string][]string, src map[string][]string, label string) error { +func mergeFailoverRules(dst map[string][]string, src map[string][]string, label string) error { seen := make(map[string]struct{}, len(src)) for key, models := range src { key = strings.TrimSpace(key) @@ -211,7 +211,7 @@ func mergeFallbackRules(dst map[string][]string, src map[string][]string, label return nil } -func fallbackDisabledModels(cfg *FallbackConfig) (map[string]bool, error) { +func failoverDisabledModels(cfg *FailoverConfig) (map[string]bool, error) { disabled := make(map[string]bool) for _, model := range cfg.DisabledModels { model = strings.TrimSpace(model) @@ -236,7 +236,7 @@ func fallbackDisabledModels(cfg *FallbackConfig) (map[string]bool, error) { } var keyed map[string]bool if err := json.Unmarshal([]byte(expanded), &keyed); err != nil { - return nil, fmt.Errorf("fallback.disabled_models_json: must be a JSON array or boolean object: %w", err) + return nil, fmt.Errorf("failover.disabled_models_json: must be a JSON array or boolean object: %w", err) } for key, value := range keyed { key = strings.TrimSpace(key) diff --git a/docs/advanced/guardrails.mdx b/docs/advanced/guardrails.mdx index ab37a1ff9..53ff41e05 100644 --- a/docs/advanced/guardrails.mdx +++ b/docs/advanced/guardrails.mdx @@ -209,7 +209,7 @@ so a minimal config acts as an anonymizing preprocessor. When `llm_based_altering` calls the auxiliary model, GoModel runs that call through the normal translated request path in-process. That means ordinary -workflow selection, fallback, usage, audit, and cache behavior still apply. +workflow selection, failover, usage, audit, and cache behavior still apply. The internal request uses: - path: `/v1/chat/completions` diff --git a/docs/advanced/resilience.mdx b/docs/advanced/resilience.mdx index 81f0d0c3f..6b1c85e03 100644 --- a/docs/advanced/resilience.mdx +++ b/docs/advanced/resilience.mdx @@ -12,7 +12,7 @@ GoModel wraps every upstream provider call with two resilience layers: failing repeatedly, then probes once the timeout elapses. Both layers apply per provider. They do not switch to a different model or -provider on failure. For cross-model fallback, see +provider on failure. For cross-model failover, see [Failover](/features/failover). ## Defaults @@ -115,5 +115,5 @@ Given the YAML above, the effective per-provider settings are: The retry and circuit breaker layers stay on a single provider. If you also want GoModel to try a different model or provider when the primary keeps -failing, configure manual fallback rules. See +failing, configure manual failover rules. See [Failover](/features/failover). diff --git a/docs/advanced/workflows.mdx b/docs/advanced/workflows.mdx index e85fcb605..ac6406ddf 100644 --- a/docs/advanced/workflows.mdx +++ b/docs/advanced/workflows.mdx @@ -15,7 +15,7 @@ They currently control gateway-owned behavior such as: - audit logging - usage tracking - guardrails -- translated-route fallback +- translated-route failover Each request matches exactly one active workflow. @@ -113,7 +113,7 @@ curl -X POST http://localhost:8080/admin/workflows \ "audit": true, "usage": true, "guardrails": false, - "fallback": true + "failover": true }, "guardrails": [] } diff --git a/docs/dev/possible-refactoring.md b/docs/dev/possible-refactoring.md index 17fb7e96d..cc652fbb0 100644 --- a/docs/dev/possible-refactoring.md +++ b/docs/dev/possible-refactoring.md @@ -86,38 +86,38 @@ Suggested action: - Introduce a small shared internal package for cache semantics. - Do it only if it can be done without creating import cycles. -## 6. Centralize fallback-mode semantics in `config` +## 6. Centralize failover-mode semantics in `config` Effort: low Risk: low Why: -- `config.ResolveFallbackDefaultMode()` now owns the blank-to-`auto` defaulting rule. -- `internal/app/app.go` still re-implements fallback-mode parsing in: - - `dashboardFallbackModeValue()` - - `fallbackFeatureEnabledGlobally()` - - `fallbackModeEnabled()` +- `config.ResolveFailoverDefaultMode()` now owns the blank-to-`auto` defaulting rule. +- `internal/app/app.go` still re-implements failover-mode parsing in: + - `dashboardFailoverModeValue()` + - `failoverFeatureEnabledGlobally()` + - `failoverModeEnabled()` - Those helpers currently perform their own `TrimSpace` / case-folding instead of reusing config-owned semantics. Suggested action: - Add small config-owned helpers for: - - "is fallback enabled for this mode?" + - "is failover enabled for this mode?" - "what dashboard mode should be exposed for this config?" - Remove the ad hoc mode parsing from `internal/app/app.go`. - This keeps blank, mixed-case, and future mode handling in one place. -## 7. Collapse the duplicated translated fallback attempt loops +## 7. Collapse the duplicated translated failover attempt loops Effort: medium Risk: medium Why: -- `internal/server/translated_inference_service.go` has two near-identical fallback loops: - - `tryFallbackResponse()` - - `tryFallbackStream()` +- `internal/server/translated_inference_service.go` has two near-identical failover loops: + - `tryFailoverResponse()` + - `tryFailoverStream()` - Both: - fetch selectors - - gate on `shouldAttemptFallback()` + - gate on `shouldAttemptFailover()` - derive `providerType` - log attempt/success messages - walk candidates while preserving the last error @@ -130,13 +130,13 @@ Suggested action: - last-error handling - Keep the typed wrappers only for the response/stream result shapes. -## 8. Precompute fallback source identity once per resolution +## 8. Precompute failover source identity once per resolution Effort: medium Risk: low to medium Why: -- `internal/fallback/resolver.go` recomputes trimmed selector identity several times per request: +- `internal/failover/resolver.go` recomputes trimmed selector identity several times per request: - `sourceModelInfo()` - `modeFor()` - `manualSelectorsFor()` @@ -145,21 +145,21 @@ Why: - `modeFor()` and `manualSelectorsFor()` each rebuild the same ordered match-key list. Suggested action: -- Introduce a small internal struct for one fallback resolution pass, containing: +- Introduce a small internal struct for one failover resolution pass, containing: - source model info - canonical source key - ordered match keys -- Build it once in `ResolveFallbacks()` and pass it through helper calls. +- Build it once in `ResolveFailovers()` and pass it through helper calls. - This would trim repeated string cleanup and make precedence rules easier to inspect. -## 9. Extract manual fallback-rule file parsing from `loadFallbackConfig` +## 9. Extract manual failover-rule file parsing from `loadFailoverConfig` Effort: medium Risk: low to medium Why: -- `config.loadFallbackConfig()` currently owns both: - - fallback-mode validation/defaulting +- `config.loadFailoverConfig()` currently owns both: + - failover-mode validation/defaulting - the custom JSON loader for `manual_rules_path` - The manual loader includes: - duplicate raw JSON key detection @@ -169,11 +169,11 @@ Why: - That makes the config loader harder to scan than the rest of the config pipeline. Suggested action: -- Move the manual-rule JSON parsing into a dedicated helper or file, for example `loadFallbackManualRules(path string)`. -- Keep `loadFallbackConfig()` focused on policy validation and wiring. +- Move the manual-rule JSON parsing into a dedicated helper or file, for example `loadFailoverManualRules(path string)`. +- Keep `loadFailoverConfig()` focused on policy validation and wiring. - Preserve the current strict error messages and test coverage while isolating the parser. -## 10. Pick one owner for workflow fallback defaults +## 10. Pick one owner for workflow failover defaults Effort: medium Risk: medium @@ -188,7 +188,7 @@ Suggested action: - Options: - expose default feature flags from the admin config endpoint - derive the initial dashboard form from the active managed default workflow -- This reduces UI/backend drift for fallback and other workflow features. +- This reduces UI/backend drift for failover and other workflow features. ## Recommended order @@ -197,8 +197,8 @@ Suggested action: 3. Keep cached-only policy in one layer. 4. Remove the legacy middleware path. 5. Centralize cache semantics in a shared package. -6. Centralize fallback-mode semantics in `config`. -7. Collapse the duplicated translated fallback attempt loops. -8. Precompute fallback source identity once per resolution. -9. Extract manual fallback-rule file parsing from `loadFallbackConfig`. -10. Pick one owner for workflow fallback defaults. +6. Centralize failover-mode semantics in `config`. +7. Collapse the duplicated translated failover attempt loops. +8. Precompute failover source identity once per resolution. +9. Extract manual failover-rule file parsing from `loadFailoverConfig`. +10. Pick one owner for workflow failover defaults. diff --git a/docs/features/budgets.mdx b/docs/features/budgets.mdx index 201d8ec57..192ef67f9 100644 --- a/docs/features/budgets.mdx +++ b/docs/features/budgets.mdx @@ -144,7 +144,7 @@ In a workflow payload: "audit": true, "usage": true, "guardrails": true, - "fallback": true + "failover": true } } ``` diff --git a/docs/features/failover.mdx b/docs/features/failover.mdx index 94c4616a8..dc3df79cc 100644 --- a/docs/features/failover.mdx +++ b/docs/features/failover.mdx @@ -1,13 +1,13 @@ --- title: "Failover" -description: "Configure GoModel failover with manual mappings and know when fallback attempts run." +description: "Configure GoModel failover with manual mappings and know when failover attempts run." icon: "shuffle" keywords: ["failover", "fallback"] --- ## Overview -GoModel exposes failover through the `fallback` config block, environment +GoModel exposes failover through the `failover` config block, environment variables, and the Dashboard. When a request fails, GoModel can retry it against alternate models. Runtime @@ -16,13 +16,13 @@ failover uses manual rules only. ## Manual Mappings ```yaml -fallback: +failover: enabled: true - manual_rules_path: "config/fallback.json" + manual_rules_path: "config/failover.json" ``` -`config/fallback.json` is a JSON object where each primary model entry contains -an ordered fallback model list (array); top-level keys are not ordered: +`config/failover.json` is a JSON object where each primary model entry contains +an ordered failover target list (array); top-level keys are not ordered: ```json { @@ -45,7 +45,7 @@ For env-only deployments, use inline JSON: FAILOVER_RULES_JSON='{"gpt-4o":["azure/gpt-4o","gemini/gemini-2.5-pro"]}' ``` -`fallback.default_mode` and `FEATURE_FALLBACK_MODE` are deprecated +`failover.default_mode` and `FAILOVER_MODE` are deprecated compatibility inputs. They are accepted but ignored by runtime failover. The Dashboard can generate suggested mappings from model metadata, but diff --git a/docs/features/user-path.mdx b/docs/features/user-path.mdx index 6d3aaee21..77f29182e 100644 --- a/docs/features/user-path.mdx +++ b/docs/features/user-path.mdx @@ -69,7 +69,7 @@ different model access rules. ## Workflows Workflows can also include `scope_user_path`, so different teams or services can -use different budget, cache, audit, usage, guardrail, and fallback settings. +use different budget, cache, audit, usage, guardrail, and failover settings. You can combine user path with provider and model scope, for example: diff --git a/docs/openapi.json b/docs/openapi.json index 8954c4670..b9a20ac8d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1101,6 +1101,16 @@ "admin" ], "summary": "Generate failover mapping suggestions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.generateFailoverRulesRequest" + } + } + }, + "description": "Optional source model filter" + }, "responses": { "200": { "description": "OK", @@ -1115,6 +1125,16 @@ } } }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, "401": { "description": "Unauthorized", "content": { @@ -6802,6 +6822,17 @@ "source" ] }, + "admin.generateFailoverRulesRequest": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "primary_model": { + "type": "string" + } + } + }, "admin.modelAccessResponse": { "type": "object", "properties": { @@ -7278,6 +7309,56 @@ } } }, + "auditlog.AttemptSnapshot": { + "type": "object", + "properties": { + "duration_ns": { + "type": "integer" + }, + "error_code": { + "type": "string" + }, + "error_message": { + "type": "string" + }, + "error_type": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "model": { + "type": "string" + }, + "provider_name": { + "type": "string" + }, + "provider_type": { + "type": "string" + }, + "response_body": { + "description": "ResponseBody and ResponseHeaders capture the raw upstream error response\nof a failed attempt. ResponseBody is the parsed JSON (or a string when the\nbody is not JSON); ResponseHeaders is redacted. Both are populated only\nwhen audit body/header logging is enabled." + }, + "response_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "seq": { + "type": "integer" + }, + "started_at": { + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "success": { + "type": "boolean" + } + } + }, "auditlog.ConversationResult": { "type": "object", "properties": { @@ -7306,6 +7387,13 @@ "api_key_hash": { "type": "string" }, + "attempts": { + "description": "Attempts captures provider calls made for this logical request. SQL\nstores split this into audit_log_attempts; Mongo stores it embedded.", + "type": "array", + "items": { + "$ref": "#/components/schemas/auditlog.AttemptSnapshot" + } + }, "error_code": { "type": "string" }, @@ -7457,7 +7545,7 @@ "cache": { "type": "boolean" }, - "fallback": { + "failover": { "type": "boolean" }, "guardrails": { diff --git a/internal/admin/dashboard/static/js/modules/workflows-layout.test.cjs b/internal/admin/dashboard/static/js/modules/workflows-layout.test.cjs index 5a276556b..dfa6eaa07 100644 --- a/internal/admin/dashboard/static/js/modules/workflows-layout.test.cjs +++ b/internal/admin/dashboard/static/js/modules/workflows-layout.test.cjs @@ -256,11 +256,11 @@ test("workflow failover controls are gated by the runtime FAILOVER_ENABLED flag" assert.match( template, - /x-show="workflowFailoverVisible\(\)"[\s\S]*x-model="workflowForm\.features\.fallback"/, + /x-show="workflowFailoverVisible\(\)"[\s\S]*x-model="workflowForm\.features\.failover"/, ); assert.match( template, - /x-show="workflowFailoverVisible\(\)"[\s\S]*x-text="'Failover: ' \+ workflowFallbackLabel\(workflow\)"/, + /x-show="workflowFailoverVisible\(\)"[\s\S]*x-text="'Failover: ' \+ workflowFailoverLabel\(workflow\)"/, ); }); @@ -307,7 +307,7 @@ test("workflow editor renders a live preview card from the draft workflow state" assert.match( template, - /
[\s\S]*x-text="workflowDisplayName\(workflowPreview\(\)\)"[\s\S]*x-text="workflowScopeLabel\(workflowPreview\(\)\)"[\s\S]*x-text="'Failover: ' \+ workflowFallbackLabel\(workflowPreview\(\)\)"[\s\S]*{{template "workflow-chart" "workflowChart\(workflowPreview\(\)\)"}}[\s\S]*x-show="workflowGuardrails\(workflowPreview\(\)\)\.length > 0"/, + /
[\s\S]*x-text="workflowDisplayName\(workflowPreview\(\)\)"[\s\S]*x-text="workflowScopeLabel\(workflowPreview\(\)\)"[\s\S]*x-text="'Failover: ' \+ workflowFailoverLabel\(workflowPreview\(\)\)"[\s\S]*{{template "workflow-chart" "workflowChart\(workflowPreview\(\)\)"}}[\s\S]*x-show="workflowGuardrails\(workflowPreview\(\)\)\.length > 0"/, ); assert.match( chartTemplate, diff --git a/internal/admin/dashboard/static/js/modules/workflows.js b/internal/admin/dashboard/static/js/modules/workflows.js index c3f2efc7f..883c36fe1 100644 --- a/internal/admin/dashboard/static/js/modules/workflows.js +++ b/internal/admin/dashboard/static/js/modules/workflows.js @@ -55,7 +55,7 @@ usage: true, budget: true, guardrails: false, - fallback: true + failover: true }, guardrails: [] }, @@ -73,7 +73,7 @@ usage: true, budget: true, guardrails: false, - fallback: true + failover: true }, guardrails: [] }; @@ -148,7 +148,7 @@ usage: this.workflowUsageVisible(), budget: this.workflowBudgetVisible(), guardrails: this.workflowGuardrailsVisible(), - fallback: this.workflowFailoverVisible() + failover: this.workflowFailoverVisible() }; }, @@ -184,7 +184,7 @@ usage: !!this.workflowReadFeatureFlag(raw, 'usage', false), budget: this.workflowReadFeatureFlag(raw, 'budget', true) !== false, guardrails: !!this.workflowReadFeatureFlag(raw, 'guardrails', false), - fallback: this.workflowReadFeatureFlag(raw, 'fallback', true) !== false + failover: this.workflowReadFeatureFlag(raw, 'failover', true) !== false }; }, @@ -198,12 +198,12 @@ usage, budget: usage && features.budget && caps.budget, guardrails: features.guardrails && caps.guardrails, - fallback: features.fallback && caps.fallback + failover: features.failover && caps.failover }; }, - workflowFallbackLabel(source) { - return this.workflowSourceFeatures(source).fallback ? 'On' : 'Off'; + workflowFailoverLabel(source) { + return this.workflowSourceFeatures(source).failover ? 'On' : 'Off'; }, defaultWorkflowGuardrailStep(step) { @@ -399,7 +399,7 @@ const scope = this.workflowCurrentScope(); const rawFeatures = this.workflowNormalizedFeatures(form.features || {}); const features = this.workflowApplyGlobalFeatureCaps(rawFeatures); - features.fallback = rawFeatures.fallback; + features.failover = rawFeatures.failover; const guardrailsEnabled = !!features.guardrails; const guardrails = guardrailsEnabled ? this.workflowSourceGuardrails(form) : []; const scopeType = this.workflowScopeType(scope); @@ -424,7 +424,7 @@ usage: !!features.usage, budget: !!features.budget, guardrails: guardrailsEnabled, - fallback: !!features.fallback + failover: !!features.failover }, guardrails } @@ -443,7 +443,7 @@ const features = this.workflowApplyGlobalFeatureCaps(effective || raw); return { ...features, - fallback: this.workflowNormalizedFeatures(raw).fallback + failover: this.workflowNormalizedFeatures(raw).failover }; }, @@ -584,7 +584,7 @@ usage: !!storedFeatures.usage, budget: !!storedFeatures.budget, guardrails: !!storedFeatures.guardrails, - fallback: !!storedFeatures.fallback + failover: !!storedFeatures.failover }, guardrails: storedGuardrails.map((step) => ({ ref: String(step && step.ref || ''), @@ -689,9 +689,9 @@ const features = this.workflowApplyGlobalFeatureCaps(rawFeatures); const activeScopeMatch = this.workflowActiveScopeMatch(); const activeScopeFeatures = activeScopeMatch && activeScopeMatch.workflow_payload && activeScopeMatch.workflow_payload.features; - const activeScopeHasFallback = this.workflowHasDefinedFeatureFlag(activeScopeFeatures, 'fallback'); - const preservedActiveFallback = activeScopeHasFallback - ? this.workflowReadFeatureFlag(activeScopeFeatures, 'fallback', true) !== false + const activeScopeHasFailover = this.workflowHasDefinedFeatureFlag(activeScopeFeatures, 'failover'); + const preservedActiveFailover = activeScopeHasFailover + ? this.workflowReadFeatureFlag(activeScopeFeatures, 'failover', true) !== false : null; const hydratedScope = this.workflowHydratedScope || { scope_provider: '', @@ -701,13 +701,13 @@ const sameHydratedScope = String(hydratedScope.scope_provider || '').trim() === provider && String(hydratedScope.scope_model || '').trim() === model && this.normalizeWorkflowScopeUserPath(hydratedScope.scope_user_path) === this.normalizeWorkflowScopeUserPath(userPath); - const includeFallback = this.workflowFailoverVisible() + const includeFailover = this.workflowFailoverVisible() || (!!this.workflowFormHydrated && sameHydratedScope - && Object.prototype.hasOwnProperty.call(rawFeatures, 'fallback')) + && Object.prototype.hasOwnProperty.call(rawFeatures, 'failover')) || (!this.workflowFormHydrated && !!activeScopeMatch - && activeScopeHasFallback); + && activeScopeHasFailover); const guardrails = !!features.guardrails ? (Array.isArray(form.guardrails) ? form.guardrails : []).map((step) => { @@ -736,13 +736,13 @@ guardrails } }; - if (includeFallback) { - payload.workflow_payload.features.fallback = !this.workflowFailoverVisible() + if (includeFailover) { + payload.workflow_payload.features.failover = !this.workflowFailoverVisible() && !this.workflowFormHydrated && !!activeScopeMatch - && activeScopeHasFallback - ? preservedActiveFallback - : !!rawFeatures.fallback; + && activeScopeHasFailover + ? preservedActiveFailover + : !!rawFeatures.failover; } return payload; @@ -1222,7 +1222,7 @@ const showUsage = !!features.usage; const showAudit = forceAudit || !!features.audit; const showAsync = !!config.forceAsync || !!(showUsage || showAudit); - const showFailover = !!features.fallback || this.workflowRuntimeUsedFailover(runtime); + const showFailover = !!features.failover || this.workflowRuntimeUsedFailover(runtime); const workflowID = this.workflowChartWorkflowID(source, config.entry); const liveStep = this.workflowLiveCurrentStep(config.entry, runtime, features); const usagePending = this.workflowLiveUsagePending(config.entry); @@ -1278,7 +1278,7 @@ usage: false, budget: false, guardrails: false, - fallback: false + failover: false }); return this.workflowChartModel(source, runtime, { entry, diff --git a/internal/admin/dashboard/static/js/modules/workflows.test.cjs b/internal/admin/dashboard/static/js/modules/workflows.test.cjs index 8f4332685..45ce2d07f 100644 --- a/internal/admin/dashboard/static/js/modules/workflows.test.cjs +++ b/internal/admin/dashboard/static/js/modules/workflows.test.cjs @@ -62,12 +62,12 @@ test('workflowProviderOptions returns unique sorted provider names', () => { ); }); -test('defaultWorkflowForm starts fallback enabled for new workflows', () => { +test('defaultWorkflowForm starts failover enabled for new workflows', () => { const module = createWorkflowsModule(); - assert.equal(module.workflowForm.features.fallback, true); + assert.equal(module.workflowForm.features.failover, true); assert.equal(module.workflowForm.features.budget, true); - assert.equal(module.defaultWorkflowForm().features.fallback, true); + assert.equal(module.defaultWorkflowForm().features.failover, true); assert.equal(module.defaultWorkflowForm().features.budget, true); }); @@ -83,7 +83,7 @@ test('workflowPreview mirrors the draft workflow card state from the editor form audit: false, usage: true, guardrails: true, - fallback: false + failover: false }, guardrails: [ { ref: 'policy-system', step: 10 } @@ -110,7 +110,7 @@ test('workflowPreview mirrors the draft workflow card state from the editor form usage: true, budget: true, guardrails: true, - fallback: false + failover: false }, guardrails: [ { ref: 'policy-system', step: 10 } @@ -133,7 +133,7 @@ test('workflowPreview renders path-scoped draft labels using canonical scope dis audit: true, usage: true, guardrails: false, - fallback: false + failover: false }, guardrails: [] }; @@ -159,7 +159,7 @@ test('workflowPreview renders path-scoped draft labels using canonical scope dis usage: true, budget: true, guardrails: false, - fallback: false + failover: false }, guardrails: [] } @@ -179,7 +179,7 @@ test('workflowPreview does not coerce blank guardrail steps into step zero', () audit: true, usage: true, guardrails: true, - fallback: false + failover: false }, guardrails: [ { ref: 'policy-system', step: ' ' } @@ -209,7 +209,7 @@ test('workflowChart returns the shared chart contract for workflow sources', () usage: false, budget: true, guardrails: true, - fallback: true + failover: true }, guardrails: [ { ref: 'policy-system', step: 10 }, @@ -276,7 +276,7 @@ test('workflowChart masks globally disabled workflow features from persisted wor usage: true, budget: true, guardrails: true, - fallback: true + failover: true }, guardrails: [ { ref: 'policy-system', step: 10 } @@ -420,7 +420,7 @@ test('workflowAuditChart returns the shared chart contract for audit runtime ent usage: true, budget: true, guardrails: true, - fallback: true + failover: true }, guardrails: [ { ref: 'policy-system', step: 10 } @@ -533,7 +533,7 @@ test('workflowAuditChart prefers request-time workflow features over current wor usage: true, budget: true, guardrails: true, - fallback: true + failover: true }, guardrails: [ { ref: 'policy-system', step: 10 } @@ -555,7 +555,7 @@ test('workflowAuditChart prefers request-time workflow features over current wor usage: false, budget: false, guardrails: false, - fallback: true + failover: true } } })), @@ -609,7 +609,7 @@ test('workflowAuditChart highlights configured failover redirects and exposes th usage: true, budget: true, guardrails: false, - fallback: true + failover: true }, guardrails: [] } @@ -630,7 +630,7 @@ test('workflowAuditChart highlights configured failover redirects and exposes th usage: true, budget: true, guardrails: false, - fallback: true + failover: true }, failover: { target_model: 'azure/gpt-4o' @@ -832,7 +832,7 @@ test('workflowSubmitMode switches to save when an active workflow already matche audit: true, usage: true, guardrails: false, - fallback: false + failover: false }, guardrails: [] }; @@ -891,7 +891,7 @@ test('buildWorkflowRequest emits provider-model payload and strips guardrails wh audit: true, usage: true, guardrails: false, - fallback: false + failover: false }, guardrails: [ { ref: 'policy-system', step: 10 } @@ -914,7 +914,7 @@ test('buildWorkflowRequest emits provider-model payload and strips guardrails wh usage: true, budget: true, guardrails: false, - fallback: false + failover: false }, guardrails: [] } @@ -939,7 +939,7 @@ test('buildWorkflowRequest disables budget when usage is disabled in the form', usage: false, budget: true, guardrails: false, - fallback: true + failover: true }, guardrails: [] }; @@ -958,7 +958,7 @@ test('openWorkflowCreate hydrates saved features and guardrails from payload', ( usage: true, budget: false, guardrails: true, - fallback: false + failover: false }); module.workflowSourceGuardrails = () => ([ { ref: 'policy-system', step: 30 } @@ -994,7 +994,7 @@ test('openWorkflowCreate hydrates saved features and guardrails from payload', ( usage: false, budget: true, guardrails: false, - fallback: true + failover: true }) ); assert.equal(module.workflowFormHydrated, true); @@ -1022,7 +1022,7 @@ test('openWorkflowCreate drops blank guardrail steps instead of hydrating them a audit: true, usage: true, guardrails: true, - fallback: false + failover: false }, guardrails: [ { ref: 'policy-system', step: ' ' } @@ -1077,7 +1077,7 @@ test('editing a cloned workflow preserves retired provider and model options', ( audit: true, usage: true, guardrails: false, - fallback: true + failover: true }, guardrails: [] } @@ -1166,7 +1166,7 @@ test('buildWorkflowRequest preserves blank guardrail steps as invalid so validat audit: true, usage: true, guardrails: true, - fallback: true + failover: true }, guardrails: [ { ref: 'policy-system', step: ' ' } @@ -1182,7 +1182,7 @@ test('buildWorkflowRequest preserves blank guardrail steps as invalid so validat ); }); -test('workflowSourceFeatures defaults fallback to true when omitted', () => { +test('workflowSourceFeatures defaults failover to true when omitted', () => { const module = createWorkflowsModule(); assert.equal( @@ -1202,7 +1202,7 @@ test('workflowSourceFeatures defaults fallback to true when omitted', () => { usage: true, budget: true, guardrails: false, - fallback: true + failover: true }) ); }); @@ -1218,7 +1218,7 @@ test('workflowSourceFeatures respects effective runtime features for persisted w audit: true, usage: true, guardrails: true, - fallback: true + failover: true } }, effective_features: { @@ -1227,7 +1227,7 @@ test('workflowSourceFeatures respects effective runtime features for persisted w usage: true, budget: true, guardrails: false, - fallback: false + failover: false } })), JSON.stringify({ @@ -1236,7 +1236,7 @@ test('workflowSourceFeatures respects effective runtime features for persisted w usage: true, budget: true, guardrails: false, - fallback: true + failover: true }) ); }); @@ -1261,7 +1261,7 @@ test('workflowSourceFeatures masks raw workflow features by global runtime confi audit: true, usage: true, guardrails: true, - fallback: true + failover: true } } })), @@ -1271,7 +1271,7 @@ test('workflowSourceFeatures masks raw workflow features by global runtime confi usage: false, budget: false, guardrails: false, - fallback: true + failover: true }) ); }); @@ -1376,7 +1376,7 @@ test('fetchWorkflowRuntimeConfig aborts hung requests and clears the timeout', a assert.equal(timeoutCleared, true); }); -test('buildWorkflowRequest omits fallback for new workflows when the control is hidden', () => { +test('buildWorkflowRequest omits failover for new workflows when the control is hidden', () => { const module = createWorkflowsModule(); module.workflowRuntimeConfig = { FAILOVER_ENABLED: 'off', @@ -1390,13 +1390,13 @@ test('buildWorkflowRequest omits fallback for new workflows when the control is scope_provider: 'openai', scope_model: 'gpt-5', name: 'OpenAI GPT-5', - description: 'Preserve hidden fallback state', + description: 'Preserve hidden failover state', features: { cache: true, audit: true, usage: true, guardrails: false, - fallback: false + failover: false }, guardrails: [] }; @@ -1413,7 +1413,7 @@ test('buildWorkflowRequest omits fallback for new workflows when the control is ); }); -test('buildWorkflowRequest preserves fallback state for hydrated workflows even when the control is hidden', () => { +test('buildWorkflowRequest preserves failover state for hydrated workflows even when the control is hidden', () => { const module = createWorkflowsModule(); module.workflowRuntimeConfig = { FAILOVER_ENABLED: 'off', @@ -1432,13 +1432,13 @@ test('buildWorkflowRequest preserves fallback state for hydrated workflows even scope_provider: 'openai', scope_model: 'gpt-5', name: 'OpenAI GPT-5', - description: 'Preserve hidden fallback state', + description: 'Preserve hidden failover state', features: { cache: true, audit: true, usage: true, guardrails: false, - fallback: false + failover: false }, guardrails: [] }; @@ -1451,12 +1451,12 @@ test('buildWorkflowRequest preserves fallback state for hydrated workflows even usage: true, budget: true, guardrails: false, - fallback: false + failover: false }) ); }); -test('buildWorkflowRequest preserves hidden fallback for fresh save flows that match an active workflow', () => { +test('buildWorkflowRequest preserves hidden failover for fresh save flows that match an active workflow', () => { const module = createWorkflowsModule(); module.workflowRuntimeConfig = { FAILOVER_ENABLED: 'off', @@ -1479,7 +1479,7 @@ test('buildWorkflowRequest preserves hidden fallback for fresh save flows that m audit: true, usage: true, guardrails: false, - fallback: false + failover: false }, guardrails: [] } @@ -1490,13 +1490,13 @@ test('buildWorkflowRequest preserves hidden fallback for fresh save flows that m scope_provider: 'openai', scope_model: 'gpt-5', name: 'OpenAI GPT-5', - description: 'Preserve hidden fallback from the active workflow', + description: 'Preserve hidden failover from the active workflow', features: { cache: true, audit: true, usage: true, guardrails: false, - fallback: true + failover: true }, guardrails: [] }; @@ -1510,12 +1510,12 @@ test('buildWorkflowRequest preserves hidden fallback for fresh save flows that m usage: true, budget: true, guardrails: false, - fallback: false + failover: false }) ); }); -test('buildWorkflowRequest omits hidden fallback when a hydrated workflow is retargeted to a new scope', () => { +test('buildWorkflowRequest omits hidden failover when a hydrated workflow is retargeted to a new scope', () => { const module = createWorkflowsModule(); module.workflowRuntimeConfig = { FAILOVER_ENABLED: 'off', @@ -1534,13 +1534,13 @@ test('buildWorkflowRequest omits hidden fallback when a hydrated workflow is ret scope_provider: 'openai', scope_model: 'gpt-4o-mini', name: 'OpenAI GPT-4o mini', - description: 'Retargeted hidden fallback should not carry over', + description: 'Retargeted hidden failover should not carry over', features: { cache: true, audit: true, usage: true, guardrails: false, - fallback: true + failover: true }, guardrails: [] }; @@ -1578,7 +1578,7 @@ test('buildWorkflowRequest clamps globally disabled workflow features off even w audit: true, usage: true, guardrails: true, - fallback: true + failover: true }, guardrails: [ { ref: 'policy-system', step: 10 } @@ -2239,7 +2239,7 @@ test('auditEntryWorkflow prefers an exact historical workflow version cache over audit: false, usage: false, guardrails: false, - fallback: true + failover: true }, guardrails: [] } @@ -2259,7 +2259,7 @@ test('auditEntryWorkflow prefers an exact historical workflow version cache over audit: true, usage: true, guardrails: true, - fallback: true + failover: true }, guardrails: [ { ref: 'policy-system', step: 10 } @@ -2300,7 +2300,7 @@ test('fetchWorkflowVersion loads a historical workflow version once and caches m audit: true, usage: true, guardrails: false, - fallback: true + failover: true }, guardrails: [] } diff --git a/internal/admin/dashboard/templates/page-workflows.html b/internal/admin/dashboard/templates/page-workflows.html index 7ce559af8..50e8e9d49 100644 --- a/internal/admin/dashboard/templates/page-workflows.html +++ b/internal/admin/dashboard/templates/page-workflows.html @@ -127,7 +127,7 @@

- + Failover @@ -150,7 +150,7 @@

-

+

{{template "workflow-chart" "workflowChart(workflowPreview())"}} @@ -235,7 +235,7 @@

-

+

{{template "workflow-chart" "workflowChart(workflow)"}} diff --git a/internal/admin/handler_failover.go b/internal/admin/handler_failover.go index 0544a5235..bb6277d62 100644 --- a/internal/admin/handler_failover.go +++ b/internal/admin/handler_failover.go @@ -11,7 +11,6 @@ import ( "gomodel/config" "gomodel/internal/core" "gomodel/internal/failover" - fallbackresolver "gomodel/internal/fallback" "gomodel/internal/providers" ) @@ -189,7 +188,7 @@ func (h *Handler) GenerateFailoverRules(c *echo.Context) error { if err != nil { return handleError(c, err) } - resolver := fallbackresolver.NewResolverWithRuleProvider(config.FallbackConfig{Enabled: true}, h.registry, h.failoverRules) + resolver := failover.NewResolverWithRuleProvider(config.FailoverConfig{Enabled: true}, h.registry, h.failoverRules) if resolver == nil { return c.JSON(http.StatusOK, []failover.View{}) } @@ -214,7 +213,7 @@ func (h *Handler) GenerateFailoverRules(c *echo.Context) error { ProviderName: model.ProviderName, ProviderType: model.ProviderType, } - candidates := resolver.SuggestFallbacks(resolution, core.OperationChatCompletions) + candidates := resolver.SuggestFailovers(resolution, core.OperationChatCompletions) if len(candidates) == 0 { continue } diff --git a/internal/admin/handler_failover_test.go b/internal/admin/handler_failover_test.go index 344a4b9ac..12f46a6a0 100644 --- a/internal/admin/handler_failover_test.go +++ b/internal/admin/handler_failover_test.go @@ -66,7 +66,7 @@ func (s *failoverHandlerTestStore) Close() error { return nil } func newFailoverHandlerTestService(t *testing.T, store *failoverHandlerTestStore) *failoverrules.Service { t.Helper() - service, err := failoverrules.NewService(store, config.FallbackConfig{Enabled: true}) + service, err := failoverrules.NewService(store, config.FailoverConfig{Enabled: true}) if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -174,7 +174,7 @@ func TestGenerateFailoverRulesFiltersByPrimaryModel(t *testing.T) { t.Fatalf("Source = %q, want openai/gpt-4o", body[0].Source) } if len(body[0].Targets) == 0 { - t.Fatalf("Targets empty, want generated fallback suggestions") + t.Fatalf("Targets empty, want generated failover suggestions") } } diff --git a/internal/admin/handler_workflows_test.go b/internal/admin/handler_workflows_test.go index ee8544573..cb6ea3bbd 100644 --- a/internal/admin/handler_workflows_test.go +++ b/internal/admin/handler_workflows_test.go @@ -205,7 +205,7 @@ func newWorkflowHandlerWithModelRegistry(t *testing.T, store workflows.Store, mo } func TestListWorkflows(t *testing.T) { - fallbackDisabled := false + failoverDisabled := false store := &workflowTestStore{ versions: []workflows.Version{ { @@ -217,7 +217,7 @@ func TestListWorkflows(t *testing.T) { Name: "global", Payload: workflows.Payload{ SchemaVersion: 1, - Features: workflows.FeatureFlags{Cache: true, Audit: true, Usage: true, Guardrails: false, Fallback: &fallbackDisabled}, + Features: workflows.FeatureFlags{Cache: true, Audit: true, Usage: true, Guardrails: false, Failover: &failoverDisabled}, }, WorkflowHash: "hash-global", }, @@ -247,14 +247,14 @@ func TestListWorkflows(t *testing.T) { if body[0].ScopeDisplay != "global" { t.Fatalf("scope display = %q, want global", body[0].ScopeDisplay) } - if body[0].Payload.Features.Fallback == nil || *body[0].Payload.Features.Fallback { - t.Fatalf("payload fallback = %v, want explicit false", body[0].Payload.Features.Fallback) + if body[0].Payload.Features.Failover == nil || *body[0].Payload.Features.Failover { + t.Fatalf("payload failover = %v, want explicit false", body[0].Payload.Features.Failover) } if !body[0].EffectiveFeatures.Cache || !body[0].EffectiveFeatures.Audit || !body[0].EffectiveFeatures.Usage { t.Fatalf("effective features = %+v, want cache/audit/usage enabled", body[0].EffectiveFeatures) } - if body[0].EffectiveFeatures.Fallback { - t.Fatalf("effective features = %+v, want fallback disabled", body[0].EffectiveFeatures) + if body[0].EffectiveFeatures.Failover { + t.Fatalf("effective features = %+v, want failover disabled", body[0].EffectiveFeatures) } } @@ -354,7 +354,7 @@ func TestWorkflowsEndpointsReturn503WhenServiceUnavailable(t *testing.T) { } func TestGetWorkflow(t *testing.T) { - fallbackEnabled := true + failoverEnabled := true store := &workflowTestStore{ versions: []workflows.Version{ { @@ -389,7 +389,7 @@ func TestGetWorkflow(t *testing.T) { Audit: true, Usage: true, Guardrails: true, - Fallback: &fallbackEnabled, + Failover: &failoverEnabled, }, Guardrails: []workflows.GuardrailStep{ {Ref: "policy-system", Step: 10}, @@ -421,11 +421,14 @@ func TestGetWorkflow(t *testing.T) { if err := json.Unmarshal(rawBody["effective_features"], &effectiveFeatures); err != nil { t.Fatalf("unmarshal effective_features: %v", err) } - for _, key := range []string{"cache", "audit", "usage", "guardrails", "fallback"} { + for _, key := range []string{"cache", "audit", "usage", "guardrails", "failover"} { if _, ok := effectiveFeatures[key]; !ok { t.Fatalf("effective_features missing lower-case key %q: %s", key, rec.Body.String()) } } + if !effectiveFeatures["failover"] { + t.Fatalf("effective_features failover = false, want true (renamed field must round-trip): %s", rec.Body.String()) + } if _, ok := effectiveFeatures["Cache"]; ok { t.Fatalf("effective_features leaked Go field key %q: %s", "Cache", rec.Body.String()) } @@ -449,6 +452,9 @@ func TestGetWorkflow(t *testing.T) { if !body.Payload.Features.Usage || !body.Payload.Features.Audit || !body.Payload.Features.Guardrails { t.Fatalf("payload features = %+v, want usage/audit/guardrails enabled", body.Payload.Features) } + if body.Payload.Features.Failover == nil || !*body.Payload.Features.Failover { + t.Fatalf("payload failover = %v, want true (renamed field must round-trip)", body.Payload.Features.Failover) + } } func TestCreateWorkflow_NormalizesScopeUserPath(t *testing.T) { @@ -552,7 +558,7 @@ func TestCreateWorkflow(t *testing.T) { "description":"provider-model workflow", "workflow_payload":{ "schema_version":1, - "features":{"cache":false,"audit":true,"usage":true,"guardrails":false,"fallback":false}, + "features":{"cache":false,"audit":true,"usage":true,"guardrails":false,"failover":false}, "guardrails":[] } }`)) @@ -577,8 +583,8 @@ func TestCreateWorkflow(t *testing.T) { if body.Name != "openai gpt-5" { t.Fatalf("name = %q, want openai gpt-5", body.Name) } - if body.Payload.Features.Fallback == nil || *body.Payload.Features.Fallback { - t.Fatalf("payload fallback = %v, want explicit false", body.Payload.Features.Fallback) + if body.Payload.Features.Failover == nil || *body.Payload.Features.Failover { + t.Fatalf("payload failover = %v, want explicit false", body.Payload.Features.Failover) } views, err := h.workflows.ListViews(context.Background()) diff --git a/internal/app/app.go b/internal/app/app.go index f1da39d0a..732f633c9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,8 +23,7 @@ import ( "gomodel/internal/batch" "gomodel/internal/budget" "gomodel/internal/core" - failoverrules "gomodel/internal/failover" - "gomodel/internal/fallback" + "gomodel/internal/failover" "gomodel/internal/filestore" "gomodel/internal/guardrails" "gomodel/internal/live" @@ -49,7 +48,7 @@ type App struct { batch *batch.Result fileStore *filestore.Result virtualModels *virtualmodels.Result - failover *failoverrules.Result + failover *failover.Result pricingOverrides *pricingoverrides.Result authKeys *authkeys.Result guardrails *guardrails.Result @@ -251,11 +250,11 @@ func New(ctx context.Context, cfg Config) (*App, error) { // listing. vm := app.virtualModels.Service - var failoverResult *failoverrules.Result + var failoverResult *failover.Result if sharedStorage != nil { - failoverResult, err = failoverrules.NewWithSharedStorage(ctx, appCfg, sharedStorage) + failoverResult, err = failover.NewWithSharedStorage(ctx, appCfg, sharedStorage) } else { - failoverResult, err = failoverrules.New(ctx, appCfg) + failoverResult, err = failover.New(ctx, appCfg) } if err != nil { return fail("failed to initialize failover rules", err) @@ -397,7 +396,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { PricingResolver: pricingResolver, ModelResolver: vm, ModelAuthorizer: vm, - FallbackResolver: fallback.NewResolverWithRuleProvider(appCfg.Fallback, providerResult.Registry, failoverResult.Service), + FailoverResolver: failover.NewResolverWithRuleProvider(appCfg.Failover, providerResult.Registry, failoverResult.Service), WorkflowPolicyResolver: workflowResult.Service, TranslatedRequestPatcher: translatedRequestPatcher, BatchRequestPreparer: batchRequestPreparer, @@ -500,7 +499,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { ModelResolver: vm, ModelAuthorizer: vm, WorkflowPolicyResolver: workflowResult.Service, - FallbackResolver: serverCfg.FallbackResolver, + FailoverResolver: serverCfg.FailoverResolver, AuditLogger: auditResult.Logger, UsageLogger: usageResult.Logger, PricingResolver: pricingResolver, @@ -856,7 +855,7 @@ func initAdmin( configuredProviders []providers.SanitizedProviderConfig, authKeyService *authkeys.Service, virtualModelService *virtualmodels.Service, - failoverService *failoverrules.Service, + failoverService *failover.Service, pricingOverrideService *pricingoverrides.Service, workflowService *workflows.Service, guardrailService *guardrails.Service, @@ -991,7 +990,7 @@ func configGuardrailDefinitions(cfg config.GuardrailsConfig) ([]guardrails.Defin } func defaultWorkflowInput(cfg *config.Config, availableGuardrails []string, configuredGuardrails []guardrails.Definition) workflows.CreateInput { - fallbackEnabled := fallbackFeatureEnabledGlobally(cfg) + failoverEnabled := failoverFeatureEnabledGlobally(cfg) budgetEnabled := cfg.Budgets.Enabled payload := workflows.Payload{ SchemaVersion: 1, @@ -1000,7 +999,7 @@ func defaultWorkflowInput(cfg *config.Config, availableGuardrails []string, conf Audit: cfg.Logging.Enabled, Usage: cfg.Usage.Enabled, Budget: &budgetEnabled, - Fallback: &fallbackEnabled, + Failover: &failoverEnabled, }, } available := make(map[string]struct{}, len(availableGuardrails)) @@ -1045,7 +1044,7 @@ func defaultWorkflowInput(cfg *config.Config, availableGuardrails []string, conf func dashboardRuntimeConfig(cfg *config.Config, usageEnabled bool) admin.DashboardConfigResponse { return admin.DashboardConfigResponse{ - FailoverEnabled: dashboardEnabledValue(fallbackFeatureEnabledGlobally(cfg)), + FailoverEnabled: dashboardEnabledValue(failoverFeatureEnabledGlobally(cfg)), LoggingEnabled: dashboardEnabledValue(cfg != nil && cfg.Logging.Enabled), UsageEnabled: dashboardEnabledValue(cfg != nil && cfg.Usage.Enabled), BudgetsEnabled: dashboardEnabledValue(cfg != nil && cfg.Budgets.Enabled), @@ -1082,7 +1081,7 @@ func runtimeWorkflowFeatureCaps(cfg *config.Config) core.WorkflowFeatures { Usage: cfg.Usage.Enabled, Budget: cfg.Budgets.Enabled, Guardrails: cfg.Guardrails.Enabled, - Fallback: fallbackFeatureEnabledGlobally(cfg), + Failover: failoverFeatureEnabledGlobally(cfg), } } @@ -1120,6 +1119,6 @@ func semanticResponseCacheConfiguredFromResponse(cfg config.ResponseCacheConfig) return cfg.Semantic != nil && config.SemanticCacheActive(cfg.Semantic) } -func fallbackFeatureEnabledGlobally(cfg *config.Config) bool { - return cfg != nil && cfg.Fallback.Enabled +func failoverFeatureEnabledGlobally(cfg *config.Config) bool { + return cfg != nil && cfg.Failover.Enabled } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 00cc2a534..5933f837b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -258,32 +258,32 @@ func runtimeRefreshStepByName(steps []admin.RuntimeRefreshStep, name string) *ad return nil } -func TestRuntimeWorkflowFeatureCaps_EnableFallbackFromExplicitFlag(t *testing.T) { +func TestRuntimeWorkflowFeatureCaps_EnableFailoverFromExplicitFlag(t *testing.T) { cfg := &config.Config{ - Fallback: config.FallbackConfig{ + Failover: config.FailoverConfig{ Enabled: true, }, } caps := runtimeWorkflowFeatureCaps(cfg) - if !caps.Fallback { - t.Fatal("runtimeWorkflowFeatureCaps().Fallback = false, want true") + if !caps.Failover { + t.Fatal("runtimeWorkflowFeatureCaps().Failover = false, want true") } } -func TestDefaultWorkflowInput_SetsFallbackFeature(t *testing.T) { +func TestDefaultWorkflowInput_SetsFailoverFeature(t *testing.T) { cfg := &config.Config{ - Fallback: config.FallbackConfig{ + Failover: config.FailoverConfig{ Enabled: true, }, } input := defaultWorkflowInput(cfg, nil, nil) - if input.Payload.Features.Fallback == nil { - t.Fatal("defaultWorkflowInput().Payload.Features.Fallback = nil, want non-nil") + if input.Payload.Features.Failover == nil { + t.Fatal("defaultWorkflowInput().Payload.Features.Failover = nil, want non-nil") } - if !*input.Payload.Features.Fallback { - t.Fatal("defaultWorkflowInput().Payload.Features.Fallback = false, want true") + if !*input.Payload.Features.Failover { + t.Fatal("defaultWorkflowInput().Payload.Features.Failover = false, want true") } } @@ -433,7 +433,7 @@ func TestConfigGuardrailDefinitions_RejectsBlankNameOrType(t *testing.T) { func TestDashboardRuntimeConfig_ExposesFailoverEnabled(t *testing.T) { cfg := &config.Config{ - Fallback: config.FallbackConfig{ + Failover: config.FailoverConfig{ Enabled: true, }, } @@ -446,7 +446,7 @@ func TestDashboardRuntimeConfig_ExposesFailoverEnabled(t *testing.T) { func TestDashboardRuntimeConfig_FailoverDisabled(t *testing.T) { cfg := &config.Config{ - Fallback: config.FallbackConfig{ + Failover: config.FailoverConfig{ Enabled: false, }, } @@ -459,9 +459,9 @@ func TestDashboardRuntimeConfig_FailoverDisabled(t *testing.T) { func TestDashboardRuntimeConfig_DefaultModeDoesNotEnableFailover(t *testing.T) { cfg := &config.Config{ - Fallback: config.FallbackConfig{ + Failover: config.FailoverConfig{ Enabled: false, - DefaultMode: config.FallbackModeManual, + DefaultMode: config.FailoverModeManual, }, } diff --git a/internal/auditlog/auditlog.go b/internal/auditlog/auditlog.go index bf6e15fd4..8b67baee4 100644 --- a/internal/auditlog/auditlog.go +++ b/internal/auditlog/auditlog.go @@ -156,7 +156,7 @@ type WorkflowFeaturesSnapshot struct { Usage bool `json:"usage" bson:"usage"` Budget bool `json:"budget" bson:"budget"` Guardrails bool `json:"guardrails" bson:"guardrails"` - Fallback bool `json:"fallback" bson:"fallback"` + Failover bool `json:"failover" bson:"failover"` } // FailoverSnapshot stores the runtime failover selection used for one request. diff --git a/internal/auditlog/auditlog_test.go b/internal/auditlog/auditlog_test.go index 8275d3152..38ea22820 100644 --- a/internal/auditlog/auditlog_test.go +++ b/internal/auditlog/auditlog_test.go @@ -1420,7 +1420,7 @@ func TestCreateStreamEntry(t *testing.T) { Audit: true, Usage: true, Guardrails: false, - Fallback: true, + Failover: true, }, Failover: &FailoverSnapshot{ TargetModel: "azure/gpt-4o", @@ -1523,8 +1523,8 @@ func TestCreateStreamEntry(t *testing.T) { if streamEntry.Data.WorkflowFeatures.Guardrails != baseEntry.Data.WorkflowFeatures.Guardrails { t.Error("WorkflowFeatures.Guardrails mismatch") } - if streamEntry.Data.WorkflowFeatures.Fallback != baseEntry.Data.WorkflowFeatures.Fallback { - t.Error("WorkflowFeatures.Fallback mismatch") + if streamEntry.Data.WorkflowFeatures.Failover != baseEntry.Data.WorkflowFeatures.Failover { + t.Error("WorkflowFeatures.Failover mismatch") } } @@ -1545,7 +1545,7 @@ func TestEnrichEntryWithWorkflowStoresWorkflowFeatures(t *testing.T) { Audit: true, Usage: false, Guardrails: true, - Fallback: false, + Failover: false, }, }, }) @@ -1568,8 +1568,8 @@ func TestEnrichEntryWithWorkflowStoresWorkflowFeatures(t *testing.T) { if !entry.Data.WorkflowFeatures.Guardrails { t.Fatal("WorkflowFeatures.Guardrails = false, want true") } - if entry.Data.WorkflowFeatures.Fallback { - t.Fatal("WorkflowFeatures.Fallback = true, want false") + if entry.Data.WorkflowFeatures.Failover { + t.Fatal("WorkflowFeatures.Failover = true, want false") } } diff --git a/internal/auditlog/middleware.go b/internal/auditlog/middleware.go index 470f4c219..e6d329ad3 100644 --- a/internal/auditlog/middleware.go +++ b/internal/auditlog/middleware.go @@ -255,7 +255,7 @@ func enrichEntryWithWorkflow(entry *LogEntry, workflow *core.Workflow) { Usage: workflow.Policy.Features.Usage, Budget: workflow.Policy.Features.Budget, Guardrails: workflow.Policy.Features.Guardrails, - Fallback: workflow.Policy.Features.Fallback, + Failover: workflow.Policy.Features.Failover, } } } diff --git a/internal/core/context.go b/internal/core/context.go index 08796b040..35a310dd5 100644 --- a/internal/core/context.go +++ b/internal/core/context.go @@ -34,11 +34,11 @@ const ( // PatchChatRequest; consumed by the semantic cache to build params_hash. guardrailsHashKey contextKey = "guardrails-hash" - // fallbackUsedKey stores whether the translated execution path successfully - // served the request from a fallback model rather than the primary selector. - // Response cache writers use this to avoid storing fallback responses under + // failoverUsedKey stores whether the translated execution path successfully + // served the request from a failover model rather than the primary selector. + // Response cache writers use this to avoid storing failover responses under // the primary request key. - fallbackUsedKey contextKey = "fallback-used" + failoverUsedKey contextKey = "failover-used" // requestOriginKey stores the logical request origin for internal execution // flows that still reuse the translated request pipeline. @@ -205,14 +205,14 @@ func GetGuardrailsHash(ctx context.Context) string { return "" } -// WithFallbackUsed returns a new context marked as having used a fallback model. -func WithFallbackUsed(ctx context.Context) context.Context { - return context.WithValue(ctx, fallbackUsedKey, true) +// WithFailoverUsed returns a new context marked as having used a failover model. +func WithFailoverUsed(ctx context.Context) context.Context { + return context.WithValue(ctx, failoverUsedKey, true) } -// GetFallbackUsed reports whether the request was served by a fallback model. -func GetFallbackUsed(ctx context.Context) bool { - if v := ctx.Value(fallbackUsedKey); v != nil { +// GetFailoverUsed reports whether the request was served by a failover model. +func GetFailoverUsed(ctx context.Context) bool { + if v := ctx.Value(failoverUsedKey); v != nil { if used, ok := v.(bool); ok { return used } diff --git a/internal/core/workflow.go b/internal/core/workflow.go index 7659e2a72..2951e19e6 100644 --- a/internal/core/workflow.go +++ b/internal/core/workflow.go @@ -98,7 +98,7 @@ type WorkflowFeatures struct { Usage bool `json:"usage"` Budget bool `json:"budget"` Guardrails bool `json:"guardrails"` - Fallback bool `json:"fallback"` + Failover bool `json:"failover"` } // ApplyUpperBound returns features with process-level caps applied. @@ -110,7 +110,7 @@ func (f WorkflowFeatures) ApplyUpperBound(caps WorkflowFeatures) WorkflowFeature Usage: usage, Budget: usage && f.Budget && caps.Budget, Guardrails: f.Guardrails && caps.Guardrails, - Fallback: f.Fallback && caps.Fallback, + Failover: f.Failover && caps.Failover, } } @@ -123,7 +123,7 @@ func DefaultWorkflowFeatures() WorkflowFeatures { Usage: true, Budget: true, Guardrails: true, - Fallback: true, + Failover: true, } } @@ -205,9 +205,9 @@ func (p *Workflow) GuardrailsEnabled() bool { return p.featureEnabled(func(features WorkflowFeatures) bool { return features.Guardrails }) } -// FallbackEnabled reports whether translated-route fallback is enabled for the request. -func (p *Workflow) FallbackEnabled() bool { - return p.featureEnabled(func(features WorkflowFeatures) bool { return features.Fallback }) +// FailoverEnabled reports whether translated-route failover is enabled for the request. +func (p *Workflow) FailoverEnabled() bool { + return p.featureEnabled(func(features WorkflowFeatures) bool { return features.Failover }) } // GuardrailsHash returns the matched workflow's guardrails hash. diff --git a/internal/failover/factory.go b/internal/failover/factory.go index a92982f7b..366bd8786 100644 --- a/internal/failover/factory.go +++ b/internal/failover/factory.go @@ -80,7 +80,7 @@ func NewWithSharedStorage(ctx context.Context, cfg *config.Config, shared storag if err != nil { return nil, err } - service, err := NewService(store, cfg.Fallback) + service, err := NewService(store, cfg.Failover) if err != nil { return nil, err } diff --git a/internal/fallback/resolver.go b/internal/failover/resolver.go similarity index 94% rename from internal/fallback/resolver.go rename to internal/failover/resolver.go index 51e960958..016add283 100644 --- a/internal/fallback/resolver.go +++ b/internal/failover/resolver.go @@ -1,4 +1,4 @@ -package fallback +package failover import ( "math" @@ -10,7 +10,7 @@ import ( "gomodel/internal/providers" ) -const maxAutoFallbackCandidates = 5 +const maxAutoFailoverCandidates = 5 var preferredRankingNames = []string{ "chatbot_arena", @@ -20,7 +20,7 @@ var preferredRankingNames = []string{ "chatbot_arena_vision", } -// Registry is the minimal provider inventory surface needed for fallback +// Registry is the minimal provider inventory surface needed for failover // candidate resolution. type Registry interface { GetModel(model string) *providers.ModelInfo @@ -33,7 +33,7 @@ type RuleProvider interface { Disabled() map[string]bool } -// Resolver computes fallback model chains for translated routes. +// Resolver computes failover model chains for translated routes. type Resolver struct { enabled bool manual map[string][]string @@ -42,15 +42,15 @@ type Resolver struct { registry Registry } -// NewResolver builds a fallback resolver from config and the current model -// inventory. Returns nil when fallback is effectively disabled. -func NewResolver(cfg config.FallbackConfig, registry Registry) *Resolver { +// NewResolver builds a failover resolver from config and the current model +// inventory. Returns nil when failover is effectively disabled. +func NewResolver(cfg config.FailoverConfig, registry Registry) *Resolver { return NewResolverWithRuleProvider(cfg, registry, nil) } // NewResolverWithRuleProvider builds a resolver backed by static config and an // optional dynamic manual-rule provider. -func NewResolverWithRuleProvider(cfg config.FallbackConfig, registry Registry, ruleProvider RuleProvider) *Resolver { +func NewResolverWithRuleProvider(cfg config.FailoverConfig, registry Registry, ruleProvider RuleProvider) *Resolver { if registry == nil { return nil } @@ -80,10 +80,10 @@ func NewResolverWithRuleProvider(cfg config.FallbackConfig, registry Registry, r } } -// ResolveFallbacks returns the ordered fallback chain for a resolved request. -// Manual fallbacks preserve configured order. Runtime auto mode is intentionally +// ResolveFailovers returns the ordered failover chain for a resolved request. +// Manual failovers preserve configured order. Runtime auto mode is intentionally // not used; generated candidates must be saved as manual rules first. -func (r *Resolver) ResolveFallbacks(resolution *core.RequestModelResolution, op core.Operation) []core.ModelSelector { +func (r *Resolver) ResolveFailovers(resolution *core.RequestModelResolution, op core.Operation) []core.ModelSelector { if r == nil || resolution == nil || r.registry == nil || !r.enabled { return nil } @@ -166,9 +166,9 @@ func (r *Resolver) manualSelectorsFor( return nil } -// SuggestFallbacks returns ranked candidate selectors for an operator to review +// SuggestFailovers returns ranked candidate selectors for an operator to review // and save as a manual rule. Suggestions are never used directly at runtime. -func (r *Resolver) SuggestFallbacks(resolution *core.RequestModelResolution, op core.Operation) []core.ModelSelector { +func (r *Resolver) SuggestFailovers(resolution *core.RequestModelResolution, op core.Operation) []core.ModelSelector { if r == nil || resolution == nil || r.registry == nil || !r.enabled { return nil } @@ -327,7 +327,7 @@ func (r *Resolver) autoSelectorsFor( return a.key < b.key }) - limit := maxAutoFallbackCandidates + limit := maxAutoFailoverCandidates if len(candidates) < limit { limit = len(candidates) } diff --git a/internal/fallback/resolver_test.go b/internal/failover/resolver_test.go similarity index 87% rename from internal/fallback/resolver_test.go rename to internal/failover/resolver_test.go index b724f703a..b17eb664d 100644 --- a/internal/fallback/resolver_test.go +++ b/internal/failover/resolver_test.go @@ -1,4 +1,4 @@ -package fallback +package failover import ( "testing" @@ -21,21 +21,21 @@ func (r *fakeRegistry) ListModelsWithProvider() []providers.ModelWithProvider { return append([]providers.ModelWithProvider(nil), r.models...) } -func TestResolverManualModeUsesConfiguredFallbacks(t *testing.T) { +func TestResolverManualModeUsesConfiguredFailovers(t *testing.T) { registry := newFakeRegistry( modelInfo("gpt-4o", "openai", "openai", 1287, "gpt-4o"), modelInfo("gpt-4o", "azure", "azure", 1287, "gpt-4o"), modelInfo("gemini-2.5-pro", "gemini", "gemini", 1290, "gemini-2.5-pro"), ) - resolver := NewResolver(config.FallbackConfig{ + resolver := NewResolver(config.FailoverConfig{ Enabled: true, Manual: map[string][]string{ "gpt-4o": []string{"azure/gpt-4o", "gemini/gemini-2.5-pro"}, }, }, registry) - got := resolver.ResolveFallbacks(&core.RequestModelResolution{ + got := resolver.ResolveFailovers(&core.RequestModelResolution{ Requested: core.NewRequestedModelSelector("gpt-4o", ""), ResolvedSelector: core.ModelSelector{Model: "gpt-4o"}, ProviderType: "openai", @@ -52,7 +52,7 @@ func TestResolverManualModeUsesConfiguredFallbacks(t *testing.T) { } } -func TestResolverSuggestFallbacksReturnsRankingCandidates(t *testing.T) { +func TestResolverSuggestFailoversReturnsRankingCandidates(t *testing.T) { registry := newFakeRegistry( modelInfo("gpt-4o", "openai", "openai", 1287, "gpt-4o"), modelInfo("gpt-4o", "azure", "azure", 1287, "gpt-4o"), @@ -60,14 +60,14 @@ func TestResolverSuggestFallbacksReturnsRankingCandidates(t *testing.T) { modelInfo("claude-sonnet-4", "anthropic", "anthropic", 1305, "claude-sonnet"), ) - resolver := NewResolver(config.FallbackConfig{ + resolver := NewResolver(config.FailoverConfig{ Enabled: true, Manual: map[string][]string{ "gpt-4o": []string{"azure/gpt-4o"}, }, }, registry) - got := resolver.SuggestFallbacks(&core.RequestModelResolution{ + got := resolver.SuggestFailovers(&core.RequestModelResolution{ Requested: core.NewRequestedModelSelector("gpt-4o", ""), ResolvedSelector: core.ModelSelector{Model: "gpt-4o"}, ProviderType: "openai", @@ -84,19 +84,19 @@ func TestResolverSuggestFallbacksReturnsRankingCandidates(t *testing.T) { } } -func TestResolverBlankDefaultModeUsesManualFallback(t *testing.T) { +func TestResolverBlankDefaultModeUsesManualFailover(t *testing.T) { registry := newFakeRegistry( modelInfo("gpt-4o", "openai", "openai", 1287, "gpt-4o"), modelInfo("gpt-4o", "azure", "azure", 1287, "gpt-4o"), modelInfo("gemini-2.5-pro", "gemini", "gemini", 1290, "gemini-2.5-pro"), ) - resolver := NewResolver(config.FallbackConfig{Enabled: true}, registry) + resolver := NewResolver(config.FailoverConfig{Enabled: true}, registry) if resolver == nil { t.Fatal("NewResolver() = nil, want manual-enabled resolver") } - got := resolver.ResolveFallbacks(&core.RequestModelResolution{ + got := resolver.ResolveFailovers(&core.RequestModelResolution{ Requested: core.NewRequestedModelSelector("gpt-4o", ""), ResolvedSelector: core.ModelSelector{Model: "gpt-4o"}, ProviderType: "openai", @@ -107,13 +107,13 @@ func TestResolverBlankDefaultModeUsesManualFallback(t *testing.T) { } } -func TestResolverOverrideOffDisablesFallbacks(t *testing.T) { +func TestResolverOverrideOffDisablesFailovers(t *testing.T) { registry := newFakeRegistry( modelInfo("gpt-4o", "openai", "openai", 1287, "gpt-4o"), modelInfo("gpt-4o", "azure", "azure", 1287, "gpt-4o"), ) - resolver := NewResolver(config.FallbackConfig{ + resolver := NewResolver(config.FailoverConfig{ Enabled: true, Manual: map[string][]string{ "gpt-4o": []string{"azure/gpt-4o"}, @@ -123,7 +123,7 @@ func TestResolverOverrideOffDisablesFallbacks(t *testing.T) { }, }, registry) - got := resolver.ResolveFallbacks(&core.RequestModelResolution{ + got := resolver.ResolveFailovers(&core.RequestModelResolution{ Requested: core.NewRequestedModelSelector("gpt-4o", ""), ResolvedSelector: core.ModelSelector{Model: "gpt-4o"}, ProviderType: "openai", @@ -134,20 +134,20 @@ func TestResolverOverrideOffDisablesFallbacks(t *testing.T) { } } -func TestResolverDoesNotReturnFallbacksForEmbeddings(t *testing.T) { +func TestResolverDoesNotReturnFailoversForEmbeddings(t *testing.T) { registry := newFakeRegistry( modelInfoWithCategories("text-embedding-3-small", "openai", "openai", 1287, "text-embedding-3", core.CategoryEmbedding), modelInfoWithCategories("text-embedding-3-large", "azure", "azure", 1288, "text-embedding-3", core.CategoryEmbedding), ) - resolver := NewResolver(config.FallbackConfig{ + resolver := NewResolver(config.FailoverConfig{ Enabled: true, Manual: map[string][]string{ "text-embedding-3-small": []string{"azure/text-embedding-3-large"}, }, }, registry) - got := resolver.ResolveFallbacks(&core.RequestModelResolution{ + got := resolver.ResolveFailovers(&core.RequestModelResolution{ Requested: core.NewRequestedModelSelector("text-embedding-3-small", ""), ResolvedSelector: core.ModelSelector{Model: "text-embedding-3-small", Provider: "openai"}, ProviderType: "openai", @@ -165,7 +165,7 @@ func TestResolverPrefersProviderQualifiedOverrideForBareRequests(t *testing.T) { modelInfo("gemini-2.5-pro", "gemini", "gemini", 1290, "gemini-2.5-pro"), ) - resolver := NewResolver(config.FallbackConfig{ + resolver := NewResolver(config.FailoverConfig{ Enabled: true, Manual: map[string][]string{ "gpt-4o": []string{"gemini/gemini-2.5-pro"}, @@ -173,7 +173,7 @@ func TestResolverPrefersProviderQualifiedOverrideForBareRequests(t *testing.T) { }, }, registry) - got := resolver.ResolveFallbacks(&core.RequestModelResolution{ + got := resolver.ResolveFailovers(&core.RequestModelResolution{ Requested: core.NewRequestedModelSelector("gpt-4o", ""), ResolvedSelector: core.ModelSelector{Model: "gpt-4o", Provider: "openai"}, ProviderType: "openai", @@ -193,14 +193,14 @@ func TestResolverTreatsBareModelIDsContainingSlashAsGenericKeys(t *testing.T) { modelInfo("meta-llama/Meta-Llama-3-70B", "groq", "groq", 1287, "llama-3"), ) - resolver := NewResolver(config.FallbackConfig{ + resolver := NewResolver(config.FailoverConfig{ Enabled: true, Manual: map[string][]string{ "openrouter/meta-llama/Meta-Llama-3-70B": {"groq/meta-llama/Meta-Llama-3-70B"}, }, }, registry) - got := resolver.ResolveFallbacks(&core.RequestModelResolution{ + got := resolver.ResolveFailovers(&core.RequestModelResolution{ Requested: core.NewRequestedModelSelector("meta-llama/Meta-Llama-3-70B", ""), ResolvedSelector: core.ModelSelector{Model: "meta-llama/Meta-Llama-3-70B", Provider: "openrouter"}, ProviderType: "openrouter", @@ -240,7 +240,7 @@ func TestResolverDynamicRuleProviderOverridesStaticRules(t *testing.T) { modelInfo("gemini-2.5-pro", "gemini", "gemini", 1290, "gemini-2.5-pro"), ) - resolver := NewResolverWithRuleProvider(config.FallbackConfig{ + resolver := NewResolverWithRuleProvider(config.FailoverConfig{ Enabled: true, Manual: map[string][]string{"gpt-4o": {"azure/gpt-4o"}}, }, registry, &fakeRuleProvider{ @@ -249,7 +249,7 @@ func TestResolverDynamicRuleProviderOverridesStaticRules(t *testing.T) { }, }) - got := resolver.ResolveFallbacks(&core.RequestModelResolution{ + got := resolver.ResolveFailovers(&core.RequestModelResolution{ Requested: core.NewRequestedModelSelector("gpt-4o", ""), ResolvedSelector: core.ModelSelector{Model: "gpt-4o"}, ProviderType: "openai", @@ -264,7 +264,7 @@ func TestResolverDynamicRuleProviderOverridesStaticRules(t *testing.T) { } } -// A dynamic disabled entry suppresses fallback even when a static manual rule +// A dynamic disabled entry suppresses failover even when a static manual rule // exists for the same model. func TestResolverDynamicDisabledSuppressesStaticRule(t *testing.T) { registry := newFakeRegistry( @@ -272,21 +272,21 @@ func TestResolverDynamicDisabledSuppressesStaticRule(t *testing.T) { modelInfo("gpt-4o", "azure", "azure", 1287, "gpt-4o"), ) - resolver := NewResolverWithRuleProvider(config.FallbackConfig{ + resolver := NewResolverWithRuleProvider(config.FailoverConfig{ Enabled: true, Manual: map[string][]string{"gpt-4o": {"azure/gpt-4o"}}, }, registry, &fakeRuleProvider{ disabled: map[string]bool{"gpt-4o": true}, }) - got := resolver.ResolveFallbacks(&core.RequestModelResolution{ + got := resolver.ResolveFailovers(&core.RequestModelResolution{ Requested: core.NewRequestedModelSelector("gpt-4o", ""), ResolvedSelector: core.ModelSelector{Model: "gpt-4o"}, ProviderType: "openai", }, core.OperationChatCompletions) if len(got) != 0 { - t.Fatalf("len(got) = %d, want 0 (dynamic disabled suppresses fallback)", len(got)) + t.Fatalf("len(got) = %d, want 0 (dynamic disabled suppresses failover)", len(got)) } } diff --git a/internal/failover/service.go b/internal/failover/service.go index 843e4958c..d68457afe 100644 --- a/internal/failover/service.go +++ b/internal/failover/service.go @@ -49,7 +49,7 @@ func newRuleSnapshot(rows []Rule) *ruleSnapshot { return &ruleSnapshot{rows: rows, rules: rules, disabled: disabled} } -func NewService(store Store, cfg config.FallbackConfig) (*Service, error) { +func NewService(store Store, cfg config.FailoverConfig) (*Service, error) { if store == nil { return nil, fmt.Errorf("store is required") } @@ -58,7 +58,7 @@ func NewService(store Store, cfg config.FallbackConfig) (*Service, error) { return service, nil } -func ConfigRules(cfg config.FallbackConfig) []Rule { +func ConfigRules(cfg config.FailoverConfig) []Rule { rows := make([]Rule, 0, len(cfg.Manual)) now := time.Now().UTC() for source, targets := range cfg.Manual { diff --git a/internal/failover/service_test.go b/internal/failover/service_test.go index 6c9c4860b..ae8d0e5bf 100644 --- a/internal/failover/service_test.go +++ b/internal/failover/service_test.go @@ -72,7 +72,7 @@ func (s *errGetStore) Get(context.Context, string) (*Rule, error) { func TestServiceUpsertPropagatesUnexpectedGetError(t *testing.T) { wantErr := errors.New("boom") store := &errGetStore{memoryStore: newMemoryStore(), getErr: wantErr} - service, err := NewService(store, config.FallbackConfig{Enabled: true}) + service, err := NewService(store, config.FailoverConfig{Enabled: true}) if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -93,7 +93,7 @@ func TestServiceConfigRulesOverrideDashboardRules(t *testing.T) { Enabled: true, ManagedSource: ManagedSourceDashboard, }) - service, err := NewService(store, config.FallbackConfig{ + service, err := NewService(store, config.FailoverConfig{ Enabled: true, Manual: map[string][]string{ "gpt-4o": {"azure/gpt-4o"}, @@ -126,7 +126,7 @@ func TestServiceRulesReuseCachedSnapshot(t *testing.T) { Rule{Source: "gpt-4o", Targets: []string{"azure/gpt-4o"}, Enabled: true, ManagedSource: ManagedSourceDashboard}, Rule{Source: "gpt-4o-mini", Enabled: false, ManagedSource: ManagedSourceDashboard}, ) - service, err := NewService(store, config.FallbackConfig{Enabled: true}) + service, err := NewService(store, config.FailoverConfig{Enabled: true}) if err != nil { t.Fatalf("NewService() error = %v", err) } diff --git a/internal/gateway/fallback.go b/internal/gateway/failover.go similarity index 83% rename from internal/gateway/fallback.go rename to internal/gateway/failover.go index eaa9d4e61..abb9915f6 100644 --- a/internal/gateway/fallback.go +++ b/internal/gateway/failover.go @@ -12,12 +12,12 @@ import ( "gomodel/internal/core" ) -// FallbackSelectors returns fallback selectors for a translated workflow. -func (o *InferenceOrchestrator) FallbackSelectors(workflow *core.Workflow) []core.ModelSelector { - if o.fallbackResolver == nil || workflow == nil || workflow.Resolution == nil || !workflow.FallbackEnabled() { +// FailoverSelectors returns failover selectors for a translated workflow. +func (o *InferenceOrchestrator) FailoverSelectors(workflow *core.Workflow) []core.ModelSelector { + if o.failoverResolver == nil || workflow == nil || workflow.Resolution == nil || !workflow.FailoverEnabled() { return nil } - return o.fallbackResolver.ResolveFallbacks(workflow.Resolution, workflow.Endpoint.Operation) + return o.failoverResolver.ResolveFailovers(workflow.Resolution, workflow.Endpoint.Operation) } // ProviderTypeForSelector returns the provider type for a selector. @@ -38,7 +38,7 @@ func (o *InferenceOrchestrator) ProviderTypeForSelector(selector core.ModelSelec return fallback } -func tryFallbackResponse[T any]( +func tryFailoverResponse[T any]( ctx context.Context, o *InferenceOrchestrator, workflow *core.Workflow, @@ -48,22 +48,22 @@ func tryFallbackResponse[T any]( ) (T, string, string, string, bool, error) { var zero T - fallbacks := o.FallbackSelectors(workflow) - if len(fallbacks) == 0 || !ShouldAttemptFallback(primaryErr) { + failovers := o.FailoverSelectors(workflow) + if len(failovers) == 0 || !ShouldAttemptFailover(primaryErr) { return zero, "", "", "", false, primaryErr } requestID := strings.TrimSpace(core.GetRequestID(ctx)) primaryModel := currentSelectorForWorkflow(workflow, model, provider) lastErr := primaryErr - for _, selector := range fallbacks { + for _, selector := range failovers { if o.modelAuthorizer != nil && !o.modelAuthorizer.AllowsModel(ctx, selector) { continue } qualified := selector.QualifiedModel() providerType := o.ProviderTypeForSelector(selector, ProviderTypeFromWorkflow(workflow)) providerName := ResolvedProviderName(o.provider, selector, ProviderNameFromWorkflow(workflow)) - slog.Warn("primary model attempt failed, trying fallback", + slog.Warn("primary model attempt failed, trying failover", "request_id", requestID, "from", primaryModel, "to", qualified, @@ -75,7 +75,7 @@ func tryFallbackResponse[T any]( resp, resolvedProviderType, err := call(selector, providerType, providerName) recordProviderAttempt(ctx, providerAttemptFromResult(AttemptKindFailover, firstNonEmptyString(resolvedProviderType, providerType), providerName, qualified, started, err)) if err == nil { - slog.Info("fallback model attempt succeeded", + slog.Info("failover model attempt succeeded", "request_id", requestID, "from", primaryModel, "to", qualified, @@ -89,22 +89,22 @@ func tryFallbackResponse[T any]( return zero, "", "", "", false, lastErr } -func executeWithFallbackResponse[T any]( +func executeWithFailoverResponse[T any]( ctx context.Context, o *InferenceOrchestrator, workflow *core.Workflow, model, provider string, primary func() (T, string, string, error), - fallback func(selector core.ModelSelector, providerType, providerName string) (T, string, error), + failoverFn func(selector core.ModelSelector, providerType, providerName string) (T, string, error), ) (T, string, string, string, bool, error) { resp, resolvedProviderType, resolvedProviderName, err := primary() if err == nil { return resp, resolvedProviderType, resolvedProviderName, "", false, nil } - return tryFallbackResponse(ctx, o, workflow, model, provider, err, fallback) + return tryFailoverResponse(ctx, o, workflow, model, provider, err, failoverFn) } -func executeTranslatedWithFallback[Req any, Resp any]( +func executeTranslatedWithFailover[Req any, Resp any]( ctx context.Context, o *InferenceOrchestrator, workflow *core.Workflow, @@ -113,7 +113,7 @@ func executeTranslatedWithFallback[Req any, Resp any]( cloneForSelector func(Req, core.ModelSelector) Req, call func(context.Context, Req) (Resp, string, error), ) (Resp, string, string, string, bool, error) { - return executeWithFallbackResponse(ctx, o, workflow, model, provider, + return executeWithFailoverResponse(ctx, o, workflow, model, provider, func() (Resp, string, string, error) { started := time.Now() resp, responseProvider, err := call(ctx, req) @@ -136,7 +136,7 @@ func executeTranslatedWithFallback[Req any, Resp any]( ) } -func tryFallbackStream( +func tryFailoverStream( ctx context.Context, o *InferenceOrchestrator, workflow *core.Workflow, @@ -144,22 +144,22 @@ func tryFallbackStream( primaryErr error, call func(selector core.ModelSelector, providerType, providerName string) (io.ReadCloser, string, string, error), ) (io.ReadCloser, string, string, string, string, error) { - fallbacks := o.FallbackSelectors(workflow) - if len(fallbacks) == 0 || !ShouldAttemptFallback(primaryErr) { + failovers := o.FailoverSelectors(workflow) + if len(failovers) == 0 || !ShouldAttemptFailover(primaryErr) { return nil, "", "", "", "", primaryErr } requestID := strings.TrimSpace(core.GetRequestID(ctx)) primaryModel := currentSelectorForWorkflow(workflow, model, provider) lastErr := primaryErr - for _, selector := range fallbacks { + for _, selector := range failovers { if o.modelAuthorizer != nil && !o.modelAuthorizer.AllowsModel(ctx, selector) { continue } qualified := selector.QualifiedModel() providerType := o.ProviderTypeForSelector(selector, ProviderTypeFromWorkflow(workflow)) providerName := ResolvedProviderName(o.provider, selector, ProviderNameFromWorkflow(workflow)) - slog.Warn("primary model attempt failed, trying fallback stream", + slog.Warn("primary model attempt failed, trying failover stream", "request_id", requestID, "from", primaryModel, "to", qualified, @@ -171,7 +171,7 @@ func tryFallbackStream( stream, resolvedProviderType, usageModel, err := call(selector, providerType, providerName) recordProviderAttempt(ctx, providerAttemptFromResult(AttemptKindFailover, firstNonEmptyString(resolvedProviderType, providerType), providerName, qualified, started, err)) if err == nil { - slog.Info("fallback stream attempt succeeded", + slog.Info("failover stream attempt succeeded", "request_id", requestID, "from", primaryModel, "to", qualified, @@ -185,8 +185,8 @@ func tryFallbackStream( return nil, "", "", "", "", lastErr } -// ShouldAttemptFallback reports whether err should trigger translated fallback. -func ShouldAttemptFallback(err error) bool { +// ShouldAttemptFailover reports whether err should trigger translated failover. +func ShouldAttemptFailover(err error) bool { var gatewayErr *core.GatewayError if !errors.As(err, &gatewayErr) || gatewayErr == nil { return false diff --git a/internal/gateway/fallback_test.go b/internal/gateway/failover_test.go similarity index 90% rename from internal/gateway/fallback_test.go rename to internal/gateway/failover_test.go index 32ae05551..49ae6c51b 100644 --- a/internal/gateway/fallback_test.go +++ b/internal/gateway/failover_test.go @@ -7,7 +7,7 @@ import ( "gomodel/internal/core" ) -func TestShouldAttemptFallback(t *testing.T) { +func TestShouldAttemptFailover(t *testing.T) { tests := []struct { name string status int @@ -39,8 +39,8 @@ func TestShouldAttemptFallback(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := core.NewProviderError("anthropic", tt.status, tt.message, nil) - if got := ShouldAttemptFallback(err); got != tt.want { - t.Fatalf("ShouldAttemptFallback(%d, %q) = %v, want %v", tt.status, tt.message, got, tt.want) + if got := ShouldAttemptFailover(err); got != tt.want { + t.Fatalf("ShouldAttemptFailover(%d, %q) = %v, want %v", tt.status, tt.message, got, tt.want) } }) } diff --git a/internal/gateway/inference_execute.go b/internal/gateway/inference_execute.go index b52b425ea..244af6880 100644 --- a/internal/gateway/inference_execute.go +++ b/internal/gateway/inference_execute.go @@ -37,7 +37,7 @@ func (o *InferenceOrchestrator) StreamChatCompletion(ctx context.Context, workfl return nil, err } streamReq, providerType, providerName, usageModel := o.ResolveChatRoute(workflow, req) - stream, resolvedProviderType, resolvedProviderName, resolvedUsageModel, failoverModel, usedFallback, err := o.streamChatCompletion(ctx, workflow, streamReq, providerType, providerName, usageModel) + stream, resolvedProviderType, resolvedProviderName, resolvedUsageModel, failoverModel, usedFailover, err := o.streamChatCompletion(ctx, workflow, streamReq, providerType, providerName, usageModel) if err != nil { return nil, err } @@ -48,7 +48,7 @@ func (o *InferenceOrchestrator) StreamChatCompletion(ctx context.Context, workfl ProviderName: resolvedProviderName, Model: resolvedUsageModel, FailoverModel: failoverModel, - UsedFallback: usedFallback, + UsedFailover: usedFailover, }, }, nil } @@ -82,7 +82,7 @@ func (o *InferenceOrchestrator) StreamResponses(ctx context.Context, workflow *c if (workflow == nil || workflow.UsageEnabled()) && o.ShouldEnforceReturningUsageData() { ctx = core.WithEnforceReturningUsageData(ctx, true) } - stream, resolvedProviderType, resolvedProviderName, resolvedUsageModel, failoverModel, usedFallback, err := o.streamResponses(ctx, workflow, req, providerType, providerName, usageModel) + stream, resolvedProviderType, resolvedProviderName, resolvedUsageModel, failoverModel, usedFailover, err := o.streamResponses(ctx, workflow, req, providerType, providerName, usageModel) if err != nil { return nil, err } @@ -93,7 +93,7 @@ func (o *InferenceOrchestrator) StreamResponses(ctx context.Context, workflow *c ProviderName: resolvedProviderName, Model: resolvedUsageModel, FailoverModel: failoverModel, - UsedFallback: usedFallback, + UsedFailover: usedFailover, }, }, nil } @@ -151,10 +151,10 @@ func (o *InferenceOrchestrator) ResolveChatRoute(workflow *core.Workflow, req *c return streamReq, providerType, providerName, usageModel } -func (o *InferenceOrchestrator) routeMetadata(workflow *core.Workflow, fallbackModel string) (string, string, string) { +func (o *InferenceOrchestrator) routeMetadata(workflow *core.Workflow, failoverModel string) (string, string, string) { providerType := ProviderTypeFromWorkflow(workflow) providerName := ProviderNameFromWorkflow(workflow) - model := ResolvedModelFromWorkflow(workflow, fallbackModel) + model := ResolvedModelFromWorkflow(workflow, failoverModel) return providerType, providerName, model } @@ -325,7 +325,7 @@ func executeWithUsage[Resp any]( modelFromResponse func(Resp) string, entry func(Resp, string, *core.ModelPricing) *usage.UsageEntry, ) (Resp, ExecutionMeta, error) { - resp, providerType, providerName, failoverModel, usedFallback, err := execute() + resp, providerType, providerName, failoverModel, usedFailover, err := execute() if err != nil { var zero Resp return zero, ExecutionMeta{}, err @@ -340,7 +340,7 @@ func executeWithUsage[Resp any]( ProviderName: providerName, Model: model, FailoverModel: failoverModel, - UsedFallback: usedFallback, + UsedFailover: usedFailover, }, nil } @@ -373,7 +373,7 @@ func executeTranslatedProviderRequest[Req any, Resp any]( call func(context.Context, Req) (Resp, error), responseProvider func(Resp) string, ) (Resp, string, string, string, bool, error) { - return executeTranslatedWithFallback(ctx, o, workflow, req, model, provider, cloneForSelector, + return executeTranslatedWithFailover(ctx, o, workflow, req, model, provider, cloneForSelector, func(ctx context.Context, req Req) (Resp, string, error) { resp, err := call(ctx, req) if err != nil { @@ -406,7 +406,7 @@ func streamTranslatedProviderRequest[Req any]( } recordProviderAttempt(ctx, providerAttemptFromResult(AttemptKindPrimary, providerType, providerName, currentSelectorForWorkflow(workflow, model, provider), started, err)) - stream, resolvedProviderType, resolvedProviderName, resolvedUsageModel, failoverModel, err := tryFallbackStream(ctx, o, workflow, model, provider, err, + stream, resolvedProviderType, resolvedProviderName, resolvedUsageModel, failoverModel, err := tryFailoverStream(ctx, o, workflow, model, provider, err, func(selector core.ModelSelector, providerType, providerName string) (io.ReadCloser, string, string, error) { stream, err := call(ctx, cloneForSelector(req, selector)) if err != nil { @@ -550,16 +550,16 @@ func (o *InferenceOrchestrator) executeEmbeddings( return resp, ResponseProviderType(providerType, resp.Provider), providerName, nil } - return o.tryFallbackEmbeddings(ctx, workflow, req, err) + return o.tryFailoverEmbeddings(ctx, workflow, req, err) } -func (o *InferenceOrchestrator) tryFallbackEmbeddings( +func (o *InferenceOrchestrator) tryFailoverEmbeddings( ctx context.Context, workflow *core.Workflow, req *core.EmbeddingRequest, primaryErr error, ) (*core.EmbeddingResponse, string, string, error) { - // Embeddings fallback is intentionally disabled until the shared model + // Embeddings failover is intentionally disabled until the shared model // contract can prove vector-size compatibility for alternates. return nil, "", "", primaryErr } diff --git a/internal/gateway/inference_orchestrator.go b/internal/gateway/inference_orchestrator.go index ca0fecd24..7ca42c079 100644 --- a/internal/gateway/inference_orchestrator.go +++ b/internal/gateway/inference_orchestrator.go @@ -14,7 +14,7 @@ type InferenceConfig struct { ModelResolver ModelResolver ModelAuthorizer ModelAuthorizer WorkflowPolicyResolver WorkflowPolicyResolver - FallbackResolver FallbackResolver + FailoverResolver FailoverResolver TranslatedRequestPatcher TranslatedRequestPatcher UsageLogger usage.LoggerInterface PricingResolver usage.PricingResolver @@ -22,13 +22,13 @@ type InferenceConfig struct { } // InferenceOrchestrator owns translated inference workflow resolution, request -// patching, provider dispatch, fallback, usage logging, and cache metadata. +// patching, provider dispatch, failover, usage logging, and cache metadata. type InferenceOrchestrator struct { provider core.RoutableProvider modelResolver ModelResolver modelAuthorizer ModelAuthorizer workflowPolicyResolver WorkflowPolicyResolver - fallbackResolver FallbackResolver + failoverResolver FailoverResolver translatedRequestPatcher TranslatedRequestPatcher usageLogger usage.LoggerInterface pricingResolver usage.PricingResolver @@ -42,7 +42,7 @@ func NewInferenceOrchestrator(cfg InferenceConfig) *InferenceOrchestrator { modelResolver: cfg.ModelResolver, modelAuthorizer: cfg.ModelAuthorizer, workflowPolicyResolver: cfg.WorkflowPolicyResolver, - fallbackResolver: cfg.FallbackResolver, + failoverResolver: cfg.FailoverResolver, translatedRequestPatcher: cfg.TranslatedRequestPatcher, usageLogger: cfg.UsageLogger, pricingResolver: cfg.PricingResolver, @@ -84,7 +84,7 @@ type ExecutionMeta struct { ProviderName string Model string FailoverModel string - UsedFallback bool + UsedFailover bool } // ChatCompletionResult is the non-streaming chat completion result. diff --git a/internal/gateway/inference_orchestrator_test.go b/internal/gateway/inference_orchestrator_test.go index f76fd2015..e76f06f62 100644 --- a/internal/gateway/inference_orchestrator_test.go +++ b/internal/gateway/inference_orchestrator_test.go @@ -154,7 +154,7 @@ func TestInferenceOrchestratorWithCacheRequestContextClearsInheritedGuardrailsHa Audit: true, Usage: true, Guardrails: false, - Fallback: true, + Failover: true, }, }, } diff --git a/internal/gateway/interfaces.go b/internal/gateway/interfaces.go index 1acf1d002..9869a6e58 100644 --- a/internal/gateway/interfaces.go +++ b/internal/gateway/interfaces.go @@ -22,10 +22,10 @@ type UserPathModelResolver interface { ResolveModelForUserPath(ctx context.Context, requested core.RequestedModelSelector) (core.ModelSelector, bool, error) } -// FallbackResolver resolves alternate concrete model selectors for a translated +// FailoverResolver resolves alternate concrete model selectors for a translated // request after the primary selector has already been resolved. -type FallbackResolver interface { - ResolveFallbacks(resolution *core.RequestModelResolution, op core.Operation) []core.ModelSelector +type FailoverResolver interface { + ResolveFailovers(resolution *core.RequestModelResolution, op core.Operation) []core.ModelSelector } // ModelAuthorizer validates request-scoped access to concrete models. diff --git a/internal/gateway/refactor_findings_test.go b/internal/gateway/refactor_findings_test.go index 72b684101..8033b4642 100644 --- a/internal/gateway/refactor_findings_test.go +++ b/internal/gateway/refactor_findings_test.go @@ -214,14 +214,14 @@ func TestStreamResponsesRejectsEmptyProviderStream(t *testing.T) { } func TestStreamResponsesFallsBackAfterEmptyPrimaryStream(t *testing.T) { - provider := &streamFallbackProvider{ + provider := &streamFailoverProvider{ streamsByModel: map[string]io.ReadCloser{ "fallback": io.NopCloser(strings.NewReader("data: {}\n\n")), }, } orchestrator := NewInferenceOrchestrator(InferenceConfig{ Provider: provider, - FallbackResolver: fallbackResolverFunc(func(*core.RequestModelResolution, core.Operation) []core.ModelSelector { + FailoverResolver: failoverResolverFunc(func(*core.RequestModelResolution, core.Operation) []core.ModelSelector { return []core.ModelSelector{{Provider: "openai", Model: "fallback"}} }), }) @@ -238,7 +238,7 @@ func TestStreamResponsesFallsBackAfterEmptyPrimaryStream(t *testing.T) { Audit: true, Usage: true, Guardrails: true, - Fallback: true, + Failover: true, }, }, } @@ -249,8 +249,8 @@ func TestStreamResponsesFallsBackAfterEmptyPrimaryStream(t *testing.T) { } defer result.Stream.Close() - if !result.Meta.UsedFallback { - t.Fatal("UsedFallback = false, want true") + if !result.Meta.UsedFailover { + t.Fatal("UsedFailover = false, want true") } if result.Meta.FailoverModel != "openai/fallback" { t.Fatalf("FailoverModel = %q, want openai/fallback", result.Meta.FailoverModel) @@ -260,45 +260,45 @@ func TestStreamResponsesFallsBackAfterEmptyPrimaryStream(t *testing.T) { } } -type fallbackResolverFunc func(*core.RequestModelResolution, core.Operation) []core.ModelSelector +type failoverResolverFunc func(*core.RequestModelResolution, core.Operation) []core.ModelSelector -func (f fallbackResolverFunc) ResolveFallbacks(resolution *core.RequestModelResolution, op core.Operation) []core.ModelSelector { +func (f failoverResolverFunc) ResolveFailovers(resolution *core.RequestModelResolution, op core.Operation) []core.ModelSelector { return f(resolution, op) } -type streamFallbackProvider struct { +type streamFailoverProvider struct { streamsByModel map[string]io.ReadCloser responseStreamCalls []string } -func (p *streamFallbackProvider) ChatCompletion(context.Context, *core.ChatRequest) (*core.ChatResponse, error) { +func (p *streamFailoverProvider) ChatCompletion(context.Context, *core.ChatRequest) (*core.ChatResponse, error) { return nil, nil } -func (p *streamFallbackProvider) StreamChatCompletion(context.Context, *core.ChatRequest) (io.ReadCloser, error) { +func (p *streamFailoverProvider) StreamChatCompletion(context.Context, *core.ChatRequest) (io.ReadCloser, error) { return nil, nil } -func (p *streamFallbackProvider) ListModels(context.Context) (*core.ModelsResponse, error) { +func (p *streamFailoverProvider) ListModels(context.Context) (*core.ModelsResponse, error) { return nil, nil } -func (p *streamFallbackProvider) Responses(context.Context, *core.ResponsesRequest) (*core.ResponsesResponse, error) { +func (p *streamFailoverProvider) Responses(context.Context, *core.ResponsesRequest) (*core.ResponsesResponse, error) { return nil, nil } -func (p *streamFallbackProvider) StreamResponses(_ context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { +func (p *streamFailoverProvider) StreamResponses(_ context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { p.responseStreamCalls = append(p.responseStreamCalls, req.Model) return p.streamsByModel[req.Model], nil } -func (p *streamFallbackProvider) Embeddings(context.Context, *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { +func (p *streamFailoverProvider) Embeddings(context.Context, *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { return nil, nil } -func (p *streamFallbackProvider) Supports(string) bool { return true } +func (p *streamFailoverProvider) Supports(string) bool { return true } -func (p *streamFallbackProvider) GetProviderType(model string) string { +func (p *streamFailoverProvider) GetProviderType(model string) string { selector, err := core.ParseModelSelector(model, "") if err == nil && selector.Provider != "" { return selector.Provider diff --git a/internal/live/broker_test.go b/internal/live/broker_test.go index 3391a772f..5edd1ac3c 100644 --- a/internal/live/broker_test.go +++ b/internal/live/broker_test.go @@ -623,7 +623,7 @@ func TestBrokerAuditPreviewIncludesCompactWorkflowData(t *testing.T) { Cache: true, Audit: true, Usage: true, - Fallback: true, + Failover: true, }, Failover: &auditlog.FailoverSnapshot{TargetModel: "fallback-model"}, }, @@ -638,7 +638,7 @@ func TestBrokerAuditPreviewIncludesCompactWorkflowData(t *testing.T) { if !ok { t.Fatalf("workflow_features = %T, want object", data["workflow_features"]) } - if features["cache"] != true || features["fallback"] != true { + if features["cache"] != true || features["failover"] != true { t.Fatalf("workflow_features = %#v, want compact workflow flags", features) } failover, ok := data["failover"].(map[string]any) diff --git a/internal/responsecache/handle_request_test.go b/internal/responsecache/handle_request_test.go index e54640b7e..cabc8f6e4 100644 --- a/internal/responsecache/handle_request_test.go +++ b/internal/responsecache/handle_request_test.go @@ -284,7 +284,7 @@ func TestInternalCacheType_ParsesHeaderShapes(t *testing.T) { } } -func TestHandleRequest_FallbackUsedSkipsCacheWrites(t *testing.T) { +func TestHandleRequest_FailoverUsedSkipsCacheWrites(t *testing.T) { store := cache.NewMapStore() defer store.Close() @@ -305,7 +305,7 @@ func TestHandleRequest_FallbackUsedSkipsCacheWrites(t *testing.T) { e := echo.New() handlerCalls := 0 - run := func(markFallback bool) *httptest.ResponseRecorder { + run := func(markFailover bool) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") @@ -313,8 +313,8 @@ func TestHandleRequest_FallbackUsedSkipsCacheWrites(t *testing.T) { c := e.NewContext(req, rec) if err := m.HandleRequest(c, body, func() error { handlerCalls++ - if markFallback { - c.SetRequest(c.Request().WithContext(core.WithFallbackUsed(c.Request().Context()))) + if markFailover { + c.SetRequest(c.Request().WithContext(core.WithFailoverUsed(c.Request().Context()))) } return c.JSON(http.StatusOK, map[string]string{"n": "1"}) }); err != nil { @@ -325,7 +325,7 @@ func TestHandleRequest_FallbackUsedSkipsCacheWrites(t *testing.T) { rec1 := run(true) if rec1.Header().Get("X-Cache") != "" { - t.Fatalf("fallback-served response should not be cached, got X-Cache=%q", rec1.Header().Get("X-Cache")) + t.Fatalf("failover-served response should not be cached, got X-Cache=%q", rec1.Header().Get("X-Cache")) } if handlerCalls != 1 { t.Fatalf("expected 1 handler invocation after first request, got %d", handlerCalls) @@ -336,7 +336,7 @@ func TestHandleRequest_FallbackUsedSkipsCacheWrites(t *testing.T) { rec2 := run(false) if rec2.Header().Get("X-Cache") != "" { - t.Fatalf("fallback-served response should not populate cache, got X-Cache=%q", rec2.Header().Get("X-Cache")) + t.Fatalf("failover-served response should not populate cache, got X-Cache=%q", rec2.Header().Get("X-Cache")) } if handlerCalls != 2 { t.Fatalf("expected second request to execute handler again, got %d calls", handlerCalls) diff --git a/internal/responsecache/simple.go b/internal/responsecache/simple.go index ff7e041ea..fb2c761ff 100644 --- a/internal/responsecache/simple.go +++ b/internal/responsecache/simple.go @@ -339,7 +339,7 @@ func captureResponseForCache(c *echo.Context, path, warnMessage string, next fun if !shouldStoreCapturedResponse(capture.effectiveStatusCode()) || capture.body.Len() == 0 { return nil, false, nil } - if core.GetFallbackUsed(c.Request().Context()) { + if core.GetFailoverUsed(c.Request().Context()) { return nil, false, nil } data, ok := capture.cachedBody(c.Response().Header().Get("Content-Type")) diff --git a/internal/server/fallback_test.go b/internal/server/failover_test.go similarity index 82% rename from internal/server/fallback_test.go rename to internal/server/failover_test.go index 89cb5236c..201dff128 100644 --- a/internal/server/fallback_test.go +++ b/internal/server/failover_test.go @@ -15,15 +15,15 @@ import ( "gomodel/internal/core" ) -type fallbackResolverStub struct { +type failoverResolverStub struct { selectors []core.ModelSelector } -func (s fallbackResolverStub) ResolveFallbacks(_ *core.RequestModelResolution, _ core.Operation) []core.ModelSelector { +func (s failoverResolverStub) ResolveFailovers(_ *core.RequestModelResolution, _ core.Operation) []core.ModelSelector { return append([]core.ModelSelector(nil), s.selectors...) } -type fallbackProvider struct { +type failoverProvider struct { chatResponses map[string]*core.ChatResponse chatStreams map[string]string chatErrors map[string]error @@ -38,7 +38,7 @@ type fallbackProvider struct { embeddingCalls []string } -func (p *fallbackProvider) ChatCompletion(_ context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { +func (p *failoverProvider) ChatCompletion(_ context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { key := requestSelector(req.Model, req.Provider) p.chatCalls = append(p.chatCalls, key) if err := p.chatErrors[key]; err != nil { @@ -47,7 +47,7 @@ func (p *fallbackProvider) ChatCompletion(_ context.Context, req *core.ChatReque return p.chatResponses[key], nil } -func (p *fallbackProvider) StreamChatCompletion(_ context.Context, req *core.ChatRequest) (io.ReadCloser, error) { +func (p *failoverProvider) StreamChatCompletion(_ context.Context, req *core.ChatRequest) (io.ReadCloser, error) { key := requestSelector(req.Model, req.Provider) p.chatCalls = append(p.chatCalls, key) if err := p.chatErrors[key]; err != nil { @@ -59,11 +59,11 @@ func (p *fallbackProvider) StreamChatCompletion(_ context.Context, req *core.Cha return io.NopCloser(strings.NewReader("data: [DONE]\n\n")), nil } -func (p *fallbackProvider) ListModels(_ context.Context) (*core.ModelsResponse, error) { +func (p *failoverProvider) ListModels(_ context.Context) (*core.ModelsResponse, error) { return &core.ModelsResponse{Object: "list"}, nil } -func (p *fallbackProvider) Responses(_ context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { +func (p *failoverProvider) Responses(_ context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { key := requestSelector(req.Model, req.Provider) p.responsesCalls = append(p.responsesCalls, key) if err := p.responsesErrors[key]; err != nil { @@ -72,7 +72,7 @@ func (p *fallbackProvider) Responses(_ context.Context, req *core.ResponsesReque return p.responsesResponses[key], nil } -func (p *fallbackProvider) StreamResponses(_ context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { +func (p *failoverProvider) StreamResponses(_ context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { key := requestSelector(req.Model, req.Provider) p.responsesCalls = append(p.responsesCalls, key) if err := p.responsesErrors[key]; err != nil { @@ -84,7 +84,7 @@ func (p *fallbackProvider) StreamResponses(_ context.Context, req *core.Response return io.NopCloser(strings.NewReader("data: [DONE]\n\n")), nil } -func (p *fallbackProvider) Embeddings(_ context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { +func (p *failoverProvider) Embeddings(_ context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { key := requestSelector(req.Model, req.Provider) p.embeddingCalls = append(p.embeddingCalls, key) if err := p.embeddingErrors[key]; err != nil { @@ -93,7 +93,7 @@ func (p *fallbackProvider) Embeddings(_ context.Context, req *core.EmbeddingRequ return p.embeddingResponses[key], nil } -func (p *fallbackProvider) Supports(model string) bool { +func (p *failoverProvider) Supports(model string) bool { selector, err := core.ParseModelSelector(model, "") if err == nil { model = selector.QualifiedModel() @@ -102,7 +102,7 @@ func (p *fallbackProvider) Supports(model string) bool { return ok } -func (p *fallbackProvider) GetProviderType(model string) string { +func (p *failoverProvider) GetProviderType(model string) string { selector, err := core.ParseModelSelector(model, "") if err == nil { model = selector.QualifiedModel() @@ -111,16 +111,16 @@ func (p *fallbackProvider) GetProviderType(model string) string { } func TestChatCompletion_FallsBackToAlternateModel(t *testing.T) { - provider := &fallbackProvider{ + provider := &failoverProvider{ chatResponses: map[string]*core.ChatResponse{ "azure/gpt-4o": { - ID: "chatcmpl-fallback", + ID: "chatcmpl-failover", Object: "chat.completion", Model: "gpt-4o", Provider: "azure", Choices: []core.Choice{{ Index: 0, - Message: core.ResponseMessage{Role: "assistant", Content: "fallback ok"}, + Message: core.ResponseMessage{Role: "assistant", Content: "failover ok"}, FinishReason: "stop", }}, }, @@ -134,7 +134,7 @@ func TestChatCompletion_FallsBackToAlternateModel(t *testing.T) { }, } - handler := newHandler(provider, nil, nil, nil, nil, nil, fallbackResolverStub{ + handler := newHandler(provider, nil, nil, nil, nil, nil, failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -158,11 +158,11 @@ func TestChatCompletion_FallsBackToAlternateModel(t *testing.T) { if provider.chatCalls[0] != "gpt-4o" || provider.chatCalls[1] != "azure/gpt-4o" { t.Fatalf("chat calls = %v, want [gpt-4o azure/gpt-4o]", provider.chatCalls) } - if !strings.Contains(rec.Body.String(), "fallback ok") { - t.Fatalf("response body = %s, want fallback response", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "failover ok") { + t.Fatalf("response body = %s, want failover response", rec.Body.String()) } - if !core.GetFallbackUsed(c.Request().Context()) { - t.Fatal("expected request context to be marked as fallback-used") + if !core.GetFailoverUsed(c.Request().Context()) { + t.Fatal("expected request context to be marked as failover-used") } if entry.Data == nil || entry.Data.Failover == nil { t.Fatal("expected audit entry to capture failover details") @@ -187,8 +187,8 @@ func TestChatCompletion_FallsBackToAlternateModel(t *testing.T) { } } -func TestChatCompletion_DoesNotFallbackOnNonAvailabilityError(t *testing.T) { - provider := &fallbackProvider{ +func TestChatCompletion_DoesNotFailoverOnNonAvailabilityError(t *testing.T) { + provider := &failoverProvider{ chatErrors: map[string]error{ "gpt-4o": core.NewInvalidRequestError("temperature must be between 0 and 2", nil), }, @@ -198,7 +198,7 @@ func TestChatCompletion_DoesNotFallbackOnNonAvailabilityError(t *testing.T) { }, } - handler := newHandler(provider, nil, nil, nil, nil, nil, fallbackResolverStub{ + handler := newHandler(provider, nil, nil, nil, nil, nil, failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -219,17 +219,17 @@ func TestChatCompletion_DoesNotFallbackOnNonAvailabilityError(t *testing.T) { } } -func TestChatCompletion_DoesNotFallbackWhenWorkflowPolicyDisablesFallback(t *testing.T) { - provider := &fallbackProvider{ +func TestChatCompletion_DoesNotFailoverWhenWorkflowPolicyDisablesFailover(t *testing.T) { + provider := &failoverProvider{ chatResponses: map[string]*core.ChatResponse{ "azure/gpt-4o": { - ID: "chatcmpl-fallback", + ID: "chatcmpl-failover", Object: "chat.completion", Model: "gpt-4o", Provider: "azure", Choices: []core.Choice{{ Index: 0, - Message: core.ResponseMessage{Role: "assistant", Content: "fallback ok"}, + Message: core.ResponseMessage{Role: "assistant", Content: "failover ok"}, FinishReason: "stop", }}, }, @@ -245,16 +245,16 @@ func TestChatCompletion_DoesNotFallbackWhenWorkflowPolicyDisablesFallback(t *tes handler := newHandler(provider, nil, nil, nil, nil, requestWorkflowPolicyResolverFunc(func(core.WorkflowSelector) (*core.ResolvedWorkflowPolicy, error) { return &core.ResolvedWorkflowPolicy{ - VersionID: "workflow-fallback-off", + VersionID: "workflow-failover-off", Features: core.WorkflowFeatures{ Cache: true, Audit: true, Usage: true, Guardrails: true, - Fallback: false, + Failover: false, }, }, nil - }), fallbackResolverStub{ + }), failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -276,9 +276,9 @@ func TestChatCompletion_DoesNotFallbackWhenWorkflowPolicyDisablesFallback(t *tes } func TestChatCompletion_StreamFallsBackToAlternateModel(t *testing.T) { - provider := &fallbackProvider{ + provider := &failoverProvider{ chatStreams: map[string]string{ - "azure/gpt-4o": "data: {\"choices\":[{\"delta\":{\"content\":\"fallback ok\"}}]}\n\ndata: [DONE]\n\n", + "azure/gpt-4o": "data: {\"choices\":[{\"delta\":{\"content\":\"failover ok\"}}]}\n\ndata: [DONE]\n\n", }, chatErrors: map[string]error{ "gpt-4o": core.NewProviderError("openai", http.StatusServiceUnavailable, "model temporarily unavailable", nil), @@ -289,7 +289,7 @@ func TestChatCompletion_StreamFallsBackToAlternateModel(t *testing.T) { }, } - handler := newHandler(provider, nil, nil, nil, nil, nil, fallbackResolverStub{ + handler := newHandler(provider, nil, nil, nil, nil, nil, failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -311,18 +311,18 @@ func TestChatCompletion_StreamFallsBackToAlternateModel(t *testing.T) { if provider.chatCalls[0] != "gpt-4o" || provider.chatCalls[1] != "azure/gpt-4o" { t.Fatalf("chat calls = %v, want [gpt-4o azure/gpt-4o]", provider.chatCalls) } - if !strings.Contains(rec.Body.String(), "fallback ok") { - t.Fatalf("response body = %s, want fallback stream content", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "failover ok") { + t.Fatalf("response body = %s, want failover stream content", rec.Body.String()) } - if !core.GetFallbackUsed(c.Request().Context()) { - t.Fatal("expected request context to be marked as fallback-used") + if !core.GetFailoverUsed(c.Request().Context()) { + t.Fatal("expected request context to be marked as failover-used") } } -func TestChatCompletion_StreamDoesNotFallbackWhenWorkflowPolicyDisablesFallback(t *testing.T) { - provider := &fallbackProvider{ +func TestChatCompletion_StreamDoesNotFailoverWhenWorkflowPolicyDisablesFailover(t *testing.T) { + provider := &failoverProvider{ chatStreams: map[string]string{ - "azure/gpt-4o": "data: {\"choices\":[{\"delta\":{\"content\":\"fallback ok\"}}]}\n\ndata: [DONE]\n\n", + "azure/gpt-4o": "data: {\"choices\":[{\"delta\":{\"content\":\"failover ok\"}}]}\n\ndata: [DONE]\n\n", }, chatErrors: map[string]error{ "gpt-4o": core.NewProviderError("openai", http.StatusServiceUnavailable, "model temporarily unavailable", nil), @@ -335,16 +335,16 @@ func TestChatCompletion_StreamDoesNotFallbackWhenWorkflowPolicyDisablesFallback( handler := newHandler(provider, nil, nil, nil, nil, requestWorkflowPolicyResolverFunc(func(core.WorkflowSelector) (*core.ResolvedWorkflowPolicy, error) { return &core.ResolvedWorkflowPolicy{ - VersionID: "workflow-fallback-off", + VersionID: "workflow-failover-off", Features: core.WorkflowFeatures{ Cache: true, Audit: true, Usage: true, Guardrails: true, - Fallback: false, + Failover: false, }, }, nil - }), fallbackResolverStub{ + }), failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -366,10 +366,10 @@ func TestChatCompletion_StreamDoesNotFallbackWhenWorkflowPolicyDisablesFallback( } func TestResponses_FallsBackToAlternateModel(t *testing.T) { - provider := &fallbackProvider{ + provider := &failoverProvider{ responsesResponses: map[string]*core.ResponsesResponse{ "azure/gpt-4o": { - ID: "resp-fallback", + ID: "resp-failover", Object: "response", Model: "gpt-4o", Provider: "azure", @@ -381,7 +381,7 @@ func TestResponses_FallsBackToAlternateModel(t *testing.T) { Status: "completed", Content: []core.ResponsesContentItem{{ Type: "output_text", - Text: "fallback response", + Text: "failover response", }}, }}, }, @@ -395,7 +395,7 @@ func TestResponses_FallsBackToAlternateModel(t *testing.T) { }, } - handler := newHandler(provider, nil, nil, nil, nil, nil, fallbackResolverStub{ + handler := newHandler(provider, nil, nil, nil, nil, nil, failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -423,14 +423,14 @@ func TestResponses_FallsBackToAlternateModel(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("response body is not valid JSON: %v body=%s", err, rec.Body.String()) } - if resp.ID != "resp-fallback" || resp.Provider != "azure" || resp.Model != "gpt-4o" || resp.Status != "completed" { - t.Fatalf("response = %+v, want fallback response metadata", resp) + if resp.ID != "resp-failover" || resp.Provider != "azure" || resp.Model != "gpt-4o" || resp.Status != "completed" { + t.Fatalf("response = %+v, want failover response metadata", resp) } - if len(resp.Output) != 1 || len(resp.Output[0].Content) != 1 || resp.Output[0].Content[0].Text != "fallback response" { - t.Fatalf("response output = %+v, want fallback response content", resp.Output) + if len(resp.Output) != 1 || len(resp.Output[0].Content) != 1 || resp.Output[0].Content[0].Text != "failover response" { + t.Fatalf("response output = %+v, want failover response content", resp.Output) } - if !core.GetFallbackUsed(c.Request().Context()) { - t.Fatal("expected request context to be marked as fallback-used") + if !core.GetFailoverUsed(c.Request().Context()) { + t.Fatal("expected request context to be marked as failover-used") } if entry.Data == nil || entry.Data.Failover == nil { t.Fatal("expected audit entry to capture streaming failover details") @@ -441,9 +441,9 @@ func TestResponses_FallsBackToAlternateModel(t *testing.T) { } func TestResponses_StreamFallsBackToAlternateModel(t *testing.T) { - provider := &fallbackProvider{ + provider := &failoverProvider{ responsesStreams: map[string]string{ - "azure/gpt-4o": "data: {\"type\":\"response.output_text.delta\",\"delta\":\"fallback response\"}\n\ndata: [DONE]\n\n", + "azure/gpt-4o": "data: {\"type\":\"response.output_text.delta\",\"delta\":\"failover response\"}\n\ndata: [DONE]\n\n", }, responsesErrors: map[string]error{ "gpt-4o": core.NewNotFoundError("model not found"), @@ -454,7 +454,7 @@ func TestResponses_StreamFallsBackToAlternateModel(t *testing.T) { }, } - handler := newHandler(provider, nil, nil, nil, nil, nil, fallbackResolverStub{ + handler := newHandler(provider, nil, nil, nil, nil, nil, failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -476,18 +476,18 @@ func TestResponses_StreamFallsBackToAlternateModel(t *testing.T) { if provider.responsesCalls[0] != "gpt-4o" || provider.responsesCalls[1] != "azure/gpt-4o" { t.Fatalf("responses calls = %v, want [gpt-4o azure/gpt-4o]", provider.responsesCalls) } - if !strings.Contains(rec.Body.String(), "fallback response") { - t.Fatalf("response body = %s, want fallback stream content", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "failover response") { + t.Fatalf("response body = %s, want failover stream content", rec.Body.String()) } - if !core.GetFallbackUsed(c.Request().Context()) { - t.Fatal("expected request context to be marked as fallback-used") + if !core.GetFailoverUsed(c.Request().Context()) { + t.Fatal("expected request context to be marked as failover-used") } } -func TestResponses_StreamDoesNotFallbackOnNonAvailabilityError(t *testing.T) { - provider := &fallbackProvider{ +func TestResponses_StreamDoesNotFailoverOnNonAvailabilityError(t *testing.T) { + provider := &failoverProvider{ responsesStreams: map[string]string{ - "azure/gpt-4o": "data: {\"type\":\"response.output_text.delta\",\"delta\":\"fallback response\"}\n\ndata: [DONE]\n\n", + "azure/gpt-4o": "data: {\"type\":\"response.output_text.delta\",\"delta\":\"failover response\"}\n\ndata: [DONE]\n\n", }, responsesErrors: map[string]error{ "gpt-4o": core.NewInvalidRequestError("temperature must be between 0 and 2", nil), @@ -498,7 +498,7 @@ func TestResponses_StreamDoesNotFallbackOnNonAvailabilityError(t *testing.T) { }, } - handler := newHandler(provider, nil, nil, nil, nil, nil, fallbackResolverStub{ + handler := newHandler(provider, nil, nil, nil, nil, nil, failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -517,15 +517,15 @@ func TestResponses_StreamDoesNotFallbackOnNonAvailabilityError(t *testing.T) { if len(provider.responsesCalls) != 1 || provider.responsesCalls[0] != "gpt-4o" { t.Fatalf("responses calls = %v, want only the primary model", provider.responsesCalls) } - if core.GetFallbackUsed(c.Request().Context()) { - t.Fatal("expected request context to remain unmarked for fallback") + if core.GetFailoverUsed(c.Request().Context()) { + t.Fatal("expected request context to remain unmarked for failover") } } -func TestResponses_StreamDoesNotFallbackWhenWorkflowPolicyDisablesFallback(t *testing.T) { - provider := &fallbackProvider{ +func TestResponses_StreamDoesNotFailoverWhenWorkflowPolicyDisablesFailover(t *testing.T) { + provider := &failoverProvider{ responsesStreams: map[string]string{ - "azure/gpt-4o": "data: {\"type\":\"response.output_text.delta\",\"delta\":\"fallback response\"}\n\ndata: [DONE]\n\n", + "azure/gpt-4o": "data: {\"type\":\"response.output_text.delta\",\"delta\":\"failover response\"}\n\ndata: [DONE]\n\n", }, responsesErrors: map[string]error{ "gpt-4o": core.NewProviderError("openai", http.StatusServiceUnavailable, "model temporarily unavailable", nil), @@ -538,16 +538,16 @@ func TestResponses_StreamDoesNotFallbackWhenWorkflowPolicyDisablesFallback(t *te handler := newHandler(provider, nil, nil, nil, nil, requestWorkflowPolicyResolverFunc(func(core.WorkflowSelector) (*core.ResolvedWorkflowPolicy, error) { return &core.ResolvedWorkflowPolicy{ - VersionID: "workflow-fallback-off", + VersionID: "workflow-failover-off", Features: core.WorkflowFeatures{ Cache: true, Audit: true, Usage: true, Guardrails: true, - Fallback: false, + Failover: false, }, }, nil - }), fallbackResolverStub{ + }), failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -566,13 +566,13 @@ func TestResponses_StreamDoesNotFallbackWhenWorkflowPolicyDisablesFallback(t *te if len(provider.responsesCalls) != 1 || provider.responsesCalls[0] != "gpt-4o" { t.Fatalf("responses calls = %v, want only the primary model", provider.responsesCalls) } - if core.GetFallbackUsed(c.Request().Context()) { - t.Fatal("expected request context to remain unmarked for fallback") + if core.GetFailoverUsed(c.Request().Context()) { + t.Fatal("expected request context to remain unmarked for failover") } } -func TestChatCompletion_DoesNotFallbackOnNonModelNotFound(t *testing.T) { - provider := &fallbackProvider{ +func TestChatCompletion_DoesNotFailoverOnNonModelNotFound(t *testing.T) { + provider := &failoverProvider{ chatErrors: map[string]error{ "gpt-4o": core.NewProviderError("openai", http.StatusNotFound, "endpoint not found", nil), }, @@ -582,7 +582,7 @@ func TestChatCompletion_DoesNotFallbackOnNonModelNotFound(t *testing.T) { }, } - handler := newHandler(provider, nil, nil, nil, nil, nil, fallbackResolverStub{ + handler := newHandler(provider, nil, nil, nil, nil, nil, failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "gpt-4o"}}, }, nil) @@ -602,8 +602,8 @@ func TestChatCompletion_DoesNotFallbackOnNonModelNotFound(t *testing.T) { t.Fatalf("chat calls = %v, want only the primary model", provider.chatCalls) } } -func TestEmbeddings_DoesNotFallback(t *testing.T) { - provider := &fallbackProvider{ +func TestEmbeddings_DoesNotFailover(t *testing.T) { + provider := &failoverProvider{ embeddingResponses: map[string]*core.EmbeddingResponse{ "azure/text-embedding-3-small": { Object: "list", @@ -625,7 +625,7 @@ func TestEmbeddings_DoesNotFallback(t *testing.T) { }, } - handler := newHandler(provider, nil, nil, nil, nil, nil, fallbackResolverStub{ + handler := newHandler(provider, nil, nil, nil, nil, nil, failoverResolverStub{ selectors: []core.ModelSelector{{Provider: "azure", Model: "text-embedding-3-small"}}, }, nil) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index cecf85577..9c6abcb6d 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -23,7 +23,7 @@ type Handler struct { provider core.RoutableProvider modelResolver RequestModelResolver modelAuthorizer RequestModelAuthorizer - fallbackResolver RequestFallbackResolver + failoverResolver RequestFailoverResolver workflowPolicyResolver RequestWorkflowPolicyResolver translatedRequestPatcher TranslatedRequestPatcher batchRequestPreparer BatchRequestPreparer @@ -63,7 +63,7 @@ func newHandler( pricingResolver usage.PricingResolver, modelResolver RequestModelResolver, workflowPolicyResolver RequestWorkflowPolicyResolver, - fallbackResolver RequestFallbackResolver, + failoverResolver RequestFailoverResolver, translatedRequestPatcher TranslatedRequestPatcher, ) *Handler { return newHandlerWithAuthorizer( @@ -74,7 +74,7 @@ func newHandler( modelResolver, nil, workflowPolicyResolver, - fallbackResolver, + failoverResolver, translatedRequestPatcher, ) } @@ -87,14 +87,14 @@ func newHandlerWithAuthorizer( modelResolver RequestModelResolver, modelAuthorizer RequestModelAuthorizer, workflowPolicyResolver RequestWorkflowPolicyResolver, - fallbackResolver RequestFallbackResolver, + failoverResolver RequestFailoverResolver, translatedRequestPatcher TranslatedRequestPatcher, ) *Handler { return &Handler{ provider: provider, modelResolver: modelResolver, modelAuthorizer: modelAuthorizer, - fallbackResolver: fallbackResolver, + failoverResolver: failoverResolver, workflowPolicyResolver: workflowPolicyResolver, translatedRequestPatcher: translatedRequestPatcher, logger: logger, @@ -169,7 +169,7 @@ func (h *Handler) translatedInference() *translatedInferenceService { modelResolver: h.modelResolver, modelAuthorizer: h.modelAuthorizer, workflowPolicyResolver: h.workflowPolicyResolver, - fallbackResolver: h.fallbackResolver, + failoverResolver: h.failoverResolver, translatedRequestPatcher: h.translatedRequestPatcher, logger: h.logger, usageLogger: h.usageLogger, diff --git a/internal/server/http.go b/internal/server/http.go index 2603c1cca..4a06e3099 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -60,7 +60,7 @@ type Config struct { ModelResolver RequestModelResolver // Optional: explicit model resolver used during workflow resolution ModelAuthorizer RequestModelAuthorizer // Optional: request-scoped concrete model access controller WorkflowPolicyResolver RequestWorkflowPolicyResolver // Optional: persisted workflow resolver used during workflow resolution - FallbackResolver RequestFallbackResolver // Optional: translated-route fallback resolver + FailoverResolver RequestFailoverResolver // Optional: translated-route failover resolver TranslatedRequestPatcher TranslatedRequestPatcher // Optional: request patcher for translated routes after workflow resolution BatchRequestPreparer BatchRequestPreparer // Optional: batch request preparer before native provider submission ExposedModelLister ExposedModelLister // Optional: additional public models to merge into GET /v1/models @@ -127,17 +127,17 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { var modelResolver RequestModelResolver var modelAuthorizer RequestModelAuthorizer var workflowPolicyResolver RequestWorkflowPolicyResolver - var fallbackResolver RequestFallbackResolver + var failoverResolver RequestFailoverResolver var translatedRequestPatcher TranslatedRequestPatcher if cfg != nil { modelResolver = cfg.ModelResolver modelAuthorizer = cfg.ModelAuthorizer workflowPolicyResolver = cfg.WorkflowPolicyResolver - fallbackResolver = cfg.FallbackResolver + failoverResolver = cfg.FailoverResolver translatedRequestPatcher = cfg.TranslatedRequestPatcher } - handler := newHandlerWithAuthorizer(provider, auditLogger, usageLogger, pricingResolver, modelResolver, modelAuthorizer, workflowPolicyResolver, fallbackResolver, translatedRequestPatcher) + handler := newHandlerWithAuthorizer(provider, auditLogger, usageLogger, pricingResolver, modelResolver, modelAuthorizer, workflowPolicyResolver, failoverResolver, translatedRequestPatcher) handler.budgetChecker = budgetChecker if cfg != nil { handler.batchRequestPreparer = cfg.BatchRequestPreparer diff --git a/internal/server/internal_chat_completion_executor.go b/internal/server/internal_chat_completion_executor.go index 2da98dac6..6d642b8c1 100644 --- a/internal/server/internal_chat_completion_executor.go +++ b/internal/server/internal_chat_completion_executor.go @@ -26,7 +26,7 @@ type InternalChatCompletionExecutorConfig struct { ModelResolver RequestModelResolver ModelAuthorizer RequestModelAuthorizer WorkflowPolicyResolver RequestWorkflowPolicyResolver - FallbackResolver RequestFallbackResolver + FailoverResolver RequestFailoverResolver AuditLogger auditlog.LoggerInterface UsageLogger usage.LoggerInterface PricingResolver usage.PricingResolver @@ -46,7 +46,7 @@ type InternalChatCompletionExecutor struct { } // NewInternalChatCompletionExecutor creates a transport-free translated chat -// executor that reuses workflow resolution, fallback, usage, and audit logic. +// executor that reuses workflow resolution, failover, usage, and audit logic. func NewInternalChatCompletionExecutor(provider core.RoutableProvider, cfg InternalChatCompletionExecutorConfig) *InternalChatCompletionExecutor { return &InternalChatCompletionExecutor{ provider: provider, @@ -60,7 +60,7 @@ func NewInternalChatCompletionExecutor(provider core.RoutableProvider, cfg Inter ModelResolver: cfg.ModelResolver, ModelAuthorizer: cfg.ModelAuthorizer, WorkflowPolicyResolver: cfg.WorkflowPolicyResolver, - FallbackResolver: cfg.FallbackResolver, + FailoverResolver: cfg.FailoverResolver, UsageLogger: cfg.UsageLogger, PricingResolver: cfg.PricingResolver, TranslatedRequestPatcher: nil, @@ -145,16 +145,16 @@ func (e *InternalChatCompletionExecutor) executeChatCompletion( providerType string providerName string failoverModel string - usedFallback bool + usedFailover bool ) result, err := e.responseCache.HandleInternalRequest(ctx, http.MethodPost, "/v1/chat/completions", body, func(c *echo.Context) error { var execErr error - resp, providerType, providerName, failoverModel, usedFallback, execErr = e.orchestrator.DispatchChatCompletion(c.Request().Context(), workflow, req) + resp, providerType, providerName, failoverModel, usedFailover, execErr = e.orchestrator.DispatchChatCompletion(c.Request().Context(), workflow, req) if execErr != nil { return execErr } - if usedFallback { - c.SetRequest(c.Request().WithContext(core.WithFallbackUsed(c.Request().Context()))) + if usedFailover { + c.SetRequest(c.Request().WithContext(core.WithFailoverUsed(c.Request().Context()))) } return c.JSON(http.StatusOK, resp) }) @@ -174,7 +174,7 @@ func (e *InternalChatCompletionExecutor) executeChatCompletion( } return &cached, cachedProviderType, cachedProviderName, "", false, result.CacheType, nil } - return resp, providerType, providerName, failoverModel, usedFallback, "", nil + return resp, providerType, providerName, failoverModel, usedFailover, "", nil } func (e *InternalChatCompletionExecutor) dispatchChatCompletionNoCache( @@ -182,8 +182,8 @@ func (e *InternalChatCompletionExecutor) dispatchChatCompletionNoCache( workflow *core.Workflow, req *core.ChatRequest, ) (*core.ChatResponse, string, string, string, bool, string, error) { - resp, providerType, providerName, failoverModel, usedFallback, err := e.orchestrator.DispatchChatCompletion(ctx, workflow, req) - return resp, providerType, providerName, failoverModel, usedFallback, "", err + resp, providerType, providerName, failoverModel, usedFailover, err := e.orchestrator.DispatchChatCompletion(ctx, workflow, req) + return resp, providerType, providerName, failoverModel, usedFailover, "", err } func (e *InternalChatCompletionExecutor) newAuditEntry( diff --git a/internal/server/internal_chat_completion_executor_test.go b/internal/server/internal_chat_completion_executor_test.go index fb168984c..0c45f8670 100644 --- a/internal/server/internal_chat_completion_executor_test.go +++ b/internal/server/internal_chat_completion_executor_test.go @@ -69,7 +69,7 @@ func TestInternalChatCompletionExecutor_UsesTranslatedPlanAndAuditMetadata(t *te Audit: true, Usage: true, Guardrails: true, - Fallback: true, + Failover: true, }, }, nil }), diff --git a/internal/server/messages_handler.go b/internal/server/messages_handler.go index 8d7c0b824..d90f4795c 100644 --- a/internal/server/messages_handler.go +++ b/internal/server/messages_handler.go @@ -103,8 +103,8 @@ func (s *translatedInferenceService) dispatchMessages(c *echo.Context, req *core if err != nil { return handleStreamingDispatchError(c, err) } - if result.Meta.UsedFallback { - markRequestFallbackUsed(c) + if result.Meta.UsedFailover { + markRequestFailoverUsed(c) } model := result.Meta.Model return s.handleStreamingReadCloser( @@ -126,8 +126,8 @@ func (s *translatedInferenceService) dispatchMessages(c *echo.Context, req *core return handleError(c, err) } enrichAuditEntryWithProviderAttempts(c) - if result.Meta.UsedFallback { - markRequestFallbackUsed(c) + if result.Meta.UsedFailover { + markRequestFailoverUsed(c) auditlog.EnrichEntryWithFailover(c, result.Meta.FailoverModel) } auditlog.EnrichEntryWithResolvedRoute( diff --git a/internal/server/request_model_resolution.go b/internal/server/request_model_resolution.go index a22a1ab3e..624d15873 100644 --- a/internal/server/request_model_resolution.go +++ b/internal/server/request_model_resolution.go @@ -14,9 +14,9 @@ import ( // selectors before provider execution. type RequestModelResolver = gateway.ModelResolver -// RequestFallbackResolver resolves alternate concrete model selectors for a +// RequestFailoverResolver resolves alternate concrete model selectors for a // translated request after the primary selector has already been resolved. -type RequestFallbackResolver = gateway.FallbackResolver +type RequestFailoverResolver = gateway.FailoverResolver func workflowProviderNameForType(provider core.RoutableProvider, providerType string) string { return gateway.WorkflowProviderNameForType(provider, providerType) diff --git a/internal/server/translated_inference_service.go b/internal/server/translated_inference_service.go index 7a068037a..5b463ca85 100644 --- a/internal/server/translated_inference_service.go +++ b/internal/server/translated_inference_service.go @@ -32,7 +32,7 @@ type translatedInferenceService struct { modelResolver RequestModelResolver modelAuthorizer RequestModelAuthorizer workflowPolicyResolver RequestWorkflowPolicyResolver - fallbackResolver RequestFallbackResolver + failoverResolver RequestFailoverResolver translatedRequestPatcher TranslatedRequestPatcher logger auditlog.LoggerInterface usageLogger usage.LoggerInterface @@ -67,7 +67,7 @@ func (s *translatedInferenceService) newInferenceOrchestrator() *gateway.Inferen ModelResolver: s.modelResolver, ModelAuthorizer: s.modelAuthorizer, WorkflowPolicyResolver: s.workflowPolicyResolver, - FallbackResolver: s.fallbackResolver, + FailoverResolver: s.failoverResolver, TranslatedRequestPatcher: s.translatedRequestPatcher, UsageLogger: s.usageLogger, PricingResolver: s.pricingResolver, @@ -93,7 +93,7 @@ func (s *translatedInferenceService) dispatchChatCompletion(c *echo.Context, req } if req.Stream { - if len(s.inference().FallbackSelectors(workflow)) == 0 { + if len(s.inference().FailoverSelectors(workflow)) == 0 { if handled, err := s.tryFastPathStreamingChatPassthrough(c, workflow, req); handled { return err } @@ -102,8 +102,8 @@ func (s *translatedInferenceService) dispatchChatCompletion(c *echo.Context, req if err != nil { return handleStreamingDispatchError(c, err) } - if result.Meta.UsedFallback { - markRequestFallbackUsed(c) + if result.Meta.UsedFailover { + markRequestFailoverUsed(c) } return s.handleStreamingReadCloser( c, @@ -122,8 +122,8 @@ func (s *translatedInferenceService) dispatchChatCompletion(c *echo.Context, req return handleError(c, err) } enrichAuditEntryWithProviderAttempts(c) - if result.Meta.UsedFallback { - markRequestFallbackUsed(c) + if result.Meta.UsedFailover { + markRequestFailoverUsed(c) auditlog.EnrichEntryWithFailover(c, result.Meta.FailoverModel) } auditlog.EnrichEntryWithResolvedRoute( @@ -261,8 +261,8 @@ func (s *translatedInferenceService) dispatchResponses(c *echo.Context, req *cor if err != nil { return handleStreamingDispatchError(c, err) } - if result.Meta.UsedFallback { - markRequestFallbackUsed(c) + if result.Meta.UsedFailover { + markRequestFailoverUsed(c) } stream := result.Stream if turn := conversationTurnFromContext(ctx); turn != nil { @@ -285,8 +285,8 @@ func (s *translatedInferenceService) dispatchResponses(c *echo.Context, req *cor return handleError(c, err) } enrichAuditEntryWithProviderAttempts(c) - if result.Meta.UsedFallback { - markRequestFallbackUsed(c) + if result.Meta.UsedFailover { + markRequestFailoverUsed(c) auditlog.EnrichEntryWithFailover(c, result.Meta.FailoverModel) } auditlog.EnrichEntryWithResolvedRoute( @@ -478,7 +478,7 @@ func attachPreparedWorkflow(c *echo.Context, ctx context.Context, workflow *core // observer only when failover targets exist, so non-failover requests — the hot // path — take on no extra per-request work. func (s *translatedInferenceService) observeLiveProviderAttempts(c *echo.Context, workflow *core.Workflow) { - if len(s.inference().FallbackSelectors(workflow)) == 0 { + if len(s.inference().FailoverSelectors(workflow)) == 0 { return } req := c.Request() @@ -662,11 +662,11 @@ func qualifyExecutedModel(workflow *core.Workflow, model, providerName string) s return gateway.QualifyExecutedModel(workflow, model, providerName) } -func markRequestFallbackUsed(c *echo.Context) { +func markRequestFailoverUsed(c *echo.Context) { if c == nil || c.Request() == nil { return } - c.SetRequest(c.Request().WithContext(core.WithFallbackUsed(c.Request().Context()))) + c.SetRequest(c.Request().WithContext(core.WithFailoverUsed(c.Request().Context()))) } func resolvedModelFromWorkflow(workflow *core.Workflow, fallback string) string { diff --git a/internal/workflows/compiler_test.go b/internal/workflows/compiler_test.go index 60789247d..cf898a23a 100644 --- a/internal/workflows/compiler_test.go +++ b/internal/workflows/compiler_test.go @@ -57,13 +57,13 @@ func TestCompilerCompile_Guardrails(t *testing.T) { } func TestCompilerCompile_AppliesProcessFeatureCaps(t *testing.T) { - fallbackEnabled := true + failoverEnabled := true compiled, err := NewCompilerWithFeatureCaps(nil, core.WorkflowFeatures{ Cache: false, Audit: true, Usage: false, Guardrails: false, - Fallback: false, + Failover: false, }).Compile(Version{ ID: "workflow-1", Scope: Scope{}, @@ -71,7 +71,7 @@ func TestCompilerCompile_AppliesProcessFeatureCaps(t *testing.T) { Name: "global", Payload: Payload{ SchemaVersion: 1, - Features: FeatureFlags{Cache: true, Audit: true, Usage: true, Guardrails: true, Fallback: &fallbackEnabled}, + Features: FeatureFlags{Cache: true, Audit: true, Usage: true, Guardrails: true, Failover: &failoverEnabled}, Guardrails: []GuardrailStep{ {Ref: "policy-system", Step: 10}, }, @@ -95,8 +95,8 @@ func TestCompilerCompile_AppliesProcessFeatureCaps(t *testing.T) { if compiled.Policy.Features.Guardrails { t.Fatal("Policy.Features.Guardrails = true, want false") } - if compiled.Policy.Features.Fallback { - t.Fatal("Policy.Features.Fallback = true, want false") + if compiled.Policy.Features.Failover { + t.Fatal("Policy.Features.Failover = true, want false") } if compiled.Pipeline != nil { t.Fatal("compiled pipeline is not nil") @@ -106,7 +106,7 @@ func TestCompilerCompile_AppliesProcessFeatureCaps(t *testing.T) { } } -func TestCompilerCompile_DefaultsFallbackEnabledWhenUnset(t *testing.T) { +func TestCompilerCompile_DefaultsFailoverEnabledWhenUnset(t *testing.T) { compiled, err := NewCompilerWithFeatureCaps(nil, core.DefaultWorkflowFeatures()).Compile(Version{ ID: "workflow-1", Scope: Scope{}, @@ -128,8 +128,8 @@ func TestCompilerCompile_DefaultsFallbackEnabledWhenUnset(t *testing.T) { if compiled == nil || compiled.Policy == nil { t.Fatal("Compile() returned nil policy") } - if !compiled.Policy.Features.Fallback { - t.Fatal("Policy.Features.Fallback = false, want true") + if !compiled.Policy.Features.Failover { + t.Fatal("Policy.Features.Failover = false, want true") } } diff --git a/internal/workflows/types.go b/internal/workflows/types.go index 98ee23eb0..20cde14f2 100644 --- a/internal/workflows/types.go +++ b/internal/workflows/types.go @@ -69,7 +69,7 @@ type FeatureFlags struct { Usage bool `json:"usage" bson:"usage"` Budget *bool `json:"budget,omitempty" bson:"budget,omitempty"` Guardrails bool `json:"guardrails" bson:"guardrails"` - Fallback *bool `json:"fallback,omitempty" bson:"fallback,omitempty"` + Failover *bool `json:"failover,omitempty" bson:"failover,omitempty"` } func (f FeatureFlags) canonicalize() FeatureFlags { @@ -77,9 +77,9 @@ func (f FeatureFlags) canonicalize() FeatureFlags { budgetEnabled := true f.Budget = &budgetEnabled } - if f.Fallback == nil { - fallbackEnabled := true - f.Fallback = &fallbackEnabled + if f.Failover == nil { + failoverEnabled := true + f.Failover = &failoverEnabled } return f } @@ -92,7 +92,7 @@ func (f FeatureFlags) runtimeFeatures() core.WorkflowFeatures { Usage: f.Usage, Budget: f.Usage && *f.Budget, Guardrails: f.Guardrails, - Fallback: *f.Fallback, + Failover: *f.Failover, } } diff --git a/internal/workflows/types_test.go b/internal/workflows/types_test.go index 27f4b5d03..0c8d2dd5d 100644 --- a/internal/workflows/types_test.go +++ b/internal/workflows/types_test.go @@ -110,7 +110,7 @@ func TestNormalizeCreateInput_RejectsManagedDefaultForNonGlobalScope(t *testing. } } -func TestFeatureFlagsRuntimeFeatures_FallbackDefaultsToTrue(t *testing.T) { +func TestFeatureFlagsRuntimeFeatures_FailoverDefaultsToTrue(t *testing.T) { features := FeatureFlags{ Cache: true, Audit: true, @@ -118,8 +118,8 @@ func TestFeatureFlagsRuntimeFeatures_FallbackDefaultsToTrue(t *testing.T) { Guardrails: false, }.runtimeFeatures() - if !features.Fallback { - t.Fatal("runtimeFeatures().Fallback = false, want true") + if !features.Failover { + t.Fatal("runtimeFeatures().Failover = false, want true") } } @@ -167,7 +167,7 @@ func TestFeatureFlagsRuntimeFeatures_DisablesBudgetWhenUsageDisabled(t *testing. } } -func TestNormalizePayload_CanonicalizesFallbackForStableWorkflowHash(t *testing.T) { +func TestNormalizePayload_CanonicalizesFailoverForStableWorkflowHash(t *testing.T) { explicitTrue := true implicitPayload, implicitHash, err := normalizePayload(Payload{ @@ -190,7 +190,7 @@ func TestNormalizePayload_CanonicalizesFallbackForStableWorkflowHash(t *testing. Audit: true, Usage: true, Guardrails: false, - Fallback: &explicitTrue, + Failover: &explicitTrue, Budget: &explicitTrue, }, }) @@ -198,11 +198,11 @@ func TestNormalizePayload_CanonicalizesFallbackForStableWorkflowHash(t *testing. t.Fatalf("normalizePayload() error = %v", err) } - if implicitPayload.Features.Fallback == nil || !*implicitPayload.Features.Fallback { - t.Fatalf("implicit payload fallback = %v, want explicit true", implicitPayload.Features.Fallback) + if implicitPayload.Features.Failover == nil || !*implicitPayload.Features.Failover { + t.Fatalf("implicit payload failover = %v, want explicit true", implicitPayload.Features.Failover) } - if explicitPayload.Features.Fallback == nil || !*explicitPayload.Features.Fallback { - t.Fatalf("explicit payload fallback = %v, want explicit true", explicitPayload.Features.Fallback) + if explicitPayload.Features.Failover == nil || !*explicitPayload.Features.Failover { + t.Fatalf("explicit payload failover = %v, want explicit true", explicitPayload.Features.Failover) } if implicitPayload.Features.Budget == nil || !*implicitPayload.Features.Budget { t.Fatalf("implicit payload budget = %v, want explicit true", implicitPayload.Features.Budget) diff --git a/tests/e2e/release-e2e-scenarios.md b/tests/e2e/release-e2e-scenarios.md index 69a4c9d6c..9711fe5ac 100644 --- a/tests/e2e/release-e2e-scenarios.md +++ b/tests/e2e/release-e2e-scenarios.md @@ -1345,7 +1345,7 @@ Creates a scoped workflow for `openai/gpt-4.1-nano` that disables cache for the curl -fsS -X POST "$AUTH_BASE_URL/admin/workflows" \ -H "$ADMIN_AUTH_HEADER" \ -H 'Content-Type: application/json' \ - -d "{\"scope_provider\":\"openai\",\"scope_model\":\"gpt-4.1-nano\",\"scope_user_path\":\"$QA_USER_PATH\",\"name\":\"$QA_WORKFLOW_NAME\",\"description\":\"Disable cache for managed-key release e2e scope\",\"workflow_payload\":{\"schema_version\":1,\"features\":{\"cache\":false,\"audit\":true,\"usage\":true,\"guardrails\":false,\"fallback\":false},\"guardrails\":[]}}" \ + -d "{\"scope_provider\":\"openai\",\"scope_model\":\"gpt-4.1-nano\",\"scope_user_path\":\"$QA_USER_PATH\",\"name\":\"$QA_WORKFLOW_NAME\",\"description\":\"Disable cache for managed-key release e2e scope\",\"workflow_payload\":{\"schema_version\":1,\"features\":{\"cache\":false,\"audit\":true,\"usage\":true,\"guardrails\":false,\"failover\":false},\"guardrails\":[]}}" \ > "$QA_WORKFLOW_JSON" if ! jq -er '.id | select(type == "string" and length > 0)' "$QA_WORKFLOW_JSON" > "$QA_WORKFLOW_ID_FILE"; then echo "error: workflow creation failed or did not return a usable workflow id" >&2 @@ -1356,7 +1356,7 @@ require_release_artifact "$QA_WORKFLOW_JSON" require_release_artifact "$QA_WORKFLOW_ID_FILE" jq -e --arg user_path "$QA_USER_PATH" ' {id,name,scope,workflow_payload} - | select(.id != null and .scope.scope_user_path == $user_path and .workflow_payload.features.cache == false) + | select(.id != null and .scope.scope_user_path == $user_path and .workflow_payload.features.cache == false and .workflow_payload.features.failover == false) ' "$QA_WORKFLOW_JSON" ``` @@ -1376,6 +1376,8 @@ jq -e --arg workflow_id "$WORKFLOW_ID" --arg user_path "$QA_USER_PATH" ' .id == $workflow_id and .scope.scope_user_path == $user_path and .effective_features.cache == false + and .workflow_payload.features.failover == false + and .effective_features.failover == false ' "$WORKFLOW_DETAIL_FILE" >/dev/null ``` diff --git a/tests/integration/workflows_guardrails_test.go b/tests/integration/workflows_guardrails_test.go index 072da40ca..d9d1be37e 100644 --- a/tests/integration/workflows_guardrails_test.go +++ b/tests/integration/workflows_guardrails_test.go @@ -53,7 +53,7 @@ func TestManagedAuthKeyWorkflow_AuditAndUsageValidity_PostgreSQL(t *testing.T) { Audit: true, Usage: true, Guardrails: false, - Fallback: boolPtr(false), + Failover: boolPtr(false), }, Guardrails: []workflows.GuardrailStep{}, }, @@ -106,7 +106,7 @@ func TestManagedAuthKeyWorkflow_AuditAndUsageValidity_PostgreSQL(t *testing.T) { assert.True(t, auditEntry.Data.WorkflowFeatures.Audit) assert.True(t, auditEntry.Data.WorkflowFeatures.Usage) assert.False(t, auditEntry.Data.WorkflowFeatures.Guardrails) - assert.False(t, auditEntry.Data.WorkflowFeatures.Fallback) + assert.False(t, auditEntry.Data.WorkflowFeatures.Failover) usageEntries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, requestID) require.Len(t, usageEntries, 1, "expected one usage entry") @@ -162,7 +162,7 @@ func TestGuardrailWorkflow_RewritesUpstreamRequestAndPreservesAuditUsage_Postgre Audit: true, Usage: true, Guardrails: true, - Fallback: boolPtr(false), + Failover: boolPtr(false), }, Guardrails: []workflows.GuardrailStep{ {Ref: "policy-system", Step: 10}, @@ -234,7 +234,7 @@ func TestGuardrailWorkflow_RewritesUpstreamRequestAndPreservesAuditUsage_Postgre assert.True(t, auditEntry.Data.WorkflowFeatures.Audit) assert.True(t, auditEntry.Data.WorkflowFeatures.Usage) assert.True(t, auditEntry.Data.WorkflowFeatures.Guardrails) - assert.False(t, auditEntry.Data.WorkflowFeatures.Fallback) + assert.False(t, auditEntry.Data.WorkflowFeatures.Failover) usageEntries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, requestID) require.Len(t, usageEntries, 1, "expected one usage entry") diff --git a/tools/seed-demo-data.sh b/tools/seed-demo-data.sh index 0af92f78f..9d5a6c5a4 100755 --- a/tools/seed-demo-data.sh +++ b/tools/seed-demo-data.sh @@ -436,7 +436,7 @@ SELECT 'usage', json('true'), 'budget', json('true'), 'guardrails', json('false'), - 'fallback', json('true') + 'failover', json('true') ), 'cache_type', cache_type, 'cache_story', CASE