Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .claude/skills/forge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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`. 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
per-invocation `LLMUsageAccumulator` so the response headers + the
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion docs/core-concepts/runtime-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -141,6 +141,25 @@ 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/<id>/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:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (docs): worth one sentence on upstream key transit — Kong key-auth defaults to hide_credentials: false, meaning the gateway forwards the apikey header upstream, so the credential transits in a header conventional redaction tooling (which knows Authorization/x-api-key) won't scrub. Recommend hide_credentials: true on the Kong plugin here. (Forge's own tracing redactor matches key values by shape, so opt-in content capture is already covered.)


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

Configure fallback providers for automatic failover when the primary provider is unavailable:
Expand Down
11 changes: 10 additions & 1 deletion docs/reference/forge-yaml-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -41,6 +42,14 @@ 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. auth_scheme applies to
# the PRIMARY model only — fallbacks authenticate with their provider-native
# header. Issue #302.

tools:
- name: "web_search"
- name: "cli_execute"
Expand Down
29 changes: 29 additions & 0 deletions forge-core/llm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -32,9 +51,19 @@ type ClientConfig struct {
// client sets `Authorization: Bearer <APIKey>`. 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:
//
Expand Down
31 changes: 17 additions & 14 deletions forge-core/llm/providers/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions forge-core/llm/providers/auth_scheme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package providers

import (
"net/http"
"strings"

"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
}
// 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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (security): guard against auth_header_name colliding with a native auth header.

This runs AFTER the provider's native header set and uses Header.Set — so auth_header_name: Authorization silently overwrites the native Bearer header with the raw key (no prefix), breaking auth in a maximally confusing way; auth_header_name: x-api-key on Anthropic is a harmless same-value overwrite but equally nonsensical. Reject or warn when the custom name case-insensitively equals Authorization or x-api-key — fits naturally into the scheme validation suggested on config.go.

}
106 changes: 106 additions & 0 deletions forge-core/llm/providers/auth_scheme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,109 @@ 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)
}
}

// 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)
}
}
35 changes: 19 additions & 16 deletions forge-core/llm/providers/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading