fix(terraform-provider): apply custom headers on all request paths

MakeRequest was missing custom_headers, so most resources never sent
them. Share header application, build the client from ProviderConfig,
and drop credential headers on cross-origin redirects
This commit is contained in:
Matthijs Vogel 2026-08-10 20:09:44 +02:00
parent 441ec017da
commit 00ec28d121
No known key found for this signature in database
9 changed files with 152 additions and 49 deletions

View file

@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **provider**: Optional `custom_headers` map to send extra HTTP headers on every LiteLLM API request (e.g. proxy or gateway headers). `x-api-key` from `api_key` always takes precedence
- **provider**: Optional `custom_headers` map to send extra HTTP headers on every LiteLLM API request (e.g. proxy or gateway headers). Applied by both request helpers. `x-api-key` from `api_key` always takes precedence. Cross-origin redirects drop custom headers and `x-api-key`
## [0.4.0] - 2026-08-06

View file

@ -20,18 +20,48 @@ type Client struct {
CustomHeaders map[string]string
}
func NewClient(apiBase, apiKey string, insecureSkipVerify bool) *Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify},
func NewClient(cfg ProviderConfig) *Client {
headers := make(map[string]string, len(cfg.CustomHeaders))
for k, v := range cfg.CustomHeaders {
headers[k] = v
}
return &Client{
APIBase: apiBase,
APIKey: apiKey,
httpClient: &http.Client{Transport: tr},
InsecureSkipVerify: insecureSkipVerify,
CustomHeaders: map[string]string{},
c := &Client{
APIBase: cfg.APIBase,
APIKey: cfg.APIKey,
InsecureSkipVerify: cfg.InsecureSkipVerify,
CustomHeaders: headers,
}
c.httpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify},
},
CheckRedirect: c.checkRedirect,
}
return c
}
func (c *Client) checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
orig := via[0].URL
if req.URL.Scheme != orig.Scheme || req.URL.Host != orig.Host {
for k := range c.CustomHeaders {
req.Header.Del(k)
}
req.Header.Del("x-api-key")
}
return nil
}
func (c *Client) applyRequestHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("accept", "application/json")
for k, v := range c.CustomHeaders {
req.Header.Set(k, v)
}
req.Header.Set("x-api-key", c.APIKey)
}
// Organization member methods
@ -281,12 +311,7 @@ func (c *Client) sendRequest(method, path string, body interface{}) (map[string]
return nil, fmt.Errorf("error creating request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("accept", "application/json")
for k, v := range c.CustomHeaders {
req.Header.Set(k, v)
}
req.Header.Set("x-api-key", c.APIKey)
c.applyRequestHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {

View file

@ -2,12 +2,22 @@ package litellm
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func testClient(apiBase, apiKey string, insecure bool, headers map[string]string) *Client {
return NewClient(ProviderConfig{
APIBase: apiBase,
APIKey: apiKey,
InsecureSkipVerify: insecure,
CustomHeaders: headers,
})
}
func TestSendRequestCustomHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Proxy-Auth"); got != "proxy-token" {
@ -24,20 +34,84 @@ func TestSendRequestCustomHeaders(t *testing.T) {
}))
defer srv.Close()
client := NewClient(srv.URL, "sk-real-key", false)
client.CustomHeaders = map[string]string{
client := testClient(srv.URL, "sk-real-key", false, map[string]string{
"X-Proxy-Auth": "proxy-token",
"X-Request-Source": "terraform",
"x-api-key": "should-not-win",
}
})
if _, err := client.sendRequest("GET", "/health", nil); err != nil {
t.Fatalf("sendRequest: %v", err)
}
}
func TestMakeRequestCustomHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Proxy-Auth"); got != "proxy-token" {
t.Errorf("X-Proxy-Auth = %q, want %q", got, "proxy-token")
}
if got := r.Header.Get("x-api-key"); got != "sk-real-key" {
t.Errorf("x-api-key = %q, want configured API key (must not be overridden)", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}))
defer srv.Close()
client := testClient(srv.URL, "sk-real-key", false, map[string]string{
"X-Proxy-Auth": "proxy-token",
"x-api-key": "should-not-win",
})
resp, err := MakeRequest(client, "GET", "/health", nil)
if err != nil {
t.Fatalf("MakeRequest: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
func TestCrossOriginRedirectStripsSensitiveHeaders(t *testing.T) {
var finalAuth, finalAPIKey string
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
finalAuth = r.Header.Get("X-Proxy-Auth")
finalAPIKey = r.Header.Get("x-api-key")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}))
defer target.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Proxy-Auth"); got != "proxy-token" {
t.Errorf("origin X-Proxy-Auth = %q, want proxy-token", got)
}
http.Redirect(w, r, target.URL+"/final", http.StatusFound)
}))
defer origin.Close()
client := testClient(origin.URL, "sk-real-key", false, map[string]string{
"X-Proxy-Auth": "proxy-token",
})
resp, err := MakeRequest(client, "GET", "/start", nil)
if err != nil {
t.Fatalf("MakeRequest: %v", err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if finalAuth != "" {
t.Errorf("cross-origin redirect kept X-Proxy-Auth = %q", finalAuth)
}
if finalAPIKey != "" {
t.Errorf("cross-origin redirect kept x-api-key = %q", finalAPIKey)
}
}
func TestRedactSensitiveDataNestedCredentialValues(t *testing.T) {
c := NewClient("http://localhost:4000", "sk-test", false)
c := testClient("http://localhost:4000", "sk-test", false, nil)
input := `{"credential_name":"azure-cred","credential_values":{"api_key":"sk-secret-123","config":{"region":"us-east-1","client_secret":"nested-secret"}}}`
got := c.redactSensitiveData(input)
@ -56,7 +130,7 @@ func TestRedactSensitiveDataNestedCredentialValues(t *testing.T) {
}
func TestRedactSensitiveDataDeeplyNestedSensitiveKeys(t *testing.T) {
c := NewClient("http://localhost:4000", "sk-test", false)
c := testClient("http://localhost:4000", "sk-test", false, nil)
input := `{"data":[{"litellm_params":{"model":"gpt-4","api_key":"sk-deep-456","aws_secret_access_key":"aws-secret"}}]}`
got := c.redactSensitiveData(input)
@ -72,7 +146,7 @@ func TestRedactSensitiveDataDeeplyNestedSensitiveKeys(t *testing.T) {
}
func TestRedactSensitiveDataTopLevelStringFields(t *testing.T) {
c := NewClient("http://localhost:4000", "sk-test", false)
c := testClient("http://localhost:4000", "sk-test", false, nil)
input := `{"model_api_key":"sk-top-789","vertex_credentials":"{\"type\":\"service_account\"}","team_alias":"eng"}`
got := c.redactSensitiveData(input)
@ -88,7 +162,7 @@ func TestRedactSensitiveDataTopLevelStringFields(t *testing.T) {
}
func TestRedactSensitiveDataNonJSONFallback(t *testing.T) {
c := NewClient("http://localhost:4000", "sk-test", false)
c := testClient("http://localhost:4000", "sk-test", false, nil)
input := `error before "api_key": "sk-fallback-000" after`
got := c.redactSensitiveData(input)

View file

@ -60,18 +60,23 @@ func Provider() *schema.Provider {
// providerConfigure configures the provider with the given schema data.
func providerConfigure(d *schema.ResourceData) (interface{}, error) {
customHeaders := map[string]string{}
if v, ok := d.GetOk("custom_headers"); ok {
for k, val := range v.(map[string]interface{}) {
customHeaders[k] = val.(string)
}
}
client := NewClient(
d.Get("api_base").(string),
d.Get("api_key").(string),
d.Get("insecure_skip_verify").(bool),
)
client.CustomHeaders = customHeaders
return client, nil
return NewClient(ProviderConfig{
APIBase: d.Get("api_base").(string),
APIKey: d.Get("api_key").(string),
InsecureSkipVerify: d.Get("insecure_skip_verify").(bool),
CustomHeaders: customHeadersFromSchema(d),
}), nil
}
func customHeadersFromSchema(d *schema.ResourceData) map[string]string {
v, ok := d.GetOk("custom_headers")
if !ok {
return map[string]string{}
}
raw := v.(map[string]interface{})
headers := make(map[string]string, len(raw))
for k, val := range raw {
headers[k] = val.(string)
}
return headers
}

View file

@ -48,7 +48,7 @@ func createTestUsers(t *testing.T) {
return
}
client := NewClient(apiBase, apiKey, false)
client := NewClient(ProviderConfig{APIBase: apiBase, APIKey: apiKey, InsecureSkipVerify: false})
// Create test users
users := []map[string]interface{}{

View file

@ -39,7 +39,7 @@ func TestRetryCredentialRead_SuccessOnFirstAttempt(t *testing.T) {
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true})
d := newTestResourceData(t, "test-cred")
err := retryCredentialRead(d, client, 3)
@ -72,7 +72,7 @@ func TestRetryCredentialRead_SuccessAfterRetries(t *testing.T) {
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true})
d := newTestResourceData(t, "test-cred")
err := retryCredentialRead(d, client, 3)
@ -93,7 +93,7 @@ func TestRetryCredentialRead_ExhaustsRetries(t *testing.T) {
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true})
d := newTestResourceData(t, "test-cred")
err := retryCredentialRead(d, client, 2)
@ -118,7 +118,7 @@ func TestRetryCredentialRead_NonRetryableError(t *testing.T) {
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true})
d := newTestResourceData(t, "test-cred")
err := retryCredentialRead(d, client, 3)
@ -154,7 +154,7 @@ func TestRetryCredentialRead_IDRestoredBetweenRetries(t *testing.T) {
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true})
d := newTestResourceData(t, "my-cred")
err := retryCredentialRead(d, client, 2)
@ -172,7 +172,7 @@ func TestRetryCredentialRead_MaxRetriesOne(t *testing.T) {
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true})
d := newTestResourceData(t, "test-cred")
err := retryCredentialRead(d, client, 1)
@ -189,7 +189,7 @@ func TestRetryCredentialRead_ConnectionError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
srv.Close()
client := NewClient(srv.URL, "test-key", true)
client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true})
d := newTestResourceData(t, "test-cred")
err := retryCredentialRead(d, client, 1)

View file

@ -21,7 +21,7 @@ func TestTeamMemberUpdateSendsRole(t *testing.T) {
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true})
d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMember().Schema, map[string]interface{}{
"team_id": "team-1",
"user_id": "user-1",

View file

@ -28,7 +28,7 @@ func TestVectorStoreReadDoesNotPersistServerLitellmParams(t *testing.T) {
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true})
d := schema.TestResourceDataRaw(t, resourceLiteLLMVectorStore().Schema, map[string]interface{}{
"vector_store_name": "kb",
"custom_llm_provider": "openai",

View file

@ -79,8 +79,7 @@ func MakeRequest(client *Client, method, endpoint string, body interface{}) (*ht
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", client.APIKey)
client.applyRequestHeaders(req)
return client.httpClient.Do(req)
}