Skip to content
Open
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
4 changes: 4 additions & 0 deletions cmd/meat/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ Environment:
OPENAI_BASE_URL Optional. Override the OpenAI API base URL.
ANTHROPIC_API_KEY API key for Claude models.
ANTHROPIC_BASE_URL Optional. Override the Anthropic API base URL.
ANTHROPIC_CUSTOM_HEADERS
Optional. Extra headers for Anthropic requests, one
"Name: value" per line, for gateways that authenticate
on their own header (e.g. Ocp-Apim-Subscription-Key).
MEAT_MODEL Optional. Default model id.
MEAT_CACHE Optional. Cache directory (default ~/.meat; empty disables).

Expand Down
82 changes: 76 additions & 6 deletions meat/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"strings"
"time"
"unicode/utf8"
)

// DefaultAnthropicModel is used when AnthropicModel.Model is empty or the
Expand All @@ -22,6 +23,7 @@ type AnthropicModel struct {
APIKey string // API key (or "implicit" for the exe.dev gateway)
Model string // defaults to DefaultAnthropicModel
BaseURL string // bare origin/prefix; defaults to https://api.anthropic.com. "/v1/messages" is appended.
Headers [][2]string // extra request headers, applied after the defaults
HTTPC *http.Client // defaults to a client with a 2m timeout
}

Expand All @@ -48,10 +50,15 @@ const implicitGatewayKey = "implicit"
func NewAnthropicFromEnv(ctx context.Context, model string) (*AnthropicModel, error) {
model = resolveModel(model, DefaultAnthropicModel)

headers, err := customHeadersFromEnv()
if err != nil {
return nil, err
}

key := os.Getenv("ANTHROPIC_API_KEY")
baseURL := os.Getenv("ANTHROPIC_BASE_URL")
if key != "" || baseURL != "" {
return &AnthropicModel{APIKey: key, Model: model, BaseURL: baseURL}, nil
if key != "" || baseURL != "" || len(headers) > 0 {
return &AnthropicModel{APIKey: key, Model: model, BaseURL: baseURL, Headers: headers}, nil
}

// No explicit credentials: try the exe.dev managed gateway.
Expand All @@ -66,6 +73,61 @@ func NewAnthropicFromEnv(ctx context.Context, model string) (*AnthropicModel, er
return nil, fmt.Errorf("no LLM credentials: set ANTHROPIC_API_KEY, or run on an exe.dev VM with an attached 'llm' integration")
}

// customHeadersEnv holds extra headers to attach to every Anthropic request, in
// the same format Claude Code uses: "Name: value", one per line. It exists for
// gateways that authenticate on a header meat does not otherwise send (for
// example an Azure API Management front end wanting
// Ocp-Apim-Subscription-Key).
const customHeadersEnv = "ANTHROPIC_CUSTOM_HEADERS"

// customHeadersFromEnv parses $ANTHROPIC_CUSTOM_HEADERS. Blank lines are
// skipped. A line that is not "Name: value", or that carries a name/value
// illegal in an HTTP header, is a hard error: a silently dropped auth header
// resurfaces as a confusing 401 from the gateway.
func customHeadersFromEnv() ([][2]string, error) {
raw := os.Getenv(customHeadersEnv)
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var headers [][2]string
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, value, ok := strings.Cut(line, ":")
name = strings.TrimSpace(name)
value = strings.TrimSpace(value)
if !ok || name == "" {
return nil, fmt.Errorf("meat: %s: want \"Name: value\", got %q", customHeadersEnv, line)
}
if !validHeaderName(name) {
return nil, fmt.Errorf("meat: %s: invalid header name %q", customHeadersEnv, name)
}
// Reject control characters rather than let net/http panic on them. The
// value is secret, so it is never echoed back in the error.
if strings.ContainsFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
return nil, fmt.Errorf("meat: %s: header %q has a control character in its value", customHeadersEnv, name)
}
headers = append(headers, [2]string{name, value})
}
return headers, nil
}

// validHeaderName reports whether s is a valid RFC 7230 field-name (a token).
func validHeaderName(s string) bool {
for _, r := range s {
if r >= utf8.RuneSelf || !strings.ContainsRune(headerNameChars, r) {
return false
}
}
return s != ""
}

const headerNameChars = "!#$%&'*+-.^_`|~0123456789" +
"abcdefghijklmnopqrstuvwxyz" +
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"

// --- wire types ---

type antReq struct {
Expand Down Expand Up @@ -113,7 +175,9 @@ type antResp struct {

// Generate implements Model.
func (m *AnthropicModel) Generate(ctx context.Context, system string, messages []Message, tools []Tool) (*Response, error) {
if m.APIKey == "" {
// A gateway may authenticate purely on a custom header, so an empty APIKey
// is only an error when there is no other credential to send.
if m.APIKey == "" && len(m.Headers) == 0 {
return nil, fmt.Errorf("meat: AnthropicModel.APIKey is empty")
}

Expand All @@ -134,7 +198,7 @@ func (m *AnthropicModel) Generate(ctx context.Context, system string, messages [
if client == nil {
client = &http.Client{Timeout: 2 * time.Minute}
}
raw, err := postWithRetry(ctx, client, url, m.APIKey, body)
raw, err := postWithRetry(ctx, client, url, m.APIKey, m.Headers, body)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -180,10 +244,16 @@ var retryBaseDelay = time.Second
// overloaded_error, and transport errors) with exponential backoff. It returns
// the raw response body on the first 200. Non-retryable statuses (4xx other
// than 408/429) fail immediately — retrying a bad request can't help.
func postWithRetry(ctx context.Context, client *http.Client, url, apiKey string, body []byte) ([]byte, error) {
func postWithRetry(ctx context.Context, client *http.Client, url, apiKey string, headers [][2]string, body []byte) ([]byte, error) {
return postJSONWithRetry(ctx, client, url, body, "anthropic", func(req *http.Request) {
req.Header.Set("x-api-key", apiKey)
if apiKey != "" {
req.Header.Set("x-api-key", apiKey)
}
req.Header.Set("anthropic-version", "2023-06-01")
// Applied last so a gateway can override a default when it needs to.
for _, h := range headers {
req.Header.Set(h[0], h[1])
}
})
}

Expand Down
126 changes: 126 additions & 0 deletions meat/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,132 @@ func TestGenerate_MaxTokensStopIsAnError(t *testing.T) {
}
}

func TestCustomHeadersFromEnv(t *testing.T) {
tests := []struct {
name string
env string
want [][2]string
}{
{"unset", "", nil},
{"whitespace only", " \n\t\n", nil},
{"single", "Ocp-Apim-Subscription-Key: secret", [][2]string{{"Ocp-Apim-Subscription-Key", "secret"}}},
{"no space after colon", "X-Key:secret", [][2]string{{"X-Key", "secret"}}},
{"multiple with blank lines", "A: 1\n\nB: 2\n", [][2]string{{"A", "1"}, {"B", "2"}}},
{"value keeps inner colons", "X-Trace: a:b:c", [][2]string{{"X-Trace", "a:b:c"}}},
{"empty value allowed", "X-Key:", [][2]string{{"X-Key", ""}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(customHeadersEnv, tt.env)
got, err := customHeadersFromEnv()
if err != nil {
t.Fatalf("customHeadersFromEnv() error = %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("headers = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("header %d = %v, want %v", i, got[i], tt.want[i])
}
}
})
}
}

// TestCustomHeadersFromEnv_Rejects: a header meat cannot send must be a hard
// error, since silently dropping it resurfaces as an opaque 401 from a gateway.
func TestCustomHeadersFromEnv_Rejects(t *testing.T) {
tests := []struct {
name string
env string
}{
{"no colon", "Ocp-Apim-Subscription-Key secret"},
{"empty name", ": secret"},
{"space in name", "Bad Name: secret"},
{"non-token name", "X-Keyé: secret"},
{"carriage return would inject a header", "X-Key: a\rb"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(customHeadersEnv, tt.env)
if _, err := customHeadersFromEnv(); err == nil {
t.Fatalf("customHeadersFromEnv(%q) = nil error, want rejection", tt.env)
}
})
}
}

// TestCustomHeadersFromEnv_ErrorHidesValue: the value is a credential, so it
// must never reach an error string a user might paste into a bug report.
func TestCustomHeadersFromEnv_ErrorHidesValue(t *testing.T) {
t.Setenv(customHeadersEnv, "X-Key: super\rsecret")
_, err := customHeadersFromEnv()
if err == nil {
t.Fatal("want error for a control character in the value")
}
if strings.Contains(err.Error(), "secret") {
t.Errorf("error leaks the header value: %v", err)
}
}

// TestGenerate_AppliesCustomHeaders: custom headers ride along with the
// defaults, and win when they name the same header.
func TestGenerate_AppliesCustomHeaders(t *testing.T) {
var got http.Header
m, _ := antServer(t, func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Clone()
w.Write(okBody("end_turn"))
})
m.Headers = [][2]string{
{"Ocp-Apim-Subscription-Key", "sub-key"},
{"anthropic-version", "2099-01-01"},
}
if _, err := m.Generate(context.Background(), "sys", []Message{{Role: RoleUser, Content: []Block{textBlock("x")}}}, nil); err != nil {
t.Fatal(err)
}
if v := got.Get("Ocp-Apim-Subscription-Key"); v != "sub-key" {
t.Errorf("subscription key header = %q, want sub-key", v)
}
if v := got.Get("x-api-key"); v != "test-key" {
t.Errorf("x-api-key = %q, want test-key", v)
}
if v := got.Get("anthropic-version"); v != "2099-01-01" {
t.Errorf("anthropic-version = %q, want the custom header to override the default", v)
}
}

// TestGenerate_HeaderOnlyAuth: a gateway that authenticates purely on a custom
// header needs no API key, and must not receive an empty x-api-key.
func TestGenerate_HeaderOnlyAuth(t *testing.T) {
var got http.Header
m, _ := antServer(t, func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Clone()
w.Write(okBody("end_turn"))
})
m.APIKey = ""
m.Headers = [][2]string{{"Ocp-Apim-Subscription-Key", "sub-key"}}
if _, err := m.Generate(context.Background(), "sys", []Message{{Role: RoleUser, Content: []Block{textBlock("x")}}}, nil); err != nil {
t.Fatal(err)
}
if _, ok := got["X-Api-Key"]; ok {
t.Errorf("x-api-key sent with no API key configured: %q", got.Get("x-api-key"))
}
if v := got.Get("Ocp-Apim-Subscription-Key"); v != "sub-key" {
t.Errorf("subscription key header = %q, want sub-key", v)
}
}

func TestGenerate_NoCredentialAtAll(t *testing.T) {
m, _ := antServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Write(okBody("end_turn"))
})
m.APIKey = ""
if _, err := m.Generate(context.Background(), "sys", []Message{{Role: RoleUser, Content: []Block{textBlock("x")}}}, nil); err == nil {
t.Fatal("want error with neither an API key nor custom headers")
}
}

// TestGenerate_SendsMaxOutputTokens pins the request-side cap.
func TestGenerate_SendsMaxOutputTokens(t *testing.T) {
var gotMax int
Expand Down
38 changes: 38 additions & 0 deletions meat/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ func TestDiscoverExeGatewayBase_Team(t *testing.T) {
func TestNewAnthropicFromEnv_PrefersExplicitKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "sk-explicit")
t.Setenv("ANTHROPIC_BASE_URL", "")
t.Setenv(customHeadersEnv, "")
// Even on an exe.dev VM with an llm integration, an explicit key wins.
withMarker(t)
withReflection(t, `{"integrations":[{"name":"llm","type":"llm"}]}`)
Expand All @@ -96,6 +97,7 @@ func TestNewAnthropicFromEnv_PrefersExplicitKey(t *testing.T) {
func TestNewAnthropicFromEnv_FallsBackToGateway(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
t.Setenv("ANTHROPIC_BASE_URL", "")
t.Setenv(customHeadersEnv, "")
t.Setenv("MEAT_MODEL", "")
withMarker(t)
withReflection(t, `{"integrations":[{"name":"llm","type":"llm"}]}`)
Expand All @@ -118,6 +120,7 @@ func TestNewAnthropicFromEnv_FallsBackToGateway(t *testing.T) {
func TestNewAnthropicFromEnv_NoCredentials(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
t.Setenv("ANTHROPIC_BASE_URL", "")
t.Setenv(customHeadersEnv, "")
old := exeDevMarkerPath
exeDevMarkerPath = filepath.Join(t.TempDir(), "nope")
t.Cleanup(func() { exeDevMarkerPath = old })
Expand All @@ -127,6 +130,41 @@ func TestNewAnthropicFromEnv_NoCredentials(t *testing.T) {
}
}

// TestNewAnthropicFromEnv_CustomHeadersAreCredentials: a gateway fronted by
// Azure API Management authenticates on a header alone, so its presence must
// count as configuration rather than falling through to exe.dev discovery.
func TestNewAnthropicFromEnv_CustomHeadersAreCredentials(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
t.Setenv("ANTHROPIC_BASE_URL", "")
t.Setenv(customHeadersEnv, "Ocp-Apim-Subscription-Key: sub-key")
withMarker(t)
withReflection(t, `{"integrations":[{"name":"llm","type":"llm"}]}`)

m, err := NewAnthropicFromEnv(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if m.APIKey != "" {
t.Errorf("APIKey = %q, want empty (the header is the credential)", m.APIKey)
}
if len(m.Headers) != 1 || m.Headers[0] != [2]string{"Ocp-Apim-Subscription-Key", "sub-key"} {
t.Errorf("Headers = %v, want the parsed subscription key", m.Headers)
}
if m.BaseURL != "" {
t.Errorf("BaseURL = %q, want empty rather than the exe.dev gateway", m.BaseURL)
}
}

func TestNewAnthropicFromEnv_RejectsMalformedCustomHeaders(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "sk-explicit")
t.Setenv("ANTHROPIC_BASE_URL", "")
t.Setenv(customHeadersEnv, "not a header line")

if _, err := NewAnthropicFromEnv(context.Background(), ""); err == nil {
t.Errorf("want error for a malformed %s", customHeadersEnv)
}
}

func TestNewOpenAIFromEnv_PrefersExplicitKey(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-openai-explicit")
t.Setenv("OPENAI_BASE_URL", "")
Expand Down