From f21a298efa3e0074ee09f88fbb53ad0daf560268 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 13 Jul 2026 18:32:00 -0400 Subject: [PATCH 1/2] feat(llm): apikey_header auth scheme for Kong AI Gateway key-auth (closes #302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kong AI Gateway's key-auth plugin reads the consumer key from a fixed `apikey` header and ignores provider-native headers, so an agent pointed at a Kong-fronted OPENAI_BASE_URL/ANTHROPIC_BASE_URL 401s every LLM call with "No API key found in request". Extend the existing AuthScheme extension point (added for aws_sigv4 in #202) with auth_scheme: apikey_header — sends the API key in a gateway header IN ADDITION TO the provider-native header (additive, so safe against non-gateway endpoints; Kong's ai-proxy replaces/injects the upstream provider header anyway). Header name defaults to "apikey" (Kong key-auth's default key_names) and is overridable via model.auth_header_name for gateways with custom key names. - llm.ClientConfig / types.ModelRef: new AuthHeaderName field; scheme constants (AuthSchemeAWSSigV4 / AuthSchemeAPIKeyHeader / DefaultAPIKeyHeaderName) replace the scattered "aws_sigv4" literals. - providers/auth_scheme.go: shared setGatewayAPIKeyHeader helper, wired into both anthropic and openai setHeaders (additive, no-op off-path or when APIKey is empty). - runtime/config.go: carry ModelRef.auth_header_name onto the client. - Tests: both providers send both headers; custom header name; no-op for the default scheme and empty key; config carry-over. - Docs: runtime-engine.md, forge-yaml-schema.md, knowledge skill. Platform wiring (agent-builder env → generated forge.yaml) is a follow-up, matching the SigV4 precedent. --- .claude/skills/forge.md | 7 +- docs/core-concepts/runtime-engine.md | 19 ++++- docs/reference/forge-yaml-schema.md | 9 +- forge-core/llm/client.go | 29 +++++++ forge-core/llm/providers/anthropic.go | 31 +++---- forge-core/llm/providers/auth_scheme.go | 28 +++++++ forge-core/llm/providers/auth_scheme_test.go | 87 ++++++++++++++++++++ forge-core/llm/providers/openai.go | 35 ++++---- forge-core/runtime/config.go | 7 ++ forge-core/runtime/config_test.go | 26 ++++++ forge-core/types/config.go | 12 +++ 11 files changed, 257 insertions(+), 33 deletions(-) create mode 100644 forge-core/llm/providers/auth_scheme.go diff --git a/.claude/skills/forge.md b/.claude/skills/forge.md index 91c6ac64..6c9be427 100644 --- a/.claude/skills/forge.md +++ b/.claude/skills/forge.md @@ -269,6 +269,10 @@ model: Credentials read from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`. Matches the inbound-auth posture (`forge-core/auth/providers/aws_sigv4`). Today this is passthrough only — native Bedrock URL/body rewriting (`POST /model//invoke` + event-stream framing) is tracked under issue #205; today operators front Bedrock with a compat proxy (e.g. litellm) when the model doesn't expose the OpenAI or Anthropic wire shape. Issue #202 Phase 2. +### `auth_scheme: apikey_header` (API-gateway key header) + +`model.auth_scheme: apikey_header` sends the API key in a gateway header **in addition to** the provider-native header (`x-api-key` / `Authorization: Bearer`), for gateways whose auth plugin reads a fixed header name — e.g. Kong AI Gateway `key-auth`, which reads `apikey` and ignores the provider headers (otherwise every call 401s with `No API key found in request`). Additive, so safe against non-gateway endpoints. Header name defaults to `apikey`, overridable via `model.auth_header_name` (e.g. `x-gateway-key`) for custom `key_names`. Key comes from the usual `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`. Additive header set in `forge-core/llm/providers/auth_scheme.go` (`setGatewayAPIKeyHeader`), wired in both providers' `setHeaders`. Issue #302. + Token usage and request IDs are captured per provider at the call site and folded into the `llm_call` audit event (FWS-3) and into the per-invocation `LLMUsageAccumulator` so the response headers + the @@ -884,8 +888,9 @@ model: name: gpt-4o base_url: "" # override the provider's default API host (#139) organization_id: org-xxx # OpenAI enterprise org - auth_scheme: "" # "" (default) | x_api_key | bearer | aws_sigv4 (#202) + auth_scheme: "" # "" (default) | x_api_key | bearer | aws_sigv4 (#202) | apikey_header (#302) aws_region: "" # required when auth_scheme: aws_sigv4 + auth_header_name: "" # apikey_header custom header; default "apikey" (#302) fallbacks: - provider: anthropic name: claude-sonnet-4-20250514 diff --git a/docs/core-concepts/runtime-engine.md b/docs/core-concepts/runtime-engine.md index 5ed51af5..f006e96c 100644 --- a/docs/core-concepts/runtime-engine.md +++ b/docs/core-concepts/runtime-engine.md @@ -41,7 +41,7 @@ Forge supports multiple LLM providers with automatic fallback: | `anthropic` | `claude-sonnet-4-20250514` | API key | | `gemini` | `gemini-2.5-flash` | API key | | `ollama` | `llama3` | None (local) | -| Custom URL | Configurable | API key (OpenAI or Anthropic shape); or AWS SigV4 via `auth_scheme: aws_sigv4` for Bedrock | +| Custom URL | Configurable | API key (OpenAI or Anthropic shape); AWS SigV4 via `auth_scheme: aws_sigv4` for Bedrock; or a gateway key header via `auth_scheme: apikey_header` (e.g. Kong `key-auth`) | ### Configuration @@ -141,6 +141,23 @@ Credentials come from the standard AWS env chain: `AWS_ACCESS_KEY_ID`, `AWS_SECR Today this is a passthrough — Forge speaks the wire format the endpoint exposes and signs the request. Native Bedrock URL/body rewriting (`POST /model//invoke` with the event-stream framing) is tracked separately under issue #205; today operators front Bedrock with a compat proxy (e.g. litellm) when calling models that don't expose the OpenAI or Anthropic shape. +### API-gateway key header (`apikey_header`) + +Some API gateways authenticate with a fixed header name rather than the provider-native scheme. Kong AI Gateway's `key-auth` plugin, for example, reads the consumer key from an `apikey` header and ignores `Authorization` / `x-api-key`, so an agent pointed at a Kong-fronted `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL` otherwise fails every LLM call with `401 {"message":"No API key found in request"}`. + +`model.auth_scheme: apikey_header` sends the API key in the gateway header **in addition to** the provider-native header — additive, so it stays safe against non-gateway endpoints (the gateway consumes `apikey`; Kong's ai-proxy replaces/injects the upstream provider header). The header name defaults to `apikey` (Kong `key-auth`'s default `key_names`) and is overridable via `auth_header_name` for gateways with custom key names: + +```yaml +model: + provider: openai # or anthropic — symmetric + name: gpt-4o + base_url: https://kong-gateway.internal/openai + auth_scheme: apikey_header + # auth_header_name: x-gateway-key # optional; default: apikey +``` + +The key comes from the usual `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` env var. Issue #302. + ### Fallback Chains Configure fallback providers for automatic failover when the primary provider is unavailable: diff --git a/docs/reference/forge-yaml-schema.md b/docs/reference/forge-yaml-schema.md index b2392895..e7401563 100644 --- a/docs/reference/forge-yaml-schema.md +++ b/docs/reference/forge-yaml-schema.md @@ -20,8 +20,9 @@ model: name: "gpt-4o" # Model name base_url: "" # Override the provider's default API host (issue #139) organization_id: "org-xxx" # OpenAI Organization ID (enterprise, optional) - auth_scheme: "" # "" (default) / "x_api_key" / "bearer" / "aws_sigv4" — issue #202 + auth_scheme: "" # "" (default) / "x_api_key" / "bearer" / "aws_sigv4" (#202) / "apikey_header" (#302) aws_region: "" # Required when auth_scheme: aws_sigv4 — issue #202 + auth_header_name: "" # apikey_header custom header name; default "apikey" — issue #302 fallbacks: # Fallback providers (optional) - provider: "anthropic" name: "claude-sonnet-4-20250514" @@ -41,6 +42,12 @@ model: # env) for outbound LLM calls — works against any SigV4-fronted endpoint # that speaks OpenAI or Anthropic wire format. Issue #202 Phase 2. +# API gateways with fixed key headers: auth_scheme: apikey_header sends the +# API key in the auth_header_name header (default "apikey") IN ADDITION to +# the provider-native header, so a gateway whose auth plugin reads a fixed +# header — e.g. Kong AI Gateway key-auth — authenticates the request. +# Additive, so safe against non-gateway endpoints. Issue #302. + tools: - name: "web_search" - name: "cli_execute" diff --git a/forge-core/llm/client.go b/forge-core/llm/client.go index e995e210..929e5494 100644 --- a/forge-core/llm/client.go +++ b/forge-core/llm/client.go @@ -2,6 +2,25 @@ package llm import "context" +// Outbound LLM auth schemes (ClientConfig.AuthScheme / ModelRef.auth_scheme). +const ( + // AuthSchemeAWSSigV4 signs every outbound request with AWS SigV4 + // and skips the provider-native API-key header (issue #202 Phase 2). + AuthSchemeAWSSigV4 = "aws_sigv4" + + // AuthSchemeAPIKeyHeader additionally sends the API key in a gateway + // header (default `apikey`, overridable via AuthHeaderName) ON TOP OF + // the provider-native header. For API gateways whose auth plugin reads + // a fixed header name — e.g. Kong AI Gateway's key-auth, which reads + // `apikey` and ignores `Authorization` / `x-api-key`. Additive, so it + // is safe to enable against non-gateway endpoints too (issue #302). + AuthSchemeAPIKeyHeader = "apikey_header" + + // DefaultAPIKeyHeaderName is the header AuthSchemeAPIKeyHeader uses + // when AuthHeaderName is unset — Kong key-auth's default key_names. + DefaultAPIKeyHeaderName = "apikey" +) + // Client is the interface for interacting with an LLM provider. type Client interface { // Chat sends a chat completion request and returns the response. @@ -32,9 +51,19 @@ type ClientConfig struct { // client sets `Authorization: Bearer `. AuthScheme == // "aws_sigv4" wraps the client's transport with the SigV4 // signer and skips the native header logic; APIKey is ignored. + // AuthScheme == "apikey_header" ADDITIONALLY sends APIKey in the + // AuthHeaderName header (default "apikey") alongside the native + // header, for gateways like Kong that read a fixed key header + // (issue #302). AuthScheme string AWSRegion string + // AuthHeaderName overrides the header used by the "apikey_header" + // scheme. Empty → DefaultAPIKeyHeaderName ("apikey"). Ignored for + // every other scheme. Set it for a gateway with custom key_names, + // e.g. "x-gateway-key". Issue #302. + AuthHeaderName string + // PromptCaching opts the provider client into injecting the // provider's prompt-cache primitives on every request: // diff --git a/forge-core/llm/providers/anthropic.go b/forge-core/llm/providers/anthropic.go index 79a8c26f..3172ac36 100644 --- a/forge-core/llm/providers/anthropic.go +++ b/forge-core/llm/providers/anthropic.go @@ -16,12 +16,13 @@ import ( // AnthropicClient implements llm.Client for the Anthropic Messages API. type AnthropicClient struct { - apiKey string - baseURL string - model string - authScheme string - promptCaching bool - client *http.Client + apiKey string + baseURL string + model string + authScheme string + authHeaderName string + promptCaching bool + client *http.Client } // NewAnthropicClient creates a new Anthropic client. @@ -48,16 +49,17 @@ func NewAnthropicClient(cfg llm.ClientConfig) *AnthropicClient { timeout = 120 * time.Second } httpClient := &http.Client{Timeout: timeout} - if cfg.AuthScheme == "aws_sigv4" { + if cfg.AuthScheme == llm.AuthSchemeAWSSigV4 { httpClient.Transport = newBedrockSigningTransport(cfg.AWSRegion, http.DefaultTransport) } return &AnthropicClient{ - apiKey: cfg.APIKey, - baseURL: strings.TrimRight(baseURL, "/"), - model: cfg.Model, - authScheme: cfg.AuthScheme, - promptCaching: cfg.PromptCaching, - client: httpClient, + apiKey: cfg.APIKey, + baseURL: strings.TrimRight(baseURL, "/"), + model: cfg.Model, + authScheme: cfg.AuthScheme, + authHeaderName: cfg.AuthHeaderName, + promptCaching: cfg.PromptCaching, + client: httpClient, } } @@ -154,9 +156,10 @@ func (c *AnthropicClient) setHeaders(req *http.Request) { // validated upstream and because it would be included in the // SigV4 signed-headers set and complicate proxy debugging. // Issue #202 Phase 2. - if c.authScheme != "aws_sigv4" { + if c.authScheme != llm.AuthSchemeAWSSigV4 { req.Header.Set("x-api-key", c.apiKey) } + setGatewayAPIKeyHeader(req, c.authScheme, c.authHeaderName, c.apiKey) } // Anthropic-specific request types. diff --git a/forge-core/llm/providers/auth_scheme.go b/forge-core/llm/providers/auth_scheme.go new file mode 100644 index 00000000..f7e186c8 --- /dev/null +++ b/forge-core/llm/providers/auth_scheme.go @@ -0,0 +1,28 @@ +package providers + +import ( + "net/http" + + "github.com/initializ/forge/forge-core/llm" +) + +// setGatewayAPIKeyHeader implements the "apikey_header" outbound auth +// scheme (issue #302). When selected it sends the API key in a gateway +// header IN ADDITION TO the provider-native header, so an API gateway +// whose auth plugin reads a fixed header — e.g. Kong AI Gateway's +// key-auth, which reads `apikey` and ignores Authorization / x-api-key — +// authenticates the request while the upstream provider still receives +// (or has Kong inject) its native header. +// +// The header name defaults to llm.DefaultAPIKeyHeaderName ("apikey") and +// is overridable via ModelRef.auth_header_name for gateways with custom +// key_names. No-op for every other scheme, and when apiKey is empty. +func setGatewayAPIKeyHeader(req *http.Request, authScheme, headerName, apiKey string) { + if authScheme != llm.AuthSchemeAPIKeyHeader || apiKey == "" { + return + } + if headerName == "" { + headerName = llm.DefaultAPIKeyHeaderName + } + req.Header.Set(headerName, apiKey) +} diff --git a/forge-core/llm/providers/auth_scheme_test.go b/forge-core/llm/providers/auth_scheme_test.go index 481d48b4..e4577a8f 100644 --- a/forge-core/llm/providers/auth_scheme_test.go +++ b/forge-core/llm/providers/auth_scheme_test.go @@ -145,3 +145,90 @@ func TestOpenAIClient_OrgIDStillSetUnderSigV4(t *testing.T) { t.Errorf("OpenAI-Organization dropped under aws_sigv4: %q", got) } } + +// --- issue #302: apikey_header gateway scheme ----------------------------- + +// TestAnthropicClient_APIKeyHeaderSendsBothHeaders is the #302 invariant on +// the Anthropic side: apikey_header is ADDITIVE — the native x-api-key still +// rides (Kong's ai-proxy replaces/injects the upstream provider header) AND +// the gateway's `apikey` header carries the key so Kong key-auth admits the +// request. +func TestAnthropicClient_APIKeyHeaderSendsBothHeaders(t *testing.T) { + c := NewAnthropicClient(llm.ClientConfig{ + APIKey: "sk-ant-test", + Model: "claude-test", + AuthScheme: llm.AuthSchemeAPIKeyHeader, + }) + req, _ := http.NewRequest(http.MethodPost, "https://kong.example/v1/messages", nil) + c.setHeaders(req) + + if got := req.Header.Get("x-api-key"); got != "sk-ant-test" { + t.Errorf("apikey_header dropped native x-api-key: %q", got) + } + if got := req.Header.Get("apikey"); got != "sk-ant-test" { + t.Errorf("apikey_header did not set the gateway apikey header: %q", got) + } + if got := req.Header.Get("anthropic-version"); got != "2023-06-01" { + t.Errorf("anthropic-version dropped under apikey_header: %q", got) + } +} + +// TestOpenAIClient_APIKeyHeaderSendsBothHeaders mirrors the above for OpenAI: +// Authorization: Bearer still rides alongside the gateway apikey header. +func TestOpenAIClient_APIKeyHeaderSendsBothHeaders(t *testing.T) { + c := NewOpenAIClient(llm.ClientConfig{ + APIKey: "sk-test", + Model: "gpt-test", + AuthScheme: llm.AuthSchemeAPIKeyHeader, + }) + req, _ := http.NewRequest(http.MethodPost, "https://kong.example/v1/chat/completions", nil) + c.setHeaders(req) + + if got := req.Header.Get("Authorization"); got != "Bearer sk-test" { + t.Errorf("apikey_header dropped native Authorization: %q", got) + } + if got := req.Header.Get("apikey"); got != "sk-test" { + t.Errorf("apikey_header did not set the gateway apikey header: %q", got) + } +} + +// TestAPIKeyHeaderScheme_CustomHeaderName pins the auth_header_name override +// for gateways with non-default key_names (#302). +func TestAPIKeyHeaderScheme_CustomHeaderName(t *testing.T) { + c := NewOpenAIClient(llm.ClientConfig{ + APIKey: "sk-test", + Model: "gpt-test", + AuthScheme: llm.AuthSchemeAPIKeyHeader, + AuthHeaderName: "x-gateway-key", + }) + req, _ := http.NewRequest(http.MethodPost, "https://kong.example/v1/chat/completions", nil) + c.setHeaders(req) + + if got := req.Header.Get("x-gateway-key"); got != "sk-test" { + t.Errorf("custom auth_header_name not honored: %q", got) + } + if got := req.Header.Get("apikey"); got != "" { + t.Errorf("default apikey header should not be set when a custom name is given: %q", got) + } +} + +// TestAPIKeyHeaderScheme_NoopOffPath confirms the scheme is inert everywhere +// it should be: the default (unset) scheme never emits the gateway header, +// and an empty APIKey emits nothing even under apikey_header. +func TestAPIKeyHeaderScheme_NoopOffPath(t *testing.T) { + // Default scheme → no apikey header. + def := NewOpenAIClient(llm.ClientConfig{APIKey: "sk-test", Model: "gpt-test"}) + req, _ := http.NewRequest(http.MethodPost, "https://api.openai.com/v1/chat/completions", nil) + def.setHeaders(req) + if got := req.Header.Get("apikey"); got != "" { + t.Errorf("default scheme leaked an apikey header: %q", got) + } + + // apikey_header but empty key → nothing to send. + empty := NewOpenAIClient(llm.ClientConfig{Model: "gpt-test", AuthScheme: llm.AuthSchemeAPIKeyHeader}) + req2, _ := http.NewRequest(http.MethodPost, "https://kong.example/v1/chat/completions", nil) + empty.setHeaders(req2) + if got := req2.Header.Get("apikey"); got != "" { + t.Errorf("empty APIKey should not set an apikey header: %q", got) + } +} diff --git a/forge-core/llm/providers/openai.go b/forge-core/llm/providers/openai.go index 413410ea..0712d1c3 100644 --- a/forge-core/llm/providers/openai.go +++ b/forge-core/llm/providers/openai.go @@ -20,13 +20,14 @@ import ( // OpenAIClient implements llm.Client for the OpenAI Chat Completions API. // Also works with Azure OpenAI and any OpenAI-compatible endpoint. type OpenAIClient struct { - apiKey string - baseURL string - model string - orgID string - authScheme string - promptCaching bool - client *http.Client + apiKey string + baseURL string + model string + orgID string + authScheme string + authHeaderName string + promptCaching bool + client *http.Client } // NewOpenAIClient creates a new OpenAI client. @@ -50,17 +51,18 @@ func NewOpenAIClient(cfg llm.ClientConfig) *OpenAIClient { timeout = 120 * time.Second } httpClient := &http.Client{Timeout: timeout} - if cfg.AuthScheme == "aws_sigv4" { + if cfg.AuthScheme == llm.AuthSchemeAWSSigV4 { httpClient.Transport = newBedrockSigningTransport(cfg.AWSRegion, http.DefaultTransport) } return &OpenAIClient{ - apiKey: cfg.APIKey, - baseURL: strings.TrimRight(baseURL, "/"), - model: cfg.Model, - orgID: cfg.OrgID, - authScheme: cfg.AuthScheme, - promptCaching: cfg.PromptCaching, - client: httpClient, + apiKey: cfg.APIKey, + baseURL: strings.TrimRight(baseURL, "/"), + model: cfg.Model, + orgID: cfg.OrgID, + authScheme: cfg.AuthScheme, + authHeaderName: cfg.AuthHeaderName, + promptCaching: cfg.PromptCaching, + client: httpClient, } } @@ -134,12 +136,13 @@ func (c *OpenAIClient) setHeaders(req *http.Request) { // When AuthScheme=aws_sigv4 the SigV4 transport will stamp the // Authorization header itself; don't pre-populate a Bearer token // that would be replaced and confuse trace logs. Issue #202 Phase 2. - if c.apiKey != "" && c.authScheme != "aws_sigv4" { + if c.apiKey != "" && c.authScheme != llm.AuthSchemeAWSSigV4 { req.Header.Set("Authorization", "Bearer "+c.apiKey) } if c.orgID != "" { req.Header.Set("OpenAI-Organization", c.orgID) } + setGatewayAPIKeyHeader(req, c.authScheme, c.authHeaderName, c.apiKey) } // openaiRequest is the OpenAI-specific request format. diff --git a/forge-core/runtime/config.go b/forge-core/runtime/config.go index f24dd16f..55117d8f 100644 --- a/forge-core/runtime/config.go +++ b/forge-core/runtime/config.go @@ -97,6 +97,13 @@ func ResolveModelConfig(cfg *types.ForgeConfig, envVars map[string]string, provi if cfg.Model.AWSRegion != "" { mc.Client.AWSRegion = cfg.Model.AWSRegion } + // Issue #302 — the apikey_header scheme's optional custom header name + // (e.g. a gateway with non-default key_names). Only meaningful when + // AuthScheme == "apikey_header"; carried unconditionally so the client + // owns the default-name fallback. + if cfg.Model.AuthHeaderName != "" { + mc.Client.AuthHeaderName = cfg.Model.AuthHeaderName + } // AWS_REGION env safety-net for the SigV4 path. Mirrors the // OPENAI_BASE_URL / ANTHROPIC_BASE_URL env pattern above — lets // an operator override the region per-deploy without touching diff --git a/forge-core/runtime/config_test.go b/forge-core/runtime/config_test.go index db84a8a2..9acdb140 100644 --- a/forge-core/runtime/config_test.go +++ b/forge-core/runtime/config_test.go @@ -197,6 +197,32 @@ func TestResolveModelConfig_OrgIDFromYAML(t *testing.T) { } } +// TestResolveModelConfig_APIKeyHeaderSchemeCarriesOver pins the #302 mapping: +// auth_scheme + auth_header_name on the forge.yaml ModelRef flow onto the +// client config so the provider client can emit the gateway header. +func TestResolveModelConfig_APIKeyHeaderSchemeCarriesOver(t *testing.T) { + cfg := &types.ForgeConfig{ + Model: types.ModelRef{ + Provider: "openai", + Name: "gpt-5.4", + AuthScheme: "apikey_header", + AuthHeaderName: "x-gateway-key", + }, + } + envVars := map[string]string{"OPENAI_API_KEY": "sk-test"} + + mc := ResolveModelConfig(cfg, envVars, "") + if mc == nil { + t.Fatal("expected non-nil ModelConfig") + } + if mc.Client.AuthScheme != "apikey_header" { + t.Errorf("AuthScheme = %q, want apikey_header", mc.Client.AuthScheme) + } + if mc.Client.AuthHeaderName != "x-gateway-key" { + t.Errorf("AuthHeaderName = %q, want x-gateway-key", mc.Client.AuthHeaderName) + } +} + func TestResolveModelConfig_OrgIDEnvOverridesYAML(t *testing.T) { cfg := &types.ForgeConfig{ Model: types.ModelRef{ diff --git a/forge-core/types/config.go b/forge-core/types/config.go index e1cd7ca5..453d2157 100644 --- a/forge-core/types/config.go +++ b/forge-core/types/config.go @@ -806,6 +806,12 @@ type ModelRef struct { // environment (AWS_ACCESS_KEY_ID / _SECRET_ / // _SESSION_TOKEN) plus the AWSRegion field // below. APIKey is ignored on this path. + // "apikey_header" — sends APIKey in the AuthHeaderName header + // (default "apikey") IN ADDITION TO the + // provider-native header. For API gateways whose + // auth plugin reads a fixed header — e.g. Kong AI + // Gateway key-auth. Additive, so safe against + // non-gateway endpoints. Issue #302. // // Unset for the vast majority of deployments — the default // behavior matches the pre-#202 contract byte-for-byte. @@ -817,6 +823,12 @@ type ModelRef struct { // often go through customer-side proxies that re-write the host. AWSRegion string `yaml:"aws_region,omitempty"` + // AuthHeaderName overrides the header used by the "apikey_header" + // scheme (issue #302). Empty → "apikey" (Kong key-auth's default + // key_names). Set it for a gateway with custom key_names, e.g. + // "x-gateway-key". Ignored for every other AuthScheme. + AuthHeaderName string `yaml:"auth_header_name,omitempty"` + Version string `yaml:"version,omitempty"` OrganizationID string `yaml:"organization_id,omitempty"` Fallbacks []ModelFallback `yaml:"fallbacks,omitempty"` From 8e262f205f704c288ee2ea7984aa780236df25e9 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 13 Jul 2026 18:49:34 -0400 Subject: [PATCH 2/2] fix(llm): validate auth_scheme + guard header collision (#303 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the PR #303 review's three minors: 1. auth_scheme is now validated (forge-core/validate/forge_config.go): an unrecognized value errors (a typo previously degraded silently to native-headers-only, reproducing the exact 401 apikey_header fixes), auth_scheme on a non-openai/anthropic provider warns (only those clients honor it), and a stray auth_header_name warns. 3. Header collision: an auth_header_name that case-insensitively equals a native auth header (Authorization / x-api-key) errors in validation, and setGatewayAPIKeyHeader refuses to overwrite a native header as defense-in-depth for the programmatic path. 2. Fallbacks: documented that auth_scheme applies to the PRIMARY model only (ModelFallback godoc + schema doc + knowledge skill) — matches the pre-existing aws_sigv4 shape; per-fallback fields tracked as a follow-up (the FORGE_MODEL_FALLBACKS env source needs a parallel encoding). 4. Docs: note Kong key-auth hide_credentials: false forwards the apikey header upstream; recommend hide_credentials: true. Tests cover unknown scheme, collision (incl. lowercase), provider-combo and stray-header warnings, and the runtime no-clobber guard. --- .claude/skills/forge.md | 2 +- docs/core-concepts/runtime-engine.md | 4 +- docs/reference/forge-yaml-schema.md | 4 +- forge-core/llm/providers/auth_scheme.go | 9 +++ forge-core/llm/providers/auth_scheme_test.go | 19 ++++++ forge-core/types/config.go | 7 +++ forge-core/validate/forge_config.go | 41 +++++++++++++ forge-core/validate/forge_config_test.go | 64 ++++++++++++++++++++ 8 files changed, 147 insertions(+), 3 deletions(-) diff --git a/.claude/skills/forge.md b/.claude/skills/forge.md index 6c9be427..d2e9366c 100644 --- a/.claude/skills/forge.md +++ b/.claude/skills/forge.md @@ -271,7 +271,7 @@ Credentials read from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSI ### `auth_scheme: apikey_header` (API-gateway key header) -`model.auth_scheme: apikey_header` sends the API key in a gateway header **in addition to** the provider-native header (`x-api-key` / `Authorization: Bearer`), for gateways whose auth plugin reads a fixed header name — e.g. Kong AI Gateway `key-auth`, which reads `apikey` and ignores the provider headers (otherwise every call 401s with `No API key found in request`). Additive, so safe against non-gateway endpoints. Header name defaults to `apikey`, overridable via `model.auth_header_name` (e.g. `x-gateway-key`) for custom `key_names`. Key comes from the usual `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`. Additive header set in `forge-core/llm/providers/auth_scheme.go` (`setGatewayAPIKeyHeader`), wired in both providers' `setHeaders`. Issue #302. +`model.auth_scheme: apikey_header` sends the API key in a gateway header **in addition to** the provider-native header (`x-api-key` / `Authorization: Bearer`), for gateways whose auth plugin reads a fixed header name — e.g. Kong AI Gateway `key-auth`, which reads `apikey` and ignores the provider headers (otherwise every call 401s with `No API key found in request`). Additive, so safe against non-gateway endpoints. Header name defaults to `apikey`, overridable via `model.auth_header_name` (e.g. `x-gateway-key`) for custom `key_names`. Key comes from the usual `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`. Additive header set in `forge-core/llm/providers/auth_scheme.go` (`setGatewayAPIKeyHeader`), wired in both providers' `setHeaders`. Applies to the **primary model only** (fallbacks use their provider-native header). `forge validate` rejects an unknown `auth_scheme` and an `auth_header_name` that collides with `Authorization` / `x-api-key` (the helper also refuses to overwrite a native header as defense-in-depth), and warns when `auth_scheme` is set on a non-openai/anthropic provider that ignores it. Issue #302. Token usage and request IDs are captured per provider at the call site and folded into the `llm_call` audit event (FWS-3) and into the diff --git a/docs/core-concepts/runtime-engine.md b/docs/core-concepts/runtime-engine.md index f006e96c..fddc1273 100644 --- a/docs/core-concepts/runtime-engine.md +++ b/docs/core-concepts/runtime-engine.md @@ -156,7 +156,9 @@ model: # auth_header_name: x-gateway-key # optional; default: apikey ``` -The key comes from the usual `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` env var. Issue #302. +The key comes from the usual `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` env var. `auth_scheme` applies to the **primary model only** — a fallback routed through the same gateway authenticates with its provider-native header. `forge validate` rejects an unrecognized `auth_scheme` and an `auth_header_name` that collides with a native auth header (`Authorization` / `x-api-key`), so a typo surfaces as a config error rather than the silent 401 this scheme exists to fix. Issue #302. + +> **Kong operators:** `key-auth` defaults to `hide_credentials: false`, which forwards the `apikey` header **upstream** — the credential then transits in a header that conventional redaction tooling (which knows `Authorization` / `x-api-key`) won't scrub. Set `hide_credentials: true` on the Kong plugin so it strips the key before proxying. (Forge's own trace redactor matches key values by shape, so opt-in content capture is already covered.) ### Fallback Chains diff --git a/docs/reference/forge-yaml-schema.md b/docs/reference/forge-yaml-schema.md index e7401563..30cd9b1a 100644 --- a/docs/reference/forge-yaml-schema.md +++ b/docs/reference/forge-yaml-schema.md @@ -46,7 +46,9 @@ model: # API key in the auth_header_name header (default "apikey") IN ADDITION to # the provider-native header, so a gateway whose auth plugin reads a fixed # header — e.g. Kong AI Gateway key-auth — authenticates the request. -# Additive, so safe against non-gateway endpoints. Issue #302. +# Additive, so safe against non-gateway endpoints. auth_scheme applies to +# the PRIMARY model only — fallbacks authenticate with their provider-native +# header. Issue #302. tools: - name: "web_search" diff --git a/forge-core/llm/providers/auth_scheme.go b/forge-core/llm/providers/auth_scheme.go index f7e186c8..5d2c4062 100644 --- a/forge-core/llm/providers/auth_scheme.go +++ b/forge-core/llm/providers/auth_scheme.go @@ -2,6 +2,7 @@ package providers import ( "net/http" + "strings" "github.com/initializ/forge/forge-core/llm" ) @@ -24,5 +25,13 @@ func setGatewayAPIKeyHeader(req *http.Request, authScheme, headerName, apiKey st if headerName == "" { headerName = llm.DefaultAPIKeyHeaderName } + // Defense-in-depth: never clobber a native auth header with the raw key. + // `forge validate` rejects such a config, but guard the programmatic + // path too so a misconfigured ClientConfig can't silently overwrite the + // provider's Bearer / x-api-key (#303 review). + switch strings.ToLower(headerName) { + case "authorization", "x-api-key": + return + } req.Header.Set(headerName, apiKey) } diff --git a/forge-core/llm/providers/auth_scheme_test.go b/forge-core/llm/providers/auth_scheme_test.go index e4577a8f..2dff465b 100644 --- a/forge-core/llm/providers/auth_scheme_test.go +++ b/forge-core/llm/providers/auth_scheme_test.go @@ -232,3 +232,22 @@ func TestAPIKeyHeaderScheme_NoopOffPath(t *testing.T) { t.Errorf("empty APIKey should not set an apikey header: %q", got) } } + +// TestAPIKeyHeaderScheme_NeverClobbersNativeHeader is the defense-in-depth +// guard (#303 review): even if a ClientConfig sets auth_header_name to a +// native auth header (which `forge validate` rejects), the helper must NOT +// overwrite the provider's Bearer token with the raw key. +func TestAPIKeyHeaderScheme_NeverClobbersNativeHeader(t *testing.T) { + c := NewOpenAIClient(llm.ClientConfig{ + APIKey: "sk-test", + Model: "gpt-test", + AuthScheme: llm.AuthSchemeAPIKeyHeader, + AuthHeaderName: "authorization", // lowercase — must still be caught + }) + req, _ := http.NewRequest(http.MethodPost, "https://kong.example/v1/chat/completions", nil) + c.setHeaders(req) + + if got := req.Header.Get("Authorization"); got != "Bearer sk-test" { + t.Errorf("native Authorization was clobbered by the gateway header: %q", got) + } +} diff --git a/forge-core/types/config.go b/forge-core/types/config.go index 453d2157..f7b00bde 100644 --- a/forge-core/types/config.go +++ b/forge-core/types/config.go @@ -845,6 +845,13 @@ type ModelFallback struct { BaseURL string `yaml:"base_url,omitempty"` OrganizationID string `yaml:"organization_id,omitempty"` + + // NOTE: AuthScheme / AuthHeaderName / AWSRegion are intentionally + // absent — auth_scheme (#202 aws_sigv4, #302 apikey_header) applies to + // the PRIMARY model only. A fallback routed through the same gateway + // authenticates with its provider-native header. Adding per-fallback + // scheme fields is tracked as a follow-up (the FORGE_MODEL_FALLBACKS + // env source would need a parallel encoding). } // ToolRef is a lightweight reference to a tool in forge.yaml. diff --git a/forge-core/validate/forge_config.go b/forge-core/validate/forge_config.go index 29df314f..9e1ef411 100644 --- a/forge-core/validate/forge_config.go +++ b/forge-core/validate/forge_config.go @@ -3,13 +3,30 @@ package validate import ( "fmt" "regexp" + "strings" + "github.com/initializ/forge/forge-core/llm" "github.com/initializ/forge/forge-core/scheduler" "github.com/initializ/forge/forge-core/types" ) var kebabCasePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) +// knownModelAuthSchemes is the accepted set for model.auth_scheme (outbound +// LLM auth). "" / x_api_key / bearer all resolve to the provider-native +// header; aws_sigv4 (#202) and apikey_header (#302) are the active schemes. +var knownModelAuthSchemes = map[string]bool{ + "": true, + "x_api_key": true, + "bearer": true, + llm.AuthSchemeAWSSigV4: true, + llm.AuthSchemeAPIKeyHeader: true, +} + +// nativeAuthHeaders are the provider-native auth headers apikey_header must +// not overwrite (case-insensitive). +var nativeAuthHeaders = map[string]bool{"authorization": true, "x-api-key": true} + var ( agentIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`) semverPattern = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) @@ -72,6 +89,30 @@ func ValidateForgeConfig(cfg *types.ForgeConfig) *ValidationResult { r.Warnings = append(r.Warnings, fmt.Sprintf("model.organization_id is set but provider is %q (only used by openai)", cfg.Model.Provider)) } + // model.auth_scheme validation (#202 / #302). An unrecognized value + // silently degrades to native-headers-only — reproducing the exact 401 + // the apikey_header scheme exists to fix — so reject it here. + if s := cfg.Model.AuthScheme; !knownModelAuthSchemes[s] { + r.Errors = append(r.Errors, fmt.Sprintf("model.auth_scheme %q is not recognized (known: x_api_key, bearer, aws_sigv4, apikey_header)", s)) + } + // Only the openai and anthropic clients honor auth_scheme; warn if it's + // set on a provider that will silently ignore it. + if s := cfg.Model.AuthScheme; (s == llm.AuthSchemeAWSSigV4 || s == llm.AuthSchemeAPIKeyHeader) && + cfg.Model.Provider != "" && cfg.Model.Provider != "openai" && cfg.Model.Provider != "anthropic" { + r.Warnings = append(r.Warnings, fmt.Sprintf("model.auth_scheme %q only affects the openai and anthropic clients; provider %q ignores it", s, cfg.Model.Provider)) + } + // apikey_header's custom header must not collide with a native auth + // header, or it would overwrite the provider's Bearer / x-api-key with + // the raw key — breaking auth in a maximally confusing way (#303 review). + if cfg.Model.AuthScheme == llm.AuthSchemeAPIKeyHeader && cfg.Model.AuthHeaderName != "" && + nativeAuthHeaders[strings.ToLower(cfg.Model.AuthHeaderName)] { + r.Errors = append(r.Errors, fmt.Sprintf("model.auth_header_name %q collides with a native auth header; choose a distinct gateway header (e.g. apikey, x-gateway-key)", cfg.Model.AuthHeaderName)) + } + // auth_header_name only applies to apikey_header. + if cfg.Model.AuthHeaderName != "" && cfg.Model.AuthScheme != llm.AuthSchemeAPIKeyHeader { + r.Warnings = append(r.Warnings, `model.auth_header_name is set but auth_scheme is not "apikey_header"; it will be ignored`) + } + if cfg.Framework != "" && !knownFrameworks[cfg.Framework] { r.Warnings = append(r.Warnings, fmt.Sprintf("unknown framework %q (known: forge, crewai, langchain)", cfg.Framework)) } diff --git a/forge-core/validate/forge_config_test.go b/forge-core/validate/forge_config_test.go index 6a921586..04ed5a23 100644 --- a/forge-core/validate/forge_config_test.go +++ b/forge-core/validate/forge_config_test.go @@ -1,6 +1,7 @@ package validate import ( + "strings" "testing" "github.com/initializ/forge/forge-core/types" @@ -32,6 +33,69 @@ func TestValidateForgeConfig_Valid(t *testing.T) { } } +func hasSubstr(ss []string, sub string) bool { + for _, s := range ss { + if strings.Contains(s, sub) { + return true + } + } + return false +} + +// TestValidateForgeConfig_AuthScheme covers the #303-review validation: +// unknown scheme → error; apikey_header is accepted; a custom header +// colliding with a native auth header → error; auth_scheme on an +// unsupported provider and a stray auth_header_name → warnings. +func TestValidateForgeConfig_AuthScheme(t *testing.T) { + t.Run("apikey_header is valid", func(t *testing.T) { + cfg := validConfig() + cfg.Model.AuthScheme = "apikey_header" + cfg.Model.AuthHeaderName = "x-gateway-key" + r := ValidateForgeConfig(cfg) + if !r.IsValid() { + t.Fatalf("expected valid, got errors: %v", r.Errors) + } + }) + + t.Run("unknown scheme errors", func(t *testing.T) { + cfg := validConfig() + cfg.Model.AuthScheme = "apikey_headr" // typo + r := ValidateForgeConfig(cfg) + if r.IsValid() || !hasSubstr(r.Errors, "auth_scheme") { + t.Fatalf("expected an auth_scheme error, got errors=%v", r.Errors) + } + }) + + t.Run("header collision errors", func(t *testing.T) { + cfg := validConfig() + cfg.Model.AuthScheme = "apikey_header" + cfg.Model.AuthHeaderName = "Authorization" // case-insensitive collision + r := ValidateForgeConfig(cfg) + if r.IsValid() || !hasSubstr(r.Errors, "collides with a native auth header") { + t.Fatalf("expected a collision error, got errors=%v", r.Errors) + } + }) + + t.Run("scheme on unsupported provider warns", func(t *testing.T) { + cfg := validConfig() + cfg.Model.Provider = "gemini" + cfg.Model.AuthScheme = "apikey_header" + r := ValidateForgeConfig(cfg) + if !r.IsValid() || !hasSubstr(r.Warnings, "only affects the openai and anthropic clients") { + t.Fatalf("expected a provider warning and no error; errors=%v warnings=%v", r.Errors, r.Warnings) + } + }) + + t.Run("stray auth_header_name warns", func(t *testing.T) { + cfg := validConfig() + cfg.Model.AuthHeaderName = "apikey" // no apikey_header scheme + r := ValidateForgeConfig(cfg) + if !r.IsValid() || !hasSubstr(r.Warnings, "auth_header_name is set but auth_scheme") { + t.Fatalf("expected a stray-header warning and no error; errors=%v warnings=%v", r.Errors, r.Warnings) + } + }) +} + func TestValidateForgeConfig_InvalidAgentID(t *testing.T) { cfg := validConfig() cfg.AgentID = "My_Agent!"