Skip to content

Commit 25cd552

Browse files
Copilotnacx
andauthored
feat: implement autoconfig for ANTHROPIC_API_KEY in standalone mode (#1424)
**Description** This change adds support for configuring AI Gateway with Anthropic in standalone mode using the ANTHROPIC_API_KEY environment variable, following the same pattern as OpenAI. ## Usage Configure Anthropic backend by setting the environment variable: ```bash export ANTHROPIC_API_KEY="sk-ant-your-key-here" aigw run ``` Optional: Use custom base URL for testing or custom deployments: ```bash export ANTHROPIC_BASE_URL="https://custom.anthropic.com/v1" aigw run ``` **Note**: When both OPENAI_API_KEY and ANTHROPIC_API_KEY are set, OpenAI takes precedence. ## Implementation - Added `AnthropicConfig` struct to hold Anthropic-specific configuration - Implemented `PopulateAnthropicEnvConfig()` function to parse environment variables - Updated configuration template to render Anthropic resources (AIGatewayRoute, AIServiceBackend, BackendSecurityPolicy) - Modified CLI validation to accept ANTHROPIC_API_KEY as a valid configuration option - OpenAI takes precedence when both API keys are present to avoid configuration conflicts Fixes #1390 --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Ignasi Barrera <[email protected]>
1 parent 1f8ca94 commit 25cd552

File tree

10 files changed

+546
-24
lines changed

10 files changed

+546
-24
lines changed

cmd/aigw/config.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,21 @@ func readConfig(path string, mcpServers *autoconfig.MCPServers, debug bool) (str
4040
}
4141
}
4242

43-
// Add OpenAI config from ENV if available
43+
// Add OpenAI config from ENV if available (takes precedence over Anthropic)
4444
if os.Getenv("OPENAI_API_KEY") != "" || os.Getenv("AZURE_OPENAI_API_KEY") != "" {
4545
if err := autoconfig.PopulateOpenAIEnvConfig(&data); err != nil {
4646
return "", err
4747
}
48+
} else if os.Getenv("ANTHROPIC_API_KEY") != "" {
49+
// Add Anthropic config from ENV if available (only when OpenAI is not configured)
50+
if err := autoconfig.PopulateAnthropicEnvConfig(&data); err != nil {
51+
return "", err
52+
}
4853
}
4954

5055
// If we've found no config data, return an error.
51-
if reflect.DeepEqual(data, autoconfig.ConfigData{Debug: debug}) {
52-
return "", errors.New("you must supply at least OPENAI_API_KEY or AZURE_OPENAI_API_KEY or a config file path")
56+
if reflect.DeepEqual(data, autoconfig.ConfigData{Debug: debug, EnvoyVersion: os.Getenv("ENVOY_VERSION")}) {
57+
return "", errors.New("you must supply at least OPENAI_API_KEY, AZURE_OPENAI_API_KEY, ANTHROPIC_API_KEY, or a config file path")
5358
}
5459

5560
// We have any auto-generated config: write it and apply envsubst

cmd/aigw/config_test.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,32 @@ func TestReadConfig(t *testing.T) {
7777
expectHostnames: []string{"api.openai.com"},
7878
expectPort: "443",
7979
},
80+
{
81+
name: "generates config from Anthropic env vars",
82+
envVars: map[string]string{
83+
"ANTHROPIC_API_KEY": "sk-ant-test123",
84+
},
85+
expectHostnames: []string{"api.anthropic.com"},
86+
expectPort: "443",
87+
},
88+
{
89+
name: "OpenAI takes precedence when both are set",
90+
envVars: map[string]string{
91+
"OPENAI_API_KEY": "test-key",
92+
"ANTHROPIC_API_KEY": "sk-ant-test123",
93+
},
94+
expectHostnames: []string{"api.openai.com"},
95+
expectPort: "443",
96+
},
8097
}
8198

8299
for _, tt := range tests {
83100
t.Run(tt.name, func(t *testing.T) {
84101
// Clear any existing env vars
85102
t.Setenv("OPENAI_API_KEY", "")
86103
t.Setenv("OPENAI_BASE_URL", "")
104+
t.Setenv("ANTHROPIC_API_KEY", "")
105+
t.Setenv("ANTHROPIC_BASE_URL", "")
87106

88107
for k, v := range tt.envVars {
89108
t.Setenv(k, v)
@@ -106,7 +125,7 @@ func TestReadConfig(t *testing.T) {
106125
t.Run("error when file and no OPENAI_API_KEY", func(t *testing.T) {
107126
_, err := readConfig("", nil, false)
108127
require.Error(t, err)
109-
require.EqualError(t, err, "you must supply at least OPENAI_API_KEY or AZURE_OPENAI_API_KEY or a config file path")
128+
require.EqualError(t, err, "you must supply at least OPENAI_API_KEY, AZURE_OPENAI_API_KEY, ANTHROPIC_API_KEY, or a config file path")
110129
})
111130

112131
t.Run("error when file does not exist", func(t *testing.T) {

cmd/aigw/main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ type (
3434
// cmdRun corresponds to `aigw run` command.
3535
cmdRun struct {
3636
Debug bool `help:"Enable debug logging emitted to stderr."`
37-
Path string `arg:"" name:"path" optional:"" help:"Path to the AI Gateway configuration yaml file. Optional when at least OPENAI_API_KEY or AZURE_OPENAI_API_KEY is set." type:"path"`
37+
Path string `arg:"" name:"path" optional:"" help:"Path to the AI Gateway configuration yaml file. Optional when at least OPENAI_API_KEY, AZURE_OPENAI_API_KEY, or ANTHROPIC_API_KEY is set." type:"path"`
3838
AdminPort int `help:"HTTP port for the admin server (serves /metrics and /health endpoints)." default:"1064"`
3939
McpConfig string `name:"mcp-config" help:"Path to MCP servers configuration file." type:"path"`
4040
McpJSON string `name:"mcp-json" help:"JSON string of MCP servers configuration."`
@@ -49,8 +49,8 @@ func (c *cmdRun) Validate() error {
4949
if c.McpConfig != "" && c.McpJSON != "" {
5050
return fmt.Errorf("mcp-config and mcp-json are mutually exclusive")
5151
}
52-
if c.Path == "" && os.Getenv("OPENAI_API_KEY") == "" && os.Getenv("AZURE_OPENAI_API_KEY") == "" && c.McpConfig == "" && c.McpJSON == "" {
53-
return fmt.Errorf("you must supply at least OPENAI_API_KEY or AZURE_OPENAI_API_KEY or a config file path")
52+
if c.Path == "" && os.Getenv("OPENAI_API_KEY") == "" && os.Getenv("AZURE_OPENAI_API_KEY") == "" && os.Getenv("ANTHROPIC_API_KEY") == "" && c.McpConfig == "" && c.McpJSON == "" {
53+
return fmt.Errorf("you must supply at least OPENAI_API_KEY, AZURE_OPENAI_API_KEY, ANTHROPIC_API_KEY, or a config file path")
5454
}
5555

5656
var mcpJSON string

cmd/aigw/main_test.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ Flags:
8080
env: map[string]string{"OPENAI_API_KEY": "dummy-key"},
8181
rf: func(context.Context, cmdRun, runOpts, io.Writer, io.Writer) error { return nil },
8282
},
83+
{
84+
name: "run with Anthropic env",
85+
args: []string{"run"},
86+
env: map[string]string{"ANTHROPIC_API_KEY": "dummy-key"},
87+
rf: func(context.Context, cmdRun, runOpts, io.Writer, io.Writer) error { return nil },
88+
},
8389
{
8490
name: "run help",
8591
args: []string{"run", "--help"},
@@ -90,7 +96,8 @@ Run the AI Gateway locally for given configuration.
9096
9197
Arguments:
9298
[<path>] Path to the AI Gateway configuration yaml file. Optional when at
93-
least OPENAI_API_KEY or AZURE_OPENAI_API_KEY is set.
99+
least OPENAI_API_KEY, AZURE_OPENAI_API_KEY, or ANTHROPIC_API_KEY
100+
is set.
94101
95102
Flags:
96103
-h, --help Show context-sensitive help.
@@ -143,7 +150,7 @@ func TestCmdRun_Validate(t *testing.T) {
143150
name: "no config and no env vars",
144151
cmd: cmdRun{Path: ""},
145152
envVars: map[string]string{},
146-
expectedError: "you must supply at least OPENAI_API_KEY or AZURE_OPENAI_API_KEY or a config file path",
153+
expectedError: "you must supply at least OPENAI_API_KEY, AZURE_OPENAI_API_KEY, ANTHROPIC_API_KEY, or a config file path",
147154
},
148155
{
149156
name: "config path provided",
@@ -172,6 +179,13 @@ func TestCmdRun_Validate(t *testing.T) {
172179
"AZURE_OPENAI_API_KEY": "azure-key",
173180
},
174181
},
182+
{
183+
name: "ANTHROPIC_API_KEY set",
184+
cmd: cmdRun{Path: ""},
185+
envVars: map[string]string{
186+
"ANTHROPIC_API_KEY": "sk-ant-test",
187+
},
188+
},
175189
{
176190
name: "config path and OPENAI_API_KEY both set",
177191
cmd: cmdRun{Path: "/path/to/config.yaml"},

internal/autoconfig/anthropic.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright Envoy AI Gateway Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
// The full text of the Apache license is available in the LICENSE file at
4+
// the root of the repo.
5+
6+
package autoconfig
7+
8+
import (
9+
"fmt"
10+
"os"
11+
)
12+
13+
// PopulateAnthropicEnvConfig populates ConfigData with Anthropic backend configuration
14+
// from standard Anthropic SDK environment variables.
15+
//
16+
// This errs if ANTHROPIC_API_KEY is not set.
17+
//
18+
// See https://docs.anthropic.com/en/api/client-sdks
19+
func PopulateAnthropicEnvConfig(data *ConfigData) error {
20+
if data == nil {
21+
return fmt.Errorf("ConfigData cannot be nil")
22+
}
23+
24+
// Check for Anthropic API key
25+
anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY")
26+
if anthropicAPIKey == "" {
27+
return fmt.Errorf("ANTHROPIC_API_KEY environment variable is required")
28+
}
29+
30+
// Get base URL, defaulting to the official Anthropic API endpoint
31+
baseURL := os.Getenv("ANTHROPIC_BASE_URL")
32+
if baseURL == "" {
33+
baseURL = "https://api.anthropic.com/v1"
34+
}
35+
36+
parsed, err := parseURL(baseURL)
37+
if err != nil {
38+
return err
39+
}
40+
41+
// Create Backend for Anthropic
42+
backend := Backend{
43+
Name: "anthropic",
44+
Hostname: parsed.hostname,
45+
OriginalHostname: parsed.originalHostname,
46+
Port: parsed.port,
47+
NeedsTLS: parsed.needsTLS,
48+
}
49+
50+
// Create AnthropicConfig referencing the backend
51+
anthropicConfig := &AnthropicConfig{
52+
BackendName: "anthropic",
53+
SchemaName: "Anthropic",
54+
Version: parsed.version,
55+
}
56+
57+
// Add to ConfigData
58+
data.Backends = append(data.Backends, backend)
59+
data.Anthropic = anthropicConfig
60+
61+
return nil
62+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright Envoy AI Gateway Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
// The full text of the Apache license is available in the LICENSE file at
4+
// the root of the repo.
5+
6+
package autoconfig
7+
8+
import (
9+
"fmt"
10+
"testing"
11+
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestPopulateAnthropicEnvConfig(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
envVars map[string]string
19+
expected ConfigData
20+
expectedError error
21+
}{
22+
{
23+
name: "default (Anthropic)",
24+
envVars: map[string]string{
25+
"ANTHROPIC_API_KEY": "sk-ant-test123",
26+
// ANTHROPIC_BASE_URL not set, defaults to https://api.anthropic.com/v1
27+
},
28+
expected: ConfigData{
29+
Backends: []Backend{
30+
{
31+
Name: "anthropic",
32+
Hostname: "api.anthropic.com",
33+
OriginalHostname: "api.anthropic.com",
34+
Port: 443,
35+
NeedsTLS: true,
36+
},
37+
},
38+
Anthropic: &AnthropicConfig{
39+
BackendName: "anthropic",
40+
SchemaName: "Anthropic",
41+
Version: "",
42+
},
43+
},
44+
},
45+
{
46+
name: "Anthropic with custom base URL",
47+
envVars: map[string]string{
48+
"ANTHROPIC_API_KEY": "sk-ant-test123",
49+
"ANTHROPIC_BASE_URL": "https://custom.anthropic.com/v2",
50+
},
51+
expected: ConfigData{
52+
Backends: []Backend{
53+
{
54+
Name: "anthropic",
55+
Hostname: "custom.anthropic.com",
56+
OriginalHostname: "custom.anthropic.com",
57+
Port: 443,
58+
NeedsTLS: true,
59+
},
60+
},
61+
Anthropic: &AnthropicConfig{
62+
BackendName: "anthropic",
63+
SchemaName: "Anthropic",
64+
Version: "v2",
65+
},
66+
},
67+
},
68+
{
69+
name: "Anthropic with localhost",
70+
envVars: map[string]string{
71+
"ANTHROPIC_API_KEY": "sk-ant-test123",
72+
"ANTHROPIC_BASE_URL": "http://localhost:8080/v1",
73+
},
74+
expected: ConfigData{
75+
Backends: []Backend{
76+
{
77+
Name: "anthropic",
78+
Hostname: "127.0.0.1.nip.io",
79+
OriginalHostname: "localhost",
80+
Port: 8080,
81+
NeedsTLS: false,
82+
},
83+
},
84+
Anthropic: &AnthropicConfig{
85+
BackendName: "anthropic",
86+
SchemaName: "Anthropic",
87+
Version: "",
88+
},
89+
},
90+
},
91+
{
92+
name: "missing required API key",
93+
envVars: map[string]string{},
94+
expectedError: fmt.Errorf("ANTHROPIC_API_KEY environment variable is required"),
95+
},
96+
}
97+
98+
for _, tt := range tests {
99+
t.Run(tt.name, func(t *testing.T) {
100+
// Clear any existing env vars first
101+
t.Setenv("ANTHROPIC_API_KEY", "")
102+
t.Setenv("ANTHROPIC_BASE_URL", "")
103+
104+
// Set test environment variables
105+
for k, v := range tt.envVars {
106+
t.Setenv(k, v)
107+
}
108+
109+
// Test PopulateAnthropicEnvConfig
110+
data := &ConfigData{}
111+
err := PopulateAnthropicEnvConfig(data)
112+
113+
// Check result
114+
if tt.expectedError != nil {
115+
require.Error(t, err)
116+
require.Equal(t, tt.expectedError.Error(), err.Error())
117+
} else {
118+
require.NoError(t, err)
119+
require.Equal(t, tt.expected, *data)
120+
}
121+
})
122+
}
123+
}

internal/autoconfig/config.go

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ type OpenAIConfig struct {
3838
ProjectID string // Optional OpenAI-Project header value
3939
}
4040

41+
// AnthropicConfig holds Anthropic-specific configuration for generating AIServiceBackend resources.
42+
// This is nil when no Anthropic configuration is present.
43+
type AnthropicConfig struct {
44+
BackendName string // References a Backend.Name (typically "anthropic")
45+
SchemaName string // Schema name: "Anthropic"
46+
Version string // API version (Anthropic path prefix)
47+
}
48+
4149
// MCPBackendRef references a backend with MCP-specific routing configuration.
4250
// Used to generate MCPRoute backendRefs with path, tool filtering, and authentication.
4351
type MCPBackendRef struct {
@@ -49,13 +57,14 @@ type MCPBackendRef struct {
4957
}
5058

5159
// ConfigData holds all template data for generating the AI Gateway configuration.
52-
// It supports OpenAI-only, MCP-only, or combined OpenAI+MCP configurations.
60+
// It supports OpenAI-only, Anthropic-only, MCP-only, or combined configurations.
5361
type ConfigData struct {
54-
Backends []Backend // All backend endpoints (unified - includes OpenAI and MCP backends)
55-
OpenAI *OpenAIConfig // OpenAI-specific configuration (nil for MCP-only mode)
56-
MCPBackendRefs []MCPBackendRef // MCP routing configuration (nil/empty for OpenAI-only mode)
57-
Debug bool // Enable debug logging for Envoy (includes component-level logging for ext_proc, http, connection)
58-
EnvoyVersion string // Explicitly configure the version of Envoy to use.
62+
Backends []Backend // All backend endpoints (unified - includes OpenAI, Anthropic, and MCP backends)
63+
OpenAI *OpenAIConfig // OpenAI-specific configuration (nil when not present)
64+
Anthropic *AnthropicConfig // Anthropic-specific configuration (nil when not present)
65+
MCPBackendRefs []MCPBackendRef // MCP routing configuration (nil/empty for OpenAI-only or Anthropic-only mode)
66+
Debug bool // Enable debug logging for Envoy (includes component-level logging for ext_proc, http, connection)
67+
EnvoyVersion string // Explicitly configure the version of Envoy to use.
5968
}
6069

6170
// WriteConfig generates the AI Gateway configuration.
@@ -74,7 +83,7 @@ func WriteConfig(data *ConfigData) (string, error) {
7483
return buf.String(), nil
7584
}
7685

77-
// parsedURL holds parsed URL components for creating Backend and OpenAIConfig.
86+
// parsedURL holds parsed URL components for creating Backend, OpenAIConfig, and AnthropicConfig.
7887
type parsedURL struct {
7988
hostname string
8089
originalHostname string
@@ -87,13 +96,13 @@ type parsedURL struct {
8796
func parseURL(baseURL string) (*parsedURL, error) {
8897
u, err := url.Parse(baseURL)
8998
if err != nil {
90-
return nil, fmt.Errorf("invalid OPENAI_BASE_URL: %w", err)
99+
return nil, fmt.Errorf("invalid base URL: %w", err)
91100
}
92101

93102
// Extract hostname
94103
hostname := u.Hostname()
95104
if hostname == "" {
96-
return nil, fmt.Errorf("invalid OPENAI_BASE_URL: missing hostname")
105+
return nil, fmt.Errorf("invalid base URL: missing hostname")
97106
}
98107
originalHostname := hostname
99108

@@ -112,13 +121,13 @@ func parseURL(baseURL string) (*parsedURL, error) {
112121
case "http":
113122
port = 80
114123
default:
115-
return nil, fmt.Errorf("invalid OPENAI_BASE_URL: unsupported scheme %q", u.Scheme)
124+
return nil, fmt.Errorf("invalid base URL: unsupported scheme %q", u.Scheme)
116125
}
117126
} else {
118127
var err error
119128
port, err = strconv.Atoi(portStr)
120129
if err != nil {
121-
return nil, fmt.Errorf("invalid port in OPENAI_BASE_URL: %w", err)
130+
return nil, fmt.Errorf("invalid port in base URL: %w", err)
122131
}
123132
}
124133

0 commit comments

Comments
 (0)