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
14 changes: 14 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# GoModel-defined variables are canonically spelled GOMODEL_<NAME> (e.g.
# GOMODEL_SQLITE_PATH). The unprefixed spellings shown below still work but are
# deprecated: each one logs a warning at startup naming its replacement, and
# they will be removed in a future major release. Setting both spellings
# resolves to the GOMODEL_ one.
Comment on lines +4 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the actual canonical-versus-legacy precedence.

Both summaries say canonical always wins when both spellings exist, but a blank or whitespace-only canonical value falls back to a nonblank legacy value.

  • .env.template#L4-L5: add the blank-canonical fallback exception.
  • CLAUDE.md#L109-L110: describe the same exception consistently.
📍 Affects 2 files
  • .env.template#L4-L5 (this comment)
  • CLAUDE.md#L109-L110
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.template around lines 4 - 5, Update the canonical-versus-legacy
precedence documentation to state that the canonical GOMODEL_ value wins when
nonblank, but a blank or whitespace-only canonical value falls back to a
nonblank legacy value; apply this wording consistently at .env.template lines
4-5 and CLAUDE.md lines 109-110.

Source: Coding guidelines

#
# Two groups keep their bare names permanently and must not be prefixed:
# - PORT and REDIS_URL, which PaaS platforms inject.
# - The provider family (OPENAI_API_KEY, ANTHROPIC_API_KEY,
# <PROVIDER>_BASE_URL, <PROVIDER>_MODELS, ...), which lives in each
# vendor's namespace and is what makes GoModel drop-in compatible.
#
# See docs/dev/2026-07-17_env-prefix-migration.md for the full mapping.

# Server Configuration
# PORT=8080
# Mount the whole gateway under a path prefix, e.g. https://example.com/g/
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ If this repository is not the official GoModel repository, ask the user whether

Full reference: `.env.template` and `config/config.yaml`

**Env var naming:** GoModel-defined variables are canonically spelled
`GOMODEL_<NAME>` (`GOMODEL_SQLITE_PATH`, `GOMODEL_LOGGING_ENABLED`, ...). The
unprefixed spellings listed below still resolve but are deprecated and warn once
each at startup; when both are set, the `GOMODEL_` one wins. Two groups keep
their bare names permanently: `PORT` and `REDIS_URL` (injected by PaaS
platforms), and the provider family (`OPENAI_API_KEY`, `<PROVIDER>_BASE_URL`,
`<PROVIDER>_MODELS`, ...), which lives in each vendor's namespace and is what
makes GoModel drop-in compatible. New variables must take the prefix unless they
fall in one of those two groups. Details and the full mapping:
`docs/dev/2026-07-17_env-prefix-migration.md`.

**Key config groups:**

- **Server:**
Expand Down
53 changes: 27 additions & 26 deletions config/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ package config
import (
"fmt"
"math"
"os"
"strconv"
"strings"

"github.com/enterpilot/gomodel/internal/envcompat"
)

// CacheConfig holds model and response cache configuration.
Expand Down Expand Up @@ -262,7 +263,7 @@ func mergeSemanticResponseDefaults(sem *SemanticCacheConfig) {
}

func applyResponseSimpleEnv(resp *ResponseCacheConfig) error {
v, ok := os.LookupEnv("RESPONSE_CACHE_SIMPLE_ENABLED")
v, ok := envcompat.Lookup("RESPONSE_CACHE_SIMPLE_ENABLED")
if ok && !parseBool(v) {
resp.Simple = nil
return nil
Expand All @@ -279,19 +280,19 @@ func applyResponseSimpleEnv(resp *ResponseCacheConfig) error {
b := parseBool(v)
simple.Enabled = &b
}
if u := os.Getenv("REDIS_URL"); u != "" {
if u := envcompat.Get("REDIS_URL"); u != "" {
if simple.Redis == nil {
simple.Redis = &RedisResponseConfig{}
}
simple.Redis.URL = u
}
if k := os.Getenv("REDIS_KEY_RESPONSES"); k != "" {
if k := envcompat.Get("REDIS_KEY_RESPONSES"); k != "" {
if simple.Redis == nil {
simple.Redis = &RedisResponseConfig{}
}
simple.Redis.Key = k
}
if ts := os.Getenv("REDIS_TTL_RESPONSES"); ts != "" {
if ts := envcompat.Get("REDIS_TTL_RESPONSES"); ts != "" {
if simple.Redis == nil {
simple.Redis = &RedisResponseConfig{}
}
Expand All @@ -305,7 +306,7 @@ func applyResponseSimpleEnv(resp *ResponseCacheConfig) error {
}

func applyResponseSemanticEnv(resp *ResponseCacheConfig) error {
v, enabledKeySet := os.LookupEnv("SEMANTIC_CACHE_ENABLED")
v, enabledKeySet := envcompat.Lookup("SEMANTIC_CACHE_ENABLED")
if enabledKeySet && !parseBool(v) {
resp.Semantic = nil
return nil
Expand All @@ -322,84 +323,84 @@ func applyResponseSemanticEnv(resp *ResponseCacheConfig) error {
b := parseBool(v)
sem.Enabled = &b
}
if val := os.Getenv("SEMANTIC_CACHE_THRESHOLD"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_THRESHOLD"); val != "" {
f, err := strconv.ParseFloat(val, 64)
if err != nil {
return fmt.Errorf("invalid value for SEMANTIC_CACHE_THRESHOLD: %q is not a valid float", val)
}
sem.SimilarityThreshold = f
}
if val := os.Getenv("SEMANTIC_CACHE_TTL"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_TTL"); val != "" {
i, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("invalid value for SEMANTIC_CACHE_TTL: %q is not a valid integer", val)
}
sem.TTL = &i
}
if val := os.Getenv("SEMANTIC_CACHE_MAX_CONV_MESSAGES"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_MAX_CONV_MESSAGES"); val != "" {
i, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("invalid value for SEMANTIC_CACHE_MAX_CONV_MESSAGES: %q is not a valid integer", val)
}
sem.MaxConversationMessages = &i
}
if val := os.Getenv("SEMANTIC_CACHE_EXCLUDE_SYSTEM_PROMPT"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_EXCLUDE_SYSTEM_PROMPT"); val != "" {
sem.ExcludeSystemPrompt = parseBool(val)
}
if val := os.Getenv("SEMANTIC_CACHE_EMBEDDER_PROVIDER"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_EMBEDDER_PROVIDER"); val != "" {
sem.Embedder.Provider = val
}
if val := os.Getenv("SEMANTIC_CACHE_EMBEDDER_MODEL"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_EMBEDDER_MODEL"); val != "" {
sem.Embedder.Model = val
}
if val := os.Getenv("SEMANTIC_CACHE_VECTOR_STORE_TYPE"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_VECTOR_STORE_TYPE"); val != "" {
sem.VectorStore.Type = val
}
if val := os.Getenv("SEMANTIC_CACHE_QDRANT_URL"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_QDRANT_URL"); val != "" {
sem.VectorStore.Qdrant.URL = val
}
if val := os.Getenv("SEMANTIC_CACHE_QDRANT_COLLECTION"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_QDRANT_COLLECTION"); val != "" {
sem.VectorStore.Qdrant.Collection = val
}
if val := os.Getenv("SEMANTIC_CACHE_QDRANT_API_KEY"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_QDRANT_API_KEY"); val != "" {
sem.VectorStore.Qdrant.APIKey = val
}
if val := os.Getenv("SEMANTIC_CACHE_PGVECTOR_URL"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_PGVECTOR_URL"); val != "" {
sem.VectorStore.PGVector.URL = val
}
if val := os.Getenv("SEMANTIC_CACHE_PGVECTOR_TABLE"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_PGVECTOR_TABLE"); val != "" {
sem.VectorStore.PGVector.Table = val
}
if val := os.Getenv("SEMANTIC_CACHE_PGVECTOR_DIMENSION"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_PGVECTOR_DIMENSION"); val != "" {
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("invalid value for SEMANTIC_CACHE_PGVECTOR_DIMENSION: %q is not a valid integer", val)
}
sem.VectorStore.PGVector.Dimension = n
}
if val := os.Getenv("SEMANTIC_CACHE_PINECONE_HOST"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_PINECONE_HOST"); val != "" {
sem.VectorStore.Pinecone.Host = val
}
if val := os.Getenv("SEMANTIC_CACHE_PINECONE_API_KEY"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_PINECONE_API_KEY"); val != "" {
sem.VectorStore.Pinecone.APIKey = val
}
if val := os.Getenv("SEMANTIC_CACHE_PINECONE_NAMESPACE"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_PINECONE_NAMESPACE"); val != "" {
sem.VectorStore.Pinecone.Namespace = val
}
if val := os.Getenv("SEMANTIC_CACHE_PINECONE_DIMENSION"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_PINECONE_DIMENSION"); val != "" {
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("invalid value for SEMANTIC_CACHE_PINECONE_DIMENSION: %q is not a valid integer", val)
}
sem.VectorStore.Pinecone.Dimension = n
}
if val := os.Getenv("SEMANTIC_CACHE_WEAVIATE_URL"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_WEAVIATE_URL"); val != "" {
sem.VectorStore.Weaviate.URL = val
}
if val := os.Getenv("SEMANTIC_CACHE_WEAVIATE_CLASS"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_WEAVIATE_CLASS"); val != "" {
sem.VectorStore.Weaviate.Class = val
}
if val := os.Getenv("SEMANTIC_CACHE_WEAVIATE_API_KEY"); val != "" {
if val := envcompat.Get("SEMANTIC_CACHE_WEAVIATE_API_KEY"); val != "" {
sem.VectorStore.Weaviate.APIKey = val
}
return nil
Expand Down
5 changes: 3 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"gopkg.in/yaml.v3"

"github.com/enterpilot/gomodel/internal/envcompat"
"github.com/enterpilot/gomodel/internal/storage"
)

Expand Down Expand Up @@ -256,7 +257,7 @@ var configFilePaths = []string{

const envConfigStrict = "CONFIG_STRICT"

// resolveConfigStrict reads CONFIG_STRICT, which defaults to true: an unknown key
// resolveConfigStrict reads GOMODEL_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
Expand All @@ -265,7 +266,7 @@ const envConfigStrict = "CONFIG_STRICT"
// 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))
raw := strings.TrimSpace(envcompat.Get(envConfigStrict))
if raw == "" {
return true, nil
}
Expand Down
31 changes: 24 additions & 7 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import (
"strings"
"testing"

"gopkg.in/yaml.v3"
"time"

"gopkg.in/yaml.v3"

"github.com/enterpilot/gomodel/internal/envcompat"
)

// clearProviderEnvVars unsets all known provider-related environment variables.
Expand Down Expand Up @@ -38,7 +41,7 @@ func clearProviderEnvVars(t *testing.T) {
func clearAllConfigEnvVars(t *testing.T) {
t.Helper()
for _, key := range []string{
"CONFIG_STRICT",
"CONFIG_STRICT", "VIRTUAL_MODELS", "MCP_SERVERS",
"PORT", "BASE_PATH", "GOMODEL_MASTER_KEY", "BODY_SIZE_LIMIT", "SWAGGER_ENABLED", "PPROF_ENABLED", "ENABLE_PASSTHROUGH_ROUTES", "ALLOW_PASSTHROUGH_V1_ALIAS", "USER_PATH_HEADER", "ENABLED_PASSTHROUGH_PROVIDERS",
"GOMODEL_CACHE_DIR", "CACHE_REFRESH_INTERVAL",
"REDIS_URL", "REDIS_KEY_MODELS", "REDIS_KEY_RESPONSES", "REDIS_TTL_MODELS", "REDIS_TTL_RESPONSES",
Expand Down Expand Up @@ -69,14 +72,28 @@ func clearAllConfigEnvVars(t *testing.T) {
"HTTP_TIMEOUT", "HTTP_RESPONSE_HEADER_TIMEOUT",
"WORKFLOW_REFRESH_INTERVAL",
} {
t.Setenv(key, "")
os.Unsetenv(key)
// Both spellings: the canonical GOMODEL_-prefixed name takes precedence
// over the legacy bare one, so an ambient canonical value would shadow
// whatever a test sets bare (and vice versa). Keys already carrying
// the prefix (GOMODEL_MASTER_KEY) have only the one spelling.
names := []string{key}
if !strings.HasPrefix(key, envcompat.Prefix) {
names = append(names, envcompat.Prefix+key)
}
for _, name := range names {
t.Setenv(name, "")
os.Unsetenv(name)
}
}
for _, item := range os.Environ() {
key, _, _ := strings.Cut(item, "=")
if strings.HasPrefix(key, "SET_BUDGET_") || strings.HasPrefix(key, "SET_RATE_LIMIT_") || strings.HasPrefix(key, "SET_PROVIDER_RATE_LIMIT_") || strings.HasPrefix(key, "TAGGING_HEADER_") {
t.Setenv(key, "")
os.Unsetenv(key)
bare := strings.TrimPrefix(key, envcompat.Prefix)
for _, prefix := range []string{"SET_BUDGET_", "SET_RATE_LIMIT_", "SET_PROVIDER_RATE_LIMIT_", "TAGGING_HEADER_"} {
if strings.HasPrefix(bare, prefix) {
t.Setenv(key, "")
os.Unsetenv(key)
break
}
}
}
clearProviderEnvVars(t)
Expand Down
10 changes: 8 additions & 2 deletions config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"time"

"github.com/enterpilot/gomodel/internal/envcompat"
)

// applyEnvOverrides walks cfg's struct fields and applies env var overrides
Expand Down Expand Up @@ -84,7 +86,7 @@ func applyEnvOverridesValue(v reflect.Value) error {
if envKey == "" {
continue
}
envVal := os.Getenv(envKey)
envVal := envcompat.Get(envKey)
if envVal == "" {
continue
}
Expand Down Expand Up @@ -140,7 +142,11 @@ func applyEnvOverridesValue(v reflect.Value) error {
return nil
}

// expandString expands environment variable references like ${VAR} or ${VAR:-default} in a string.
// expandString expands environment variable references like ${VAR} or
// ${VAR:-default} in a string. It deliberately reads the environment verbatim,
// never through envcompat: the referenced names are operator-chosen and
// commonly provider-family ones (${OPENAI_API_KEY}), so GOMODEL_-prefix
// resolution and deprecation warnings must not apply.
func expandString(s string) string {
if s == "" {
return s
Expand Down
Loading