diff --git a/.env.template b/.env.template index a7ef3574..9f701f91 100644 --- a/.env.template +++ b/.env.template @@ -2,6 +2,8 @@ # PORT=8080 # Mount the whole gateway under a path prefix, e.g. https://example.com/g/ # BASE_PATH=/g +# Header used to read/write request user_path values (default: X-GoModel-User-Path) +# USER_PATH_HEADER=X-GoModel-User-Path # Log output format: leave unset to auto-detect, or set to "json" / "text" # LOG_FORMAT=text # Log verbosity: "debug", "info" (default), "warn", or "error" diff --git a/CLAUDE.md b/CLAUDE.md index 5307fe74..addec4ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,7 @@ Full reference: `.env.template` and `config/config.yaml` - `PORT` (8080) - `GOMODEL_MASTER_KEY` (empty = unsafe mode) - `BODY_SIZE_LIMIT` ("10M") + - `USER_PATH_HEADER` (`X-GoModel-User-Path`: Header used to read/write request `user_path` values) - `ENABLE_PASSTHROUGH_ROUTES` (true: Enable provider-native passthrough routes under /p/{provider}/...) - `ALLOW_PASSTHROUGH_V1_ALIAS` (true: Allow /p/{provider}/v1/... aliases while keeping /p/{provider}/... canonical) - `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,zai,vllm: Comma-separated list of enabled passthrough providers) diff --git a/README.md b/README.md index e35fc02c..985c4953 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,7 @@ Key settings: | `PORT` | `8080` | Server port | | `BASE_PATH` | `/` | Mount the gateway under a path prefix such as `/g` | | `GOMODEL_MASTER_KEY` | (none) | API key for authentication | +| `USER_PATH_HEADER` | `X-GoModel-User-Path` | Header used to read/write request `user_path` values | | `ENABLE_PASSTHROUGH_ROUTES` | `true` | Enable provider-native passthrough routes under `/p/{provider}/...` | | `ALLOW_PASSTHROUGH_V1_ALIAS` | `true` | Allow `/p/{provider}/v1/...` aliases while keeping `/p/{provider}/...` canonical | | `ENABLED_PASSTHROUGH_PROVIDERS` | `openai,anthropic,openrouter,zai,vllm,deepseek` | Comma-separated list of enabled passthrough providers | diff --git a/config/config.example.yaml b/config/config.example.yaml index c2fa435b..24ea3bed 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -12,6 +12,7 @@ server: pprof_enabled: false # expose /debug/pprof/* for local profiling only enable_passthrough_routes: true # expose /p/{provider}/{endpoint} passthrough routes allow_passthrough_v1_alias: true # allow /p/{provider}/v1/... while keeping /p/{provider}/... canonical + user_path_header: "X-GoModel-User-Path" # env: USER_PATH_HEADER; inbound header used for user_path scoping enabled_passthrough_providers: ["openai", "anthropic", "openrouter", "zai", "vllm", "deepseek"] # providers enabled on /p/{provider}/... models: diff --git a/config/config.go b/config/config.go index d2d1e781..9c6412c1 100644 --- a/config/config.go +++ b/config/config.go @@ -43,6 +43,7 @@ func buildDefaultConfig() *Config { Server: ServerConfig{ Port: "8080", BasePath: "/", + UserPathHeader: "X-GoModel-User-Path", SwaggerEnabled: false, PprofEnabled: false, EnablePassthroughRoutes: true, @@ -160,6 +161,10 @@ func Load() (*LoadResult, error) { return nil, err } cfg.Server.BasePath = NormalizeBasePath(cfg.Server.BasePath) + cfg.Server.UserPathHeader, err = NormalizeHeaderName(cfg.Server.UserPathHeader, "X-GoModel-User-Path") + if err != nil { + return nil, fmt.Errorf("invalid server.user_path_header: %w", err) + } cfg.Models.ConfiguredProviderModelsMode = ResolveConfiguredProviderModelsMode(cfg.Models.ConfiguredProviderModelsMode) if !cfg.Models.ConfiguredProviderModelsMode.Valid() { return nil, fmt.Errorf("models.configured_provider_models_mode must be one of: fallback, allowlist") diff --git a/config/config_test.go b/config/config_test.go index 676d9afc..2bfe5405 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -37,7 +37,7 @@ func clearProviderEnvVars(t *testing.T) { func clearAllConfigEnvVars(t *testing.T) { t.Helper() for _, key := range []string{ - "PORT", "BASE_PATH", "GOMODEL_MASTER_KEY", "BODY_SIZE_LIMIT", "SWAGGER_ENABLED", "PPROF_ENABLED", "ENABLE_PASSTHROUGH_ROUTES", "ALLOW_PASSTHROUGH_V1_ALIAS", "ENABLED_PASSTHROUGH_PROVIDERS", + "PORT", "BASE_PATH", "GOMODEL_MASTER_KEY", "BODY_SIZE_LIMIT", "SWAGGER_ENABLED", "PPROF_ENABLED", "ENABLE_PASSTHROUGH_ROUTES", "ALLOW_PASSTHROUGH_V1_ALIAS", "USER_PATH_HEADER", "ENABLED_PASSTHROUGH_PROVIDERS", "GOMODEL_CACHE_DIR", "CACHE_REFRESH_INTERVAL", "REDIS_URL", "REDIS_KEY_MODELS", "REDIS_KEY_RESPONSES", "REDIS_TTL_MODELS", "REDIS_TTL_RESPONSES", "RESPONSE_CACHE_SIMPLE_ENABLED", @@ -101,6 +101,9 @@ func TestBuildDefaultConfig(t *testing.T) { if cfg.Server.BasePath != "/" { t.Errorf("expected Server.BasePath=/, got %s", cfg.Server.BasePath) } + if cfg.Server.UserPathHeader != "X-GoModel-User-Path" { + t.Errorf("expected Server.UserPathHeader=X-GoModel-User-Path, got %s", cfg.Server.UserPathHeader) + } if cfg.Server.PprofEnabled { t.Error("expected Server.PprofEnabled=false") } @@ -1138,6 +1141,64 @@ server: }) } +func TestLoad_UserPathHeaderConfig(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(dir string) { + yaml := ` +server: + user_path_header: "x-tenant-path" +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if got := result.Config.Server.UserPathHeader; got != "X-Tenant-Path" { + t.Fatalf("Server.UserPathHeader = %q, want X-Tenant-Path", got) + } + }) + + withTempDir(t, func(dir string) { + yaml := ` +server: + user_path_header: "X-Yaml-Path" +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + t.Setenv("USER_PATH_HEADER", "x-env-path") + + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if got := result.Config.Server.UserPathHeader; got != "X-Env-Path" { + t.Fatalf("Server.UserPathHeader = %q, want X-Env-Path", got) + } + }) +} + +func TestLoad_UserPathHeaderRejectsInvalidName(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(_ string) { + t.Setenv("USER_PATH_HEADER", "Bad Header") + + _, err := Load() + if err == nil { + t.Fatal("expected Load() to reject invalid USER_PATH_HEADER") + } + if !strings.Contains(err.Error(), "invalid server.user_path_header") { + t.Fatalf("Load() error = %v, want invalid server.user_path_header", err) + } + }) +} + func TestLoad_EnvOverridesYAML(t *testing.T) { clearAllConfigEnvVars(t) diff --git a/config/server.go b/config/server.go index 3c67e199..40784cda 100644 --- a/config/server.go +++ b/config/server.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net/textproto" "path" "regexp" "strconv" @@ -31,12 +32,33 @@ type ServerConfig struct { // AllowPassthroughV1Alias allows /p/{provider}/v1/... style passthrough routes // while keeping /p/{provider}/... as the canonical form. Default: true. AllowPassthroughV1Alias bool `yaml:"allow_passthrough_v1_alias" env:"ALLOW_PASSTHROUGH_V1_ALIAS"` + // UserPathHeader is the inbound HTTP header used to read/write user paths. + // Default: X-GoModel-User-Path. + UserPathHeader string `yaml:"user_path_header" env:"USER_PATH_HEADER"` // EnabledPassthroughProviders lists the provider types enabled on // /p/{provider}/... passthrough routes. Default: // ["openai", "anthropic", "openrouter", "zai", "vllm", "deepseek"]. EnabledPassthroughProviders []string `yaml:"enabled_passthrough_providers" env:"ENABLED_PASSTHROUGH_PROVIDERS"` } +var headerNameRegex = regexp.MustCompile(`^[!#$%&'*+\-.^_` + "`" + `|~0-9A-Za-z]+$`) + +// NormalizeHeaderName canonicalizes an HTTP header field name. Empty values +// fall back to fallback. +func NormalizeHeaderName(value, fallback string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + value = fallback + } + if !headerNameRegex.MatchString(value) { + return "", fmt.Errorf("invalid HTTP header name %q", value) + } + if strings.EqualFold(value, fallback) { + return fallback, nil + } + return textproto.CanonicalMIMEHeaderKey(value), nil +} + // NormalizeBasePath canonicalizes the public mount path for the HTTP server. // Empty, whitespace-only, and "/" all resolve to root. func NormalizeBasePath(value string) string { diff --git a/docs/adr/0002-ingress-frame-and-semantic-envelope.md b/docs/adr/0002-ingress-frame-and-semantic-envelope.md index 581cd81b..0bb235db 100644 --- a/docs/adr/0002-ingress-frame-and-semantic-envelope.md +++ b/docs/adr/0002-ingress-frame-and-semantic-envelope.md @@ -23,7 +23,7 @@ GoModel needs a model that preserves the original request faithfully while still ## Flow Diagram - + ## Decision diff --git a/docs/adr/0006-semantic-response-cache.md b/docs/adr/0006-semantic-response-cache.md index 4a05c544..ff3b8d83 100644 --- a/docs/adr/0006-semantic-response-cache.md +++ b/docs/adr/0006-semantic-response-cache.md @@ -51,7 +51,7 @@ Unified `Embedder` interface with a single implementation: an **HTTP client** ca ### Vector Store -`VecStore` interface + `type`-switched factory in [`internal/responsecache/vecstore.go`](../../internal/responsecache/vecstore.go). When semantic caching is enabled, **`vector_store.type` is required** (no default). Supported values: +`VecStore` interface + `type`-switched factory in [`internal/responsecache/vecstore.go`](https://github.com/ENTERPILOT/GoModel/blob/main/internal/responsecache/vecstore.go). When semantic caching is enabled, **`vector_store.type` is required** (no default). Supported values: | Type | Notes | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/advanced/config-yaml.mdx b/docs/advanced/config-yaml.mdx index 434f19fd..4384d617 100644 --- a/docs/advanced/config-yaml.mdx +++ b/docs/advanced/config-yaml.mdx @@ -61,6 +61,9 @@ For deployments mounted below a domain root, set `server.base_path` or `BASE_PATH`. For example, `BASE_PATH=/g` serves the gateway at `/g/v1/...`, `/g/admin/...`, and `/g/health`. +To change the inbound user path header, set `server.user_path_header` or +`USER_PATH_HEADER`. The default remains `X-GoModel-User-Path`. + ## Docker GoModel reads `config/config.yaml` first, then `config.yaml`. diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 3e346456..1a69b82e 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -44,6 +44,7 @@ The most common way to configure GoModel. Set any of the variables below to over | `BASE_PATH` | Mount path prefix, for example `/g` | `/` | | `GOMODEL_MASTER_KEY` | Authentication key for securing the gateway | _(empty, unsafe mode)_ | | `BODY_SIZE_LIMIT` | Max request body size (e.g., `10M`, `1024K`, `500KB`) | _(no limit)_ | +| `USER_PATH_HEADER` | Header used to read/write request `user_path` values | `X-GoModel-User-Path` | #### Logging @@ -251,6 +252,7 @@ Then uncomment and edit the settings you want to change: server: port: "3000" base_path: "/g" + user_path_header: "X-GoModel-User-Path" master_key: "my-secret-key" cache: diff --git a/docs/advanced/workflows.mdx b/docs/advanced/workflows.mdx index 8bdf08a7..e85fcb60 100644 --- a/docs/advanced/workflows.mdx +++ b/docs/advanced/workflows.mdx @@ -124,5 +124,5 @@ curl -X POST http://localhost:8080/admin/workflows \ - `scope_model` requires `scope_provider_name` - `scope_user_path` is normalized to canonical slash form -- managed API keys can override the request `X-GoModel-User-Path`; workflow matching uses the effective request user path +- 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) diff --git a/docs/features/user-path.mdx b/docs/features/user-path.mdx index 0d950c46..a31c911f 100644 --- a/docs/features/user-path.mdx +++ b/docs/features/user-path.mdx @@ -29,6 +29,10 @@ Clients can also send a user path directly: X-GoModel-User-Path: /team/alpha ``` +The header name is configurable with `USER_PATH_HEADER` or +`server.user_path_header` in `config.yaml`. If unset, GoModel uses +`X-GoModel-User-Path`. + If the API key has its own `user_path`, the key wins. GoModel overwrites the header value with the key-bound path before workflow matching, audit logging, usage tracking, and model access checks run. diff --git a/helm/README.md b/helm/README.md index d8e5e6e3..7ad5e3cb 100644 --- a/helm/README.md +++ b/helm/README.md @@ -51,6 +51,7 @@ helm install gomodel ./helm \ | `image.tag` | Image tag | `""` (uses appVersion) | | `server.port` | Server port | `8080` | | `server.basePath` | URL path prefix where GoModel is mounted | `"/"` | +| `server.userPathHeader` | Header used to read/write request user_path values | `"X-GoModel-User-Path"` | | `server.bodySizeLimit` | Max request body size | `"10M"` | | `auth.masterKey` | Master key for auth | `""` | | `auth.existingSecret` | Existing secret for auth | `""` | diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index ea8b8121..cd8debbe 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -7,6 +7,7 @@ metadata: data: PORT: {{ .Values.server.port | quote }} BASE_PATH: {{ .Values.server.basePath | default "/" | quote }} + USER_PATH_HEADER: {{ .Values.server.userPathHeader | default "X-GoModel-User-Path" | quote }} BODY_SIZE_LIMIT: {{ .Values.server.bodySizeLimit | quote }} {{- if or .Values.redis.enabled .Values.cache.redis.url }} REDIS_KEY_MODELS: {{ .Values.cache.redis.keyModels | default "gomodel:models" | quote }} diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 221390a2..e12dd561 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -53,6 +53,11 @@ spec: configMapKeyRef: name: {{ include "gomodel.fullname" . }} key: BASE_PATH + - name: USER_PATH_HEADER + valueFrom: + configMapKeyRef: + name: {{ include "gomodel.fullname" . }} + key: USER_PATH_HEADER - name: BODY_SIZE_LIMIT valueFrom: configMapKeyRef: diff --git a/helm/values.schema.json b/helm/values.schema.json index 80230252..0ac3d821 100644 --- a/helm/values.schema.json +++ b/helm/values.schema.json @@ -325,6 +325,7 @@ "properties": { "port": { "type": "integer" }, "basePath": { "type": "string" }, + "userPathHeader": { "type": "string" }, "bodySizeLimit": { "type": "string" } } }, diff --git a/helm/values.yaml b/helm/values.yaml index 0851e8a1..ce342fe4 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -25,6 +25,8 @@ server: port: 8080 # -- URL path prefix where GoModel is mounted (for example, "/g") basePath: "/" + # -- Header used to read/write request user_path values + userPathHeader: "X-GoModel-User-Path" # -- Maximum request body size (e.g., "10M", "1G", "500K") bodySizeLimit: "10M" diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs index ceb7bd9c..61450c1e 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs @@ -497,7 +497,7 @@ test("auth key expirations render as a UTC date with the full UTC timestamp in t assert.match(authKeyForm, /copyId: 'auth-key-user-path-help-copy'/); assert.match( authKeyForm, - /When set, this key overrides X-GoModel-User-Path for audit logging and downstream request context\./, + /When set, this key overrides the configured user path request header for audit logging and downstream request context\./, ); assert.doesNotMatch(authKeyForm, /id="auth-key-user-path"[^>]*aria-label=/); assert.doesNotMatch(authKeyForm, /User Path Override/); @@ -507,7 +507,7 @@ test("auth key expirations render as a UTC date with the full UTC timestamp in t /
\s*When set, this key overrides X-GoModel-User-Path<\/code> for audit logging and downstream request context\.\s*<\/p>/,
);
assert.match(indexTemplate, /x-text="key\.user_path \|\| '\\u2014'"/);
- assert.match(indexTemplate, /X-GoModel-User-Path/);
+ assert.match(indexTemplate, /configured user path request header/);
assert.match(indexTemplate, /:disabled="authKeyFormSubmitting"/);
assert.match(
indexTemplate,
@@ -1071,7 +1071,7 @@ test("model category tables lazy mount only the active table body", () => {
);
assert.match(
modelsBlock,
- /The selector uses \/<\/code> for all providers and models, \{provider_name\}\/<\/code> for one provider, or \{provider_name\}\/\{model\}<\/code> for one model\.[\s\S]*managed API key user_path<\/code> when present, otherwise the X-GoModel-User-Path<\/code> request header\./,
+ /The selector uses \/<\/code> for all providers and models, \{provider_name\}\/<\/code> for one provider, or \{provider_name\}\/\{model\}<\/code> for one model\.[\s\S]*managed API key user_path<\/code> when present, otherwise the configured user path request header\./,
);
const loadingRule = readCSSRule(css, ".loading-state");
diff --git a/internal/admin/dashboard/templates/page-auth-keys.html b/internal/admin/dashboard/templates/page-auth-keys.html
index a3226f4c..abbd9c5f 100644
--- a/internal/admin/dashboard/templates/page-auth-keys.html
+++ b/internal/admin/dashboard/templates/page-auth-keys.html
@@ -90,7 +90,7 @@ Create API Key
- The selector uses
diff --git a/internal/app/app.go b/internal/app/app.go
index f25119b3..2274be25 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -434,6 +434,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
DisablePassthroughRoutes: !appCfg.Server.EnablePassthroughRoutes,
EnabledPassthroughProviders: appCfg.Server.EnabledPassthroughProviders,
AllowPassthroughV1Alias: &allowPassthroughV1Alias,
+ UserPathHeader: appCfg.Server.UserPathHeader,
SwaggerEnabled: swaggerEnabled,
}
diff --git a/internal/auditlog/entry_capture.go b/internal/auditlog/entry_capture.go
index 1dd88bd7..a034ca12 100644
--- a/internal/auditlog/entry_capture.go
+++ b/internal/auditlog/entry_capture.go
@@ -193,7 +193,7 @@ func internalJSONAuditHeaders(ctx context.Context, requestID string) http.Header
headers.Set("X-Request-ID", requestID)
}
if userPath := strings.TrimSpace(core.UserPathFromContext(ctx)); userPath != "" {
- headers.Set(core.UserPathHeader, userPath)
+ headers.Set(core.UserPathHeaderNameFromContext(ctx), userPath)
}
if snapshot := core.GetRequestSnapshot(ctx); snapshot != nil {
snapshotHeaders := snapshot.GetHeaders()
diff --git a/internal/core/context.go b/internal/core/context.go
index 029c9349..08796b04 100644
--- a/internal/core/context.go
+++ b/internal/core/context.go
@@ -19,6 +19,9 @@ const (
// effectiveUserPathKey stores a request-scoped user path override applied
// after ingress capture, for example from a managed auth key.
effectiveUserPathKey contextKey = "effective-user-path"
+ // userPathHeaderNameKey stores the configured request header that carries
+ // the user path at the HTTP boundary.
+ userPathHeaderNameKey contextKey = "user-path-header-name"
// batchPreparationMetadataKey stores request-scoped batch preprocessing metadata.
batchPreparationMetadataKey contextKey = "batch-preparation-metadata"
@@ -142,6 +145,17 @@ func GetEffectiveUserPath(ctx context.Context) string {
return ""
}
+// WithUserPathHeaderName returns a new context with a non-default configured
+// user-path request header name attached. The default header is intentionally a
+// no-op and does not clear an existing value.
+func WithUserPathHeaderName(ctx context.Context, headerName string) context.Context {
+ headerName = UserPathHeaderName(headerName)
+ if headerName == UserPathHeader {
+ return ctx
+ }
+ return context.WithValue(ctx, userPathHeaderNameKey, headerName)
+}
+
// WithBatchPreparationMetadata returns a new context with batch preprocessing metadata attached.
func WithBatchPreparationMetadata(ctx context.Context, metadata *BatchPreparationMetadata) context.Context {
return context.WithValue(ctx, batchPreparationMetadataKey, metadata)
diff --git a/internal/core/request_snapshot.go b/internal/core/request_snapshot.go
index 3f1db035..d060d0b4 100644
--- a/internal/core/request_snapshot.go
+++ b/internal/core/request_snapshot.go
@@ -14,8 +14,8 @@ type RequestSnapshot struct {
Method string
// Path is the request URL path as received at ingress.
Path string
- // UserPath is the canonical business hierarchy path sourced from
- // X-GoModel-User-Path when provided.
+ // UserPath is the canonical business hierarchy path sourced from the
+ // configured user-path request header when provided.
UserPath string
// RouteParams contains resolved router parameters such as provider or file id.
routeParams map[string]string
@@ -80,24 +80,31 @@ func firstUserPath(values []string) string {
}
// WithUserPath returns a shallow-cloned snapshot with UserPath and the captured
-// X-GoModel-User-Path header rewritten to the provided canonical value.
+// default user-path header rewritten to the provided canonical value.
func (s *RequestSnapshot) WithUserPath(userPath string) *RequestSnapshot {
+ return s.WithUserPathHeader(userPath, UserPathHeader)
+}
+
+// WithUserPathHeader returns a shallow-cloned snapshot with UserPath and the
+// captured configured user-path header rewritten to the provided canonical value.
+func (s *RequestSnapshot) WithUserPathHeader(userPath, headerName string) *RequestSnapshot {
if s == nil {
return nil
}
+ headerName = UserPathHeaderName(headerName)
cloned := *s
cloned.UserPath = strings.TrimSpace(userPath)
cloned.headers = cloneMultiMap(s.headers)
if cloned.UserPath == "" {
if cloned.headers != nil {
- delete(cloned.headers, UserPathHeader)
+ delete(cloned.headers, headerName)
}
return &cloned
}
if cloned.headers == nil {
cloned.headers = make(map[string][]string, 1)
}
- cloned.headers[UserPathHeader] = []string{cloned.UserPath}
+ cloned.headers[headerName] = []string{cloned.UserPath}
return &cloned
}
diff --git a/internal/core/user_path.go b/internal/core/user_path.go
index bae0fac4..5e0d361a 100644
--- a/internal/core/user_path.go
+++ b/internal/core/user_path.go
@@ -3,11 +3,35 @@ package core
import (
"context"
"fmt"
+ "net/textproto"
"strings"
)
const UserPathHeader = "X-GoModel-User-Path"
+// UserPathHeaderName canonicalizes the configured user-path header name.
+func UserPathHeaderName(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return UserPathHeader
+ }
+ if strings.EqualFold(raw, UserPathHeader) {
+ return UserPathHeader
+ }
+ return textproto.CanonicalMIMEHeaderKey(raw)
+}
+
+// UserPathHeaderNameFromContext returns the request-scoped user-path header
+// name, falling back to the default public header.
+func UserPathHeaderNameFromContext(ctx context.Context) string {
+ if ctx != nil {
+ if value, ok := ctx.Value(userPathHeaderNameKey).(string); ok {
+ return UserPathHeaderName(value)
+ }
+ }
+ return UserPathHeader
+}
+
// NormalizeUserPath canonicalizes one user hierarchy path from request ingress.
func NormalizeUserPath(raw string) (string, error) {
raw = strings.TrimSpace(raw)
diff --git a/internal/core/user_path_test.go b/internal/core/user_path_test.go
index 40e7c7b6..b9d7628f 100644
--- a/internal/core/user_path_test.go
+++ b/internal/core/user_path_test.go
@@ -53,6 +53,50 @@ func TestUserPathFromContext_PrefersEffectiveOverride(t *testing.T) {
}
}
+func TestUserPathHeaderName(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ raw string
+ want string
+ }{
+ {name: "empty defaults", raw: "", want: UserPathHeader},
+ {name: "default preserves GoModel spelling", raw: "x-gomodel-user-path", want: UserPathHeader},
+ {name: "custom canonicalized", raw: "x-tenant-path", want: "X-Tenant-Path"},
+ {name: "trim custom", raw: " X-Custom-User-Path ", want: "X-Custom-User-Path"},
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ if got := UserPathHeaderName(tt.raw); got != tt.want {
+ t.Fatalf("UserPathHeaderName(%q) = %q, want %q", tt.raw, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestUserPathHeaderNameFromContext(t *testing.T) {
+ t.Parallel()
+
+ if got := UserPathHeaderNameFromContext(context.Background()); got != UserPathHeader {
+ t.Fatalf("UserPathHeaderNameFromContext(empty) = %q, want %q", got, UserPathHeader)
+ }
+
+ customCtx := WithUserPathHeaderName(context.Background(), "x-tenant-path")
+ if got := UserPathHeaderNameFromContext(customCtx); got != "X-Tenant-Path" {
+ t.Fatalf("UserPathHeaderNameFromContext(custom) = %q, want X-Tenant-Path", got)
+ }
+
+ defaultCtx := WithUserPathHeaderName(customCtx, UserPathHeader)
+ if got := UserPathHeaderNameFromContext(defaultCtx); got != "X-Tenant-Path" {
+ t.Fatalf("UserPathHeaderNameFromContext(default no-op) = %q, want X-Tenant-Path", got)
+ }
+}
+
func TestUserPathAncestors(t *testing.T) {
t.Parallel()
diff --git a/internal/server/auth.go b/internal/server/auth.go
index 73a429fc..3920121a 100644
--- a/internal/server/auth.go
+++ b/internal/server/auth.go
@@ -29,7 +29,8 @@ func AuthMiddleware(masterKey string, skipPaths []string) echo.MiddlewareFunc {
// AuthMiddlewareWithAuthenticator validates the legacy master key and, when
// configured, managed auth keys from the auth key service.
-func AuthMiddlewareWithAuthenticator(masterKey string, authenticator BearerTokenAuthenticator, skipPaths []string) echo.MiddlewareFunc {
+func AuthMiddlewareWithAuthenticator(masterKey string, authenticator BearerTokenAuthenticator, skipPaths []string, userPathHeader ...string) echo.MiddlewareFunc {
+ userPathHeaderName := configuredUserPathHeaderName(userPathHeader...)
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// If no auth mechanism is configured, allow all requests.
@@ -81,10 +82,11 @@ func AuthMiddlewareWithAuthenticator(masterKey string, authenticator BearerToken
ctx := core.WithAuthKeyID(c.Request().Context(), authResult.ID)
if userPath := strings.TrimSpace(authResult.UserPath); userPath != "" {
ctx = core.WithEffectiveUserPath(ctx, userPath)
+ ctx = core.WithUserPathHeaderName(ctx, userPathHeaderName)
if snapshot := core.GetRequestSnapshot(ctx); snapshot != nil {
- ctx = core.WithRequestSnapshot(ctx, snapshot.WithUserPath(userPath))
+ ctx = core.WithRequestSnapshot(ctx, snapshot.WithUserPathHeader(userPath, userPathHeaderName))
}
- c.Request().Header.Set(core.UserPathHeader, userPath)
+ c.Request().Header.Set(userPathHeaderName, userPath)
auditlog.EnrichEntryWithUserPath(c, userPath)
}
c.SetRequest(c.Request().WithContext(ctx))
diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go
index 9911ea28..eb5871a3 100644
--- a/internal/server/auth_test.go
+++ b/internal/server/auth_test.go
@@ -263,6 +263,46 @@ func TestAuthMiddlewareWithAuthenticator_ManagedKeyUserPathOverridesHeader(t *te
assert.Equal(t, http.StatusOK, rec.Code)
}
+func TestAuthMiddlewareWithAuthenticator_ManagedKeyUserPathUsesConfiguredHeader(t *testing.T) {
+ e := echo.New()
+ const headerName = "X-Tenant-Path"
+ testHandler := func(c *echo.Context) error {
+ if got := core.UserPathFromContext(c.Request().Context()); got != "/team/auth-key" {
+ t.Fatalf("effective user path = %q, want /team/auth-key", got)
+ }
+ snapshot := core.GetRequestSnapshot(c.Request().Context())
+ if snapshot == nil {
+ t.Fatal("request snapshot missing from context")
+ }
+ if got := snapshot.GetHeaders()[headerName][0]; got != "/team/auth-key" {
+ t.Fatalf("snapshot header = %q, want /team/auth-key", got)
+ }
+ if got := c.Request().Header.Get(headerName); got != "/team/auth-key" {
+ t.Fatalf("%s = %q, want /team/auth-key", headerName, got)
+ }
+ if got := c.Request().Header.Get(core.UserPathHeader); got != "" {
+ t.Fatalf("%s = %q, want empty", core.UserPathHeader, got)
+ }
+ return c.String(http.StatusOK, "ok")
+ }
+
+ handler := RequestSnapshotCapture(headerName)(AuthMiddlewareWithAuthenticator("", mockAuthenticator{
+ enabled: true,
+ tokenToID: map[string]string{"sk_gom_token": "key-123"},
+ tokenPath: map[string]string{"sk_gom_token": "/team/auth-key"},
+ }, nil, headerName)(testHandler))
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5-mini"}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer sk_gom_token")
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ err := handler(c)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, rec.Code)
+}
+
func TestAuthMiddlewareWithAuthenticator_ManagedKeyFailureUsesGenericClientMessage(t *testing.T) {
e := echo.New()
handler := AuthMiddlewareWithAuthenticator("", mockAuthenticator{
diff --git a/internal/server/base_path.go b/internal/server/base_path.go
index ebe0de3f..2e2b660e 100644
--- a/internal/server/base_path.go
+++ b/internal/server/base_path.go
@@ -6,6 +6,7 @@ import (
"strings"
"gomodel/config"
+ "gomodel/internal/core"
"github.com/labstack/echo/v5"
)
@@ -17,6 +18,13 @@ func configuredBasePath(cfg *Config) string {
return config.NormalizeBasePath(cfg.BasePath)
}
+func configuredUserPathHeader(cfg *Config) string {
+ if cfg == nil {
+ return core.UserPathHeader
+ }
+ return core.UserPathHeaderName(cfg.UserPathHeader)
+}
+
func stripBasePathMiddleware(basePath string) echo.MiddlewareFunc {
basePath = config.NormalizeBasePath(basePath)
return func(next echo.HandlerFunc) echo.HandlerFunc {
diff --git a/internal/server/http.go b/internal/server/http.go
index 725d965b..ddc07c26 100644
--- a/internal/server/http.go
+++ b/internal/server/http.go
@@ -71,6 +71,7 @@ type Config struct {
DisablePassthroughRoutes bool // Disable /p/{provider}/{endpoint} route registration
EnabledPassthroughProviders []string // Provider types enabled on /p/{provider}/... passthrough routes
AllowPassthroughV1Alias *bool // Allow /p/{provider}/v1/... aliases; nil defaults to true
+ UserPathHeader string // Header carrying the request user path (default: X-GoModel-User-Path)
AdminEndpointsEnabled bool // Whether admin API endpoints are enabled
AdminUIEnabled bool // Whether admin dashboard UI is enabled
AdminHandler *admin.Handler // Admin API handler (nil if disabled)
@@ -244,7 +245,8 @@ func New(provider core.RoutableProvider, cfg *Config) *Server {
e.Use(modelInteractionWriteDeadlineMiddleware())
// Ingress capture (before auth/audit/model validation so they can consume shared raw request state)
- e.Use(RequestSnapshotCapture())
+ userPathHeaderName := configuredUserPathHeader(cfg)
+ e.Use(RequestSnapshotCapture(userPathHeaderName))
if cfg != nil && len(cfg.PassthroughSemanticEnrichers) > 0 {
e.Use(PassthroughSemanticEnrichment(provider, cfg.PassthroughSemanticEnrichers, passthroughV1PrefixNormalizationEnabled(cfg)))
@@ -260,7 +262,7 @@ func New(provider core.RoutableProvider, cfg *Config) *Server {
// Authentication (skips public paths)
if cfg != nil && (cfg.MasterKey != "" || cfg.Authenticator != nil) {
- e.Use(AuthMiddlewareWithAuthenticator(cfg.MasterKey, cfg.Authenticator, authSkipPaths))
+ e.Use(AuthMiddlewareWithAuthenticator(cfg.MasterKey, cfg.Authenticator, authSkipPaths, userPathHeaderName))
}
// Workflow resolution resolves the request-scoped workflow after auth so
diff --git a/internal/server/passthrough_support.go b/internal/server/passthrough_support.go
index dd89b203..c91ad322 100644
--- a/internal/server/passthrough_support.go
+++ b/internal/server/passthrough_support.go
@@ -83,10 +83,11 @@ func normalizePassthroughEndpoint(endpoint string, enabled bool) (string, error)
func buildPassthroughHeaders(ctx context.Context, src http.Header) http.Header {
connectionHeaders := passthroughConnectionHeaders(src)
+ userPathHeaderName := http.CanonicalHeaderKey(core.UserPathHeaderNameFromContext(ctx))
dst := make(http.Header)
for key, values := range src {
canonicalKey := http.CanonicalHeaderKey(strings.TrimSpace(key))
- if skipPassthroughRequestHeader(canonicalKey) || len(values) == 0 {
+ if skipPassthroughRequestHeader(canonicalKey, userPathHeaderName) || len(values) == 0 {
continue
}
if _, hopByHop := connectionHeaders[canonicalKey]; hopByHop {
@@ -121,10 +122,19 @@ func skipPassthroughHeader(key string) bool {
}
}
-func skipPassthroughRequestHeader(key string) bool {
- if http.CanonicalHeaderKey(strings.TrimSpace(key)) == http.CanonicalHeaderKey(core.UserPathHeader) {
+func skipPassthroughRequestHeader(key string, userPathHeader ...string) bool {
+ key = strings.TrimSpace(key)
+ if key == "" {
return true
}
+ if strings.EqualFold(key, core.UserPathHeader) {
+ return true
+ }
+ for _, headerName := range userPathHeader {
+ if strings.EqualFold(key, headerName) {
+ return true
+ }
+ }
return skipPassthroughHeader(key)
}
diff --git a/internal/server/passthrough_support_test.go b/internal/server/passthrough_support_test.go
new file mode 100644
index 00000000..bafeea9c
--- /dev/null
+++ b/internal/server/passthrough_support_test.go
@@ -0,0 +1,28 @@
+package server
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "gomodel/internal/core"
+)
+
+func TestBuildPassthroughHeadersSkipsConfiguredUserPathHeader(t *testing.T) {
+ ctx := core.WithUserPathHeaderName(context.Background(), "X-Tenant-Path")
+ headers := http.Header{}
+ headers.Set("X-Tenant-Path", "/team/alpha")
+ headers.Set(core.UserPathHeader, "/team/default")
+ headers.Set("OpenAI-Beta", "responses=v1")
+
+ got := buildPassthroughHeaders(ctx, headers)
+ if value := got.Get("X-Tenant-Path"); value != "" {
+ t.Fatalf("X-Tenant-Path should not be forwarded, got %q", value)
+ }
+ if value := got.Get(core.UserPathHeader); value != "" {
+ t.Fatalf("%s should not be forwarded, got %q", core.UserPathHeader, value)
+ }
+ if value := got.Get("OpenAI-Beta"); value != "responses=v1" {
+ t.Fatalf("OpenAI-Beta = %q, want responses=v1", value)
+ }
+}
diff --git a/internal/server/request_snapshot.go b/internal/server/request_snapshot.go
index dee523b1..85c9230e 100644
--- a/internal/server/request_snapshot.go
+++ b/internal/server/request_snapshot.go
@@ -19,7 +19,8 @@ const requestSnapshotInlineBodyLimit int64 = 64 * 1024
// model-facing endpoints. Known-small JSON bodies are captured once for the
// hot path; larger or unknown-size bodies only get a bounded selector peek and
// stay on the live request stream until the handler actually decodes them.
-func RequestSnapshotCapture() echo.MiddlewareFunc {
+func RequestSnapshotCapture(userPathHeader ...string) echo.MiddlewareFunc {
+ userPathHeaderName := configuredUserPathHeaderName(userPathHeader...)
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
req, requestID := ensureRequestID(c.Request())
@@ -30,12 +31,12 @@ func RequestSnapshotCapture() echo.MiddlewareFunc {
return next(c)
}
- userPath, err := core.NormalizeUserPath(req.Header.Get(core.UserPathHeader))
+ userPath, err := core.NormalizeUserPath(req.Header.Get(userPathHeaderName))
if err != nil {
- return handleError(c, core.NewInvalidRequestError("invalid X-GoModel-User-Path header", err))
+ return handleError(c, core.NewInvalidRequestError("invalid "+userPathHeaderName+" header", err))
}
if userPath != "" {
- req.Header.Set(core.UserPathHeader, userPath)
+ req.Header.Set(userPathHeaderName, userPath)
}
bodyBytes, bodyNotCaptured, bodyCaptured, err := captureSmallRequestBodyForSnapshot(req, desc.BodyMode)
@@ -57,7 +58,8 @@ func RequestSnapshotCapture() echo.MiddlewareFunc {
userPath,
)
- ctx := core.WithRequestSnapshot(req.Context(), snapshot)
+ ctx := core.WithUserPathHeaderName(req.Context(), userPathHeaderName)
+ ctx = core.WithRequestSnapshot(ctx, snapshot)
if semantics := core.DeriveWhiteBoxPrompt(snapshot); semantics != nil {
if !bodyCaptured {
seedRequestBodySelectorHints(req, desc.BodyMode, semantics)
@@ -71,6 +73,13 @@ func RequestSnapshotCapture() echo.MiddlewareFunc {
}
}
+func configuredUserPathHeaderName(headerNames ...string) string {
+ if len(headerNames) == 0 {
+ return core.UserPathHeader
+ }
+ return core.UserPathHeaderName(headerNames[0])
+}
+
func ensureRequestID(req *http.Request) (*http.Request, string) {
if req.Header == nil {
req.Header = make(http.Header)
diff --git a/internal/server/request_snapshot_test.go b/internal/server/request_snapshot_test.go
index a0c24774..b5b13503 100644
--- a/internal/server/request_snapshot_test.go
+++ b/internal/server/request_snapshot_test.go
@@ -195,6 +195,30 @@ func TestRequestSnapshotCapture_NormalizesUserPathHeader(t *testing.T) {
assert.Equal(t, "/team/alpha/user", c.Request().Header.Get(core.UserPathHeader))
}
+func TestRequestSnapshotCapture_UsesConfiguredUserPathHeader(t *testing.T) {
+ e := echo.New()
+ const headerName = "X-Tenant-Path"
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5-mini","messages":[{"role":"user","content":"hi"}]}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set(headerName, " team//alpha/user/ ")
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ var capturedFrame *core.RequestSnapshot
+ handler := RequestSnapshotCapture(headerName)(func(c *echo.Context) error {
+ capturedFrame = core.GetRequestSnapshot(c.Request().Context())
+ return c.String(http.StatusOK, "ok")
+ })
+
+ err := handler(c)
+ require.NoError(t, err)
+ require.NotNil(t, capturedFrame)
+ assert.Equal(t, "/team/alpha/user", capturedFrame.UserPath)
+ assert.Equal(t, "/team/alpha/user", c.Request().Header.Get(headerName))
+ assert.Equal(t, headerName, core.UserPathHeaderNameFromContext(c.Request().Context()))
+}
+
func TestRequestSnapshotCapture_PreservesPassthroughRouteParams(t *testing.T) {
e := echo.New()
/ for all providers and models, {provider_name}/ for one provider, or {provider_name}/{model} for one model. user_paths is matched against the effective request user_path: the managed API key user_path when present, otherwise the X-GoModel-User-Path request header.
+ The selector uses / for all providers and models, {provider_name}/ for one provider, or {provider_name}/{model} for one model. user_paths is matched against the effective request user_path: the managed API key user_path when present, otherwise the configured user path request header.