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 -![RequestSnapshot and WhiteBoxPrompt request flow](assets/0002-ingress-frame-flow.svg) +![RequestSnapshot and WhiteBoxPrompt request flow](/adr/assets/0002-ingress-frame-flow.svg) ## 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

-
+
{{template "inline-help-toggle" .}} diff --git a/internal/admin/dashboard/templates/page-models.html b/internal/admin/dashboard/templates/page-models.html index 77f4112f..2fa7f61c 100644 --- a/internal/admin/dashboard/templates/page-models.html +++ b/internal/admin/dashboard/templates/page-models.html @@ -152,7 +152,7 @@

- 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 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.

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()