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
8 changes: 8 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
# Header used to read/write request user_path values (default: X-GoModel-User-Path)
# USER_PATH_HEADER=X-GoModel-User-Path

# Reject unknown keys in config.yaml and in the JSON env vars that declare the same
# structures (VIRTUAL_MODELS, SET_RATE_LIMIT_*, SET_BUDGET_*). Default: true, so a
# typo or a misindented section fails startup instead of silently dropping providers,
# rate limits, budgets, or guardrails. Set false to downgrade unknown keys to
# warnings — intended for rolling a binary back under a newer config file. Malformed
# values stay fatal in either mode.
# CONFIG_STRICT=true

# Tagging based on headers: label every request from the listed headers (numbered
# from 1). Labels are recorded in usage tracking and audit logs. A header value can
# carry several labels split by the delimiter (default: ","). The optional prefix is
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: 1.26.4
go-version: 1.26.5
cache: true

- name: Run GoReleaser
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: true

env:
GO_VERSION: "1.26.4"
GO_VERSION: "1.26.5"

# Restrict token permissions to minimum required
permissions:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Build stage — run on the build host's native arch for speed, cross-compile for target
FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine3.23 AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine3.23 AS builder

ARG TARGETOS
ARG TARGETARCH
Expand Down
15 changes: 8 additions & 7 deletions config/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,21 @@ type BudgetUserPathConfig struct {
}

// BudgetLimitConfig declares one spend limit for a reset period.
// The json tags support the JSON-array form of SET_BUDGET_* env values.
type BudgetLimitConfig struct {
// Period accepts hourly, daily, weekly, or monthly. The resolved period is
// persisted as PeriodSeconds in the database.
Period string `yaml:"period"`
Period string `yaml:"period" json:"period"`

// PeriodSeconds can be set directly instead of Period. Standard values are
// 3600, 86400, 604800, and 2592000.
PeriodSeconds int64 `yaml:"period_seconds"`
PeriodSeconds int64 `yaml:"period_seconds" json:"period_seconds"`

// Amount is the maximum allowed tracked provider spend for the period.
Amount float64 `yaml:"amount"`
Amount float64 `yaml:"amount" json:"amount"`
}

func applyBudgetEnv(cfg *Config) error {
func applyBudgetEnv(cfg *Config, strict bool) error {
if cfg == nil {
return nil
}
Expand All @@ -54,7 +55,7 @@ func applyBudgetEnv(cfg *Config) error {
cfg.Budgets.UserPaths,
"SET_BUDGET_",
func(entry BudgetUserPathConfig) string { return entry.Path },
parseBudgetEnvLimits,
func(raw string) ([]BudgetLimitConfig, error) { return parseBudgetEnvLimits(raw, strict) },
func(path string, limits []BudgetLimitConfig) BudgetUserPathConfig {
return BudgetUserPathConfig{Path: path, Limits: limits}
},
Expand All @@ -66,7 +67,7 @@ func applyBudgetEnv(cfg *Config) error {
return nil
}

func parseBudgetEnvLimits(raw string) ([]BudgetLimitConfig, error) {
func parseBudgetEnvLimits(raw string, strict bool) ([]BudgetLimitConfig, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
Expand All @@ -89,7 +90,7 @@ func parseBudgetEnvLimits(raw string) ([]BudgetLimitConfig, error) {
}
if strings.HasPrefix(raw, "[") {
var limits []BudgetLimitConfig
if err := json.Unmarshal([]byte(raw), &limits); err != nil {
if err := decodeIaCJSON("SET_BUDGET_*", raw, &limits, strict); err != nil {
return nil, err
}
return limits, nil
Expand Down
34 changes: 34 additions & 0 deletions config/budget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package config

import (
"strings"
"testing"
)

// The JSON-array form of SET_BUDGET_* decodes through json tags. Without them
// period_seconds was silently dropped, yielding a limit with no window.
func TestParseBudgetEnvLimits_JSONArray(t *testing.T) {
limits, err := parseBudgetEnvLimits(`[{"period_seconds":7200,"amount":5}]`, true)
if err != nil {
t.Fatalf("parseBudgetEnvLimits() error = %v", err)
}
if len(limits) != 1 {
t.Fatalf("len(limits) = %d, want 1", len(limits))
}
if limits[0].PeriodSeconds != 7200 {
t.Fatalf("PeriodSeconds = %d, want 7200", limits[0].PeriodSeconds)
}
if limits[0].Amount != 5 {
t.Fatalf("Amount = %v, want 5", limits[0].Amount)
}
}

func TestParseBudgetEnvLimits_RejectsUnknownField(t *testing.T) {
_, err := parseBudgetEnvLimits(`[{"period":"daily","ammount":5}]`, true)
if err == nil {
t.Fatal("parseBudgetEnvLimits() error = nil, want unknown-field error")
}
if !strings.Contains(err.Error(), "ammount") {
t.Fatalf("parseBudgetEnvLimits() error = %q, want it to name the unknown field", err)
}
}
202 changes: 173 additions & 29 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@
package config

import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"regexp"
"strconv"
"strings"
"time"

"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -152,7 +159,12 @@ func buildDefaultConfig() *Config {
func Load() (*LoadResult, error) {
cfg := buildDefaultConfig()

rawProviders, err := applyYAML(cfg)
strict, err := resolveConfigStrict()
if err != nil {
return nil, err
}

rawProviders, err := applyYAML(cfg, strict)
if err != nil {
return nil, err
}
Expand All @@ -168,7 +180,7 @@ func Load() (*LoadResult, error) {
if err := applyEnvOverrides(cfg); err != nil {
return nil, err
}
if err := applyVirtualModelsEnv(cfg); err != nil {
if err := applyVirtualModelsEnv(cfg, strict); err != nil {
return nil, err
}
if err := applyTaggingEnv(cfg); err != nil {
Expand All @@ -178,13 +190,13 @@ func Load() (*LoadResult, error) {
return nil, err
}
applyBudgetDependencies(cfg)
if err := applyBudgetEnv(cfg); err != nil {
if err := applyBudgetEnv(cfg, strict); err != nil {
return nil, err
}
if err := validateBudgetConfig(&cfg.Budgets); err != nil {
return nil, err
}
if err := applyRateLimitEnv(cfg); err != nil {
if err := applyRateLimitEnv(cfg, strict); err != nil {
return nil, err
}
if err := validateRateLimitConfig(&cfg.RateLimits); err != nil {
Expand Down Expand Up @@ -225,32 +237,56 @@ func Load() (*LoadResult, error) {
}, nil
}

// applyYAML reads an optional config.yaml and overlays it onto cfg.
// configFilePaths are searched in order; the first readable file wins.
var configFilePaths = []string{
"config/config.yaml",
"config.yaml",
}

const envConfigStrict = "CONFIG_STRICT"

// resolveConfigStrict reads CONFIG_STRICT, which defaults to true: an unknown key
// in declarative config aborts startup rather than being ignored, because a
// dropped providers, rate_limits, budgets, or guardrails entry silently changes
// routing, cost, or security. Set it to false to downgrade unknown keys to
// warnings — useful when rolling a binary back under a newer config file.
//
// It is read directly from the environment because it governs the parse of the
// YAML layer, which runs before the env-tag overrides are applied.
func resolveConfigStrict() (bool, error) {
raw := strings.TrimSpace(os.Getenv(envConfigStrict))
if raw == "" {
return true, nil
}
strict, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("invalid %s: %q is not a boolean", envConfigStrict, raw)
}
if !strict {
slog.Warn("CONFIG_STRICT=false: unknown config keys are ignored with a warning instead of aborting startup")
}
return strict, nil
}

// applyYAML reads an optional config file and overlays it onto cfg.
// Returns the raw provider map parsed from the providers: YAML section.
// If no config file is found, this is a no-op (not an error).
func applyYAML(cfg *Config) (map[string]RawProviderConfig, error) {
paths := []string{
"config/config.yaml",
"config.yaml",
}

var data []byte
for _, p := range paths {
raw, err := os.ReadFile(p)
if err == nil {
data = raw
break
}
//
// When strict, an unknown key is an error rather than a silently ignored one. A
// misindented section — the classic `providers:` followed by entries at column
// zero — otherwise parses as a null section plus unknown top-level keys, and the
// gateway boots with none of the operator's providers. CONFIG_STRICT=false
// downgrades unknown keys to warnings; malformed values stay fatal either way.
func applyYAML(cfg *Config, strict bool) (map[string]RawProviderConfig, error) {
path, data, err := readConfigFile()
if err != nil {
return nil, err
}

rawProviders := make(map[string]RawProviderConfig)

if data == nil {
return rawProviders, nil
slog.Info("no config file found; using defaults and environment", "searched", configFilePaths)
return map[string]RawProviderConfig{}, nil
}

expanded := expandString(string(data))

// yamlTarget is a local struct that mirrors Config for YAML unmarshaling,
// using RawProviderConfig for providers so nullable resilience overrides are preserved.
type yamlTarget struct {
Expand All @@ -259,13 +295,121 @@ func applyYAML(cfg *Config) (map[string]RawProviderConfig, error) {
}

target := yamlTarget{Config: cfg}
if err := yaml.Unmarshal([]byte(expanded), &target); err != nil {
return nil, fmt.Errorf("failed to parse config.yaml: %w", err)
decoder := yaml.NewDecoder(strings.NewReader(expandString(string(data))))
// Unknown keys are always detected. Whether they are fatal is decided below,
// so the lax mode can still name each one instead of dropping it in silence.
decoder.KnownFields(true)
// A file holding only comments decodes to nothing; that is an empty overlay,
// not a failure.
decodeErr := decoder.Decode(&target)
if decodeErr != nil && !errors.Is(decodeErr, io.EOF) {
if err := reportYAMLDecodeError(path, decodeErr, strict); err != nil {
return nil, err
}
}
if err := ensureSingleDocument(path, decoder); err != nil {
return nil, err
}

slog.Info("config file loaded", "path", path, "providers", len(target.RawProviders))

if target.RawProviders != nil {
rawProviders = target.RawProviders
if target.RawProviders == nil {
return map[string]RawProviderConfig{}, nil
}
return target.RawProviders, nil
}

// ensureSingleDocument rejects a config file holding more than one YAML document.
// The decoder reads only the first, so everything after a `---` separator would be
// applied nowhere — the same silent loss a misindented section causes. Decoding into
// a yaml.Node accepts any shape, so this detects a second document without
// re-triggering the unknown-key check. A structural fault, fatal regardless of
// CONFIG_STRICT.
func ensureSingleDocument(path string, decoder *yaml.Decoder) error {
var extra yaml.Node
err := decoder.Decode(&extra)
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return formatYAMLError(path, err)
}
return fmt.Errorf("failed to parse %s: only one YAML document is supported, found another after a '---' separator", path)
}

// reportYAMLDecodeError decides the fate of a decode error. Unknown keys are fatal
// when strict and warnings otherwise; every other problem — a malformed value, a
// syntax error — is fatal regardless, because CONFIG_STRICT relaxes what the schema
// accepts, not whether the file makes sense. Returns nil when nothing is fatal.
func reportYAMLDecodeError(path string, err error, strict bool) error {
var typeErr *yaml.TypeError
if strict || !errors.As(err, &typeErr) {
return formatYAMLError(path, err)
}

var fatal []string
for _, message := range typeErr.Errors {
line, field, ok := parseUnknownFieldMessage(message)
if !ok {
fatal = append(fatal, message)
continue
}
slog.Warn("unknown config key ignored; it has no effect",
"path", path, "line", line, "field", field)
}
if len(fatal) > 0 {
return formatYAMLError(path, &yaml.TypeError{Errors: fatal})
}
return nil
}

// unknownFieldMessage matches yaml.v3's unknown-key message, the only decode error
// CONFIG_STRICT=false is allowed to downgrade.
var unknownFieldMessage = regexp.MustCompile(`^line (\d+): field (\S+) not found in type \S+$`)

func parseUnknownFieldMessage(message string) (line int, field string, ok bool) {
match := unknownFieldMessage.FindStringSubmatch(message)
if match == nil {
return 0, "", false
}
line, err := strconv.Atoi(match[1])
if err != nil {
return 0, "", false
}
return line, match[2], true
}

// readConfigFile returns the first config file that exists and its contents, or an
// empty path and nil contents when none does. A file that exists but cannot be read
// — wrong permissions, or a directory mounted where a file was expected — is an
// error, not a missing file: silently falling back to defaults is how a
// misconfigured deployment boots with no providers.
func readConfigFile() (string, []byte, error) {
for _, path := range configFilePaths {
data, err := os.ReadFile(path)
switch {
case err == nil:
return path, data, nil
case errors.Is(err, fs.ErrNotExist):
continue
default:
return "", nil, fmt.Errorf("failed to read %s: %w", path, err)
}
}
return "", nil, nil
}

// yamlTypeSuffix matches the Go type name yaml.v3 appends to unknown-field errors
// ("field foo not found in type config.yamlTarget"). It names an internal struct
// the operator cannot act on, so it is stripped.
var yamlTypeSuffix = regexp.MustCompile(` in type \S+`)

return rawProviders, nil
// formatYAMLError rewrites a yaml.v3 decode error into a single actionable line
// prefixed with the offending file.
func formatYAMLError(path string, err error) error {
msg := yamlTypeSuffix.ReplaceAllString(err.Error(), "")
msg = strings.TrimPrefix(msg, "yaml: unmarshal errors:\n")
msg = strings.TrimPrefix(msg, "yaml: ")
msg = strings.ReplaceAll(msg, "\n ", "; ")
return fmt.Errorf("failed to parse %s: %s", path, strings.TrimSpace(msg))
}
Loading