diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..575d0ad99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +pnpm-lock.yaml +резюме/ \ No newline at end of file diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 02b97aa2f..138c46b91 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -325,6 +325,30 @@ token_saver: audit: enabled: true # env: TOKEN_SAVER_AUDIT_ENABLED +# ========= RESPONSE HEADERS ========= +# Additional debug headers added to gateway responses +response_headers: + # Enable adding X-Actual-Provider / X-Actual-Model / X-Requested-Model / X-Fallback-Chain headers + enabled: false # env: RESPONSE_HEADERS_ENABLED + # Add headers when a fallback was used during request execution + include_fallback: true # env: RESPONSE_HEADERS_INCLUDE_FALLBACK + # Add headers when no fallback was used (normal request execution) + include_non_fallback: true # env: RESPONSE_HEADERS_INCLUDE_NON_FALLBACK + # Emit the X-Actual-Provider header (the provider that actually served the response) + actual_provider_header: true # env: RESPONSE_HEADERS_ACTUAL_PROVIDER + # Emit the X-Actual-Model header (the model that actually produced the response) + actual_model_header: true # env: RESPONSE_HEADERS_ACTUAL_MODEL + # Emit the X-Requested-Model header (the originally requested model selector) + requested_model_header: true # env: RESPONSE_HEADERS_REQUESTED_MODEL + # Emit the X-Fallback-Chain header (comma-separated models attempted before a success) + fallback_chain_header: true # env: RESPONSE_HEADERS_FALLBACK_CHAIN + # Custom response headers. Values support placeholders: {actual_provider}, + # {actual_model}, {requested_model}, {fallback_chain}, {request_id} + custom_headers: + - name: X-Custom-Header + value: "{actual_model} via {actual_provider}" + enabled: true + # ========= WORKFLOWS ========= # Workflow execution and refresh workflows: diff --git a/configs/dashboard-overrides.yaml b/configs/dashboard-overrides.yaml index d24d4d27c..ed3eb90f4 100644 --- a/configs/dashboard-overrides.yaml +++ b/configs/dashboard-overrides.yaml @@ -100,3 +100,15 @@ token_saver: on_error: allow emit_headers: false audit_enabled: false +response_headers: + enabled: false + include_fallback: true + include_non_fallback: true + actual_provider_header: true + actual_model_header: true + requested_model_header: true + fallback_chain_header: true + custom_headers: + - name: X-Custom-Header + value: "{actual_model} via {actual_provider}" + enabled: true diff --git a/configs/fallback.example.json b/configs/fallback.example.json index 0115baf1a..228ed913e 100644 --- a/configs/fallback.example.json +++ b/configs/fallback.example.json @@ -1,10 +1,18 @@ -{ - "gpt-4o": [ - "azure/gpt-4o", - "gemini/gemini-2.5-pro" - ], - "claude-sonnet-4": [ - "openai/gpt-5-mini", - "gemini/gemini-2.5-pro" - ] -} +[ + { + "source": "gpt-4o", + "targets": [ + "azure/gpt-4o", + "gemini/gemini-2.5-pro" + ], + "enabled": true + }, + { + "source": "claude-sonnet-4", + "targets": [ + "openai/gpt-5-mini", + "gemini/gemini-2.5-pro" + ], + "enabled": true + } +] diff --git a/configuration/config.go b/configuration/config.go index b9b4fa32a..da5cb0586 100644 --- a/configuration/config.go +++ b/configuration/config.go @@ -30,23 +30,24 @@ var bodySizeLimitRegex = regexp.MustCompile(`(?i)^(\d+)([KMG])?B?$`) // Config holds the application configuration. type Config struct { - Server ServerConfig `yaml:"server"` - Models ModelsConfig `yaml:"models"` - Cache CacheConfig `yaml:"cache"` - Storage StorageConfig `yaml:"storage"` - Logging LogConfig `yaml:"logging"` - Usage UsageConfig `yaml:"usage"` - Metrics MetricsConfig `yaml:"metrics"` - HTTP HTTPConfig `yaml:"http"` - Admin AdminConfig `yaml:"admin"` - Guardrails GuardrailsConfig `yaml:"guardrails"` - Fallback FallbackConfig `yaml:"fallback"` - Combos CombosConfig `yaml:"combos"` - CLITools CLIToolsConfig `yaml:"cli_tools"` - Workflows WorkflowsConfig `yaml:"workflows"` - Resilience ResilienceConfig `yaml:"resilience"` - TokenSaver TokenSaverConfig `yaml:"token_saver"` - Edition EditionConfig `yaml:"edition"` + Server ServerConfig `yaml:"server"` + Models ModelsConfig `yaml:"models"` + Cache CacheConfig `yaml:"cache"` + Storage StorageConfig `yaml:"storage"` + Logging LogConfig `yaml:"logging"` + Usage UsageConfig `yaml:"usage"` + Metrics MetricsConfig `yaml:"metrics"` + HTTP HTTPConfig `yaml:"http"` + Admin AdminConfig `yaml:"admin"` + Guardrails GuardrailsConfig `yaml:"guardrails"` + Fallback FallbackConfig `yaml:"fallback"` + Combos CombosConfig `yaml:"combos"` + CLITools CLIToolsConfig `yaml:"cli_tools"` + Workflows WorkflowsConfig `yaml:"workflows"` + Resilience ResilienceConfig `yaml:"resilience"` + TokenSaver TokenSaverConfig `yaml:"token_saver"` + Edition EditionConfig `yaml:"edition"` + ResponseHeaders ResponseHeadersConfig `yaml:"response_headers"` } // LoadResult is returned by Load and bundles the application config with the raw @@ -68,6 +69,14 @@ type RawProviderConfig struct { APIVersion string `yaml:"api_version"` Models []RawProviderModel `yaml:"models"` Resilience *RawResilienceConfig `yaml:"resilience"` + // BindIP optionally sets the local outbound IP address used when this + // provider's HTTP client connects to the upstream. Useful when the host + // has multiple public IPs and the upstream rate-limits per source IP. + BindIP string `yaml:"bind_ip"` + // PoolOnly, when true, hides this provider's models from the public model + // list. The provider is then only reachable through a pool that lists it as + // a member, keeping the /v1/models list uncluttered. + PoolOnly bool `yaml:"pool_only"` } // RawPoolConfig is the YAML-sourced provider pool definition. Pools group @@ -222,8 +231,6 @@ type AdminConfig struct { // a warning is logged and UI is forced to false. // Default: true UIEnabled bool `yaml:"ui_enabled" env:"ADMIN_UI_ENABLED"` - - } // GuardrailsConfig holds configuration for the request guardrails pipeline. @@ -380,6 +387,65 @@ type HTTPProxyConfig struct { CACertPEM string `yaml:"ca_cert_pem"` } +// ResponseHeadersConfig controls the additional response headers added to +// gateway responses for debugging and monitoring purposes. +type ResponseHeadersConfig struct { + // Enabled controls whether the additional response headers are added. + // Default: false. + Enabled bool `yaml:"enabled" env:"RESPONSE_HEADERS_ENABLED"` + + // Mode controls when headers are added. + // Values: "success" (only 2xx), "error" (only 4xx/5xx), "always" (all responses). + // Default: "success". + Mode string `yaml:"mode" env:"RESPONSE_HEADERS_MODE"` + + // IncludeFallback controls whether headers are added when a fallback + // was used during request execution. + // Default: true. + IncludeFallback bool `yaml:"include_fallback" env:"RESPONSE_HEADERS_INCLUDE_FALLBACK"` + + // IncludeNonFallback controls whether headers are added when no fallback + // was used (normal request execution). + // Default: true. + IncludeNonFallback bool `yaml:"include_non_fallback" env:"RESPONSE_HEADERS_INCLUDE_NON_FALLBACK"` + + // ActualProviderHeader controls whether the X-Actual-Provider header + // (the provider that actually served the response) is added. + // Default: true. + ActualProviderHeader bool `yaml:"actual_provider_header" env:"RESPONSE_HEADERS_ACTUAL_PROVIDER"` + + // ActualModelHeader controls whether the X-Actual-Model header + // (the model that actually produced the response) is added. + // Default: true. + ActualModelHeader bool `yaml:"actual_model_header" env:"RESPONSE_HEADERS_ACTUAL_MODEL"` + + // RequestedModelHeader controls whether the X-Requested-Model header + // (the originally requested model selector) is added. + // Default: true. + RequestedModelHeader bool `yaml:"requested_model_header" env:"RESPONSE_HEADERS_REQUESTED_MODEL"` + + // FallbackChainHeader controls whether the X-Fallback-Chain header + // (comma-separated models attempted before a success) is added. + // Default: true. + FallbackChainHeader bool `yaml:"fallback_chain_header" env:"RESPONSE_HEADERS_FALLBACK_CHAIN"` + + // CustomHeaders are additional headers added verbatim to responses. + // Values support the same placeholders as the built-in headers. + // Default: none. + CustomHeaders []CustomResponseHeaderConfig `yaml:"custom_headers"` +} + +// CustomResponseHeaderConfig defines a user-provided response header. +type CustomResponseHeaderConfig struct { + // Name is the HTTP header name (e.g. "X-Custom-Header"). + Name string `yaml:"name"` + // Value is the header value. Supported placeholders: {actual_provider}, + // {actual_model}, {requested_model}, {fallback_chain}, {request_id}. + Value string `yaml:"value"` + // Enabled controls whether this header is emitted. + Enabled bool `yaml:"enabled"` +} + // WorkflowsConfig holds runtime refresh behavior for persisted workflows. type WorkflowsConfig struct { // RefreshInterval controls how often the in-memory workflow snapshot @@ -603,8 +669,6 @@ type MetricsConfig struct { Endpoint string `yaml:"endpoint" env:"METRICS_ENDPOINT"` } - - // RetryConfig holds resolved retry settings for an LLM client. // This is the canonical type shared between config and llmclient. type RetryConfig struct { @@ -657,7 +721,7 @@ func buildDefaultConfig() *Config { BasePath: "/", SwaggerEnabled: false, PprofEnabled: false, - DisableRequestLogging: true, + DisableRequestLogging: true, EnablePassthroughRoutes: true, EnableAnthropicIngress: false, AllowPassthroughV1Alias: true, @@ -742,6 +806,16 @@ func buildDefaultConfig() *Config { Admin: AdminConfig{EndpointsEnabled: false, UIEnabled: false}, Guardrails: GuardrailsConfig{}, TokenSaver: defaultTokenSaverConfig(), + ResponseHeaders: ResponseHeadersConfig{ + Enabled: false, + Mode: "success", + IncludeFallback: true, + IncludeNonFallback: true, + ActualProviderHeader: true, + ActualModelHeader: true, + RequestedModelHeader: true, + FallbackChainHeader: true, + }, Edition: EditionConfig{ Name: EditionOSS, }, @@ -841,12 +915,6 @@ func applyYAML(cfg *Config) (map[string]RawProviderConfig, map[string]RawPoolCon rawProviders := make(map[string]RawProviderConfig) rawPools := make(map[string]RawPoolConfig) - if data == nil { - return rawProviders, rawPools, nil - } - - expanded := expandString(string(data)) - // yamlTarget is a local struct that mirrors Config for YAML unmarshaling, // using RawProviderConfig for providers so nullable resilience overrides are preserved. type yamlTarget struct { @@ -855,16 +923,20 @@ func applyYAML(cfg *Config) (map[string]RawProviderConfig, map[string]RawPoolCon RawPools map[string]RawPoolConfig `yaml:"pools"` } - target := yamlTarget{Config: cfg} - if err := yaml.Unmarshal([]byte(expanded), &target); err != nil { - return nil, nil, fmt.Errorf("failed to parse %s: %w", configPath, err) - } + if data != nil { + expanded := expandString(string(data)) - if target.RawProviders != nil { - rawProviders = target.RawProviders - } - if target.RawPools != nil { - rawPools = target.RawPools + target := yamlTarget{Config: cfg} + if err := yaml.Unmarshal([]byte(expanded), &target); err != nil { + return nil, nil, fmt.Errorf("failed to parse %s: %w", configPath, err) + } + + if target.RawProviders != nil { + rawProviders = target.RawProviders + } + if target.RawPools != nil { + rawPools = target.RawPools + } } for _, overlayPath := range dashboardOverridePaths(customPath) { @@ -911,8 +983,6 @@ func dashboardOverridePaths(customPath string) []string { } } - - func validatePublicHTTPURL(raw, field string) error { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u.Scheme == "" || u.Host == "" { diff --git a/configuration/fallback.go b/configuration/fallback.go index fd5bc4ee2..69d323461 100644 --- a/configuration/fallback.go +++ b/configuration/fallback.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "io" "os" "strings" ) @@ -108,62 +107,74 @@ func loadFallbackConfig(cfg *FallbackConfig) error { } expanded := expandString(string(raw)) - decoded := make(map[string][]string) - decoder := json.NewDecoder(strings.NewReader(expanded)) - - token, err := decoder.Token() - if err != nil { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) - } - delim, ok := token.(json.Delim) - if !ok || delim != '{' { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: top-level JSON value must be an object", path) + trimmed := []byte(strings.TrimSpace(expanded)) + if len(trimmed) == 0 { + cfg.Manual = nil + return nil } - seenKeys := make(map[string]struct{}) - for decoder.More() { - token, err := decoder.Token() - if err != nil { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) - } - key, ok := token.(string) - if !ok { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: object key must be a string", path) - } - if _, exists := seenKeys[key]; exists { - return fmt.Errorf("fallback.manual_rules_path: duplicate JSON key %q in %q", key, path) - } - seenKeys[key] = struct{}{} + decoded := make(map[string][]string) - var rawModels json.RawMessage - if err := decoder.Decode(&rawModels); err != nil { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) - } - if bytes.Equal(bytes.TrimSpace(rawModels), []byte("null")) { - return fmt.Errorf("fallback.manual_rules_path: null not allowed for %q in %q", key, path) + // New format: JSON array of {source, targets, enabled} objects. + if trimmed[0] == '[' { + type arrayEntry struct { + Source string `json:"source"` + Targets json.RawMessage `json:"targets"` + Enabled *bool `json:"enabled"` } - var models []string - if err := json.Unmarshal(rawModels, &models); err != nil { + var entries []arrayEntry + if err := json.Unmarshal(trimmed, &entries); err != nil { return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) } - decoded[key] = models + for _, entry := range entries { + key := strings.TrimSpace(entry.Source) + if key == "" { + return fmt.Errorf("fallback.manual_rules_path: model key cannot be empty") + } + if _, exists := decoded[key]; exists { + return fmt.Errorf("fallback.manual_rules_path: duplicate manual rule key after trimming: %q", key) + } + enabled := true + if entry.Enabled != nil { + enabled = *entry.Enabled + } + if !enabled { + continue + } + models, _, err := DecodeManualRuleValue(entry.Targets) + if err != nil { + return fmt.Errorf("fallback.manual_rules_path: failed to parse targets for %q in %q: %w", key, path, err) + } + decoded[key] = normalizeModels(models) + } + cfg.Manual = decoded + return nil } - token, err = decoder.Token() - if err != nil { + // Legacy format: JSON object {"source": {"targets": [...], "enabled": bool}}. + var data map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &data); err != nil { return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) } - delim, ok = token.(json.Delim) - if !ok || delim != '}' { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: top-level JSON value must be an object", path) - } - - var trailing json.RawMessage - if err := decoder.Decode(&trailing); err != io.EOF { + for key, rawValue := range data { + key = strings.TrimSpace(key) + if key == "" { + return fmt.Errorf("fallback.manual_rules_path: model key cannot be empty") + } + if _, exists := decoded[key]; exists { + return fmt.Errorf("fallback.manual_rules_path: duplicate manual rule key after trimming: %q", key) + } + if bytes.Equal(bytes.TrimSpace(rawValue), []byte("null")) { + return fmt.Errorf("fallback.manual_rules_path: null not allowed for %q in %q", key, path) + } + models, enabled, err := DecodeManualRuleValue(rawValue) if err != nil { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q in %q: %w", key, path, err) } - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: unexpected trailing JSON content", path) + if !enabled { + continue + } + decoded[key] = normalizeModels(models) } manual := make(map[string][]string, len(decoded)) @@ -175,16 +186,57 @@ func loadFallbackConfig(cfg *FallbackConfig) error { if _, exists := manual[key]; exists { return fmt.Errorf("fallback.manual_rules_path: duplicate manual rule key after trimming: %q", key) } - normalized := make([]string, 0, len(models)) - for _, model := range models { - model = strings.TrimSpace(model) - if model == "" { - continue - } - normalized = append(normalized, model) - } - manual[key] = normalized + manual[key] = models } cfg.Manual = manual return nil } + +func normalizeModels(models []string) []string { + out := make([]string, 0, len(models)) + for _, m := range models { + m = strings.TrimSpace(m) + if m != "" { + out = append(out, m) + } + } + return out +} + +// ReloadFallbackManualRules re-reads the manual rules file into cfg.Manual at +// runtime. Used for live reload when the dashboard rewrites fallback.json, so +// rule edits apply without a process restart. +func ReloadFallbackManualRules(cfg *FallbackConfig) error { + if cfg == nil { + return nil + } + return loadFallbackConfig(cfg) +} + +// DecodeManualRuleValue parses a single manual rule value from the rules file. +// Both the legacy array form (["t1", "t2"], always enabled) and the object form +// ({"targets": [...], "enabled": true}) are supported. It returns the target +// list and whether the rule is enabled. +func DecodeManualRuleValue(raw json.RawMessage) ([]string, bool, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) > 0 && trimmed[0] == '[' { + var models []string + if err := json.Unmarshal(trimmed, &models); err != nil { + return nil, false, err + } + return models, true, nil + } + + var obj struct { + Targets []string `json:"targets"` + Enabled *bool `json:"enabled"` + } + if err := json.Unmarshal(trimmed, &obj); err != nil { + return nil, false, err + } + enabled := true + if obj.Enabled != nil { + enabled = *obj.Enabled + } + return obj.Targets, enabled, nil +} diff --git a/configuration/overlay_without_base_test.go b/configuration/overlay_without_base_test.go new file mode 100644 index 000000000..12c20efe9 --- /dev/null +++ b/configuration/overlay_without_base_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestApplyYAML_OverlayWithoutBaseConfig(t *testing.T) { + tmp := t.TempDir() + overlayDir := filepath.Join(tmp, "configs") + if err := os.MkdirAll(overlayDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + overlayPath := filepath.Join(overlayDir, "dashboard-overrides.yaml") + content := "response_headers:\n enabled: true\n actual_provider_header: true\n custom_headers:\n - name: X-Custom\n value: \"{actual_model}\"\n enabled: true\n" + if err := os.WriteFile(overlayPath, []byte(content), 0o644); err != nil { + t.Fatalf("write overlay: %v", err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer func() { _ = os.Chdir(wd) }() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + + cfg := &Config{} + providers, pools, err := applyYAML(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if providers == nil { + t.Error("expected non-nil providers map") + } + if pools == nil { + t.Error("expected non-nil pools map") + } + + if !cfg.ResponseHeaders.Enabled { + t.Error("expected response_headers.enabled to be true from overlay without base config") + } + if !cfg.ResponseHeaders.ActualProviderHeader { + t.Error("expected actual_provider_header to be true from overlay") + } + if len(cfg.ResponseHeaders.CustomHeaders) != 1 { + t.Fatalf("expected 1 custom header, got %d", len(cfg.ResponseHeaders.CustomHeaders)) + } + if cfg.ResponseHeaders.CustomHeaders[0].Name != "X-Custom" { + t.Errorf("expected custom header name X-Custom, got %q", cfg.ResponseHeaders.CustomHeaders[0].Name) + } +} \ No newline at end of file diff --git a/dashboard-ui/index.html b/dashboard-ui/index.html index d700f8e93..4896cbabd 100644 --- a/dashboard-ui/index.html +++ b/dashboard-ui/index.html @@ -5,18 +5,12 @@ - - - + -
Requests per day across the selected range.
Input, output, and locally served cache tokens.
/p/{"{provider}"}/v1/... behave like the canonical /p/{"{provider}"}/... path.