diff --git a/.env.template b/.env.template index f00784cd..abfe09f6 100644 --- a/.env.template +++ b/.env.template @@ -329,6 +329,13 @@ # (e.g. /v1/chat/completions and /v1/responses items). # ENABLE_GUARDRAILS_FOR_BATCH_PROCESSING=false +# Enable/disable outbound header policies globally (default: true) +# HEADER_POLICIES_ENABLED=true + +# Replace header_policies.policies with a JSON array. Prefer config.yaml for +# readability and ${VARIABLE} expansion for literal secret values. +# HEADER_POLICIES_JSON=[{"name":"pin-beta","step":20,"methods":["POST"],"paths":["/v1/chat/completions"],"actions":[{"action":"set","header":"Anthropic-Beta","value":"context-1m-2025-08-07"}]}] + # In-memory buffer size before flushing to storage (default: 1000) # USAGE_BUFFER_SIZE=1000 diff --git a/CLAUDE.md b/CLAUDE.md index 4e35afdd..8f8a01c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,8 @@ Full reference: `.env.template` and `config/config.yaml` - **HTTP client:** `HTTP_TIMEOUT` (600s), `HTTP_RESPONSE_HEADER_TIMEOUT` (600s); also settable via the `http:` block in `config.yaml` (env vars win) - **Resilience:** Configured via `config/config.yaml` - global `resilience.retry.*` and `resilience.circuit_breaker.*` defaults with optional per-provider overrides under `providers..resilience.retry.*` and `providers..resilience.circuit_breaker.*`. Retry defaults: `max_retries` (3), `initial_backoff` (1s), `max_backoff` (30s), `backoff_factor` (2.0), `jitter_factor` (0.1). Circuit breaker defaults: `failure_threshold` (5), `success_threshold` (2), `timeout` (30s). Breaker state is per-process and exported as the `gomodel_circuit_breaker_state` gauge when metrics are enabled. The dashboard's provider status also folds in real-traffic request health: each provider's runtime row carries `request_health` (last observed breaker state plus per-model request/error counts over a 10-minute sliding window; a model with ≥3 errors and a ≥50% error rate is flagged). An open breaker turns the provider card's status pill to "Circuit Open" (unhealthy), a half-open breaker or a flagged model degrades an otherwise healthy provider ("Recovering"/"Degraded"), and the expanded card details list recent per-model traffic with the latest error. Signals only worsen the discovery-based status, never improve it; tracking is in-memory per instance, and providers with no recent requests show discovery-based status only. - **Metrics:** `METRICS_ENABLED` (false), `METRICS_ENDPOINT` (/metrics) -- **Guardrails:** Definitions are persisted in the `guardrail_definitions` store and managed via the admin API/dashboard; `config/config.yaml` entries are validated and upserted into that store at startup (a seed, not the source of truth). `GUARDRAILS_ENABLED` env var gates the feature. +- **Guardrails:** Message guardrail definitions are managed through the Guardrails dashboard and `/admin/guardrails`; `config/config.yaml` entries seed the store at startup. `GUARDRAILS_ENABLED` gates the feature. Types: `system_prompt` and `llm_based_altering`. +- **Header policies:** Outbound provider-request header policies are a separate egress subsystem with dedicated `header_policy_definitions` persistence, managed through the Header Policies dashboard and `/admin/header-policies`. Declare them under `header_policies.policies` or `HEADER_POLICIES_JSON`; `HEADER_POLICIES_ENABLED` is the independent kill switch. Conditions support `matches`/`equals`/`present`; actions support `set` with `value` or `from_header`, and `remove`. Credential, transport, and payload-metadata headers are rejected. Policies apply on translated, passthrough, realtime, WebRTC, and MCP upstream requests, participate in response-cache identity, and record redacted/name-only deltas on audit `request_revisions`; values copied from credential-like source headers stay redacted under innocuous target names. Realtime/MCP workflow matching is user-path/global only, so constrain provider-specific rules with `paths`. Historical `header_modification` guardrail rows are migrated once to the dedicated store and legacy workflow references remain readable. - **Provider API key rotation:** Any API-key provider accepts several keys: `[_SUFFIX]_API_KEY_` env vars (numbered from 2; `_1` is accepted as a synonym for the unsuffixed key) or `providers..api_keys` in `config.yaml` (merged after `api_key`, de-duplicated, unresolved `${...}` entries dropped; env replaces the whole YAML list). Two or more keys turn on round-robin rotation, drawn per outbound HTTP request — including retries, so a 429'd request retries under the next key. Realtime websocket sessions pick a key per session. Counters are in-memory per instance. The trailing number names a key, not a provider: `OPENAI_API_KEY_2` is key 2 of `openai`, while `OPENAI_REGION_2_API_KEY` is the sole key of provider `openai-region-2`. **Rotation defeats provider prompt caching** (providers scope the cache to the key that filled it); use it to lift per-key rate limits, not to save cost. Keyless (Ollama, vLLM) and non-API-key providers (Vertex, Bedrock) are unaffected. - **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_MAX_TOKENS` (optional default `max_tokens` for Anthropic-translated requests that omit it; default 4096), `GEMINI_API_KEY`, `USE_GOOGLE_GEMINI_NATIVE_API` (true by default; false uses Gemini's OpenAI-compatible chat API), `XAI_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `FIREWORKS_BASE_URL` (optional Fireworks AI endpoint override; default `https://api.fireworks.ai/inference/v1`), `META_API_KEY`, `META_BASE_URL` (optional Meta Model API endpoint override; default `https://api.meta.ai/v1`; Muse Spark models, e.g. `muse-spark-1.1`), `OPENROUTER_API_KEY`, `OPENROUTER_SITE_URL`/`OPENROUTER_APP_NAME` (optional OpenRouter attribution headers), `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` (optional MiniMax endpoint override), `XIAOMI_API_KEY`, `XIAOMI_BASE_URL` (optional Xiaomi MiMo endpoint override), `OPENCODE_GO_API_KEY`, `OPENCODE_GO_BASE_URL` (optional OpenCode Go/Zen endpoint override; default `https://opencode.ai/zen/go/v1`), `OPENCODE_GO_MESSAGES_MODELS` (optional comma-separated model IDs routed to the Anthropic-native `/messages` endpoint instead of `/chat/completions`; default `qwen3.7-max`), `BAILIAN_API_KEY`, `BAILIAN_BASE_URL` (optional Bailian base URL for region switching; default `https://dashscope.aliyuncs.com/compatible-mode/v1`), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `BEDROCK_BASE_URL` (Bedrock Runtime region or endpoint), `BEDROCK_MANTLE_API_KEY`, `BEDROCK_MANTLE_BASE_URL` (Mantle region or endpoint), `BEDROCK_MANTLE_API_MODE` (`auto`, `openai`, or `standard`), `[_SUFFIX]_MODELS` (comma-separated configured model list for any provider type), `OLLAMA_BASE_URL`, `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token) - **Provider model metadata:** `providers..models` accepts either model IDs (strings) or `{id, metadata}` objects. When `metadata` is supplied (`display_name`, `context_window`, `max_output_tokens`, `modes`, `capabilities`, `pricing`, …) it is merged onto the remote ai-model-list entry during enrichment, with operator values winning per-field. Primary use case: advertising context windows, capabilities, and pricing for local models (Ollama) and other custom endpoints whose IDs are not in the upstream registry. diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index 506f7dbe..b72f2756 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -1124,7 +1124,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "MCP server name", + "description": "MCP server slug", "name": "name", "in": "path", "required": true @@ -1174,7 +1174,7 @@ const docTemplate = `{ }, "/admin/mcp-servers/{name}/catalog": { "get": { - "description": "Lists the tools, prompts, resources, and resource templates the named server currently exposes through the gateway, after operator tool filters. Names are the upstream originals; the aggregated /mcp endpoint prefixes them with the server name.", + "description": "Lists the tools, prompts, resources, and resource templates the named server currently exposes through the gateway, after operator tool filters. Names are the upstream originals; the aggregated /mcp endpoint prefixes them with the server slug.", "produces": [ "application/json" ], @@ -1185,7 +1185,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "MCP server name", + "description": "MCP server slug", "name": "name", "in": "path", "required": true @@ -1236,7 +1236,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "MCP server name", + "description": "MCP server slug", "name": "name", "in": "path", "required": true @@ -2782,7 +2782,7 @@ const docTemplate = `{ }, "/mcp": { "get": { - "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {server}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset.", + "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {slug}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset of server slugs.", "consumes": [ "application/json" ], @@ -2828,7 +2828,7 @@ const docTemplate = `{ ] }, "post": { - "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {server}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset.", + "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {slug}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset of server slugs.", "consumes": [ "application/json" ], @@ -2874,7 +2874,7 @@ const docTemplate = `{ ] }, "delete": { - "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {server}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset.", + "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {slug}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset of server slugs.", "consumes": [ "application/json" ], @@ -2937,7 +2937,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Configured MCP server name", + "description": "Configured MCP server slug", "name": "server", "in": "path", "required": true @@ -2998,7 +2998,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Configured MCP server name", + "description": "Configured MCP server slug", "name": "server", "in": "path", "required": true @@ -3059,7 +3059,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Configured MCP server name", + "description": "Configured MCP server slug", "name": "server", "in": "path", "required": true @@ -6142,6 +6142,9 @@ const docTemplate = `{ "resource_count": { "type": "integer" }, + "slug": { + "type": "string" + }, "status": { "type": "string" }, @@ -6501,6 +6504,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "slug": { + "type": "string" + }, "tool_timeout_seconds": { "type": "integer" }, @@ -6916,6 +6922,21 @@ const docTemplate = `{ } } }, + "auditlog.HeaderRevisionSnapshot": { + "type": "object", + "properties": { + "removed": { + "description": "Removed contains the names of headers the revision removes.", + "type": "array", + "items": { + "type": "string" + } + }, + "set": { + "description": "Set contains either map[string]string values or a []string of names,\naccording to the LOGGING_LOG_HEADERS policy described above." + } + } + }, "auditlog.LogData": { "type": "object", "properties": { @@ -6969,7 +6990,7 @@ const docTemplate = `{ } }, "request_revisions": { - "description": "RequestRevisions captures the ingress request-rewrite chain: one entry\nper registered rewriter that changed the body, in application order.\nRequestBody always remains the original client request; the last\nrevision is what was forwarded downstream.", + "description": "RequestRevisions captures intended request-processing changes in\napplication order: body rewrites and outbound header modifications.\nRequestBody always remains the original client request. A revision records\nwhat would be applied if execution reaches provider egress; it is not proof\nof egress.", "type": "array", "items": { "$ref": "#/definitions/auditlog.RequestRevisionSnapshot" @@ -7117,6 +7138,14 @@ const docTemplate = `{ "detail": { "description": "Detail is an optional rewriter-provided structured summary of what\nchanged (for example a compression block report)." }, + "headers": { + "description": "Headers records the outbound provider-request header changes made by\nthis revision. Only the delta is stored; absent means unchanged.", + "allOf": [ + { + "$ref": "#/definitions/auditlog.HeaderRevisionSnapshot" + } + ] + }, "rewriter": { "type": "string" }, diff --git a/config/config.example.yaml b/config/config.example.yaml index 0b53f49e..bd0a951f 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -279,6 +279,30 @@ guardrails: # skip_content_prefix: "### safe" # # prompt: "Custom rewrite instructions here." +header_policies: + enabled: true # env: HEADER_POLICIES_ENABLED; default true + policies: + # Named outbound policies are bound to the managed default workflow by step. + # Conditions all have to match; actions run in order. Credential, transport, + # content-type, and content-encoding headers cannot be read or changed. + # - name: "pin-anthropic-beta" + # description: "Enable the long-context Anthropic beta for Cline clients" + # step: 20 + # methods: ["POST"] # optional; empty matches every method + # paths: ["/v1/chat/completions", "/p/anthropic/*"] # trailing * matches a prefix + # when: + # - header: "User-Agent" + # matches: "^cline/" # RE2 regex; or use equals/present + # actions: + # - action: "set" + # header: "Anthropic-Beta" + # value: "context-1m-2025-08-07" + # - action: "set" + # header: "X-Team" + # from_header: "X-Client-Team" + # - action: "remove" + # header: "X-Internal-Debug" + failover: enabled: true # env: FAILOVER_ENABLED; default true manual_rules_path: "config/failover.example.json" # optional JSON map: {"primary-model": ["fallback-1", "provider/model"]} diff --git a/config/config.go b/config/config.go index 9e6b0c2f..0109e85d 100644 --- a/config/config.go +++ b/config/config.go @@ -20,23 +20,24 @@ import ( // 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"` - Budgets BudgetsConfig `yaml:"budgets"` - RateLimits RateLimitsConfig `yaml:"rate_limits"` - Metrics MetricsConfig `yaml:"metrics"` - HTTP HTTPConfig `yaml:"http"` - Admin AdminConfig `yaml:"admin"` - Guardrails GuardrailsConfig `yaml:"guardrails"` - Failover FailoverConfig `yaml:"failover"` - Workflows WorkflowsConfig `yaml:"workflows"` - Resilience ResilienceConfig `yaml:"resilience"` - Tagging TaggingConfig `yaml:"tagging"` - MCP MCPConfig `yaml:"mcp"` + Server ServerConfig `yaml:"server"` + Models ModelsConfig `yaml:"models"` + Cache CacheConfig `yaml:"cache"` + Storage StorageConfig `yaml:"storage"` + Logging LogConfig `yaml:"logging"` + Usage UsageConfig `yaml:"usage"` + Budgets BudgetsConfig `yaml:"budgets"` + RateLimits RateLimitsConfig `yaml:"rate_limits"` + Metrics MetricsConfig `yaml:"metrics"` + HTTP HTTPConfig `yaml:"http"` + Admin AdminConfig `yaml:"admin"` + Guardrails GuardrailsConfig `yaml:"guardrails"` + HeaderPolicies HeaderPoliciesConfig `yaml:"header_policies"` + Failover FailoverConfig `yaml:"failover"` + Workflows WorkflowsConfig `yaml:"workflows"` + Resilience ResilienceConfig `yaml:"resilience"` + Tagging TaggingConfig `yaml:"tagging"` + MCP MCPConfig `yaml:"mcp"` // VirtualModels declares redirects, load balancers, and access policies as // infrastructure-as-code. They override admin-store rows of the same source. @@ -147,7 +148,8 @@ func buildDefaultConfig() *Config { LiveLogsReplayLimit: 1000, LiveLogsHeartbeatSeconds: 15, }, - Guardrails: GuardrailsConfig{}, + Guardrails: GuardrailsConfig{}, + HeaderPolicies: HeaderPoliciesConfig{Enabled: true}, MCP: MCPConfig{ Enabled: true, }, @@ -185,6 +187,9 @@ func Load() (*LoadResult, error) { if err := applyEnvOverrides(cfg); err != nil { return nil, err } + if err := applyHeaderPoliciesEnv(cfg); err != nil { + return nil, err + } if err := applyVirtualModelsEnv(cfg, strict); err != nil { return nil, err } diff --git a/config/config_test.go b/config/config_test.go index 51d2102a..7c79aefe 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -63,7 +63,7 @@ func clearAllConfigEnvVars(t *testing.T) { "RATE_LIMITS_ENABLED", "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", + "GUARDRAILS_ENABLED", "ENABLE_GUARDRAILS_FOR_BATCH_PROCESSING", "HEADER_POLICIES_ENABLED", "HEADER_POLICIES_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", @@ -99,6 +99,9 @@ func withTempDir(t *testing.T, fn func(dir string)) { func TestBuildDefaultConfig(t *testing.T) { cfg := buildDefaultConfig() + if !cfg.HeaderPolicies.Enabled { + t.Fatal("expected HeaderPolicies.Enabled=true") + } if cfg.Server.Port != "8080" { t.Errorf("expected Server.Port=8080, got %s", cfg.Server.Port) @@ -253,6 +256,92 @@ func TestBuildDefaultConfig(t *testing.T) { } } +func TestLoad_HeaderPoliciesFromYAML(t *testing.T) { + clearAllConfigEnvVars(t) + withTempDir(t, func(_ string) { + if err := os.WriteFile("config.yaml", []byte(` +header_policies: + enabled: true + policies: + - name: pin-beta + description: Anthropic beta + step: 20 + methods: [POST] + paths: [/v1/chat/completions, /p/anthropic/*] + when: + - header: User-Agent + matches: ^cline/ + actions: + - action: set + header: Anthropic-Beta + value: context-1m +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + result, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + policies := result.Config.HeaderPolicies.Policies + if len(policies) != 1 || policies[0].Name != "pin-beta" || policies[0].Step != 20 { + t.Fatalf("policies = %#v", policies) + } + if len(policies[0].Paths) != 2 || policies[0].Paths[1] != "/p/anthropic/*" { + t.Fatalf("paths = %#v", policies[0].Paths) + } + }) +} + +func TestLoad_HeaderPoliciesJSONOverridesYAML(t *testing.T) { + clearAllConfigEnvVars(t) + t.Setenv("HEADER_POLICIES_ENABLED", "false") + t.Setenv("HEADER_POLICIES_JSON", `[{"name":"from-env","step":30,"paths":["/v1/*"],"actions":[{"action":"set","header":"X-Team","from_header":"X-Client-Team"}]}]`) + withTempDir(t, func(_ string) { + if err := os.WriteFile("config.yaml", []byte(` +header_policies: + policies: + - name: from-yaml + actions: + - action: remove + header: X-Debug +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + result, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if result.Config.HeaderPolicies.Enabled { + t.Fatal("HEADER_POLICIES_ENABLED=false was ignored") + } + policies := result.Config.HeaderPolicies.Policies + if len(policies) != 1 || policies[0].Name != "from-env" || policies[0].Actions[0].FromHeader != "X-Client-Team" { + t.Fatalf("policies = %#v", policies) + } + }) +} + +func TestLoad_HeaderPoliciesJSONRejectsUnknownFields(t *testing.T) { + clearAllConfigEnvVars(t) + t.Setenv("HEADER_POLICIES_JSON", `[{"name":"bad","actons":[]}]`) + withTempDir(t, func(_ string) { + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "HEADER_POLICIES_JSON") { + t.Fatalf("Load() error = %v", err) + } + }) +} + +func TestLoad_HeaderPoliciesJSONRejectsTrailingData(t *testing.T) { + clearAllConfigEnvVars(t) + t.Setenv("HEADER_POLICIES_JSON", `[] {}`) + withTempDir(t, func(_ string) { + if _, err := Load(); err == nil { + t.Fatal("Load() error = nil, want trailing JSON error") + } + }) +} + func TestLoadBudgetEnvUserPath(t *testing.T) { clearAllConfigEnvVars(t) diff --git a/config/guardrails.go b/config/guardrails.go index 5f729f71..12aaa9df 100644 --- a/config/guardrails.go +++ b/config/guardrails.go @@ -23,7 +23,9 @@ type GuardrailRuleConfig struct { // Name is a unique identifier for this guardrail instance (used in logs and errors) Name string `yaml:"name"` - // Type selects the guardrail implementation: "system_prompt" or "llm_based_altering" + // Type selects the guardrail implementation: "system_prompt" or + // "llm_based_altering". "header_modification" remains accepted only as a + // deprecated migration input; use header_policies instead. Type string `yaml:"type"` // UserPath scopes internal auxiliary guardrail requests for workflow @@ -40,6 +42,9 @@ type GuardrailRuleConfig struct { // LLMBasedAltering holds settings when Type is "llm_based_altering" LLMBasedAltering LLMBasedAlteringSettings `yaml:"llm_based_altering"` + + // HeaderModification holds settings when Type is "header_modification" + HeaderModification HeaderModificationSettings `yaml:"header_modification"` } // SystemPromptSettings holds the type-specific settings for a system_prompt guardrail. @@ -79,3 +84,53 @@ type LLMBasedAlteringSettings struct { // Default: 4096 MaxTokens int `yaml:"max_tokens"` } + +// HeaderModificationSettings is the deprecated guardrails representation of +// an outbound header policy. New configuration uses HeaderPoliciesConfig. +type HeaderModificationSettings struct { + // Methods optionally limits the policy to these HTTP methods. + Methods []string `yaml:"methods"` + + // Endpoints optionally limits the policy to public request paths. A trailing + // '*' matches a prefix. + Endpoints []string `yaml:"endpoints"` + + // When lists inbound-header conditions; all must match. Empty = always apply. + When []HeaderConditionConfig `yaml:"when"` + + // Actions lists outbound header changes applied in order. + Actions []HeaderActionConfig `yaml:"actions"` +} + +// HeaderConditionConfig is one inbound-header predicate. +type HeaderConditionConfig struct { + // Header is the inbound header name to inspect. + Header string `json:"header" yaml:"header"` + + // Equals matches when any inbound value equals this string exactly. + Equals *string `json:"equals,omitempty" yaml:"equals"` + + // Matches matches when any inbound value matches this RE2 regular expression. + Matches *string `json:"matches,omitempty" yaml:"matches"` + + // Present requires the header to exist (true) or be absent (false). + // Ignored when Equals or Matches is set; defaults to true otherwise. + Present *bool `json:"present,omitempty" yaml:"present"` +} + +// HeaderActionConfig is one outbound-header change. +type HeaderActionConfig struct { + // Action is "set" (replace/add) or "remove". + Action string `json:"action" yaml:"action"` + + // Header is the outbound header to change. Credential and transport + // headers (Authorization, Cookie, Host, Content-Length, ...) are rejected. + Header string `json:"header" yaml:"header"` + + // Value is the literal value for "set". + Value *string `json:"value,omitempty" yaml:"value"` + + // FromHeader copies the first inbound value of this header for "set". + // When the inbound header is absent, the action is skipped. + FromHeader string `json:"from_header,omitempty" yaml:"from_header"` +} diff --git a/config/header_policies.go b/config/header_policies.go new file mode 100644 index 00000000..6699ddd6 --- /dev/null +++ b/config/header_policies.go @@ -0,0 +1,48 @@ +package config + +import ( + "fmt" + "strings" +) + +// HeaderPoliciesConfig configures reusable outbound request-header policies. +type HeaderPoliciesConfig struct { + // Enabled is an operational kill switch. With no policies configured, the + // enabled default has no effect on requests. + Enabled bool `yaml:"enabled" env:"HEADER_POLICIES_ENABLED"` + + // Policies seeds named definitions and binds them to the managed default + // workflow using each policy's Step. + Policies []HeaderPolicyConfig `yaml:"policies"` + + // PoliciesJSON replaces Policies when HEADER_POLICIES_JSON is set. + PoliciesJSON string `yaml:"-" env:"HEADER_POLICIES_JSON"` +} + +// HeaderPolicyConfig is one declarative outbound header policy. +type HeaderPolicyConfig struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description"` + Step int `json:"step,omitempty" yaml:"step"` + Methods []string `json:"methods,omitempty" yaml:"methods"` + Paths []string `json:"paths,omitempty" yaml:"paths"` + When []HeaderConditionConfig `json:"when,omitempty" yaml:"when"` + Actions []HeaderActionConfig `json:"actions" yaml:"actions"` +} + +func applyHeaderPoliciesEnv(cfg *Config) error { + if cfg == nil { + return nil + } + raw := strings.TrimSpace(cfg.HeaderPolicies.PoliciesJSON) + if raw == "" { + return nil + } + raw = expandString(raw) + var policies []HeaderPolicyConfig + if err := decodeStrictJSON(raw, &policies); err != nil { + return fmt.Errorf("invalid HEADER_POLICIES_JSON: %w", err) + } + cfg.HeaderPolicies.Policies = policies + return nil +} diff --git a/docs/advanced/guardrails.mdx b/docs/advanced/guardrails.mdx index 53ff41e0..610ed6a0 100644 --- a/docs/advanced/guardrails.mdx +++ b/docs/advanced/guardrails.mdx @@ -113,7 +113,7 @@ export GUARDRAILS_ENABLED=true | Field | Required | Description | | ------ | -------- | ------------------------------------------------------------------- | | `name` | Yes | Human-readable identifier. Supports spaces and unicode, but not `/`. | -| `type` | Yes | Guardrail type: `system_prompt` or `llm_based_altering`. | +| `type` | Yes | Definition type: `system_prompt` or `llm_based_altering`. | | `user_path` | No | Optional base user path for internal auxiliary guardrail requests. | | `order`| No | Execution order. Default `0`. Same value = parallel, different = sequential. | @@ -230,6 +230,12 @@ Guardrails are explicitly skipped for that internal request to avoid recursion. roles: ["user"] ``` + + Outbound request-header changes are configured as [Header Policies](/advanced/header-policies), + not as message guardrails. Historical `header_modification` guardrail definitions remain readable + for migration, but new definitions use the dedicated configuration and admin API. + + ## Examples ### Single Safety Guardrail diff --git a/docs/advanced/header-policies.mdx b/docs/advanced/header-policies.mdx new file mode 100644 index 00000000..d0a786d6 --- /dev/null +++ b/docs/advanced/header-policies.mdx @@ -0,0 +1,185 @@ +--- +title: "Header Policies" +description: "Conditionally set or remove outbound provider-request headers without treating transport policy as a message guardrail." +icon: "panels-top-left" +--- + +## Overview + +Header policies change headers on the outbound request to an LLM provider. They are a dedicated +egress-policy stage, separate from [Guardrails](/advanced/guardrails), which operate on normalized +messages. + +Use header policies to adapt existing clients without changing them. Common examples include +pinning an `Anthropic-Beta` feature, copying a tenant header into a provider-specific header, or +removing internal diagnostics before provider egress. + +```mermaid +flowchart LR + A[Inbound request] --> B[Authentication and routing] + B --> C[Workflow selection] + C --> D[Resolve header-policy plan] + D --> E[Cache lookup] + E --> F[Build selected provider attempt] + F --> G[Apply header-policy plan] + G --> H[Provider egress] +``` + +The resolved plan is immutable for the request. It applies to translated HTTP, passthrough, +realtime WebSocket and WebRTC provider requests, and MCP upstream HTTP requests. It does not +propagate into internal guardrail or embedding calls, and it does not carry into a different +failover provider. Retries of the selected primary provider reuse the same plan. + +## Configuration + +Header policies have their own top-level configuration: + +```yaml +header_policies: + enabled: true + policies: + - name: "pin-anthropic-beta" + description: "Enable the long-context beta for Cline" + step: 10 + methods: ["POST"] + paths: ["/v1/messages", "/p/anthropic/*"] + when: + - header: "User-Agent" + matches: "^cline/" + actions: + - action: "set" + header: "Anthropic-Beta" + value: "context-1m-2025-08-07" + - action: "set" + header: "X-Team" + from_header: "X-Client-Team" + - action: "remove" + header: "X-Internal-Debug" +``` + +`enabled` defaults to `true`. With no policies configured, this default has no effect. Set it to +`false` as an operational kill switch without deleting policy definitions or their workflow +bindings; turning it back on resumes the existing bindings. + +Every policy configured in YAML is stored as a reusable definition and attached to the managed +default workflow. `step` controls ordering: lower steps run first, and a later action can replace an +earlier action on the same header. + +### Environment variables + +```bash +export HEADER_POLICIES_ENABLED=true +export HEADER_POLICIES_JSON='[{"name":"remove-debug","actions":[{"action":"remove","header":"X-Internal-Debug"}]}]' +``` + +`HEADER_POLICIES_JSON` replaces `header_policies.policies` when set. It uses the same flattened +fields as YAML and rejects unknown fields. + +## Matching and actions + +| Field | Required | Description | +| --- | --- | --- | +| `name` | Yes | Unique reusable policy name. Names are shared with guardrails and cannot contain `/`. | +| `description` | No | Operator-facing description. | +| `step` | No | Ordering in the managed default workflow. Default `0`. | +| `methods` | No | HTTP methods to match. Empty matches every method. | +| `paths` | No | Public request paths. A trailing `*` matches a prefix. | +| `when` | No | Inbound-header conditions. All must match; empty means always. | +| `actions` | Yes | Outbound header mutations, applied in order. | + +Each condition names a `header` and at most one predicate: + +- `matches`: an RE2 regular expression +- `equals`: an exact value; `equals: ""` matches a present header with an empty value +- `present: true` or `present: false`: an explicit existence check + +With no predicate, a condition means that the header must be present. + +An action is `set` or `remove`. A `set` action takes exactly one of: + +- `value`, including an explicit empty value +- `from_header`, which copies the first inbound value; the action is skipped if the source is absent + + + Credential headers such as `Authorization`, `Cookie`, and `X-Api-Key`, and transport headers such + as `Host`, `Content-Length`, and `Connection`, cannot be condition sources or action targets. + `Accept-Encoding`, `Content-Encoding`, and `Content-Type` are also reserved because changing them + can break decompression or body parsing. Authoring validation rejects these headers, and runtime + enforcement provides a second boundary. + + +## Workflows + +Managed definitions can be attached explicitly to an API-authored workflow: + +```json +{ + "workflow_payload": { + "schema_version": 1, + "features": { "guardrails": false }, + "header_policies": [ + { "ref": "pin-anthropic-beta", "step": 10 } + ] + } +} +``` + +Header policies remain active when the workflow's message `guardrails` feature is false. Their +effective resolved plan, including values copied with `from_header`, participates in exact and +semantic response-cache identity. + +Realtime and MCP workflow selection currently matches only `user_path` and global workflow scopes. +Provider- and model-scoped workflows do not bind to those operations, even when a realtime model is +present in the query or session body. The matched workflow's feature flags still apply to the whole +realtime or MCP request. + + + An unscoped policy also reaches MCP upstream servers. Use `paths` such as `/v1/*` or + `/p/anthropic/*` when a header is meaningful only to model-provider APIs, and include `/mcp*` + only when the policy is intentionally shared with MCP egress. + + +## Dashboard and admin API + +Use **Header Policies** in the dashboard to create, edit, and delete definitions. It is intentionally +separate from the Guardrails page. + +The same operations are available through the admin API: + +| Method | Endpoint | Description | +| --- | --- | --- | +| `GET` | `/admin/header-policies` | List definitions. | +| `PUT` | `/admin/header-policies` | Create or replace a flattened definition. | +| `DELETE` | `/admin/header-policies` | Delete by JSON body: `{ "name": "..." }`. | + +A definition referenced by an active workflow cannot be deleted. A name cannot be shared by a +guardrail and a header policy. + +## Audit visibility + +Every policy that changes a request adds a delta to the audit entry's request-revision chain, the +same revision mechanism used by body rewriters. The dashboard audit-log drawer displays it in a +**Rewritten** tab. + +- With `LOGGING_LOG_HEADERS=true`, set values are stored after header redaction. Credential-like + names containing segments such as `auth`, `key`, `secret`, `session`, `token`, or `credential` + are redacted. Values copied with `from_header` from such a source remain redacted even when the + target header has an innocuous name. +- With header logging disabled, only changed header names are stored. +- Removed headers always contain names only. +- A policy that does not match records no revision. + +Revisions describe the intended change at request admission; they are not proof that execution +reached provider egress. + +## Migrating preview configuration + +Historical configurations using `guardrails.rules[].type: header_modification` and historical +workflow references under `guardrails` remain readable. At startup, GoModel moves stored preview +definitions from the guardrail store into the dedicated `header_policy_definitions` table (or +MongoDB collection), removes the legacy rows, and keeps compiling legacy workflow references into +the egress stage. The migration is idempotent; an existing dedicated definition wins by name. + +Move each definition to `header_policies.policies`, rename `order` to `step`, rename `endpoints` to +`paths`, and remove the `type` and `header_modification` wrapper. New definitions are not accepted +through `/admin/guardrails`. diff --git a/docs/advanced/workflows.mdx b/docs/advanced/workflows.mdx index ac6406dd..978da04f 100644 --- a/docs/advanced/workflows.mdx +++ b/docs/advanced/workflows.mdx @@ -15,6 +15,7 @@ They currently control gateway-owned behavior such as: - audit logging - usage tracking - guardrails +- outbound header policies - translated-route failover Each request matches exactly one active workflow. @@ -115,7 +116,10 @@ curl -X POST http://localhost:8080/admin/workflows \ "guardrails": false, "failover": true }, - "guardrails": [] + "guardrails": [], + "header_policies": [ + { "ref": "pin-anthropic-beta", "step": 10 } + ] } }' ``` @@ -126,3 +130,5 @@ curl -X POST http://localhost:8080/admin/workflows \ - `scope_user_path` is normalized to canonical slash form - managed API keys can override the request user path header; workflow matching uses the effective request user path - budget enforcement runs only when the global budget feature and the matched workflow's `budget` feature are both enabled; see [Budgets](/features/budgets) +- `header_policies` are an egress stage independent of the message `guardrails` feature flag; their resolved plan participates in response-cache identity and applies only to the selected primary provider route; see [Header Policies](/advanced/header-policies) +- `HEADER_POLICIES_ENABLED=false` is the global operational kill switch; workflow definitions remain stored and compilable while application is disabled diff --git a/docs/docs.json b/docs/docs.json index 308b2495..aac5c81e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -101,6 +101,7 @@ "advanced/audio-api", "advanced/usage-api", "advanced/guardrails", + "advanced/header-policies", "advanced/workflows", "advanced/admin-endpoints" ] diff --git a/docs/openapi.json b/docs/openapi.json index d97aadec..9abec07d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1472,7 +1472,7 @@ "summary": "Delete one admin-managed MCP server", "parameters": [ { - "description": "MCP server name", + "description": "MCP server slug", "name": "name", "in": "path", "required": true, @@ -1550,14 +1550,14 @@ }, "/admin/mcp-servers/{name}/catalog": { "get": { - "description": "Lists the tools, prompts, resources, and resource templates the named server currently exposes through the gateway, after operator tool filters. Names are the upstream originals; the aggregated /mcp endpoint prefixes them with the server name.", + "description": "Lists the tools, prompts, resources, and resource templates the named server currently exposes through the gateway, after operator tool filters. Names are the upstream originals; the aggregated /mcp endpoint prefixes them with the server slug.", "tags": [ "admin" ], "summary": "Inspect one MCP server's current catalog", "parameters": [ { - "description": "MCP server name", + "description": "MCP server slug", "name": "name", "in": "path", "required": true, @@ -1628,7 +1628,7 @@ "summary": "Force-redial one MCP server and return its fresh state", "parameters": [ { - "description": "MCP server name", + "description": "MCP server slug", "name": "name", "in": "path", "required": true, @@ -3693,7 +3693,7 @@ }, "/mcp": { "get": { - "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {server}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset.", + "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {slug}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset of server slugs.", "tags": [ "mcp" ], @@ -3774,7 +3774,7 @@ } }, "post": { - "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {server}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset.", + "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {slug}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset of server slugs.", "tags": [ "mcp" ], @@ -3855,7 +3855,7 @@ } }, "delete": { - "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {server}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset.", + "description": "Streamable-HTTP MCP endpoint aggregating every configured upstream MCP server visible to the caller. Tools and prompts are namespaced as {slug}_{name}. POST carries JSON-RPC messages, GET opens the server-notification SSE stream, DELETE ends the session. The X-MCP-Servers request header optionally narrows the visible servers to a comma-separated subset of server slugs.", "tags": [ "mcp" ], @@ -3945,7 +3945,7 @@ "summary": "MCP gateway (single server)", "parameters": [ { - "description": "Configured MCP server name", + "description": "Configured MCP server slug", "name": "server", "in": "path", "required": true, @@ -4052,7 +4052,7 @@ "summary": "MCP gateway (single server)", "parameters": [ { - "description": "Configured MCP server name", + "description": "Configured MCP server slug", "name": "server", "in": "path", "required": true, @@ -4159,7 +4159,7 @@ "summary": "MCP gateway (single server)", "parameters": [ { - "description": "Configured MCP server name", + "description": "Configured MCP server slug", "name": "server", "in": "path", "required": true, @@ -8979,6 +8979,9 @@ "resource_count": { "type": "integer" }, + "slug": { + "type": "string" + }, "status": { "type": "string" }, @@ -9345,6 +9348,9 @@ "name": { "type": "string" }, + "slug": { + "type": "string" + }, "tool_timeout_seconds": { "type": "integer" }, @@ -9764,6 +9770,21 @@ } } }, + "auditlog.HeaderRevisionSnapshot": { + "type": "object", + "properties": { + "removed": { + "description": "Removed contains the names of headers the revision removes.", + "type": "array", + "items": { + "type": "string" + } + }, + "set": { + "description": "Set contains either map[string]string values or a []string of names,\naccording to the LOGGING_LOG_HEADERS policy described above." + } + } + }, "auditlog.LogData": { "type": "object", "properties": { @@ -9817,7 +9838,7 @@ } }, "request_revisions": { - "description": "RequestRevisions captures the ingress request-rewrite chain: one entry\nper registered rewriter that changed the body, in application order.\nRequestBody always remains the original client request; the last\nrevision is what was forwarded downstream.", + "description": "RequestRevisions captures intended request-processing changes in\napplication order: body rewrites and outbound header modifications.\nRequestBody always remains the original client request. A revision records\nwhat would be applied if execution reaches provider egress; it is not proof\nof egress.", "type": "array", "items": { "$ref": "#/components/schemas/auditlog.RequestRevisionSnapshot" @@ -9965,6 +9986,14 @@ "detail": { "description": "Detail is an optional rewriter-provided structured summary of what\nchanged (for example a compression block report)." }, + "headers": { + "description": "Headers records the outbound provider-request header changes made by\nthis revision. Only the delta is stored; absent means unchanged.", + "allOf": [ + { + "$ref": "#/components/schemas/auditlog.HeaderRevisionSnapshot" + } + ] + }, "rewriter": { "type": "string" }, diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index acc1cf02..7f998338 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -5262,6 +5262,29 @@ body.conversation-drawer-open { min-width: 0; } +.guardrail-header-rows { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.guardrail-header-row { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(120px, auto) minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + width: 100%; +} + +.guardrail-action-row { + grid-template-columns: minmax(120px, auto) minmax(0, 1.1fr) minmax(0, 1fr) auto; +} + +.guardrail-header-row .guardrail-header-row-spacer { + min-width: 0; +} + .settings-guardrail-type-pill { display: inline-flex; align-items: center; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 3496b1f7..6ad3f06c 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -234,6 +234,7 @@ function dashboard() { "workflows", "audit-logs", "guardrails", + "header-policies", "mcp-servers", "auth-keys", "settings", @@ -269,6 +270,9 @@ function dashboard() { ) { this.fetchGuardrailsPage(); } + if (page === "header-policies" && typeof this.fetchHeaderPolicies === "function") { + this.fetchHeaderPolicies(); + } if ( page === "mcp-servers" && typeof this.fetchMcpServersPage === "function" @@ -477,6 +481,7 @@ function dashboard() { this.failoverDraftsOpen || (this.page === "workflows" && this.workflowFormOpen) || (this.page === "guardrails" && this.guardrailFormOpen) || + (this.page === "header-policies" && this.headerPolicyFormOpen) || (this.page === "mcp-servers" && (this.mcpServerFormOpen || this.mcpCatalogOpen)) || (this.page === "auth-keys" && this.authKeyFormOpen) || @@ -788,6 +793,9 @@ function dashboard() { ) { requests.push(this.fetchGuardrailsPage()); } + if (this.page === "header-policies" && typeof this.fetchHeaderPolicies === "function") { + requests.push(this.fetchHeaderPolicies()); + } if (this.page === "usage" && typeof this.fetchUsagePage === "function") { requests.push(this.fetchUsagePage()); } @@ -1242,6 +1250,12 @@ function dashboard() { : null, "dashboardGuardrailsModule", ), + resolveModuleFactory( + typeof dashboardHeaderPoliciesModule === "function" + ? dashboardHeaderPoliciesModule + : null, + "dashboardHeaderPoliciesModule", + ), resolveModuleFactory( typeof dashboardBudgetsModule === "function" ? dashboardBudgetsModule diff --git a/internal/admin/dashboard/static/js/modules/audit-list.js b/internal/admin/dashboard/static/js/modules/audit-list.js index 1317185f..255a064c 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.js @@ -534,7 +534,8 @@ }, // auditRequestRevisions returns the ingress rewrite chain recorded for - // the entry (one item per rewriter that changed the request body). + // the entry (one item per rewriter or header-modification step that + // changed the request body or the outbound headers). auditRequestRevisions(entry) { return entry && entry.data && Array.isArray(entry.data.request_revisions) ? entry.data.request_revisions @@ -555,15 +556,27 @@ // auditRequestRevisionPane renders one ingress rewrite: a structured // summary of what the rewriter changed plus the rewritten body when // it was captured. The original client request stays on the Request - // tab; the last revision is what the provider actually received. + // tab. Revisions describe intended changes and do not prove egress. auditRequestRevisionPane(entry, revision) { const body = revision && revision.body; const hasBody = body != null && body !== ''; + const headerDelta = (revision && revision.headers) || null; const single = this.auditRequestRevisions(entry).length <= 1; const summary = { - rewriter: (revision && revision.rewriter) || '', - bytes: Number(revision && revision.bytes_before || 0) + ' \u2192 ' + Number(revision && revision.bytes_after || 0) + rewriter: (revision && revision.rewriter) || '' }; + if (!headerDelta || revision.bytes_before || revision.bytes_after) { + summary.bytes = Number(revision && revision.bytes_before || 0) + ' \u2192 ' + Number(revision && revision.bytes_after || 0); + } + // Only changed headers are recorded. Depending on + // LOGGING_LOG_HEADERS, set is either a redacted name/value + // object or a names-only array; removed always carries names. + if (headerDelta && headerDelta.set) { + summary.headers_set = headerDelta.set; + } + if (headerDelta && headerDelta.removed) { + summary.headers_removed = headerDelta.removed; + } if (revision && revision.detail != null) { summary.detail = revision.detail; } @@ -587,7 +600,7 @@ body, showEmpty: false, emptyMessage: '', - showTooLarge: !hasBody, + showTooLarge: !hasBody && !headerDelta, tooLargeMessage: 'Rewritten body not captured (body logging disabled or body too large).' }; }, diff --git a/internal/admin/dashboard/static/js/modules/audit-list.test.cjs b/internal/admin/dashboard/static/js/modules/audit-list.test.cjs index 61ad8129..c939293a 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.test.cjs +++ b/internal/admin/dashboard/static/js/modules/audit-list.test.cjs @@ -988,3 +988,68 @@ test('entries without revisions render no revision tabs', () => { assert.equal(module.auditPanes(entry).map((p) => p.id).join(','), 'request,response'); assert.equal(module.auditRequestRevisions(entry).length, 0); }); + +test('auditRequestRevisionPane renders header-only revisions with the delta and no body warning', () => { + const module = createAuditListModule(); + const entry = { + data: { + request_revisions: [{ + seq: 1, + rewriter: 'pin-beta', + bytes_before: 0, + bytes_after: 0, + headers: { + set: { 'Anthropic-Beta': 'context-1m' }, + removed: ['X-Debug'] + } + }] + } + }; + + const pane = module.auditRequestRevisionPane(entry, entry.data.request_revisions[0]); + + assert.equal(pane.headers.rewriter, 'pin-beta'); + assert.equal('bytes' in pane.headers, false, 'byte sizes are meaningless for header-only revisions'); + assert.deepEqual(JSON.parse(JSON.stringify(pane.headers.headers_set)), { 'Anthropic-Beta': 'context-1m' }); + assert.deepEqual(JSON.parse(JSON.stringify(pane.headers.headers_removed)), ['X-Debug']); + assert.equal(pane.showBody, false); + assert.equal(pane.showTooLarge, false, 'header-only revisions must not warn about missing bodies'); + assert.equal(pane.savingsLabel, ''); +}); + +test('auditRequestRevisionPane renders names-only set deltas when header value logging is disabled', () => { + const module = createAuditListModule(); + const entry = { + data: { + request_revisions: [{ + seq: 1, + rewriter: 'pin-beta', + headers: { + set: ['Anthropic-Beta', 'X-Custom-Auth'], + removed: ['X-Debug'] + } + }] + } + }; + + const pane = module.auditRequestRevisionPane(entry, entry.data.request_revisions[0]); + + assert.deepEqual(JSON.parse(JSON.stringify(pane.headers.headers_set)), ['Anthropic-Beta', 'X-Custom-Auth']); + assert.deepEqual(JSON.parse(JSON.stringify(pane.headers.headers_removed)), ['X-Debug']); + assert.equal(pane.showTooLarge, false); +}); + +test('auditRequestRevisionPane keeps byte sizes and warning for body rewrites', () => { + const module = createAuditListModule(); + const entry = { + data: { + request_revisions: [{ seq: 1, rewriter: 'compress', bytes_before: 100, bytes_after: 60 }] + } + }; + + const pane = module.auditRequestRevisionPane(entry, entry.data.request_revisions[0]); + + assert.equal(pane.headers.bytes, '100 → 60'); + assert.equal('headers_set' in pane.headers, false); + assert.equal(pane.showTooLarge, true); +}); diff --git a/internal/admin/dashboard/static/js/modules/guardrails.js b/internal/admin/dashboard/static/js/modules/guardrails.js index ad4602a7..ba0a64a0 100644 --- a/internal/admin/dashboard/static/js/modules/guardrails.js +++ b/internal/admin/dashboard/static/js/modules/guardrails.js @@ -145,7 +145,7 @@ const parsed = Number(trimmed); nextConfig[field.key] = Number.isFinite(parsed) ? parsed : trimmed; } - } else if (field.input === 'checkboxes') { + } else if (field.input === 'checkboxes') { nextConfig[field.key] = this.normalizeGuardrailArrayValue(value); } else { nextConfig[field.key] = value; @@ -156,6 +156,10 @@ }; }, + sanitizeGuardrailConfig(_type, config) { + return config; + }, + syncGuardrailTypeSelectValue() { const select = this.$refs && this.$refs.guardrailTypeSelect; if (!select) { @@ -372,7 +376,7 @@ type, description: String(this.guardrailForm.description || '').trim() || undefined, user_path: String(this.guardrailForm.user_path || '').trim() || undefined, - config: this.cloneGuardrailJSON(this.guardrailForm.config) + config: this.sanitizeGuardrailConfig(type, this.cloneGuardrailJSON(this.guardrailForm.config)) }; try { diff --git a/internal/admin/dashboard/static/js/modules/header-policies.js b/internal/admin/dashboard/static/js/modules/header-policies.js new file mode 100644 index 00000000..f57bcf1c --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/header-policies.js @@ -0,0 +1,273 @@ +(function(global) { + function dashboardHeaderPoliciesModule() { + return { + headerPolicies: [], + headerPoliciesAvailable: true, + headerPoliciesLoading: false, + headerPolicyError: '', + headerPolicyNotice: '', + headerPolicyFilter: '', + headerPolicyFormOpen: false, + headerPolicyFormSubmitting: false, + headerPolicyDeletingName: '', + headerPolicyFormMode: 'create', + headerPolicyFormOriginalName: '', + headerPolicyForm: { + name: '', description: '', methods: [], paths: '', when: [], + actions: [{ action: 'set', header: '', value: '' }] + }, + + defaultHeaderPolicyForm() { + return { name: '', description: '', methods: [], paths: '', when: [], actions: [{ action: 'set', header: '', value: '' }] }; + }, + + get filteredHeaderPolicies() { + const filter = String(this.headerPolicyFilter || '').trim().toLowerCase(); + if (!filter) return this.headerPolicies; + return (this.headerPolicies || []).filter((policy) => [policy.name, policy.description, policy.summary] + .some((value) => String(value || '').toLowerCase().includes(filter))); + }, + + headerPoliciesRuntimeEnabled() { + return typeof this.workflowRuntimeBooleanFlag === 'function' + ? this.workflowRuntimeBooleanFlag('HEADER_POLICIES_ENABLED', true) + : true; + }, + + headerPolicyMethodSelected(method) { + return (this.headerPolicyForm.methods || []).includes(method); + }, + + toggleHeaderPolicyMethod(method, checked) { + const current = Array.isArray(this.headerPolicyForm.methods) ? this.headerPolicyForm.methods : []; + this.headerPolicyForm.methods = checked + ? Array.from(new Set([...current, method])) + : current.filter((item) => item !== method); + }, + + addHeaderPolicyCondition() { + this.headerPolicyForm.when.push({ header: '' }); + }, + + removeHeaderPolicyCondition(index) { + this.headerPolicyForm.when.splice(index, 1); + }, + + headerPolicyConditionMode(row) { + if (row && row.matches !== undefined) return 'matches'; + if (row && row.equals !== undefined) return 'equals'; + if (row && row.present === false) return 'absent'; + return 'present'; + }, + + setHeaderPolicyConditionMode(index, mode) { + const current = this.headerPolicyForm.when[index] || {}; + const next = { header: current.header || '' }; + if (mode === 'matches') next.matches = ''; + if (mode === 'equals') next.equals = ''; + if (mode === 'absent') next.present = false; + this.headerPolicyForm.when.splice(index, 1, next); + }, + + setHeaderPolicyConditionValue(index, value) { + const row = this.headerPolicyForm.when[index]; + const mode = this.headerPolicyConditionMode(row); + if (mode === 'equals' || mode === 'matches') row[mode] = value; + }, + + addHeaderPolicyAction() { + this.headerPolicyForm.actions.push({ action: 'set', header: '', value: '' }); + }, + + removeHeaderPolicyAction(index) { + this.headerPolicyForm.actions.splice(index, 1); + }, + + headerPolicyActionMode(row) { + if (row && row.action === 'remove') return 'remove'; + if (row && row.from_header !== undefined) return 'copy'; + return 'set'; + }, + + setHeaderPolicyActionMode(index, mode) { + const header = (this.headerPolicyForm.actions[index] || {}).header || ''; + const next = mode === 'remove' + ? { action: 'remove', header } + : mode === 'copy' + ? { action: 'set', header, from_header: '' } + : { action: 'set', header, value: '' }; + this.headerPolicyForm.actions.splice(index, 1, next); + }, + + setHeaderPolicyActionValue(index, value) { + const row = this.headerPolicyForm.actions[index]; + if (this.headerPolicyActionMode(row) === 'copy') row.from_header = value; + else row.value = value; + }, + + openHeaderPolicyCreate() { + this.headerPolicyFormMode = 'create'; + this.headerPolicyFormOriginalName = ''; + this.headerPolicyError = ''; + this.headerPolicyNotice = ''; + this.headerPolicyForm = this.defaultHeaderPolicyForm(); + this.headerPolicyFormOpen = true; + }, + + openHeaderPolicyEdit(policy) { + this.headerPolicyFormMode = 'edit'; + this.headerPolicyFormOriginalName = String(policy && policy.name || '').trim(); + this.headerPolicyError = ''; + this.headerPolicyNotice = ''; + this.headerPolicyForm = { + name: this.headerPolicyFormOriginalName, + description: String(policy && policy.description || '').trim(), + methods: Array.isArray(policy && policy.methods) ? [...policy.methods] : [], + paths: Array.isArray(policy && policy.paths) ? policy.paths.join(', ') : '', + when: Array.isArray(policy && policy.when) ? JSON.parse(JSON.stringify(policy.when)) : [], + actions: Array.isArray(policy && policy.actions) ? JSON.parse(JSON.stringify(policy.actions)) : [] + }; + this.headerPolicyFormOpen = true; + }, + + closeHeaderPolicyForm() { + this.headerPolicyFormOpen = false; + this.headerPolicyFormMode = 'create'; + this.headerPolicyFormOriginalName = ''; + this.headerPolicyError = ''; + this.headerPolicyForm = this.defaultHeaderPolicyForm(); + }, + + headerPolicyPayload() { + const paths = String(this.headerPolicyForm.paths || '').split(',').map((item) => item.trim()).filter(Boolean); + const when = (this.headerPolicyForm.when || []) + .filter((row) => String(row && row.header || '').trim()) + .map((row) => { + const next = { header: String(row.header).trim() }; + const mode = this.headerPolicyConditionMode(row); + if (mode === 'absent') next.present = false; + if (mode === 'equals' || mode === 'matches') next[mode] = String(row[mode] ?? ''); + return next; + }); + const actions = (this.headerPolicyForm.actions || []) + .filter((row) => String(row && row.header || '').trim()) + .map((row) => { + const mode = this.headerPolicyActionMode(row); + const next = { action: mode === 'remove' ? 'remove' : 'set', header: String(row.header).trim() }; + if (mode === 'copy') next.from_header = String(row.from_header || '').trim(); + if (mode === 'set') next.value = String(row.value ?? ''); + return next; + }); + return { + name: String(this.headerPolicyForm.name || '').trim(), + description: String(this.headerPolicyForm.description || '').trim() || undefined, + methods: (this.headerPolicyForm.methods || []).length ? this.headerPolicyForm.methods : undefined, + paths: paths.length ? paths : undefined, + when: when.length ? when : undefined, + actions + }; + }, + + async headerPolicyResponseMessage(res, fallback) { + try { + const payload = await res.json(); + if (payload && payload.error && payload.error.message) return payload.error.message; + } catch (_) { + // Ignore invalid or empty responses and return the fallback. + } + return fallback; + }, + + async fetchHeaderPolicies() { + this.headerPoliciesLoading = true; + this.headerPolicyError = ''; + try { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + const res = await fetch('/admin/header-policies', request); + if (res.status === 503) { + this.headerPoliciesAvailable = false; + this.headerPolicies = []; + return; + } + const handled = this.handleFetchResponse(res, 'header policies', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) return; + this.headerPoliciesAvailable = true; + if (!handled) { + this.headerPolicies = []; + return; + } + const payload = await res.json(); + this.headerPolicies = Array.isArray(payload) ? payload : []; + } catch (e) { + console.error('Failed to fetch header policies:', e); + this.headerPolicies = []; + this.headerPolicyError = 'Unable to load header policies.'; + } finally { + this.headerPoliciesLoading = false; + } + }, + + async submitHeaderPolicyForm() { + const payload = this.headerPolicyPayload(); + if (!payload.name) { + this.headerPolicyError = 'Name is required.'; + return; + } + if (!payload.actions.length) { + this.headerPolicyError = 'At least one complete action is required.'; + return; + } + this.headerPolicyFormSubmitting = true; + this.headerPolicyError = ''; + this.headerPolicyNotice = ''; + try { + const request = this.requestOptions({ method: 'PUT', body: JSON.stringify(payload) }); + const res = await fetch('/admin/header-policies', request); + const handled = this.handleFetchResponse(res, 'save header policy', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) return; + if (!handled) { + this.headerPolicyError = await this.headerPolicyResponseMessage(res, 'Failed to save header policy.'); + return; + } + await this.fetchHeaderPolicies(); + if (typeof this.fetchWorkflowHeaderPolicies === 'function') this.fetchWorkflowHeaderPolicies(); + this.headerPolicyNotice = 'Header policy "' + payload.name + '" saved.'; + this.closeHeaderPolicyForm(); + } catch (e) { + console.error('Failed to save header policy:', e); + this.headerPolicyError = 'Failed to save header policy.'; + } finally { + this.headerPolicyFormSubmitting = false; + } + }, + + async deleteHeaderPolicy(policy) { + const name = String(policy && policy.name || '').trim(); + if (!name || this.headerPolicyDeletingName) return; + if (!window.confirm('Delete header policy "' + name + '"? Workflows that reference it must be updated first.')) return; + this.headerPolicyDeletingName = name; + this.headerPolicyError = ''; + try { + const request = this.requestOptions({ method: 'DELETE', body: JSON.stringify({ name }) }); + const res = await fetch('/admin/header-policies', request); + const handled = this.handleFetchResponse(res, 'delete header policy', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) return; + if (!handled) { + this.headerPolicyError = await this.headerPolicyResponseMessage(res, 'Failed to delete header policy.'); + return; + } + await this.fetchHeaderPolicies(); + if (typeof this.fetchWorkflowHeaderPolicies === 'function') this.fetchWorkflowHeaderPolicies(); + this.headerPolicyNotice = 'Header policy "' + name + '" deleted.'; + } catch (e) { + console.error('Failed to delete header policy:', e); + this.headerPolicyError = 'Failed to delete header policy.'; + } finally { + this.headerPolicyDeletingName = ''; + } + } + }; + } + + global.dashboardHeaderPoliciesModule = dashboardHeaderPoliciesModule; +})(window); diff --git a/internal/admin/dashboard/static/js/modules/header-policies.test.cjs b/internal/admin/dashboard/static/js/modules/header-policies.test.cjs new file mode 100644 index 00000000..93d696dc --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/header-policies.test.cjs @@ -0,0 +1,101 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +function createModule(overrides = {}, globals = {}) { + const source = fs.readFileSync(path.join(__dirname, 'header-policies.js'), 'utf8'); + const context = { + window: { confirm: () => true }, + console, + fetch: globals.fetch || (async () => ({ status: 200, json: async () => [] })) + }; + vm.runInNewContext(source, context); + return { + ...context.window.dashboardHeaderPoliciesModule(), + requestOptions: (options = {}) => options, + handleFetchResponse: (response) => response.status >= 200 && response.status < 300, + headers: () => ({}), + ...overrides + }; +} + +test('headerPolicyPayload emits the dedicated API shape and preserves empty literals', () => { + const module = createModule(); + module.headerPolicyForm = { + name: ' pin-beta ', + description: ' beta ', + methods: ['POST'], + paths: '/v1/chat/completions, /p/anthropic/*', + when: [{ header: 'X-Empty', equals: '' }], + actions: [{ action: 'set', header: 'X-Empty-Upstream', value: '' }] + }; + const payload = module.headerPolicyPayload(); + assert.equal(JSON.stringify(payload), JSON.stringify({ + name: 'pin-beta', + description: 'beta', + methods: ['POST'], + paths: ['/v1/chat/completions', '/p/anthropic/*'], + when: [{ header: 'X-Empty', equals: '' }], + actions: [{ action: 'set', header: 'X-Empty-Upstream', value: '' }] + })); +}); + +for (const operation of ['save', 'delete']) { + test(`${operation} surfaces the API validation message`, async () => { + const response = { + status: 400, + statusText: 'Bad Request', + json: async () => ({ error: { message: 'header Content-Type cannot be used in header policies' } }) + }; + const module = createModule({}, { fetch: async () => response }); + if (operation === 'save') { + module.headerPolicyForm = { + name: 'invalid', description: '', methods: [], paths: '', when: [], + actions: [{ action: 'set', header: 'Content-Type', value: 'text/plain' }] + }; + await module.submitHeaderPolicyForm(); + } else { + await module.deleteHeaderPolicy({ name: 'in-use' }); + } + assert.equal(module.headerPolicyError, 'header Content-Type cannot be used in header policies'); + }); +} + +test('openHeaderPolicyEdit hydrates the flattened definition shape', () => { + const module = createModule(); + module.openHeaderPolicyEdit({ + name: 'pin-beta', methods: ['POST'], paths: ['/v1/*'], + when: [{ header: 'User-Agent', matches: '^cline/' }], + actions: [{ action: 'remove', header: 'X-Debug' }] + }); + assert.equal(module.headerPolicyFormOpen, true); + assert.equal(module.headerPolicyForm.paths, '/v1/*'); + assert.equal(module.headerPolicyForm.actions[0].action, 'remove'); +}); + +test('fetchHeaderPolicies reads the dedicated endpoint', async () => { + let requested = ''; + const originalFetch = global.fetch; + global.fetch = async (url) => { + requested = url; + return { status: 200, json: async () => [{ name: 'headers' }] }; + }; + try { + const source = fs.readFileSync(path.join(__dirname, 'header-policies.js'), 'utf8'); + const context = { window: { confirm: () => true }, console, fetch: global.fetch }; + vm.runInNewContext(source, context); + const module = { + ...context.window.dashboardHeaderPoliciesModule(), + requestOptions: () => ({}), + handleFetchResponse: () => true, + headers: () => ({}) + }; + await module.fetchHeaderPolicies(); + assert.equal(requested, '/admin/header-policies'); + assert.equal(module.headerPolicies.length, 1); + } finally { + global.fetch = originalFetch; + } +}); diff --git a/internal/admin/dashboard/static/js/modules/workflows.js b/internal/admin/dashboard/static/js/modules/workflows.js index a70dd3d3..ba191ed0 100644 --- a/internal/admin/dashboard/static/js/modules/workflows.js +++ b/internal/admin/dashboard/static/js/modules/workflows.js @@ -45,6 +45,7 @@ scope_user_path: '' }, guardrailRefs: [], + headerPolicyRefs: [], workflowForm: { scope_provider: '', scope_model: '', @@ -59,7 +60,8 @@ guardrails: false, failover: true }, - guardrails: [] + guardrails: [], + header_policies: [] }, defaultWorkflowForm() { @@ -77,7 +79,8 @@ guardrails: false, failover: true }, - guardrails: [] + guardrails: [], + header_policies: [] }; }, @@ -89,6 +92,7 @@ 'BUDGETS_ENABLED', 'RATE_LIMITS_ENABLED', 'GUARDRAILS_ENABLED', + 'HEADER_POLICIES_ENABLED', 'CACHE_ENABLED', 'REDIS_URL', 'SEMANTIC_CACHE_ENABLED', @@ -242,6 +246,9 @@ workflow.workflow_hash, ...(Array.isArray(workflow.workflow_payload && workflow.workflow_payload.guardrails) ? workflow.workflow_payload.guardrails.map((step) => step.ref) + : []), + ...(Array.isArray(workflow.workflow_payload && workflow.workflow_payload.header_policies) + ? workflow.workflow_payload.header_policies.map((step) => step.ref) : []) ]; return fields.some((value) => String(value || '').toLowerCase().includes(filter)); @@ -405,6 +412,7 @@ features.failover = rawFeatures.failover; const guardrailsEnabled = !!features.guardrails; const guardrails = guardrailsEnabled ? this.workflowSourceGuardrails(form) : []; + const headerPolicies = this.workflowSourceHeaderPolicies(form); const scopeType = this.workflowScopeType(scope); const scopeDisplay = this.workflowScopeDisplay(scope); @@ -429,7 +437,8 @@ guardrails: guardrailsEnabled, failover: !!features.failover }, - guardrails + guardrails, + ...(headerPolicies.length ? { header_policies: headerPolicies } : {}) } }; }, @@ -493,6 +502,24 @@ .filter((step) => Number.isInteger(step.step) && step.step >= 0); }, + workflowSourceHeaderPolicies(source) { + const raw = Array.isArray(source && source.workflow_payload && source.workflow_payload.header_policies) + ? source.workflow_payload.header_policies + : Array.isArray(source && source.header_policies) + ? source.header_policies + : []; + return raw + .map((step) => ({ + ref: String(step && step.ref || '').trim(), + step: this.parseWorkflowGuardrailStep(step && step.step) + })) + .filter((step) => Number.isInteger(step.step) && step.step >= 0); + }, + + workflowHeaderPolicies(workflow) { + return this.workflowSourceHeaderPolicies(workflow); + }, + canDeactivateWorkflow(workflow) { return String(workflow && workflow.scope_type || '').trim() !== 'global'; }, @@ -567,7 +594,7 @@ const storedFeatures = workflow.workflow_payload && workflow.workflow_payload.features ? this.workflowNormalizedFeatures(workflow.workflow_payload.features) : this.workflowSourceFeatures(workflow); - const storedGuardrails = Array.isArray(workflow.workflow_payload && workflow.workflow_payload.guardrails) + let storedGuardrails = Array.isArray(workflow.workflow_payload && workflow.workflow_payload.guardrails) ? workflow.workflow_payload.guardrails .map((step) => ({ ref: String(step && step.ref || '').trim(), @@ -575,6 +602,16 @@ })) .filter((step) => Number.isInteger(step.step) && step.step >= 0) : this.workflowSourceGuardrails(workflow); + let storedHeaderPolicies = this.workflowSourceHeaderPolicies(workflow); + const hasDedicatedHeaderPolicies = Array.isArray(workflow.workflow_payload && workflow.workflow_payload.header_policies); + if (!hasDedicatedHeaderPolicies && storedGuardrails.length > 0) { + const knownHeaderPolicies = new Set((this.headerPolicyRefs || []).map((ref) => String(ref || '').trim())); + const legacyHeaderPolicies = storedGuardrails.filter((step) => knownHeaderPolicies.has(step.ref)); + if (legacyHeaderPolicies.length > 0) { + storedHeaderPolicies = legacyHeaderPolicies; + storedGuardrails = storedGuardrails.filter((step) => !knownHeaderPolicies.has(step.ref)); + } + } this.workflowForm = { scope_provider: this.workflowScopeProviderValue(workflow.scope), scope_model: String(workflow.scope && workflow.scope.scope_model || ''), @@ -592,6 +629,10 @@ guardrails: storedGuardrails.map((step) => ({ ref: String(step && step.ref || ''), step: Number.isFinite(step && step.step) ? step.step : 10 + })), + header_policies: storedHeaderPolicies.map((step) => ({ + ref: String(step && step.ref || ''), + step: Number.isFinite(step && step.step) ? step.step : 10 })) }; this.focusWorkflowForm(); @@ -637,6 +678,20 @@ this.workflowForm.guardrails.splice(index, 1); }, + addWorkflowHeaderPolicyStep() { + const steps = Array.isArray(this.workflowForm.header_policies) ? this.workflowForm.header_policies : []; + const nextStep = steps.reduce((maxStep, step) => { + const parsed = Number(step && step.step); + return Number.isFinite(parsed) ? Math.max(maxStep, parsed) : maxStep; + }, 0) + 10; + this.workflowForm.header_policies.push(this.defaultWorkflowGuardrailStep(nextStep)); + }, + + removeWorkflowHeaderPolicyStep(index) { + if (!Array.isArray(this.workflowForm.header_policies)) return; + this.workflowForm.header_policies.splice(index, 1); + }, + workflowScopeUserPathValidationError(value) { const trimmed = String(value || '').trim(); if (!trimmed) { @@ -720,6 +775,10 @@ }; }) : []; + const headerPolicies = (Array.isArray(form.header_policies) ? form.header_policies : []).map((step) => ({ + ref: String(step && step.ref || '').trim(), + step: this.parseWorkflowGuardrailStep(step && step.step) + })); const payload = { scope_provider_name: provider, @@ -736,7 +795,8 @@ budget: !!features.budget, guardrails: !!features.guardrails }, - guardrails + guardrails, + ...(headerPolicies.length ? { header_policies: headerPolicies } : {}) } }; if (includeFailover) { @@ -855,12 +915,12 @@ const guardrails = Array.isArray(payload.workflow_payload && payload.workflow_payload.guardrails) ? payload.workflow_payload.guardrails : []; - if (!features.guardrails) { - return ''; - } + const headerPolicies = Array.isArray(payload.workflow_payload && payload.workflow_payload.header_policies) + ? payload.workflow_payload.header_policies + : []; const seen = new Set(); - for (const step of guardrails) { + for (const step of features.guardrails ? guardrails : []) { if (!step.ref) { return 'Each guardrail step needs a guardrail ref.'; } @@ -873,6 +933,20 @@ seen.add(step.ref); } + const seenPolicies = new Set(); + for (const step of headerPolicies) { + if (!step.ref) { + return 'Each header policy step needs a policy ref.'; + } + if (!Number.isInteger(step.step) || step.step < 0) { + return 'Each header policy step must use a non-negative integer step number.'; + } + if (seenPolicies.has(step.ref)) { + return 'Each header policy ref may appear only once in a workflow.'; + } + seenPolicies.add(step.ref); + } + return ''; }, @@ -952,11 +1026,30 @@ } }, + async fetchWorkflowHeaderPolicies() { + try { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + const res = await fetch('/admin/workflows/header-policies', request); + const handled = this.handleFetchResponse(res, 'workflow header policies', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) return; + if (!handled) { + this.headerPolicyRefs = []; + return; + } + const payload = await res.json(); + this.headerPolicyRefs = Array.isArray(payload) ? payload : []; + } catch (e) { + console.error('Failed to fetch workflow header policies:', e); + this.headerPolicyRefs = []; + } + }, + async fetchWorkflowsPage() { await Promise.all([ this.fetchWorkflowRuntimeConfig(), this.fetchWorkflows(), - this.fetchWorkflowGuardrails() + this.fetchWorkflowGuardrails(), + this.fetchWorkflowHeaderPolicies() ]); }, diff --git a/internal/admin/dashboard/static/js/modules/workflows.test.cjs b/internal/admin/dashboard/static/js/modules/workflows.test.cjs index 5a743fb6..be4deda2 100644 --- a/internal/admin/dashboard/static/js/modules/workflows.test.cjs +++ b/internal/admin/dashboard/static/js/modules/workflows.test.cjs @@ -1036,6 +1036,40 @@ test('openWorkflowCreate drops blank guardrail steps instead of hydrating them a ); }); +for (const guardrailsEnabled of [true, false]) { + test(`openWorkflowCreate migrates legacy header-policy steps when guardrails are ${guardrailsEnabled ? 'enabled' : 'disabled'}`, () => { + const module = createWorkflowsModule(); + module.focusWorkflowForm = () => {}; + module.headerPolicyRefs = ['pin-beta']; + + module.openWorkflowCreate({ + scope: {}, + workflow_payload: { + features: { + cache: true, + audit: true, + usage: true, + guardrails: guardrailsEnabled, + failover: true + }, + guardrails: [ + { ref: 'message-safety', step: 10 }, + { ref: 'pin-beta', step: 20 } + ] + } + }); + + assert.equal( + JSON.stringify(module.workflowForm.guardrails), + JSON.stringify([{ ref: 'message-safety', step: 10 }]) + ); + assert.equal( + JSON.stringify(module.workflowForm.header_policies), + JSON.stringify([{ ref: 'pin-beta', step: 20 }]) + ); + }); +} + test('workflowSourceGuardrails keeps step zero but drops negative and fractional steps from previews', () => { const module = createWorkflowsModule(); @@ -2455,3 +2489,31 @@ test('fetchWorkflowVersion aborts hung requests, clears the timeout, and cleans false ); }); + +test('buildWorkflowRequest emits outbound header policies independently of guardrails', () => { + const module = createWorkflowsModule(); + module.workflowForm.features.guardrails = false; + module.workflowForm.header_policies = [{ ref: 'pin-beta', step: 10 }]; + + const payload = module.buildWorkflowRequest(); + + assert.equal(payload.workflow_payload.features.guardrails, false); + assert.deepEqual( + JSON.parse(JSON.stringify(payload.workflow_payload.header_policies)), + [{ ref: 'pin-beta', step: 10 }] + ); +}); + +test('validateWorkflowRequest rejects duplicate outbound header policy refs', () => { + const module = createWorkflowsModule(); + const payload = module.buildWorkflowRequest(); + payload.workflow_payload.header_policies = [ + { ref: 'pin-beta', step: 10 }, + { ref: 'pin-beta', step: 20 } + ]; + + assert.equal( + module.validateWorkflowRequest(payload), + 'Each header policy ref may appear only once in a workflow.' + ); +}); diff --git a/internal/admin/dashboard/templates/index.html b/internal/admin/dashboard/templates/index.html index af8716b3..a2511bdf 100644 --- a/internal/admin/dashboard/templates/index.html +++ b/internal/admin/dashboard/templates/index.html @@ -5,6 +5,7 @@ {{template "dashboard-page-rate-limits" .}} {{template "dashboard-page-settings" .}} {{template "dashboard-page-guardrails" .}} +{{template "dashboard-page-header-policies" .}} {{template "dashboard-page-mcp-servers" .}} {{template "dashboard-page-auth-keys" .}} {{template "dashboard-page-models" .}} diff --git a/internal/admin/dashboard/templates/layout.html b/internal/admin/dashboard/templates/layout.html index 820e0c83..9abcaa19 100644 --- a/internal/admin/dashboard/templates/layout.html +++ b/internal/admin/dashboard/templates/layout.html @@ -322,6 +322,7 @@

Failover models

+ diff --git a/internal/admin/dashboard/templates/page-guardrails.html b/internal/admin/dashboard/templates/page-guardrails.html index ffa6e801..3e286595 100644 --- a/internal/admin/dashboard/templates/page-guardrails.html +++ b/internal/admin/dashboard/templates/page-guardrails.html @@ -123,7 +123,7 @@

diff --git a/internal/admin/dashboard/templates/page-header-policies.html b/internal/admin/dashboard/templates/page-header-policies.html new file mode 100644 index 00000000..5f85f95f --- /dev/null +++ b/internal/admin/dashboard/templates/page-header-policies.html @@ -0,0 +1,155 @@ +{{define "dashboard-page-header-policies"}} + +{{end}} diff --git a/internal/admin/dashboard/templates/page-workflows.html b/internal/admin/dashboard/templates/page-workflows.html index 50e8e9d4..ebf62963 100644 --- a/internal/admin/dashboard/templates/page-workflows.html +++ b/internal/admin/dashboard/templates/page-workflows.html @@ -169,6 +169,21 @@

Guardrails

No guardrails configured for this workflow.

+ +
+
+

Outbound Header Policies

+ +
+
+ +
+
@@ -205,6 +220,37 @@

Guardrail Steps

No guardrail steps configured yet.

+
+
+
+

Outbound Header Policies

+

Resolved before caching and applied to the selected primary provider attempt. They are not message guardrails.

+
+ +
+ +
+ No named header policies are currently registered. + +
+ +
+ +
+
+
+
+
+

Outbound Header Policies

+ +
+
+ +
+
+