Skip to content

Commit b674da7

Browse files
codefromthecryptanuraagamathetake
authored
feat(aigw): generate config from OpenAI environment variables (envoyproxy#1233)
**Description** This implements automatic configuration generation for `aigw run` when there is no config file and at least the `OPENAI_API_KEY` environment variables is set. This makes it easier to get started with OpenAI-compatible backends without writing or copy/pasting YAML configuration. Configuration: When `OPENAI_API_KEY` is set and no config file is provided, `aigw run` will read these variables and generate configuration from them: - `OPENAI_API_KEY`: API key for authentication (required) - `OPENAI_BASE_URL`: Base URL for the backend (defaults to https://api.openai.com/v1) Key features: - Automatic localhost to 127.0.0.1.nip.io conversion for Docker/K8s - TLS detection based on URL scheme (https) e.g. Tetrate Agent Router Service: https://api.router.tetrate.ai/v1 - Support for custom API path prefixes e.g. OpenRouter: https://openrouter.ai/api/v1 or LlamaStack: http://localhost:8321/v1/openai/v1 - Clean YAML output (omits version (path prefix) field when it's "v1") This simplifies common use cases: - OpenAI: `OPENAI_API_KEY=sk-your-key aigw run` - Ollama: `OPENAI_BASE_URL=http://localhost:11434/v1 OPENAI_API_KEY=unused aigw run` Here's an example: ```bash $ OPENAI_API_KEY=sk-not-tellin go run . run looking up the latest patch for Envoy version 1.35 1.35.3 is already downloaded starting: /tmp/envoy-gateway/versions/1.35.3/bin/envoy in run directory /tmp/envoy-gateway/runs/1758789738364346000 [2025-09-25 16:42:18.391][48486493][warning][config] [source/server/options_impl_platform_default.cc:9] CPU number provided by HW thread count (instead of cpuset). {"bytes_received":124,"bytes_sent":795,"connection_termination_details":null,"downstream_local_address":"127.0.0.1:1975","downstream_remote_address":"127.0.0.1:62691","duration":3635,"genai_backend_name":"default/openai/route/aigw-run/rule/0/ref/0","genai_model_name":"gpt-5-nano","genai_model_name_override":"gpt-5-nano","genai_tokens_input":21,"genai_tokens_output":268,"method":"POST","response_code":200,"start_time":"2025-09-25T08:43:07.390Z","upstream_cluster":"httproute/default/aigw-run/rule/0","upstream_host":"162.159.140.245:443","upstream_local_address":"192.168.0.108:62692","upstream_transport_failure_reason":null,"user-agent":"curl/8.14.1","x-envoy-origin-path":"/v1/chat/completions","x-request-id":"9daf1c85-f75e-4c88-a90c-f9cccf85970c"} ``` Fixes envoyproxy#1211 --------- Signed-off-by: Adrian Cole <[email protected]> Signed-off-by: Adrian Cole <[email protected]> Co-authored-by: Anuraag (Rag) Agrawal <[email protected]> Co-authored-by: Takeshi Yoneda <[email protected]>
1 parent 68d0203 commit b674da7

File tree

14 files changed

+1430
-50
lines changed

14 files changed

+1430
-50
lines changed

cmd/aigw/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@
88
- **aigw** (port 1975): Envoy AI Gateway CLI (standalone mode)
99
- **chat-completion**: curl command making a simple chat completion
1010

11+
The simplest way to get started is to have `aigw` generate a configuration for
12+
your OpenAI-compatible backend. This happens when there is no configuration
13+
file and at least the `OPENAI_API_KEY` environment variable is set.
14+
15+
Here are values we use for Ollama:
16+
- `OPENAI_API_KEY=unused` (Ollama does not require an API key)
17+
- `OPENAI_BASE_URL=http://localhost:11434/v1` (host.docker.internal in Docker)
18+
1119
1. **Start Ollama** on your host machine:
1220

1321
Start Ollama on all interfaces, with a large context. This allows it to be

cmd/aigw/config_test.go

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,58 +8,73 @@ package main
88
import (
99
"os"
1010
"path/filepath"
11-
"runtime"
1211
"testing"
1312

1413
"github.com/stretchr/testify/require"
1514
)
1615

1716
func TestReadConfig(t *testing.T) {
18-
aiGatewayLocalPath := sourceRelativePath("ai-gateway-local.yaml")
19-
2017
tests := []struct {
2118
name string
2219
path string
2320
envVars map[string]string
2421
expectHostname string
2522
expectPort string
23+
expectError string
2624
}{
2725
{
28-
name: "non default config",
29-
path: aiGatewayLocalPath,
26+
name: "generates config from OpenAI env vars for localhost",
27+
envVars: map[string]string{
28+
"OPENAI_API_KEY": "test-key",
29+
"OPENAI_BASE_URL": "http://localhost:11434/v1",
30+
},
3031
expectHostname: "127.0.0.1.nip.io",
3132
expectPort: "11434",
3233
},
3334
{
34-
name: "non default config with OPENAI_HOST OPENAI_PORT",
35-
path: aiGatewayLocalPath,
35+
name: "generates config from OpenAI env vars for custom host",
3636
envVars: map[string]string{
37-
"OPENAI_HOST": "host.docker.internal",
38-
"OPENAI_PORT": "8080",
37+
"OPENAI_API_KEY": "test-key",
38+
"OPENAI_BASE_URL": "http://myservice:8080/v1",
3939
},
40-
expectHostname: "host.docker.internal",
40+
expectHostname: "myservice",
4141
expectPort: "8080",
4242
},
43+
{
44+
name: "defaults to OpenAI when only API key is set",
45+
envVars: map[string]string{
46+
"OPENAI_API_KEY": "test-key",
47+
},
48+
expectHostname: "api.openai.com",
49+
expectPort: "443",
50+
},
4351
}
4452

4553
for _, tt := range tests {
4654
t.Run(tt.name, func(t *testing.T) {
55+
// Clear any existing env vars
56+
t.Setenv("OPENAI_API_KEY", "")
57+
t.Setenv("OPENAI_BASE_URL", "")
58+
4759
for k, v := range tt.envVars {
4860
t.Setenv(k, v)
4961
}
5062

5163
config, err := readConfig(tt.path)
52-
require.NoError(t, err)
53-
require.Contains(t, config, "hostname: "+tt.expectHostname)
54-
require.Contains(t, config, "port: "+tt.expectPort)
64+
if tt.expectError != "" {
65+
require.Error(t, err)
66+
require.Contains(t, err.Error(), tt.expectError)
67+
} else {
68+
require.NoError(t, err)
69+
require.Contains(t, config, "hostname: "+tt.expectHostname)
70+
require.Contains(t, config, "port: "+tt.expectPort)
71+
}
5572
})
5673
}
5774

58-
// Historical configuration used an IP for ollama. We can't use this
59-
// config in docker, as it needs a hostname. However, we have another
60-
// config to use in docker, ai-gateway-local.yaml. So, we leave this
61-
// one alone.
62-
t.Run("Default config uses 0.0.0.0 IP for Ollama", func(t *testing.T) {
75+
t.Run("Default config uses 0.0.0.0 IP for Ollama when no env vars", func(t *testing.T) {
76+
t.Setenv("OPENAI_API_KEY", "")
77+
t.Setenv("OPENAI_BASE_URL", "")
6378
config, err := readConfig("")
6479
require.NoError(t, err)
6580
require.Contains(t, config, "address: 0.0.0.0")
@@ -166,9 +181,3 @@ func TestMaybeResolveHome(t *testing.T) {
166181
})
167182
}
168183
}
169-
170-
func sourceRelativePath(file string) string {
171-
_, filename, _, _ := runtime.Caller(0)
172-
testDir := filepath.Dir(filename)
173-
return filepath.Join(testDir, file)
174-
}

cmd/aigw/docker-compose-otel.yaml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,14 @@ services:
5858
env_file:
5959
- .env.otel.${COMPOSE_PROFILES:-console}
6060
environment:
61-
- OPENAI_HOST=host.docker.internal
61+
- OPENAI_BASE_URL=http://host.docker.internal:11434/v1
62+
- OPENAI_API_KEY=unused
6263
ports:
6364
- "1975:1975" # OpenAI compatible endpoint at /v1
6465
- "1064:1064" # Prometheus endpoint at /metrics
6566
extra_hosts: # localhost:host-gateway trick doesn't work with aigw
6667
- "host.docker.internal:host-gateway"
67-
volumes:
68-
- ./ai-gateway-local.yaml:/config.yaml:ro
69-
command: ["run", "/config.yaml"]
68+
command: ["run"]
7069

7170
# chat-completion is the standard OpenAI client (`openai` in pip), instrumented
7271
# with the following OpenTelemetry instrumentation libraries:

cmd/aigw/docker-compose.yaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,14 @@ services:
3434
ollama-pull:
3535
condition: service_completed_successfully
3636
environment:
37-
- OPENAI_HOST=host.docker.internal
37+
- OPENAI_BASE_URL=http://host.docker.internal:11434/v1
38+
- OPENAI_API_KEY=unused
3839
ports:
3940
- "1975:1975" # OpenAI compatible endpoint at /v1
4041
- "1064:1064" # Prometheus endpoint at /metrics
4142
extra_hosts: # localhost:host-gateway trick doesn't work with aigw
4243
- "host.docker.internal:host-gateway"
43-
volumes:
44-
- ./ai-gateway-local.yaml:/config.yaml:ro
45-
command: ["run", "/config.yaml"]
44+
command: ["run"]
4645

4746
# chat-completion is a simple curl-based test client for sending requests to aigw.
4847
chat-completion:
@@ -63,3 +62,5 @@ services:
6362
-H "Authorization: Bearer unused" \
6463
-H "Content-Type: application/json" \
6564
-d "{\"model\":\"$$CHAT_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"Answer in up to 3 words: Which ocean contains Bouvet Island?\"}]}"
65+
extra_hosts: # localhost:host-gateway trick doesn't work with aigw
66+
- "host.docker.internal:host-gateway"

cmd/aigw/run.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"sigs.k8s.io/yaml"
3535

3636
"github.com/envoyproxy/ai-gateway/internal/controller"
37+
"github.com/envoyproxy/ai-gateway/internal/envgen"
3738
"github.com/envoyproxy/ai-gateway/internal/extensionserver"
3839
"github.com/envoyproxy/ai-gateway/internal/filterapi"
3940
)
@@ -247,18 +248,27 @@ func pollEnvoyReadiness(ctx context.Context, l *slog.Logger, addr string, interv
247248
}
248249
}
249250

250-
// readConfig returns config from the given path, substituting ENV variables
251-
// similar to `envsubst`. If the path is empty the default config is returned.
251+
// readConfig reads and returns the configuration as a string from the specified path,
252+
// substituting environment variables similar to envsubst. If the path is empty and
253+
// OPENAI_API_KEY is set, it generates the configuration from OpenAI environment variables.
254+
// If the path is empty and no OPENAI_API_KEY is set, it returns the default configuration.
255+
// An error is returned if the file cannot be read or if config generation fails.
252256
func readConfig(path string) (string, error) {
253257
if path == "" {
258+
if os.Getenv("OPENAI_API_KEY") != "" {
259+
config, err := envgen.GenerateOpenAIConfig()
260+
if err != nil {
261+
return "", fmt.Errorf("error generating OpenAI config: %w", err)
262+
}
263+
return config, nil
264+
}
254265
return aiGatewayDefaultResources, nil
255266
}
256-
var yamlBytes []byte
257-
yamlBytes, err := envsubst.ReadFile(path)
267+
configBytes, err := envsubst.ReadFile(path)
258268
if err != nil {
259269
return "", fmt.Errorf("error reading config: %w", err)
260270
}
261-
return string(yamlBytes), nil
271+
return string(configBytes), nil
262272
}
263273

264274
// recreateDir removes the directory at the given path and creates a new one.

internal/envgen/config.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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 envgen
7+
8+
import (
9+
"bytes"
10+
_ "embed"
11+
"fmt"
12+
"net/url"
13+
"os"
14+
"strings"
15+
"text/template"
16+
)
17+
18+
//go:embed config.yaml.tmpl
19+
var configTemplate string
20+
21+
// ConfigData holds the template data for generating the configuration.
22+
type ConfigData struct {
23+
Hostname string // Hostname for the backend (may be modified for localhost)
24+
OriginalHostname string // Original hostname for TLS validation
25+
Port string // Port number as string
26+
Version string // API version path prefix (empty for "v1" to keep output clean)
27+
NeedsTLS bool // Whether to generate BackendTLSPolicy (port 443)
28+
}
29+
30+
// GenerateOpenAIConfig generates the AI Gateway configuration for a single
31+
// OpenAI-compatible backend, using standard OpenAI SDK environment variables.
32+
//
33+
// This errs if OPENAI_API_KEY is not set.
34+
//
35+
// See https://github.com/openai/openai-python/blob/main/src/openai/_client.py
36+
func GenerateOpenAIConfig() (string, error) {
37+
// Check for required API key
38+
if os.Getenv("OPENAI_API_KEY") == "" {
39+
return "", fmt.Errorf("OPENAI_API_KEY environment variable is required")
40+
}
41+
42+
// Get base URL with default
43+
baseURL := os.Getenv("OPENAI_BASE_URL")
44+
if baseURL == "" {
45+
baseURL = "https://api.openai.com/v1"
46+
}
47+
48+
// Parse URL to extract components
49+
data, err := parseURL(baseURL)
50+
if err != nil {
51+
return "", err
52+
}
53+
54+
// Parse and execute template
55+
tmpl, err := template.New("config").Parse(configTemplate)
56+
if err != nil {
57+
return "", fmt.Errorf("failed to parse template: %w", err)
58+
}
59+
60+
var buf bytes.Buffer
61+
if err := tmpl.Execute(&buf, data); err != nil {
62+
return "", fmt.Errorf("failed to execute template: %w", err)
63+
}
64+
65+
return buf.String(), nil
66+
}
67+
68+
// parseURL extracts hostname, port, and version from the base URL.
69+
func parseURL(baseURL string) (*ConfigData, error) {
70+
u, err := url.Parse(baseURL)
71+
if err != nil {
72+
return nil, fmt.Errorf("invalid OPENAI_BASE_URL: %w", err)
73+
}
74+
75+
// Extract hostname
76+
hostname := u.Hostname()
77+
if hostname == "" {
78+
return nil, fmt.Errorf("invalid OPENAI_BASE_URL: missing hostname")
79+
}
80+
originalHostname := hostname
81+
82+
// Convert localhost/127.0.0.1 to nip.io for Docker/K8s compatibility
83+
if hostname == "localhost" || hostname == "127.0.0.1" {
84+
hostname = "127.0.0.1.nip.io"
85+
}
86+
87+
// Determine port
88+
port := u.Port()
89+
if port == "" {
90+
switch u.Scheme {
91+
case "https":
92+
port = "443"
93+
case "http":
94+
port = "80"
95+
default:
96+
return nil, fmt.Errorf("invalid OPENAI_BASE_URL: unsupported scheme %q", u.Scheme)
97+
}
98+
}
99+
100+
// Extract version from path
101+
// Strip leading slash and use the entire path as version
102+
version := strings.TrimPrefix(u.Path, "/")
103+
// For cleaner output, omit version field when it's just "v1"
104+
if version == "v1" {
105+
version = ""
106+
}
107+
108+
return &ConfigData{
109+
Hostname: hostname,
110+
OriginalHostname: originalHostname,
111+
Port: port,
112+
Version: version,
113+
NeedsTLS: u.Scheme == "https",
114+
}, nil
115+
}

0 commit comments

Comments
 (0)